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
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
// Package loadlib holds the small, pure pieces the server and load client share
|
||||
// and that are worth a unit test: a deterministic CPU burn, a percentile helper,
|
||||
// and an error classifier that says which side of a connection ran out.
|
||||
//
|
||||
// Everything network-shaped lives in the two binaries; those ceilings are
|
||||
// environmental (ulimit/sysctl/host) and are documented as pasted evidence in
|
||||
// the folder README rather than asserted in a test.
|
||||
package loadlib
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// burnIters is the inner-loop count per unit of difficulty. Kept as a named
|
||||
// constant so the test can reason about total iterations without a wall clock.
|
||||
const burnIters = 200_000
|
||||
|
||||
// Iterations returns how many inner-loop steps Burn(difficulty) will run.
|
||||
// It is the seam the test asserts on: cost is linear in difficulty, no timing.
|
||||
func Iterations(difficulty int) int {
|
||||
if difficulty < 1 {
|
||||
difficulty = 1
|
||||
}
|
||||
return difficulty * burnIters
|
||||
}
|
||||
|
||||
// Burn occupies a CPU for an amount of work proportional to difficulty and
|
||||
// returns a checksum. The caller must use the return value (write it to the
|
||||
// response) so the compiler cannot elide the loop.
|
||||
//
|
||||
// The mix is a cheap integer hash with a data dependency across iterations, so
|
||||
// each step depends on the last and the optimizer can't collapse the loop or
|
||||
// vectorize it away. There is deliberately no runtime.Gosched inside: Burn is
|
||||
// meant to genuinely hold a P, which is the whole point of the /compute probe
|
||||
// (how many CPU-bound requests before GOMAXPROCS saturates).
|
||||
func Burn(difficulty int) uint64 {
|
||||
var x uint64 = 1469598103934665603 // FNV-1a offset basis, just a seed
|
||||
n := Iterations(difficulty)
|
||||
for i := 0; i < n; i++ {
|
||||
x ^= uint64(i)
|
||||
x *= 1099511628211 // FNV-1a prime
|
||||
x = x<<13 | x>>51 // rotate so high bits feed back in
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
// Percentile returns the p-th percentile (p in [0,1]) of an already-sorted
|
||||
// slice. Empty → 0, p<=0 → first, p>=1 → last. Nearest-rank, no interpolation —
|
||||
// good enough for "orders of magnitude" latency reporting.
|
||||
func Percentile(sorted []time.Duration, p float64) time.Duration {
|
||||
if len(sorted) == 0 {
|
||||
return 0
|
||||
}
|
||||
if p < 0 {
|
||||
p = 0
|
||||
}
|
||||
if p > 1 {
|
||||
p = 1
|
||||
}
|
||||
i := int(float64(len(sorted)-1) * p)
|
||||
return sorted[i]
|
||||
}
|
||||
|
||||
// Latencies accumulates request durations and reports percentiles, keeping the
|
||||
// client's main loop thin. Not safe for concurrent use — each worker should own
|
||||
// one and they get merged, or a single collector goroutine owns it.
|
||||
type Latencies struct {
|
||||
d []time.Duration
|
||||
}
|
||||
|
||||
func (l *Latencies) Add(d time.Duration) { l.d = append(l.d, d) }
|
||||
func (l *Latencies) Len() int { return len(l.d) }
|
||||
|
||||
// Merge folds another accumulator's samples in (for per-worker collection).
|
||||
func (l *Latencies) Merge(o *Latencies) { l.d = append(l.d, o.d...) }
|
||||
|
||||
// Snapshot sorts and returns p50, p99, and max. Sorts in place; call once at end.
|
||||
func (l *Latencies) Snapshot() (p50, p99, max time.Duration) {
|
||||
sort.Slice(l.d, func(i, j int) bool { return l.d[i] < l.d[j] })
|
||||
return Percentile(l.d, 0.50), Percentile(l.d, 0.99), Percentile(l.d, 1)
|
||||
}
|
||||
|
||||
// ErrClass names which side of the connection gave out. It turns the body-drain
|
||||
// "which error string means what" table into something the client can count.
|
||||
type ErrClass int
|
||||
|
||||
const (
|
||||
ErrNone ErrClass = iota // no error
|
||||
ErrClientFdLimit // this process ran out of fds (dial: too many open files)
|
||||
ErrClientPortLimit // this process ran out of ephemeral ports (can't assign requested address)
|
||||
ErrServerCantAccept // server can't accept (out of fds / backlog full) — we time out
|
||||
ErrTimeout // request/response timed out (ambiguous, but usually the server)
|
||||
ErrConnReset // peer reset/closed the connection
|
||||
ErrRefused // nothing listening / server gone
|
||||
ErrOther
|
||||
)
|
||||
|
||||
func (c ErrClass) String() string {
|
||||
switch c {
|
||||
case ErrNone:
|
||||
return "none"
|
||||
case ErrClientFdLimit:
|
||||
return "client-fd-limit"
|
||||
case ErrClientPortLimit:
|
||||
return "client-port-limit"
|
||||
case ErrServerCantAccept:
|
||||
return "server-cant-accept"
|
||||
case ErrTimeout:
|
||||
return "timeout"
|
||||
case ErrConnReset:
|
||||
return "conn-reset"
|
||||
case ErrRefused:
|
||||
return "conn-refused"
|
||||
default:
|
||||
return "other"
|
||||
}
|
||||
}
|
||||
|
||||
// Classify maps a net/http client error to the side that most likely caused it.
|
||||
// It leans on error strings because the kernel/runtime messages are the only
|
||||
// distinguishing signal for several of these; the checks are ordered most- to
|
||||
// least-specific so, e.g., "too many open files" wins over the generic timeout.
|
||||
func Classify(err error) ErrClass {
|
||||
if err == nil {
|
||||
return ErrNone
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
|
||||
switch {
|
||||
case strings.Contains(msg, "too many open files"):
|
||||
// A dial failing this way is THIS process out of fds. (A server out of
|
||||
// fds fails on accept and never produces this string on the client.)
|
||||
return ErrClientFdLimit
|
||||
case strings.Contains(msg, "assign requested address"):
|
||||
// EADDRNOTAVAIL: no ephemeral port left for a fresh 4-tuple to this
|
||||
// server. A client-side wall, often hit before the fd limit.
|
||||
return ErrClientPortLimit
|
||||
case strings.Contains(msg, "connection refused"):
|
||||
return ErrRefused
|
||||
case strings.Contains(msg, "connection reset") || strings.Contains(msg, "broken pipe") ||
|
||||
strings.Contains(msg, "eof"):
|
||||
return ErrConnReset
|
||||
}
|
||||
|
||||
// "awaiting response headers" / a dial timeout usually means the server
|
||||
// accepted us into its backlog but can't service (or can't accept at all),
|
||||
// so we sit waiting. Treat a timeout as a server-can't-keep-up signal, but
|
||||
// keep a generic ErrTimeout for the plain deadline case.
|
||||
var ne net.Error
|
||||
if errors.As(err, &ne) && ne.Timeout() {
|
||||
if strings.Contains(msg, "awaiting response headers") {
|
||||
return ErrServerCantAccept
|
||||
}
|
||||
return ErrTimeout
|
||||
}
|
||||
if strings.Contains(msg, "timeout") || strings.Contains(msg, "deadline exceeded") {
|
||||
return ErrTimeout
|
||||
}
|
||||
return ErrOther
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
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 }
|
||||
Reference in New Issue
Block a user