Files
kacerrandClaude Fable 5 f08d4f44d4 Add 98-http-server-experiments: how many connections can a Go server hold
One stdlib server under test (/static /compute /sleep /stats, ConnState
and in-flight counters) plus a load client with four modes: throughput,
idle, slow, and slowloris. loadlib holds the tested core (Burn,
Percentile, Classify). README documents the runs and how to tell a real
server ceiling from the load generator giving out first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012MpK9niZm4jcs3mAJbxMfi
2026-09-02 09:34:12 +02:00

148 lines
4.6 KiB
Go

package loadlib
import (
"errors"
"fmt"
"net"
"testing"
"time"
)
func TestBurnDeterministic(t *testing.T) {
// Same difficulty must give the same checksum: the /compute endpoint returns
// it, and a flaky checksum would mean a non-deterministic loop.
for _, d := range []int{1, 3, 10} {
if a, b := Burn(d), Burn(d); a != b {
t.Errorf("Burn(%d) not deterministic: %d != %d", d, a, b)
}
}
}
func TestBurnDifficultyChangesResult(t *testing.T) {
// Different difficulties should not collide (they run different iteration
// counts); a collision here would suggest the loop body ignores i.
if Burn(1) == Burn(2) {
t.Error("Burn(1) == Burn(2): difficulty has no effect on the checksum")
}
}
func TestIterationsLinearAndFloored(t *testing.T) {
if got := Iterations(2); got != 2*burnIters {
t.Errorf("Iterations(2) = %d, want %d", got, 2*burnIters)
}
// difficulty < 1 is clamped to 1 so /compute?difficulty=0 still does work.
for _, d := range []int{0, -5} {
if got := Iterations(d); got != burnIters {
t.Errorf("Iterations(%d) = %d, want %d (floor of 1)", d, got, burnIters)
}
}
// Cost is monotonic in difficulty.
if Iterations(3) <= Iterations(2) {
t.Error("Iterations not increasing with difficulty")
}
}
func TestPercentileEdges(t *testing.T) {
if got := Percentile(nil, 0.5); got != 0 {
t.Errorf("Percentile(empty) = %v, want 0", got)
}
s := []time.Duration{10, 20, 30, 40, 50}
cases := []struct {
p float64
want time.Duration
}{
{-1, 10}, // clamped to first
{0, 10}, // first
{0.5, 30}, // middle
{1, 50}, // last
{2, 50}, // clamped to last
}
for _, c := range cases {
if got := Percentile(s, c.p); got != c.want {
t.Errorf("Percentile(%v, %v) = %v, want %v", s, c.p, got, c.want)
}
}
if got := Percentile([]time.Duration{99}, 0.9); got != 99 {
t.Errorf("Percentile(single) = %v, want 99", got)
}
}
func TestLatenciesSnapshot(t *testing.T) {
var l Latencies
// Add out of order; Snapshot must sort before reporting.
for _, d := range []time.Duration{50, 10, 30, 40, 20} {
l.Add(d * time.Millisecond)
}
if l.Len() != 5 {
t.Fatalf("Len = %d, want 5", l.Len())
}
p50, p99, max := l.Snapshot()
if p50 != 30*time.Millisecond {
t.Errorf("p50 = %v, want 30ms", p50)
}
if max != 50*time.Millisecond {
t.Errorf("max = %v, want 50ms", max)
}
// Nearest-rank: index int(4*0.99)=3 → 40ms for this 5-sample set.
if p99 != 40*time.Millisecond {
t.Errorf("p99 = %v, want 40ms", p99)
}
}
func TestLatenciesMerge(t *testing.T) {
var a, b Latencies
a.Add(10 * time.Millisecond)
b.Add(20 * time.Millisecond)
a.Merge(&b)
if a.Len() != 2 {
t.Fatalf("after merge Len = %d, want 2", a.Len())
}
}
func TestClassify(t *testing.T) {
timeoutErr := &net.OpError{
Op: "dial",
Err: timeoutError{},
}
headerTimeout := fmt.Errorf("net/http: timeout awaiting response headers")
cases := []struct {
name string
err error
want ErrClass
}{
{"nil", nil, ErrNone},
{"client fds", errors.New("dial tcp 127.0.0.1:8098: socket: too many open files"), ErrClientFdLimit},
{"client ports", errors.New("dial tcp 127.0.0.1:8098: connect: can't assign requested address"), ErrClientPortLimit},
{"refused", errors.New("dial tcp 127.0.0.1:8098: connect: connection refused"), ErrRefused},
{"reset", errors.New("read tcp: connection reset by peer"), ErrConnReset},
{"eof", errors.New("unexpected EOF"), ErrConnReset},
{"plain net timeout", timeoutErr, ErrTimeout},
{"server cant accept", headerTimeoutNetErr{}, ErrServerCantAccept},
{"header timeout string", headerTimeout, ErrTimeout}, // not a net.Error, but has "timeout"
{"deadline", errors.New("context deadline exceeded"), ErrTimeout},
{"other", errors.New("something weird"), ErrOther},
}
for _, c := range cases {
if got := Classify(c.err); got != c.want {
t.Errorf("%s: Classify(%v) = %v, want %v", c.name, c.err, got, c.want)
}
}
}
// timeoutError is a minimal net.Error whose Timeout() reports true, used to build
// a synthetic *net.OpError the way the runtime would on a real dial timeout.
type timeoutError struct{}
func (timeoutError) Error() string { return "i/o timeout" }
func (timeoutError) Timeout() bool { return true }
func (timeoutError) Temporary() bool { return true }
// headerTimeoutNetErr is a net.Error whose message is the exact one net/http
// produces when ResponseHeaderTimeout fires — the "server can't accept" signal.
type headerTimeoutNetErr struct{}
func (headerTimeoutNetErr) Error() string { return "net/http: timeout awaiting response headers" }
func (headerTimeoutNetErr) Timeout() bool { return true }
func (headerTimeoutNetErr) Temporary() bool { return true }