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:
2026-09-02 09:34:12 +02:00
co-authored by Claude Fable 5
parent b290957fc9
commit f08d4f44d4
5 changed files with 1300 additions and 0 deletions
@@ -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
}