body-drain: add -c concurrency and -idle-per-host to the client

Bounded concurrency via semaphore+WaitGroup, atomic counters, stop-on-first-
error without cancelling in-flight requests (cancel would hide the leaks).
Exposes MaxIdleConnsPerHost so pool thrash under concurrency is observable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GbnJj9ZqEx9nUvzkjdwfMq
This commit is contained in:
2026-08-29 00:08:27 +02:00
co-authored by Claude Fable 5
parent f80ba127d8
commit 9230d0cd3d
2 changed files with 100 additions and 23 deletions
+27
View File
@@ -8,6 +8,33 @@ go run ./99-experiments/body-drain -mode all
go run ./99-experiments/body-drain -mode leak -n 200 go run ./99-experiments/body-drain -mode leak -n 200
``` ```
## Concurrency: `-c` and `-idle-per-host`
`-c N` keeps up to N requests in flight (semaphore + WaitGroup, the same
shape as exercise 01). `-idle-per-host` sets `Transport.MaxIdleConnsPerHost`
(default 10; Go's own default is only **2**).
```
go run ./99-experiments/body-drain -mode drain -n 200 -c 8
# httptrace GotConn : reused=192 fresh=8 ← one conn per worker, then reused
# server new conns : 8
go run ./99-experiments/body-drain -mode drain -n 200 -c 8 -idle-per-host 2
# server new conns : 13+ ← pool too small: conns returned
# while 2 are already idle get CLOSED
```
That second run is the trap in exercise 01: `limit` goroutines against one
host with the default transport means only 2 conns are ever kept, the rest are
dialed and thrown away on every round — even though you drained the body
correctly. On loopback the effect is small; against a TLS host each throwaway
is a full handshake. Raise `MaxIdleConnsPerHost` to your concurrency.
With `-c` the leak modes also exhaust fds N times faster, and failures no
longer stop in-flight requests: cancelling a request context closes its body
and would clean up the very leaks we want to see, so the client only stops
*launching* new requests after the first error.
## Two processes: watch each side run out of fds on its own ## Two processes: watch each side run out of fds on its own
In one process the client and server share one fd limit and each leaked In one process the client and server share one fd limit and each leaked
+73 -23
View File
@@ -24,6 +24,8 @@
// Run in one process: // Run in one process:
// //
// go run ./99-experiments/body-drain -mode all // go run ./99-experiments/body-drain -mode all
// go run ./99-experiments/body-drain -mode drain -n 200 -c 8 # 8 in flight, pool of 10 → still 1 host conn per worker
// go run ./99-experiments/body-drain -mode drain -n 200 -c 8 -idle-per-host 2 # pool too small → fresh dials even when draining
// //
// Run as two processes (two terminals): // Run as two processes (two terminals):
// //
@@ -56,6 +58,7 @@ import (
"os" "os"
"os/signal" "os/signal"
"runtime" "runtime"
"sync"
"sync/atomic" "sync/atomic"
"time" "time"
) )
@@ -153,53 +156,73 @@ func serveForever(addr string) {
// ---- client ---- // ---- client ----
// runClient performs n GETs in the given mode and reports what it observed. // clientOpts are the knobs for one client run.
func runClient(m mode, n int, url string, headerTimeout time.Duration) { type clientOpts struct {
n int // total requests
concurrency int // max in-flight requests (1 = sequential)
idlePerHost int // Transport.MaxIdleConnsPerHost
headerTimeout time.Duration // Transport.ResponseHeaderTimeout
}
// runClient performs opts.n GETs in the given mode and reports what it observed.
func runClient(m mode, url string, opts clientOpts) {
// Fresh transport per run so runs don't share a pool. Cloning keeps sane defaults. // Fresh transport per run so runs don't share a pool. Cloning keeps sane defaults.
tr := http.DefaultTransport.(*http.Transport).Clone() tr := http.DefaultTransport.(*http.Transport).Clone()
tr.MaxIdleConnsPerHost = 10 // With concurrency > MaxIdleConnsPerHost, even `drain` mode dials fresh
// connections: conns that come back while the idle pool is full are
// closed instead of pooled. Watch `fresh` climb as -c grows past -idle-per-host.
tr.MaxIdleConnsPerHost = opts.idlePerHost
// Without this, a request whose connection the server can't accept (server // Without this, a request whose connection the server can't accept (server
// out of fds) blocks forever waiting for headers. // out of fds) blocks forever waiting for headers.
tr.ResponseHeaderTimeout = headerTimeout tr.ResponseHeaderTimeout = opts.headerTimeout
// NOTE: deliberately no Client.Timeout. With a timeout, a leaked body gets // NOTE: deliberately no Client.Timeout. With a timeout, a leaked body gets
// force-closed when the timer fires, which hides the leak (try adding one // force-closed when the timer fires, which hides the leak (try adding one
// and watch the goroutine count drop after the timeout). // and watch the goroutine count drop after the timeout).
client := &http.Client{Transport: tr} client := &http.Client{Transport: tr}
var reused, fresh int // Counters are touched from many goroutines when -c > 1 → atomics.
var reused, fresh, done, failed atomic.Int64
trace := &httptrace.ClientTrace{ trace := &httptrace.ClientTrace{
GotConn: func(info httptrace.GotConnInfo) { GotConn: func(info httptrace.GotConnInfo) {
if info.Reused { if info.Reused {
reused++ reused.Add(1)
} else { } else {
fresh++ fresh.Add(1)
} }
}, },
} }
goroutinesBefore := runtime.NumGoroutine() // stop is set on the first failure so we launch no further requests.
start := time.Now() // Deliberately NOT a cancellable request context: cancelling a request's
// ctx closes its body/conn, which would clean up the leaks we're here
// to observe. In-flight requests are left alone.
var stop atomic.Bool
ctx := httptrace.WithClientTrace(context.Background(), trace)
done := 0 var firstErr sync.Once
for i := 0; i < n; i++ { doOne := func(i int) {
req, err := http.NewRequestWithContext(httptrace.WithClientTrace(context.Background(), trace), http.MethodGet, url, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil { if err != nil {
panic(err) panic(err)
} }
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
failed.Add(1)
// This is where `leak` eventually dies. Which error you get tells // This is where `leak` eventually dies. Which error you get tells
// you which side ran out of fds: // you which side ran out of fds:
// dial tcp ...: socket: too many open files → THIS process (client) // dial tcp ...: socket: too many open files → THIS process (client)
// timeout awaiting response headers → the server can't accept (it's out of fds) // timeout awaiting response headers → the server can't accept (it's out of fds)
fmt.Printf(" request %d failed: %v\n", i, err) firstErr.Do(func() {
var ne net.Error fmt.Printf(" request %d failed: %v\n", i, err)
if errors.As(err, &ne) && ne.Timeout() { var ne net.Error
fmt.Println(" (timeout: the server probably can't accept — check its log for 'accept4: too many open files')") if errors.As(err, &ne) && ne.Timeout() {
} fmt.Println(" (timeout: the server probably can't accept — check its log for 'accept: too many open files')")
break }
stop.Store(true)
})
return
} }
done++ done.Add(1)
switch m { switch m {
case modeDrain: case modeDrain:
@@ -212,13 +235,33 @@ func runClient(m mode, n int, url string, headerTimeout time.Duration) {
} }
} }
goroutinesBefore := runtime.NumGoroutine()
start := time.Now()
// Bounded concurrency: a buffered channel as a semaphore + WaitGroup.
// Same shape as exercise 01's CheckAll.
sem := make(chan struct{}, opts.concurrency)
var wg sync.WaitGroup
for i := 0; i < opts.n && !stop.Load(); i++ {
sem <- struct{}{} // blocks while `concurrency` requests are in flight
wg.Add(1)
go func(i int) {
defer wg.Done()
defer func() { <-sem }()
doOne(i)
}(i)
}
wg.Wait()
elapsed := time.Since(start) elapsed := time.Since(start)
// Give the transport a moment to settle bookkeeping (put conn back to idle etc.). // Give the transport a moment to settle bookkeeping (put conn back to idle etc.).
time.Sleep(50 * time.Millisecond) time.Sleep(50 * time.Millisecond)
goroutinesAfter := runtime.NumGoroutine() goroutinesAfter := runtime.NumGoroutine()
fmt.Printf(" requests : %d/%d ok in %v\n", done, n, elapsed.Round(time.Millisecond)) fmt.Printf(" requests : %d/%d ok, %d failed, in %v (concurrency %d)\n",
fmt.Printf(" httptrace GotConn : reused=%d fresh=%d\n", reused, fresh) done.Load(), opts.n, failed.Load(), elapsed.Round(time.Millisecond), opts.concurrency)
fmt.Printf(" httptrace GotConn : reused=%d fresh=%d (MaxIdleConnsPerHost=%d)\n",
reused.Load(), fresh.Load(), opts.idlePerHost)
fmt.Printf(" goroutines : before=%d after=%d (delta %+d)\n", fmt.Printf(" goroutines : before=%d after=%d (delta %+d)\n",
goroutinesBefore, goroutinesAfter, goroutinesAfter-goroutinesBefore) goroutinesBefore, goroutinesAfter, goroutinesAfter-goroutinesBefore)
@@ -236,7 +279,14 @@ func main() {
modeFlag := flag.String("mode", "all", "client: drain | close | leak | all") modeFlag := flag.String("mode", "all", "client: drain | close | leak | all")
n := flag.Int("n", 20, "client: number of requests per mode") n := flag.Int("n", 20, "client: number of requests per mode")
headerTimeout := flag.Duration("header-timeout", 0, "client: ResponseHeaderTimeout (0 = none; try 3s when the server is the one out of fds)") headerTimeout := flag.Duration("header-timeout", 0, "client: ResponseHeaderTimeout (0 = none; try 3s when the server is the one out of fds)")
concurrency := flag.Int("c", 1, "client: max requests in flight (1 = sequential)")
idlePerHost := flag.Int("idle-per-host", 10, "client: Transport.MaxIdleConnsPerHost (set below -c to watch drain mode stop reusing)")
flag.Parse() flag.Parse()
if *concurrency < 1 {
fmt.Fprintln(os.Stderr, "-c must be >= 1")
os.Exit(2)
}
opts := clientOpts{n: *n, concurrency: *concurrency, idlePerHost: *idlePerHost, headerTimeout: *headerTimeout}
modes := []mode{modeDrain, modeClose, modeLeak} modes := []mode{modeDrain, modeClose, modeLeak}
if *modeFlag != "all" { if *modeFlag != "all" {
@@ -250,7 +300,7 @@ func main() {
case "client": case "client":
for _, m := range modes { for _, m := range modes {
fmt.Printf("== mode=%s (client only, target %s) ==\n", m, *url) fmt.Printf("== mode=%s (client only, target %s) ==\n", m, *url)
runClient(m, *n, *url, *headerTimeout) runClient(m, *url, opts)
fmt.Println() fmt.Println()
} }
if len(modes) == 1 && modes[0] == modeLeak { if len(modes) == 1 && modes[0] == modeLeak {
@@ -265,7 +315,7 @@ func main() {
os.Exit(1) os.Exit(1)
} }
fmt.Printf("== mode=%s ==\n", m) fmt.Printf("== mode=%s ==\n", m)
runClient(m, *n, srv.URL(), *headerTimeout) runClient(m, srv.URL(), opts)
time.Sleep(50 * time.Millisecond) time.Sleep(50 * time.Millisecond)
srv.report() srv.report()
fmt.Println() fmt.Println()