Skip to content

daemon: fix transport throughput collapse (loss amplification + recovery crawl) - #446

Draft
TeoSlayer wants to merge 4 commits into
mainfrom
fix/transport-throughput
Draft

daemon: fix transport throughput collapse (loss amplification + recovery crawl)#446
TeoSlayer wants to merge 4 commits into
mainfrom
fix/transport-throughput

Conversation

@TeoSlayer

Copy link
Copy Markdown
Collaborator

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_list sampling during transfers traced the collapse to four compounding defects.

Fixes

1. Loss amplification — MaxOOOBuf 128 → 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 (DeliverInOrder bound), turning 1 loss into ~130. Now 512 = RecvBufSize, covering the whole 2 MB advertised receive window. New static test pins MaxOOOBuf×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_sent frozen, unacked −1 per ~3 s). Partial ACKs in both recovery modes now call the new retransmitLost: up to a cwnd of un-SACKed segments below the SACK frontier (known-lost, RFC 6675 IsLost rationale) per ACK event, capped at maxRetxBurst=32. A 180-segment hole drains in ~15 RTTs (~3 s at 214 ms) instead of up to tens of minutes — pinned by TestTimeoutRecoveryHoleDrainsInBoundedRounds.

3. Slow-start overshoot — initial SSThresh 512 KB → 128 KB (ports.go)
Initial ssthresh of MaxCongWin/2 let 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)
handleEchoConn called SendData raw and treated ErrSendBufFull — routine transient backpressure during any bulk transfer — as fatal, killing the echo loop while the transport kept ACKing inbound. This is why pilot-mom echoed 0 bytes in every bench. Echo now writes through connAdapter.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/daemon suite green (incl. all RFC 5681/6582/3465 congestion tests) + go vet
  • In-repo CC simulation (TestThroughputReport): 20–35% faster at 0–1% loss, no regression at 5%
  • 7 new regression tests pin each fix
  • tests/ has 3 pre-existing failures on pristine origin/main in this env (registry/hostname, unrelated)
  • Real-fleet numbers require deployed daemons on both ends — verify post-deploy with bench/pilot-bench.py compare (bench: pilot-bench overlay throughput benchmark harness #445) against the recorded baseline

🤖 Generated with Claude Code

…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>
Comment thread pkg/daemon/ports.go
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>
@TeoSlayer

Copy link
Copy Markdown
Collaborator Author

Local A/B verification — built daemons, emulated path

Built cmd/daemon from this branch and from origin/main, ran isolated pairs (own registry+beacon, hermetic $HOME, throwaway identities) with a lossy/delayed UDP proxy in the data path (50 ms one-way delay ≈ real fleet RTT), 1 MB echo bench, 3 trials per config, 90 s deadline:

Path loss main (old) this branch (new)
0% 0.08–1.7 MB/s, one 12 s send 1.0 MB/s ×2, one slow cold-start — all 100%
2% 1/3 dead at 90 s (72% echoed), others 0.6 MB/s 0.25–1.04 MB/s — all 100%
5% 2/3 dead at 90 s (31% / 84% echoed) 0.23–0.67 MB/s — all 100%

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 attempts counter that the RTO tick uses as the give-up RST budget, so a spurious RTO + partial ACKs could RST a live connection in ~1.6 s. The budget now counts only RTO-timer retransmissions (rtoAttempts), reset on any ACK progress; two new tests pin it.

Full daemon suite green after both commits. Rig scripts: ~/.claude/jobs/ef0e14ef/tmp/{run_ab.sh,udpproxy.py} + untracked cmd/testnet in the worktree.

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>
@TeoSlayer

Copy link
Copy Markdown
Collaborator Author

Round 2: window scaling (f05ecc7) — sustained throughput 4.07 MB/s

1 MB/s wasn't great — it was the window math: 128 KB slow-start cap ÷ 100 ms rig RTT ≈ 1.3 MB/s, exactly what we measured. With recovery now robust, the windows scale safely:

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):

binary goodput completion
main 0.005 MB/s gave up at 1.6%
this branch 4.07 MB/s round-trip 100% ×2 (steady-state trials)

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):

  1. First transfer on a fresh tunnel rides the relay until the direct path flips (direct path silent, flipping to relay) and the relay leg is slow — relay-side pacing/buffers in rendezvous/beacon are the next server-side bottleneck.
  2. Fleet RTT (~214 ms) is the other half of the equation — direct-path/NAT-punch success rate improvements multiply throughput for free.
  3. Static SSThresh should eventually be replaced by pacing + HyStart-style slow-start exit, and Reno CA by CUBIC-style growth, to find per-path capacity instead of constants.
  4. 4 KB virtual MSS = high per-segment overhead; batching segments per datagram would cut crypto+syscall cost.

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>
@TeoSlayer

Copy link
Copy Markdown
Collaborator Author

Round 3: HyStart-style adaptive slow-start exit (b4ee407) — 11.9 MB/s

Empirically confirmed the round-2 ceiling: 8 MB transfers ran at exactly InitialSSThresh ÷ RTT — the window sat pinned at the static 512 KB exit because Reno CA only adds 1 MSS/RTT beyond it. The constant was the ceiling.

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):

stage 8 MB 16 MB
main (stock) dead at 1.6%
+ recovery fixes ~1 MB/s ceiling
+ 4× windows 4.07 MB/s
+ HyStart exit 8.4 MB/s 11.9 MB/s (16 MB echoed in 1.35 s)

~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; TestHyStartExitsSlowStartOnRTTRise pins the behavior.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants