daemon: fix transport throughput collapse (loss amplification + recovery crawl) - #446
daemon: fix transport throughput collapse (loss amplification + recovery crawl)#446TeoSlayer wants to merge 4 commits into
Conversation
…ery crawl) Fleet benchmarks showed ~0.2 MB/s best-case round-trip goodput with multi-minute stalls on 1 MB echo transfers (fleet median 0.068 MB/s, PR #445 baseline). Live conn-stats sampling traced it to four compounding defects, fixed here: 1. Loss amplification: MaxOOOBuf (128 segs) < MaxCongWin (256 segs), so one lost segment with a full window in flight made the receiver silently drop every later in-window segment. MaxOOOBuf is now 512 (= RecvBufSize, covers the whole 2 MB receive window). 2. Recovery crawl: timeout-based recovery had no partial-ACK retransmit path (RFC 6582 step 6a only ran in fast recovery), leaving multi- segment losses to the one-per-RTO timer with RTO backed off to 10 s (~1.3 KB/s observed). Partial ACKs in timeout recovery now retransmit ACK-clocked out of slow start, and both recovery modes use the new retransmitLost: up to a cwnd of un-SACKed segments below the SACK frontier per ACK event, capped at maxRetxBurst=32. A 180-segment hole now drains in ~15 RTTs (~3 s) instead of up to 30 min. 3. Slow-start overshoot: initial SSThresh of MaxCongWin/2 (512 KB) let slow start double straight into a path-collapsing ~550 KB burst. InitialSSThresh is now 128 KB; congestion avoidance probes beyond it. 4. Echo service died silently on backpressure: handleEchoConn treated ErrSendBufFull (routine when data arrives faster than cwnd drains) as fatal, so bulk echoes returned 0 bytes while inbound kept ACKing (observed against pilot-mom). Echo now writes through connAdapter, which blocks-and-retries per its v1.9.1 semantics. All changes are endpoint-local heuristics — no wire format change, safe in a mixed-version fleet. pkg/daemon suite green incl. all RFC congestion tests; in-repo CC simulation improves 20-35% at 0-1% loss; new zz tests pin each fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| haveFrontier := false | ||
| for _, e := range c.Unacked { | ||
| if e.sacked { | ||
| end := e.seq + uint32(len(e.data)) |
Found by A/B-testing built daemons through a delayed/lossy UDP proxy rig: with ACK-clocked recovery (previous commit), fast-retransmit and retransmitLost bursts inflated retxEntry.attempts — the same counter the RTO tick uses to declare a peer dead — so a spurious RTO plus a handful of partial ACKs could RST a healthy connection within ~8×RTOMin (1.6 s), truncating transfers that were actively progressing. Split the budget: new retxEntry.rtoAttempts counts only RTO-timer retransmissions and alone feeds the MaxRetxAttempts RST decision; any new cumulative ACK resets it (a peer making ACK progress is alive by definition). attempts keeps its Karn's-algorithm and retransmit-guard roles unchanged. Rig verification (1 MB echo, 50 ms one-way delay, 3 trials each): before this commit the new transport completed 4/9 trials at 100%; after, 9/9 at 100% with 0.2-1.05 MB/s goodput. The unfixed baseline binary on the same rig: 3/9 trials dead at the 90 s deadline (31-84% echoed, ~0.005 MB/s) — the fleet pathology reproduced and eliminated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Local A/B verification — built daemons, emulated pathBuilt
Old binary: 3/9 trials hit the deadline incomplete at ~0.005 MB/s — the exact fleet pathology (stalled echo, partial completion) reproduced locally. New binary: 9/9 trials complete, goodput up to 1.05 MB/s through a single-threaded Python proxy. The rig also caught a real regression in the first commit, fixed in 90a8ed5: ACK-clocked retransmissions were inflating the Full daemon suite green after both commits. Rig scripts: |
Throughput = window / RTT. At the overlay's ~200 ms relay RTT the 1 MB
window cap limited any connection to ~5 MB/s theoretical, and the 128 KB
slow-start threshold (previous commit's conservative choice) capped short
transfers at ~1 MB/s. With the receiver now able to buffer a full window
out of order and recovery ACK-clocked, large windows are safe: overshoot
heals in a few RTTs instead of collapsing the transfer.
- MaxCongWin 1 MB -> 4 MB (~18 MB/s ceiling at 214 ms RTT)
- RecvBufSize/MaxRecvWin 512 segs/2 MB -> 1024 segs/4 MB
- MaxOOOBuf 512 -> 1024 (still covers the full window; memory only
consumed under loss, bounded 4 MB/conn)
- InitialSSThresh 128 KB -> MaxCongWin/8 (512 KB): 1 MB transfers ramp
entirely in slow start; CA probes beyond
- SendBufLen 256 -> 1024 segs so the send queue covers a full cwnd
Rig (50 ms one-way proxy, zero loss), 8 MB echo:
old binary: 0.005 MB/s, gave up at 1.6% echoed
new binary: 4.07 MB/s round-trip, 100%, x3 trials (first trial on a
fresh tunnel rides the relay until the direct path flips
— relay-leg throughput is a separate, server-side issue)
pkg/daemon suite green; window advertisement is uint16 in segments
(max 256 MB) so no wire change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Round 2: window scaling (f05ecc7) — sustained throughput 4.07 MB/s
MaxCongWin 1→4 MB, recv window + OOO buffer to match (4 MB), InitialSSThresh 512 KB, send queue covers a full cwnd. 8 MB echo through the 50 ms proxy (zero loss):
That's window-limited (8 MB in ~1.96 s both directions) — ~4× the previous branch ceiling, ~800× main on this transfer size. 1 MB transfers stay ~1 MB/s because they live entirely in connection setup + slow start. Known residual + roadmap (not this PR):
pkg/daemon suite green after the change. |
Empirical bottleneck after the 4x window scaling: 8 MB rig transfers ran at exactly InitialSSThresh/RTT — the window sat pinned at the static 512 KB slow-start exit forever, because congestion avoidance probes at just 1 MSS per RTT (40 KB/s of growth at 100 ms). The static constant was the ceiling. Replace it as the PRIMARY exit with HyStart++-style detection (RFC 9406, simplified): per slow-start round (delimited by snd_nxt at round start), track the min clean RTT sample; when a round's min rises by eta = clamp(lastRoundMin/8, 4ms, 16ms) over the previous round's min with >= 8 samples, the bottleneck queue is filling — set SSThresh = CongWin and enter CA at the discovered capacity. Simplification vs the full RFC: no CSS interlude; on trigger we exit directly (slightly conservative, one fewer state machine). InitialSSThresh becomes a hard backstop only (MaxCongWin/2) for paths that show no RTT signal before tail-drop; real loss still halves SSThresh as usual, and the window-sized OOO buffer + ACK-clocked retransmission keep that overshoot cheap. Rig (50 ms one-way proxy, steady-state trials, round-trip goodput): 8 MB: 4.07 -> 8.4 MB/s 16 MB: 11.9 MB/s (16 MB echoed in 1.35 s) Progression across this branch: 0.2 -> 1.0 -> 4.1 -> 11.9 MB/s. pkg/daemon suite green; new zz test pins the RTT-rise exit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Round 3: HyStart-style adaptive slow-start exit (b4ee407) — 11.9 MB/sEmpirically confirmed the round-2 ceiling: 8 MB transfers ran at exactly Now slow start exits on measured RTT rise (RFC 9406 simplified: round-over-round min-RTT + eta with ≥8 samples) — the window rides to whatever the path actually supports, and the static threshold is just a backstop at MaxCongWin/2. Rig progression (steady-state round-trip goodput, 50 ms one-way proxy):
~60× the original 0.2 MB/s fleet best-case, ~2400× the collapse mode. Next ceilings in order: the 4 MB window itself (~20 MB/s echo at 100 ms RTT), then per-segment overhead (4 KB MSS × per-packet AES-GCM/syscall — loopback tops at ~50-60 MB/s), and on the real fleet the relay leg + RTT (still the dominant fleet factor — first trials on a fresh tunnel ride the relay at <0.5 MB/s until the direct path flips, reproduced every run). pkg/daemon suite green; |
Problem
Fleet benchmarks (baseline in #445) showed round-trip goodput of 0.068 MB/s fleet median / 0.206 MB/s best case on 1 MB echo transfers, with multi-minute stalls and transfers that never complete — on paths whose window/RTT ceiling is ~2.4 MB/s. Live
conn_listsampling during transfers traced the collapse to four compounding defects.Fixes
1. Loss amplification —
MaxOOOBuf128 → 512 (ports.go)The receiver's out-of-order buffer (128 segs) was half the max congestion window (256 segs). One lost segment with a full window in flight ⇒ every later in-window segment silently dropped (
DeliverInOrderbound), turning 1 loss into ~130. Now 512 =RecvBufSize, covering the whole 2 MB advertised receive window. New static test pinsMaxOOOBuf×MSS ≥ MaxCongWin.2. Recovery crawl — ACK-clocked retransmission in timeout recovery (
ports.go)Timeout-based recovery had no partial-ACK retransmit path (RFC 6582 step 6a only ran under
FastRecovery), so after a window collapse the only driver was the 1-segment-per-RTO timer with RTO backed off to 10 s — observed live draining ~180 lost segments at ~1.3 KB/s (segs_sentfrozen,unacked−1 per ~3 s). Partial ACKs in both recovery modes now call the newretransmitLost: up to a cwnd of un-SACKed segments below the SACK frontier (known-lost, RFC 6675 IsLost rationale) per ACK event, capped atmaxRetxBurst=32. A 180-segment hole drains in ~15 RTTs (~3 s at 214 ms) instead of up to tens of minutes — pinned byTestTimeoutRecoveryHoleDrainsInBoundedRounds.3. Slow-start overshoot — initial
SSThresh512 KB → 128 KB (ports.go)Initial ssthresh of
MaxCongWin/2let slow start double straight into a ~550 KB single-RTT burst (observed: cwnd 40 KB → 525 KB → mass loss → collapse to 8 KB). Slow start now hands over to congestion avoidance at 128 KB and probes beyond it at ~1 MSS/RTT.4. Echo service silently died on backpressure (
services.go)handleEchoConncalledSendDataraw and treatedErrSendBufFull— routine transient backpressure during any bulk transfer — as fatal, killing the echo loop while the transport kept ACKing inbound. This is whypilot-momechoed 0 bytes in every bench. Echo now writes throughconnAdapter.Write, which blocks-and-retries with the v1.9.1 semantics net.Conn callers already get, and still fails on real errors (conn closed, 30 s deadline).Compatibility
All changes are endpoint-local sender/receiver heuristics — no wire format change, safe to roll out incrementally across a mixed-version fleet. Benefits are per-endpoint: an upgraded sender recovers fast against an old receiver; an upgraded receiver stops amplifying loss for old senders; full gains need both ends.
Verification
pkg/daemonsuite green (incl. all RFC 5681/6582/3465 congestion tests) +go vetTestThroughputReport): 20–35% faster at 0–1% loss, no regression at 5%tests/has 3 pre-existing failures on pristineorigin/mainin this env (registry/hostname, unrelated)bench/pilot-bench.py compare(bench: pilot-bench overlay throughput benchmark harness #445) against the recorded baseline🤖 Generated with Claude Code