// 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 }