// Package httpx holds the one HTTP-transport default this repo may not lose. // // Every client here pins TLS — PBS and PVE by leaf-cert SHA-256, the hub by an optional CA file — // so none of them can use http.DefaultTransport and each hand-rolls its own. Hand-rolling silently // discards DefaultTransport's settings, and one of them is load-bearing: // // Transport: &http.Transport{TLSClientConfig: tlsCfg} // IdleConnTimeout == 0 == NO timeout // // A zero IdleConnTimeout means idle keep-alive connections are retained FOREVER, not "use a sane // default". Combined with a client that is rebuilt on a schedule and dropped (pbsTargetsFromPVE // builds a fresh pbs.Client per cycle), every cycle strands one connection that nothing will ever // close: the abandoned Transport becomes unreachable but its persistConn read-loop goroutine keeps // the socket alive, and an unreachable Transport does not close its connections. // // Measured cost, live: 388 established connections accumulated on ep0's PBS proxy between // 2026-08-18 09:51:22Z and 2026-08-20 08:02:13Z — 194 from each of the two boxes, held open on BOTH // sides, one per agent poll cycle, on a proxy whose descriptor ceiling is 65536. See R-344 and // felhom.eu/documentation/audits/SPIKE-ep0-established-connections-2026-08-20.md. package httpx import ( "crypto/tls" "net/http" "time" ) // DefaultIdleConnTimeout is how long an idle keep-alive connection is retained before it is closed. // // It is 90s because that is http.DefaultTransport's own value: the fix for R-344 restores a // standard-library default rather than inventing a number, so there is nothing here to tune and // nothing to justify. It is comfortably shorter than every cadence that drives these clients (the // 15-minute live-snapshot collect and the 6-hour verify loop), so a connection abandoned by one // cycle is closed long before the next. const DefaultIdleConnTimeout = 90 * time.Second // NewTransport builds a FRESH *http.Transport pinned to tlsCfg, with the idle-connection timeout // applied. // // Fresh, never shared: each caller pins a different endpoint, and a shared transport would pool // connections across differently pinned servers. Reusing http.DefaultTransport for the same reason // is not an option — it would drop the pin entirely. // // idleConnTimeout <= 0 means USE THE DEFAULT. It deliberately does not mean "no timeout": no-timeout // is the bug this package exists to prevent, and an unset field must never be able to reintroduce // it. Callers pass their configured value straight through; only tests pass a short one. // // Only IdleConnTimeout is set. The other DefaultTransport settings this transport also lacks // (MaxIdleConns, TLSHandshakeTimeout, ExpectContinueTimeout) are deliberately left alone: none of // them accumulates anything, every client bounds its whole request with http.Client.Timeout, and // widening the change would have made the R-344 measurement unattributable. func NewTransport(tlsCfg *tls.Config, idleConnTimeout time.Duration) *http.Transport { if idleConnTimeout <= 0 { idleConnTimeout = DefaultIdleConnTimeout } return &http.Transport{ TLSClientConfig: tlsCfg, IdleConnTimeout: idleConnTimeout, } }