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
333 lines
12 KiB
Go
333 lines
12 KiB
Go
// Experiment — what happens when you don't drain/close an HTTP response body
|
|
//
|
|
// A server hands out 1 MiB responses and counts every TCP connection it
|
|
// accepts. The client performs N sequential GETs against it in one of three
|
|
// modes and we observe:
|
|
//
|
|
// - server-side: how many *new* TCP connections were accepted (ideal: 1)
|
|
// - client-side: httptrace's GotConnInfo.Reused for every request
|
|
// - client-side: goroutine count (each pooled/leaked conn owns 2 goroutines:
|
|
// the transport's readLoop and writeLoop)
|
|
//
|
|
// Modes:
|
|
//
|
|
// drain — io.Copy(io.Discard, body); body.Close() → connection reused
|
|
// close — body.Close() only → connection thrown away, new dial every time
|
|
// leak — neither → connection AND 2 goroutines AND 1 fd leaked per request
|
|
//
|
|
// Roles (so you can watch each side fail on its own, with its own fd limit):
|
|
//
|
|
// both — server + client in one process (default)
|
|
// server — only serve on -addr, print connection stats every second, Ctrl-C to stop
|
|
// client — only run the client against -url
|
|
//
|
|
// Run in one process:
|
|
//
|
|
// 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):
|
|
//
|
|
// go run ./99-experiments/body-drain -role server
|
|
// ulimit -Hn 64; ulimit -Sn 64; go run ./99-experiments/body-drain -role client -mode leak -n 200
|
|
//
|
|
// (Lower the HARD limit too: since Go 1.19 the runtime raises the soft
|
|
// RLIMIT_NOFILE to the hard limit at startup. bash's `ulimit -n 64` sets both;
|
|
// zsh's sets only the soft one.)
|
|
//
|
|
// With the client alone at 64 fds you'll see the CLIENT die:
|
|
//
|
|
// dial tcp 127.0.0.1:8099: socket: too many open files
|
|
//
|
|
// With the limit on the SERVER terminal instead, the SERVER logs
|
|
// "accept: too many open files" and the client either hangs waiting for
|
|
// headers (Linux — use -header-timeout 3s) or gets "connection reset by
|
|
// peer" (macOS).
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptrace"
|
|
"os"
|
|
"os/signal"
|
|
"runtime"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
const bodySize = 1 << 20 // 1 MiB — large enough that it is never fully buffered in the transport
|
|
|
|
type mode string
|
|
|
|
const (
|
|
modeDrain mode = "drain"
|
|
modeClose mode = "close"
|
|
modeLeak mode = "leak"
|
|
)
|
|
|
|
// ---- server ----
|
|
|
|
// countingServer is an http.Server that counts accepted connections.
|
|
type countingServer struct {
|
|
srv *http.Server
|
|
ln net.Listener
|
|
newConns atomic.Int64
|
|
activeConns atomic.Int64
|
|
}
|
|
|
|
func (s *countingServer) URL() string { return "http://" + s.ln.Addr().String() }
|
|
|
|
// newCountingServer listens on addr ("127.0.0.1:0" = random port) and starts serving.
|
|
func newCountingServer(addr string) (*countingServer, error) {
|
|
ln, err := net.Listen("tcp", addr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
payload := make([]byte, bodySize)
|
|
s := &countingServer{ln: ln}
|
|
s.srv = &http.Server{
|
|
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Length", fmt.Sprint(bodySize))
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write(payload)
|
|
}),
|
|
// ConnState is the server-side hook that lets us see the connection lifecycle.
|
|
ConnState: func(c net.Conn, st http.ConnState) {
|
|
switch st {
|
|
case http.StateNew:
|
|
s.newConns.Add(1)
|
|
s.activeConns.Add(1)
|
|
case http.StateClosed, http.StateHijacked:
|
|
s.activeConns.Add(-1)
|
|
}
|
|
},
|
|
}
|
|
go s.srv.Serve(ln)
|
|
return s, nil
|
|
}
|
|
|
|
// close force-closes everything. In leak mode the handlers are STILL BLOCKED
|
|
// in Write(): the kernel socket buffers filled up because nobody on the
|
|
// client side is reading the 1 MiB body. A graceful Shutdown would wait for
|
|
// them forever — that's the server-side symptom of a client leak: goroutines
|
|
// and memory pinned per stuck connection. So we use Close(), not Shutdown().
|
|
func (s *countingServer) close() { s.srv.Close() }
|
|
|
|
func (s *countingServer) report() {
|
|
fmt.Printf(" server new conns : %d (ideal: 1)\n", s.newConns.Load())
|
|
fmt.Printf(" server active conns: %d (ideal after cleanup: 0)\n", s.activeConns.Load())
|
|
}
|
|
|
|
// serveForever runs the server role: serve until Ctrl-C, printing stats each second.
|
|
func serveForever(addr string) {
|
|
s, err := newCountingServer(addr)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "listen:", err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Printf("serving on %s (point the client at it with -url %s)\n", s.URL(), s.URL())
|
|
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
|
defer stop()
|
|
tick := time.NewTicker(time.Second)
|
|
defer tick.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
fmt.Println("\nshutting down")
|
|
s.close()
|
|
return
|
|
case <-tick.C:
|
|
// accepted_total only ever grows (every conn ever accepted);
|
|
// active is what's open right now.
|
|
fmt.Printf("%s accepted_total=%d active=%d goroutines=%d\n",
|
|
time.Now().Format("15:04:05"), s.newConns.Load(), s.activeConns.Load(), runtime.NumGoroutine())
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- client ----
|
|
|
|
// clientOpts are the knobs for one client run.
|
|
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.
|
|
tr := http.DefaultTransport.(*http.Transport).Clone()
|
|
// 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
|
|
// out of fds) blocks forever waiting for headers.
|
|
tr.ResponseHeaderTimeout = opts.headerTimeout
|
|
// 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
|
|
// and watch the goroutine count drop after the timeout).
|
|
client := &http.Client{Transport: tr}
|
|
|
|
// Counters are touched from many goroutines when -c > 1 → atomics.
|
|
var reused, fresh, done, failed atomic.Int64
|
|
trace := &httptrace.ClientTrace{
|
|
GotConn: func(info httptrace.GotConnInfo) {
|
|
if info.Reused {
|
|
reused.Add(1)
|
|
} else {
|
|
fresh.Add(1)
|
|
}
|
|
},
|
|
}
|
|
|
|
// stop is set on the first failure so we launch no further requests.
|
|
// 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)
|
|
|
|
var firstErr sync.Once
|
|
doOne := func(i int) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
failed.Add(1)
|
|
// This is where `leak` eventually dies. Which error you get tells
|
|
// you which side ran out of fds:
|
|
// dial tcp ...: socket: too many open files → THIS process (client)
|
|
// timeout awaiting response headers → the server can't accept (it's out of fds)
|
|
firstErr.Do(func() {
|
|
fmt.Printf(" request %d failed: %v\n", i, err)
|
|
var ne net.Error
|
|
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')")
|
|
}
|
|
stop.Store(true)
|
|
})
|
|
return
|
|
}
|
|
done.Add(1)
|
|
|
|
switch m {
|
|
case modeDrain:
|
|
io.Copy(io.Discard, resp.Body)
|
|
resp.Body.Close()
|
|
case modeClose:
|
|
resp.Body.Close()
|
|
case modeLeak:
|
|
// nothing — the body, the conn, its fd and its 2 goroutines are now orphaned
|
|
}
|
|
}
|
|
|
|
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)
|
|
// Give the transport a moment to settle bookkeeping (put conn back to idle etc.).
|
|
time.Sleep(50 * time.Millisecond)
|
|
goroutinesAfter := runtime.NumGoroutine()
|
|
|
|
fmt.Printf(" requests : %d/%d ok, %d failed, in %v (concurrency %d)\n",
|
|
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",
|
|
goroutinesBefore, goroutinesAfter, goroutinesAfter-goroutinesBefore)
|
|
|
|
// Closing idle conns cleans up whatever the transport still *knows* about.
|
|
// Leaked bodies are not idle, so they survive this — that's the point.
|
|
tr.CloseIdleConnections()
|
|
time.Sleep(50 * time.Millisecond)
|
|
fmt.Printf(" after CloseIdleConnections: goroutines=%d\n", runtime.NumGoroutine())
|
|
}
|
|
|
|
func main() {
|
|
role := flag.String("role", "both", "both | server | client")
|
|
addr := flag.String("addr", "127.0.0.1:8099", "server: address to listen on")
|
|
url := flag.String("url", "http://127.0.0.1:8099", "client: server URL to hit")
|
|
modeFlag := flag.String("mode", "all", "client: drain | close | leak | all")
|
|
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)")
|
|
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()
|
|
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}
|
|
if *modeFlag != "all" {
|
|
modes = []mode{mode(*modeFlag)}
|
|
}
|
|
|
|
switch *role {
|
|
case "server":
|
|
serveForever(*addr)
|
|
|
|
case "client":
|
|
for _, m := range modes {
|
|
fmt.Printf("== mode=%s (client only, target %s) ==\n", m, *url)
|
|
runClient(m, *url, opts)
|
|
fmt.Println()
|
|
}
|
|
if len(modes) == 1 && modes[0] == modeLeak {
|
|
fmt.Fprintln(os.Stderr, "tip: rerun with `ulimit -n 64` and -n 200 to watch the client hit 'too many open files'")
|
|
}
|
|
|
|
case "both":
|
|
for _, m := range modes {
|
|
srv, err := newCountingServer("127.0.0.1:0")
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "listen:", err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Printf("== mode=%s ==\n", m)
|
|
runClient(m, srv.URL(), opts)
|
|
time.Sleep(50 * time.Millisecond)
|
|
srv.report()
|
|
fmt.Println()
|
|
srv.close()
|
|
}
|
|
if len(modes) == 1 && modes[0] == modeLeak {
|
|
fmt.Fprintln(os.Stderr, "tip: in one process both sides share the fd limit and the SERVER usually hits it first (accept4 error) while the client hangs. Use -role server / -role client in two terminals to see each side fail alone.")
|
|
}
|
|
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "unknown -role %q\n", *role)
|
|
os.Exit(2)
|
|
}
|
|
}
|