From 37065c6f0d40719aed6f417d4916d55e7faab58d Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Sun, 2 Aug 2026 15:53:26 +0300 Subject: [PATCH 1/4] daemon: fix transport throughput collapse (loss amplification + recovery crawl) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- pkg/daemon/ports.go | 115 +++++++-- pkg/daemon/services.go | 15 +- .../zz_transport_throughput_fixes_test.go | 238 ++++++++++++++++++ 3 files changed, 352 insertions(+), 16 deletions(-) create mode 100644 pkg/daemon/zz_transport_throughput_fixes_test.go diff --git a/pkg/daemon/ports.go b/pkg/daemon/ports.go index d62b046d..8df1e790 100644 --- a/pkg/daemon/ports.go +++ b/pkg/daemon/ports.go @@ -204,9 +204,24 @@ const ( MaxSegmentSize = 4096 // MTU for virtual segments RecvBufSize = 512 // receive buffer channel capacity (segments) MaxRecvWin = RecvBufSize * MaxSegmentSize // 2 MB max receive window - MaxOOOBuf = 128 // max out-of-order segments buffered per connection - AcceptQueueLen = 64 // listener accept channel capacity - SendBufLen = 256 // send buffer channel capacity (segments) + // MaxOOOBuf must be able to hold a full congestion window of segments + // (MaxCongWin / MaxSegmentSize = 256). If it is smaller, a single lost + // segment with more than MaxOOOBuf segments in flight makes the receiver + // silently drop every subsequent in-window segment (DeliverInOrder's + // buffer bound), amplifying one loss into a whole-window loss and + // collapsing throughput. 512 = RecvBufSize, so the OOO buffer can cover + // the entire advertised receive window (2 MB). + MaxOOOBuf = 512 + + // InitialSSThresh caps the slow-start phase at 128 KB. The previous + // value (MaxCongWin/2 = 512 KB) let slow start double straight into a + // half-megabyte burst within one RTT, overrunning relay/path queues and + // causing mass loss on every large transfer. Above this threshold the + // window grows by ~1 MSS per RTT (congestion avoidance), probing for + // extra bandwidth instead of doubling into it. + InitialSSThresh = 32 * MaxSegmentSize + AcceptQueueLen = 64 // listener accept channel capacity + SendBufLen = 256 // send buffer channel capacity (segments) // MaxNagleBuf caps the per-connection NagleBuf at 64 segments // (256 KB). v1.9.1 fix: SendData previously appended without bound, @@ -487,7 +502,7 @@ func (pm *PortManager) NewConnection(localPort uint16, remoteAddr protocol.Addr, SendBuf: make(chan []byte, SendBufLen), RecvBuf: make(chan []byte, RecvBufSize), CongWin: InitialCongWin, - SSThresh: MaxCongWin / 2, + SSThresh: InitialSSThresh, WindowCh: make(chan struct{}, 1), NagleCh: make(chan struct{}, 1), PeerRecvWin: -1, // sentinel: no window advertisement received yet @@ -1060,8 +1075,26 @@ func (c *Connection) ProcessAck(ack uint32, pureACK bool) { // step 6a: retransmit the first unacknowledged segment immediately. // Without this, the next lost segment is not retransmitted until // the 100ms RTO tick — up to one full RTO of unnecessary delay. - c.fastRetransmit(recvAck) + // Extended beyond the single-segment step 6a: also resend further + // un-SACKed segments below the SACK frontier (known-lost), up to + // a cwnd of data, so a multi-segment loss heals in a few RTTs + // instead of one segment per ACK (see retransmitLost). + c.retransmitLost(recvAck, c.CongWin/MaxSegmentSize) } + } else if wasInRecovery && c.InRecovery { + // Partial ACK during TIMEOUT-based recovery (FastRecovery=false). + // Historically nothing was retransmitted here: with the window + // collapsed to 1 SMSS the sender cannot send new data to elicit dup + // ACKs, so the only recovery driver left was the RTO timer at one + // segment per (exponentially backed-off, up to 10 s) RTO — a + // multi-segment loss drained at ~1 segment per several seconds. + // Real TCP recovers from a timeout by retransmitting ACK-clocked out + // of slow start (RFC 5681 §3.1); do the same: each partial ACK + // proves the path is moving and pays for the next window of + // retransmissions. AIMD growth for this ACK (below) is unaffected — + // timeout-recovery partial ACKs grow cwnd via slow start, so the + // retransmission budget doubles per RTT until the hole is filled. + c.retransmitLost(recvAck, c.CongWin/MaxSegmentSize) } // Congestion window growth (Appropriate Byte Counting, RFC 3465). @@ -1136,22 +1169,73 @@ func (c *Connection) ProcessAck(ack uint32, pureACK bool) { // The caller gates all congestion-state adjustments on the return value so // that a no-op does not produce phantom fast-recovery state. func (c *Connection) fastRetransmit(recvAck uint32) bool { - if len(c.Unacked) == 0 || c.RetxSend == nil { - return false + return c.retransmitLost(recvAck, 1) > 0 +} + +// maxRetxBurst caps how many segments one ACK event may retransmit via +// retransmitLost. The congestion window is the primary bound; this constant +// keeps a single partial ACK arriving with a large recovery window from +// dumping hundreds of retransmissions onto an already-lossy path in one +// burst. +const maxRetxBurst = 32 + +// retransmitLost retransmits up to maxSegs lost segments and returns how +// many were sent. The first un-SACKed segment is always eligible (it is the +// cumulative-ACK gap head — the dup ACKs or partial ACK that got us here +// prove it is missing). Beyond the head, only un-SACKed segments BELOW the +// highest SACKed sequence are retransmitted: the peer has explicitly +// acknowledged data after them, so they are lost with high confidence +// (RFC 6675 IsLost rationale). Segments above the SACK frontier may simply +// still be in flight and are left to the RTO timer. +// +// Mirrors retransmitUnacked's retry-budget guard for the head segment: +// when the head is out of attempts, nothing is sent (returns 0) and +// retransmitUnacked fires the RST on its next tick. Later entries at budget +// are skipped rather than aborting the whole pass. +// +// Must be called with RetxMu held. +func (c *Connection) retransmitLost(recvAck uint32, maxSegs int) int { + if len(c.Unacked) == 0 || c.RetxSend == nil || maxSegs <= 0 { + return 0 + } + if maxSegs > maxRetxBurst { + maxSegs = maxRetxBurst + } + + // SACK frontier: end of the highest SACKed segment, if any. + var frontier uint32 + haveFrontier := false + for _, e := range c.Unacked { + if e.sacked { + end := e.seq + uint32(len(e.data)) + if !haveFrontier || seqAfter(end, frontier) { + frontier = end + haveFrontier = true + } + } } - // Find the first unacked segment that hasn't been SACKed + now := time.Now() + sent := 0 for _, e := range c.Unacked { if e.sacked { continue } - // Mirror retransmitUnacked's guard: don't send if the retry budget is - // exhausted. retransmitUnacked will fire RST on the next RTO tick. if e.attempts >= MaxRetxAttempts { - return false + if sent == 0 { + // Head segment out of retries: preserve the historical + // fastRetransmit contract — no send, no recovery entry; + // retransmitUnacked will RST on the next RTO tick. + return 0 + } + continue + } + // Beyond the head, only retransmit below the SACK frontier. + if sent > 0 && (!haveFrontier || !seqAfter(frontier, e.seq)) { + break } e.attempts++ - e.sentAt = time.Now() + e.sentAt = now // FIN entries must be resent as FlagFIN with no payload; data entries // use FlagACK with their payload (mirrors retransmitUnacked's isFIN check). flags := protocol.FlagACK @@ -1175,9 +1259,12 @@ func (c *Connection) fastRetransmit(recvAck uint32) bool { Payload: payload, } c.RetxSend(pkt) - return true + sent++ + if sent >= maxSegs { + break + } } - return false + return sent } func (c *Connection) updateRTT(rtt time.Duration) { diff --git a/pkg/daemon/services.go b/pkg/daemon/services.go index c6942d52..d164cd3e 100644 --- a/pkg/daemon/services.go +++ b/pkg/daemon/services.go @@ -238,6 +238,17 @@ func (d *Daemon) handleEchoConn(conn *Connection) { return } } + // Write through connAdapter, not SendData directly: SendData returns + // ErrSendBufFull the moment the NagleBuf cap is hit, which is routine + // transient backpressure whenever data arrives faster than the + // congestion window drains (any bulk transfer). Treating it as fatal + // silently killed the echo loop mid-transfer while the connection kept + // ACKing inbound data — the peer saw its payload accepted and nothing + // echoed back. connAdapter.Write blocks-and-retries with backoff (the + // v1.9.1 semantics net.Conn callers already rely on) and still fails on + // real errors: connection no longer established, or a peer stuck past + // connAdapterWriteDeadline. + w := &connAdapter{conn: conn, daemon: d} for { data, ok := <-conn.RecvBuf // Capture right after the channel read — before any branching — @@ -254,12 +265,12 @@ func (d *Daemon) handleEchoConn(conn *Connection) { copy(resp[0:4], data[0:4]) copy(resp[4:12], data[4:12]) binary.BigEndian.PutUint64(resp[12:20], uint64(recvNs)) - if err := d.SendData(conn, resp); err != nil { + if _, err := w.Write(resp); err != nil { return } continue } - if err := d.SendData(conn, data); err != nil { + if _, err := w.Write(data); err != nil { return } } diff --git a/pkg/daemon/zz_transport_throughput_fixes_test.go b/pkg/daemon/zz_transport_throughput_fixes_test.go new file mode 100644 index 00000000..26173ae8 --- /dev/null +++ b/pkg/daemon/zz_transport_throughput_fixes_test.go @@ -0,0 +1,238 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package daemon + +import ( + "testing" + "time" + + "github.com/pilot-protocol/common/protocol" +) + +// These tests pin the transport-throughput fixes from the 2026-08 overlay +// benchmark investigation (fleet round-trip goodput was ~0.2 MB/s best case +// with multi-minute stalls): +// +// 1. Timeout-based recovery must retransmit ACK-clocked — historically only +// fast recovery got partial-ACK retransmits (RFC 6582 §3 step 6a), so a +// window collapse after an RTO drained multi-segment losses at ONE +// segment per backed-off RTO (~1.3 KB/s observed live). +// 2. retransmitLost may resend a burst of known-lost (below-SACK-frontier) +// segments per ACK event, bounded by cwnd and maxRetxBurst. +// 3. The receiver's out-of-order buffer must cover a full congestion window, +// otherwise one loss with >MaxOOOBuf segments in flight silently drops +// every later in-window segment (loss amplification). + +// throughputFixConn builds a Connection in timeout-style recovery with a +// run of consecutive un-SACKed entries (the "hole") followed by SACKed +// entries (the frontier proof), capturing retransmitted seqs. +func throughputFixConn(t *testing.T, holeSegs, sackedSegs int) (*Connection, *[]uint32) { + t.Helper() + c := newAckTestConn(t) + var sent []uint32 + c.RetxSend = func(p *protocol.Packet) { sent = append(sent, p.Seq) } + + seq := uint32(1000) + for i := 0; i < holeSegs; i++ { + c.Unacked = append(c.Unacked, &retxEntry{ + seq: seq, data: make([]byte, MaxSegmentSize), + attempts: 1, sentAt: time.Now(), origSentAt: time.Now(), + }) + seq += MaxSegmentSize + } + for i := 0; i < sackedSegs; i++ { + c.Unacked = append(c.Unacked, &retxEntry{ + seq: seq, data: make([]byte, MaxSegmentSize), + attempts: 1, sentAt: time.Now(), origSentAt: time.Now(), sacked: true, + }) + seq += MaxSegmentSize + } + c.LastAck = 1000 + c.RecoveryPoint = seq // everything sent so far is inside the loss window + return c, &sent +} + +// TestTimeoutRecoveryPartialAckRetransmitsAckClocked: a partial ACK during +// timeout recovery (InRecovery=true, FastRecovery=false) must retransmit +// lost segments instead of leaving them to the backed-off RTO timer. +func TestTimeoutRecoveryPartialAckRetransmitsAckClocked(t *testing.T) { + t.Parallel() + c, sent := throughputFixConn(t, 8, 4) + c.InRecovery = true + c.FastRecovery = false // timeout-entered recovery + c.CongWin = 4 * MaxSegmentSize + c.SSThresh = InitialSSThresh + + // Partial ACK: first hole segment arrives (e.g. via an RTO retransmit), + // cumulative ACK advances one segment but stays below RecoveryPoint. + c.ProcessAck(1000+MaxSegmentSize, true) + + if len(*sent) == 0 { + t.Fatalf("partial ACK in timeout recovery retransmitted nothing — " + + "recovery is left to the backed-off RTO timer at one segment per " + + "up-to-10s RTO (the throughput-collapse crawl)") + } + // The new hole head must be among the retransmissions. + if (*sent)[0] != 1000+uint32(MaxSegmentSize) { + t.Errorf("first retransmit seq = %d, want hole head %d", + (*sent)[0], 1000+MaxSegmentSize) + } + // Bounded by cwnd (4 segments): the ACK freed one segment of budget and + // cwnd grew via slow start, but the burst must stay in the same order of + // magnitude — never the whole 7-segment hole beyond the window. + if len(*sent) > c.CongWin/MaxSegmentSize+1 { + t.Errorf("retransmitted %d segments, want <= cwnd budget %d", + len(*sent), c.CongWin/MaxSegmentSize+1) + } +} + +// TestRetransmitLostRespectsSackFrontier: only the hole head plus un-SACKed +// segments BELOW the highest SACKed sequence are eligible; segments above +// the frontier may still be in flight and belong to the RTO timer. +func TestRetransmitLostRespectsSackFrontier(t *testing.T) { + t.Parallel() + c := newAckTestConn(t) + var sent []uint32 + c.RetxSend = func(p *protocol.Packet) { sent = append(sent, p.Seq) } + + const mss = MaxSegmentSize + mk := func(seq uint32, sacked bool) *retxEntry { + return &retxEntry{seq: seq, data: make([]byte, mss), + attempts: 1, sentAt: time.Now(), origSentAt: time.Now(), sacked: sacked} + } + // hole(1000), hole(1000+mss), SACKed(1000+2m) — frontier = 1000+3m — + // then un-SACKed above the frontier (still plausibly in flight). + c.Unacked = []*retxEntry{ + mk(1000, false), + mk(1000+1*mss, false), + mk(1000+2*mss, true), + mk(1000+3*mss, false), + mk(1000+4*mss, false), + } + + c.RetxMu.Lock() + n := c.retransmitLost(0, 100) + c.RetxMu.Unlock() + + if n != 2 || len(sent) != 2 { + t.Fatalf("retransmitLost sent %d (%v), want exactly the 2 below-frontier holes", n, sent) + } + if sent[0] != 1000 || sent[1] != 1000+1*mss { + t.Errorf("retransmitted %v, want [1000 %d]", sent, 1000+1*mss) + } +} + +// TestRetransmitLostNoFrontierSendsHeadOnly: with no SACK information there +// is no loss evidence beyond the cumulative-ACK gap head — exactly one +// segment goes out (the historical fastRetransmit contract). +func TestRetransmitLostNoFrontierSendsHeadOnly(t *testing.T) { + t.Parallel() + c, sent := throughputFixConn(t, 6, 0) // all un-SACKed, no frontier + + c.RetxMu.Lock() + n := c.retransmitLost(0, 100) + c.RetxMu.Unlock() + + if n != 1 || len(*sent) != 1 || (*sent)[0] != 1000 { + t.Fatalf("retransmitLost with no SACK frontier sent %d (%v), want just head seq 1000", n, *sent) + } +} + +// TestRetransmitLostBurstCap: one ACK event may never dump more than +// maxRetxBurst segments onto an already-lossy path, regardless of cwnd. +func TestRetransmitLostBurstCap(t *testing.T) { + t.Parallel() + c, sent := throughputFixConn(t, 100, 4) // 100-segment hole below the frontier + + c.RetxMu.Lock() + n := c.retransmitLost(0, 1000) + c.RetxMu.Unlock() + + if n != maxRetxBurst || len(*sent) != maxRetxBurst { + t.Fatalf("retransmitLost sent %d segments, want burst cap %d", n, maxRetxBurst) + } +} + +// TestTimeoutRecoveryHoleDrainsInBoundedRounds reproduces the observed +// worst case — a ~180-segment contiguous hole (a full pre-collapse window +// lost at once) with the tail SACKed — and drives ACK-clocked recovery to +// completion. Each round models one RTT: the retransmissions from the +// previous partial ACK arrive, the receiver's cumulative ACK advances over +// them, and the next partial ACK triggers the next burst. +// +// Before the fix, round one retransmits nothing (timeout recovery had no +// partial-ACK retransmit path), the loop makes no progress, and the hole +// drains at one segment per backed-off RTO — 180 segments × up to 10 s. +// After the fix the budget grows with slow start and is capped by +// maxRetxBurst, so the hole must drain within ~hole/maxRetxBurst + log +// rounds ≈ 10 RTTs. +func TestTimeoutRecoveryHoleDrainsInBoundedRounds(t *testing.T) { + t.Parallel() + const holeSegs = 180 + c, sent := throughputFixConn(t, holeSegs, 8) + c.InRecovery = true + c.FastRecovery = false + c.CongWin = MaxSegmentSize // post-RTO collapse (RFC 5681 §3.1 LW) + c.SSThresh = InitialSSThresh + + // The RTO timer delivers the head segment; the first partial ACK follows. + ack := uint32(1000) + MaxSegmentSize + rounds := 0 + for { + rounds++ + *sent = (*sent)[:0] + c.ProcessAck(ack, true) + if !c.InRecovery { + break + } + if len(*sent) == 0 { + t.Fatalf("round %d: partial ACK at seq %d retransmitted nothing — "+ + "recovery stalled with %d unacked entries (pre-fix crawl)", + rounds, ack, len(c.Unacked)) + } + // All retransmitted segments are consecutive from the hole head, so + // the next cumulative ACK advances over every one of them. When the + // hole is fully covered, the receiver holds the SACKed tail too and + // the cumulative ACK jumps straight past it (to RecoveryPoint). + ack += uint32(len(*sent)) * MaxSegmentSize + if seqAfterOrEqual(ack, 1000+uint32(holeSegs)*MaxSegmentSize) { + ack = c.RecoveryPoint + } + if rounds > 40 { + t.Fatalf("hole not drained after %d rounds (ack=%d, unacked=%d)", + rounds, ack, len(c.Unacked)) + } + } + // Slow-start budget growth with one ACK per RTT drains 180 segments in + // ~15 rounds (1+3+5+... capped at maxRetxBurst). Real transfers see + // multiple ACKs per RTT, so this is the conservative upper bound. + if rounds > 20 { + t.Errorf("180-segment hole took %d ACK rounds (RTTs) to drain, want <= 20", rounds) + } +} + +// TestOOOBufferCoversFullCongestionWindow pins the structural relation that +// caused the loss amplification: the receiver must be able to buffer at +// least a full congestion window of out-of-order segments, or one lost +// segment with a full window in flight silently drops everything behind it. +func TestOOOBufferCoversFullCongestionWindow(t *testing.T) { + t.Parallel() + if MaxOOOBuf*MaxSegmentSize < MaxCongWin { + t.Fatalf("MaxOOOBuf (%d segs = %d bytes) < MaxCongWin (%d bytes): "+ + "a single loss with a full window in flight overflows the OOO "+ + "buffer and every later in-window segment is silently dropped", + MaxOOOBuf, MaxOOOBuf*MaxSegmentSize, MaxCongWin) + } +} + +// TestInitialSSThreshBoundsSlowStartBurst pins the slow-start exit point: +// slow start doubling must hand over to congestion avoidance well before +// the burst reaches the whole-path collapse regime observed at ~550 KB. +func TestInitialSSThreshBoundsSlowStartBurst(t *testing.T) { + t.Parallel() + if InitialSSThresh > MaxCongWin/4 { + t.Fatalf("InitialSSThresh (%d) > MaxCongWin/4 (%d): slow start may "+ + "double straight into a path-collapsing burst before congestion "+ + "avoidance takes over", InitialSSThresh, MaxCongWin/4) + } +} From 90a8ed515bf858c7a4dc80a4b7c62a055fa2dcb1 Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Sun, 2 Aug 2026 16:48:35 +0300 Subject: [PATCH 2/4] daemon: count only RTO retransmissions toward the give-up RST budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- pkg/daemon/daemon.go | 10 ++- pkg/daemon/ports.go | 18 +++++- pkg/daemon/zz_daemon_retx_test.go | 9 +-- pkg/daemon/zz_retx_test.go | 9 +-- .../zz_transport_throughput_fixes_test.go | 64 +++++++++++++++++++ 5 files changed, 98 insertions(+), 12 deletions(-) diff --git a/pkg/daemon/daemon.go b/pkg/daemon/daemon.go index bbc1b87b..fba18879 100644 --- a/pkg/daemon/daemon.go +++ b/pkg/daemon/daemon.go @@ -4324,7 +4324,14 @@ func (d *Daemon) retransmitUnacked(conn *Connection) { continue } if now.Sub(e.sentAt) > conn.RTO { - if e.attempts >= MaxRetxAttempts { + // Give-up check on rtoAttempts, NOT attempts: ACK-clocked + // retransmissions (fast retransmit / retransmitLost bursts) also + // increment attempts, but each one was triggered by an arriving + // ACK — the path is demonstrably alive. Counting them here made + // a spurious RTO plus a handful of partial ACKs RST a healthy + // connection within ~8×RTOMin (1.6 s). Only consecutive RTO + // firings with no ACK progress indicate a dead peer. + if e.rtoAttempts >= MaxRetxAttempts { // Too many retransmissions — abandon connection slog.Error("max retransmits exceeded, sending RST", "conn_id", conn.ID) // Send RST to notify the remote peer @@ -4386,6 +4393,7 @@ func (d *Daemon) retransmitUnacked(conn *Connection) { } e.attempts++ + e.rtoAttempts++ e.sentAt = now conn.Mu.Lock() conn.Stats.Retransmits++ diff --git a/pkg/daemon/ports.go b/pkg/daemon/ports.go index 8df1e790..d0e0b842 100644 --- a/pkg/daemon/ports.go +++ b/pkg/daemon/ports.go @@ -186,9 +186,16 @@ type retxEntry struct { seq uint32 sentAt time.Time // timer anchor; reset by RFC 6298 §5.3 and on retransmit origSentAt time.Time // original send time for RTT measurement; never reset - attempts int - sacked bool // true if covered by a SACK block (don't retransmit) - isFIN bool // true for the FIN sentinel entry (retransmit as FlagFIN, not data) + attempts int // total transmissions (Karn's algorithm + retx budget guard) + // rtoAttempts counts only RTO-timer retransmissions and alone feeds the + // give-up RST decision. ACK-clocked retransmissions (fast retransmit, + // retransmitLost bursts) are triggered BY arriving ACKs — proof the + // path is alive — so they must never push a live connection toward the + // MaxRetxAttempts RST. Only the RTO timer firing repeatedly with no ACK + // progress is evidence of a dead peer. + rtoAttempts int + sacked bool // true if covered by a SACK block (don't retransmit) + isFIN bool // true for the FIN sentinel entry (retransmit as FlagFIN, not data) } // recvSegment is an out-of-order received segment waiting for reassembly. @@ -1036,6 +1043,11 @@ func (c *Connection) ProcessAck(ack uint32, pureACK bool) { if !e.sacked { e.sentAt = ackNow } + // New cumulative ACK = the peer is alive and progressing. Reset the + // give-up counter so RST fires only after MaxRetxAttempts RTO + // retransmissions with NO ACK progress at all (a genuinely dead + // peer), not cumulatively across a long-but-moving recovery. + e.rtoAttempts = 0 } // Congestion-window deflation and fast-retransmit on ACKs in/after fast diff --git a/pkg/daemon/zz_daemon_retx_test.go b/pkg/daemon/zz_daemon_retx_test.go index d670726f..6872b35f 100644 --- a/pkg/daemon/zz_daemon_retx_test.go +++ b/pkg/daemon/zz_daemon_retx_test.go @@ -178,10 +178,11 @@ func TestRetransmitUnackedMaxAttemptsSendsRSTAndCloses(t *testing.T) { conn, captured := newDaemonRetxConn(t) conn.Unacked = []*retxEntry{ { - seq: 1000, - data: []byte("dead"), - sentAt: time.Now().Add(-1 * time.Hour), - attempts: MaxRetxAttempts, + seq: 1000, + data: []byte("dead"), + sentAt: time.Now().Add(-1 * time.Hour), + attempts: MaxRetxAttempts, + rtoAttempts: MaxRetxAttempts, }, } diff --git a/pkg/daemon/zz_retx_test.go b/pkg/daemon/zz_retx_test.go index 2efe34e3..b0ab901a 100644 --- a/pkg/daemon/zz_retx_test.go +++ b/pkg/daemon/zz_retx_test.go @@ -245,10 +245,11 @@ func TestRetransmitUnackedMaxAttemptsSendsRSTAndClosesState(t *testing.T) { d := New(Config{}) conn, cs := newRetxConn(t) conn.Unacked = []*retxEntry{{ - data: []byte("x"), - seq: 999, - sentAt: time.Now().Add(-2 * InitialRTO), - attempts: MaxRetxAttempts, + data: []byte("x"), + seq: 999, + sentAt: time.Now().Add(-2 * InitialRTO), + attempts: MaxRetxAttempts, + rtoAttempts: MaxRetxAttempts, }} d.retransmitUnacked(conn) diff --git a/pkg/daemon/zz_transport_throughput_fixes_test.go b/pkg/daemon/zz_transport_throughput_fixes_test.go index 26173ae8..d8dd787e 100644 --- a/pkg/daemon/zz_transport_throughput_fixes_test.go +++ b/pkg/daemon/zz_transport_throughput_fixes_test.go @@ -211,6 +211,70 @@ func TestTimeoutRecoveryHoleDrainsInBoundedRounds(t *testing.T) { } } +// TestAckClockedRetransmitsNeverTriggerRST pins the give-up semantics: the +// MaxRetxAttempts RST budget counts only RTO-timer retransmissions +// (rtoAttempts), never ACK-clocked ones. Found live in the A/B rig: a +// spurious RTO put the connection in timeout recovery, each partial ACK +// retransmitted the head (inflating attempts), and within 8×RTOMin ≈ 1.6 s +// the RTO tick RST'd a connection whose ACKs were arriving fine. +func TestAckClockedRetransmitsNeverTriggerRST(t *testing.T) { + t.Parallel() + d := New(Config{}) + c := newAckTestConn(t) + var pkts []*protocol.Packet + c.RetxSend = func(p *protocol.Packet) { q := *p; pkts = append(pkts, &q) } + c.Mu.Lock() + c.State = StateEstablished + c.Mu.Unlock() + // Head segment already retransmitted 8× by the ACK-clocked path + // (attempts inflated) but never by the RTO timer (rtoAttempts 0). + c.Unacked = []*retxEntry{{ + seq: 1000, data: make([]byte, MaxSegmentSize), + attempts: MaxRetxAttempts, rtoAttempts: 0, + sentAt: time.Now().Add(-2 * InitialRTO), origSentAt: time.Now().Add(-2 * InitialRTO), + }} + c.RTO = InitialRTO + + d.retransmitUnacked(c) + + for _, p := range pkts { + if p.Flags&protocol.FlagRST != 0 { + t.Fatalf("RTO tick sent RST for a segment with rtoAttempts=0 — " + + "ACK-clocked retransmissions must not consume the give-up budget") + } + } + c.Mu.Lock() + st := c.State + c.Mu.Unlock() + if st != StateEstablished { + t.Fatalf("connection state = %v, want Established (no give-up)", st) + } +} + +// TestNewAckResetsRTOGiveUpBudget: any new cumulative ACK proves the peer is +// alive, so the per-segment RTO give-up counter must reset — RST fires only +// after MaxRetxAttempts consecutive ACK-free RTO retransmissions. +func TestNewAckResetsRTOGiveUpBudget(t *testing.T) { + t.Parallel() + c := newAckTestConn(t) + c.RetxSend = func(*protocol.Packet) {} + const mss = MaxSegmentSize + c.LastAck = 1000 + c.Unacked = []*retxEntry{ + {seq: 1000, data: make([]byte, mss), attempts: 1, sentAt: time.Now(), origSentAt: time.Now()}, + {seq: 1000 + mss, data: make([]byte, mss), attempts: 5, rtoAttempts: 5, sentAt: time.Now(), origSentAt: time.Now()}, + } + + c.ProcessAck(1000+mss, true) // acks the first segment — progress + + if len(c.Unacked) != 1 { + t.Fatalf("Unacked = %d entries, want 1", len(c.Unacked)) + } + if got := c.Unacked[0].rtoAttempts; got != 0 { + t.Fatalf("surviving segment rtoAttempts = %d after new ACK, want 0 (budget reset on progress)", got) + } +} + // TestOOOBufferCoversFullCongestionWindow pins the structural relation that // caused the loss amplification: the receiver must be able to buffer at // least a full congestion window of out-of-order segments, or one lost From f05ecc72f5a27f508e1c5b76842880088635c7be Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Sun, 2 Aug 2026 17:03:12 +0300 Subject: [PATCH 3/4] daemon: scale transport windows 4x (MaxCongWin 4 MB, recv/OOO to match) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- pkg/daemon/ports.go | 47 ++++++++++++++++++++++++++++----------------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/pkg/daemon/ports.go b/pkg/daemon/ports.go index d0e0b842..6e4cadc7 100644 --- a/pkg/daemon/ports.go +++ b/pkg/daemon/ports.go @@ -204,31 +204,42 @@ type recvSegment struct { data []byte } -// Default window parameters +// Default window parameters. +// +// Sizing rationale (throughput = window / RTT): the overlay's typical +// relay path RTT is ~200 ms, so a 1 MB window capped goodput at ~5 MB/s +// and a small slow-start threshold capped short transfers well below +// that. With the receiver able to buffer a FULL congestion window out of +// order and loss recovery ACK-clocked (see retransmitLost), large windows +// are safe: overshoot loss heals in a few RTTs instead of collapsing the +// transfer, so the constants below favor bandwidth. const ( InitialCongWin = 10 * MaxSegmentSize // 40 KB initial congestion window (IW10, RFC 6928) - MaxCongWin = 1024 * 1024 // 1 MB max congestion window + MaxCongWin = 4 * 1024 * 1024 // 4 MB max congestion window (~18 MB/s at 214 ms RTT) MaxSegmentSize = 4096 // MTU for virtual segments - RecvBufSize = 512 // receive buffer channel capacity (segments) - MaxRecvWin = RecvBufSize * MaxSegmentSize // 2 MB max receive window + RecvBufSize = 1024 // receive buffer channel capacity (segments) + MaxRecvWin = RecvBufSize * MaxSegmentSize // 4 MB max receive window // MaxOOOBuf must be able to hold a full congestion window of segments - // (MaxCongWin / MaxSegmentSize = 256). If it is smaller, a single lost + // (MaxCongWin / MaxSegmentSize). If it is smaller, a single lost // segment with more than MaxOOOBuf segments in flight makes the receiver // silently drop every subsequent in-window segment (DeliverInOrder's // buffer bound), amplifying one loss into a whole-window loss and - // collapsing throughput. 512 = RecvBufSize, so the OOO buffer can cover - // the entire advertised receive window (2 MB). - MaxOOOBuf = 512 - - // InitialSSThresh caps the slow-start phase at 128 KB. The previous - // value (MaxCongWin/2 = 512 KB) let slow start double straight into a - // half-megabyte burst within one RTT, overrunning relay/path queues and - // causing mass loss on every large transfer. Above this threshold the - // window grows by ~1 MSS per RTT (congestion avoidance), probing for - // extra bandwidth instead of doubling into it. - InitialSSThresh = 32 * MaxSegmentSize - AcceptQueueLen = 64 // listener accept channel capacity - SendBufLen = 256 // send buffer channel capacity (segments) + // collapsing throughput. 1024 = RecvBufSize, so the OOO buffer can cover + // the entire advertised receive window (4 MB). Memory is only consumed + // under actual loss, bounded at 4 MB per connection. + MaxOOOBuf = 1024 + + // InitialSSThresh hands slow start over to congestion avoidance at + // 512 KB. High enough that short transfers ramp fast (a 1 MB transfer + // spends its whole life in slow start), low enough that the exponential + // phase cannot burst a full MaxCongWin into path queues in one RTT. + // Beyond it, congestion avoidance probes at ~1 MSS per RTT and real + // loss halves SSThresh as usual — the receiver's window-sized OOO + // buffer plus ACK-clocked retransmission make that overshoot cheap + // (a few RTTs) rather than fatal (multi-minute RTO crawl). + InitialSSThresh = MaxCongWin / 8 + AcceptQueueLen = 64 // listener accept channel capacity + SendBufLen = 1024 // send buffer channel capacity (segments) — covers a full cwnd // MaxNagleBuf caps the per-connection NagleBuf at 64 segments // (256 KB). v1.9.1 fix: SendData previously appended without bound, From b4ee407101a2647a5008867d3ea2165fa3f3c464 Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Sun, 2 Aug 2026 17:37:20 +0300 Subject: [PATCH 4/4] daemon: adaptive slow-start exit via HyStart-style RTT-rise detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- pkg/daemon/ports.go | 76 ++++++++++++++++--- .../zz_transport_throughput_fixes_test.go | 67 ++++++++++++++-- 2 files changed, 125 insertions(+), 18 deletions(-) diff --git a/pkg/daemon/ports.go b/pkg/daemon/ports.go index 6e4cadc7..47ad282f 100644 --- a/pkg/daemon/ports.go +++ b/pkg/daemon/ports.go @@ -229,17 +229,25 @@ const ( // under actual loss, bounded at 4 MB per connection. MaxOOOBuf = 1024 - // InitialSSThresh hands slow start over to congestion avoidance at - // 512 KB. High enough that short transfers ramp fast (a 1 MB transfer - // spends its whole life in slow start), low enough that the exponential - // phase cannot burst a full MaxCongWin into path queues in one RTT. - // Beyond it, congestion avoidance probes at ~1 MSS per RTT and real - // loss halves SSThresh as usual — the receiver's window-sized OOO - // buffer plus ACK-clocked retransmission make that overshoot cheap - // (a few RTTs) rather than fatal (multi-minute RTO crawl). - InitialSSThresh = MaxCongWin / 8 - AcceptQueueLen = 64 // listener accept channel capacity - SendBufLen = 1024 // send buffer channel capacity (segments) — covers a full cwnd + // InitialSSThresh is the hard upper stop for slow start. The PRIMARY + // slow-start exit is adaptive: HyStart-style RTT-rise detection (RFC + // 9406, see ProcessAck) hands over to congestion avoidance as soon as + // the path's queue visibly starts filling, at whatever window the path + // actually supports. This constant only bounds the exponential phase + // on paths where no RTT signal appears (e.g. fixed-latency links with + // tail-drop queues); real loss then halves SSThresh as usual, which + // the window-sized OOO buffer plus ACK-clocked retransmission make + // cheap (a few RTTs) rather than fatal. + InitialSSThresh = MaxCongWin / 2 + + // HyStart++ (RFC 9406, simplified) slow-start exit parameters: after + // at least hsMinSamples RTT samples in a round, if the round's min RTT + // exceeds the previous round's min by eta = clamp(lastMin/8, hsEtaMin, + // hsEtaMax), the queue is building — set SSThresh = CongWin and enter + // congestion avoidance at the discovered capacity. + hsMinSamples = 8 + AcceptQueueLen = 64 // listener accept channel capacity + SendBufLen = 1024 // send buffer channel capacity (segments) — covers a full cwnd // MaxNagleBuf caps the per-connection NagleBuf at 64 segments // (256 KB). v1.9.1 fix: SendData previously appended without bound, @@ -252,6 +260,9 @@ const ( // 256 KB accommodates the largest single data-exchange frame // (64 KB) with headroom, while still bounding per-connection memory. MaxNagleBuf = 64 * MaxSegmentSize + + hsEtaMin = 4 * time.Millisecond // min RTT-rise to trigger HyStart exit + hsEtaMax = 16 * time.Millisecond // max RTT-rise threshold ) // RTO parameters (RFC 6298) @@ -298,6 +309,13 @@ type Connection struct { RetxSend func(*protocol.Packet) // callback to send retransmitted packets WindowCh chan struct{} // signaled when window opens up PeerRecvWin int // peer's advertised receive window (-1 = not yet received, 0 = explicit zero-window) + // HyStart++ slow-start exit state (RFC 9406, simplified; RetxMu). + // Rounds are delimited by snd_nxt at round start: when the cumulative + // ACK passes hsRoundEnd, one full flight has been acknowledged. + hsRoundEnd uint32 // ack that ends the current round (0 = uninitialized) + hsCurrMinRTT time.Duration // min clean RTT sample seen this round + hsLastMinRTT time.Duration // min clean RTT sample of the previous round + hsSamples int // clean RTT samples seen this round // Nagle algorithm (write coalescing) NagleBuf []byte // pending small write data NagleMu sync.Mutex // protects NagleBuf @@ -1012,6 +1030,7 @@ func (c *Connection) ProcessAck(ack uint32, pureACK bool) { // and inflates RTTVAR with within-batch variance that is not path-level. var remaining []*retxEntry rttUpdated := false + var rttSample time.Duration for _, e := range c.Unacked { endSeq := e.seq + uint32(len(e.data)) if seqAfterOrEqual(ack, endSeq) { @@ -1033,6 +1052,7 @@ func (c *Connection) ProcessAck(ack uint32, pureACK bool) { rtt := time.Since(rttAnchor) c.updateRTT(rtt) rttUpdated = true + rttSample = rtt } } else { // Retain sacked state for remaining entries (RFC 2018 §5): @@ -1061,6 +1081,40 @@ func (c *Connection) ProcessAck(ack uint32, pureACK bool) { e.rtoAttempts = 0 } + // HyStart++ (RFC 9406, simplified): while in slow start, watch for the + // round-over-round min-RTT rise that signals the bottleneck queue is + // starting to fill, and hand over to congestion avoidance at the + // discovered window instead of doubling past capacity into mass loss. + // Simplification vs the full RFC: no CSS (conservative slow start) + // interlude — on trigger we exit directly (SSThresh = CongWin), which + // is slightly conservative but avoids a second state machine. + if c.CongWin < c.SSThresh && !c.InRecovery { + if c.hsRoundEnd == 0 || seqAfterOrEqual(ack, c.hsRoundEnd) { + c.hsLastMinRTT = c.hsCurrMinRTT + c.hsCurrMinRTT = 0 + c.hsSamples = 0 + c.hsRoundEnd = sendSeq // current snd_nxt: acked ⇒ round over + } + if rttUpdated { + c.hsSamples++ + if c.hsCurrMinRTT == 0 || rttSample < c.hsCurrMinRTT { + c.hsCurrMinRTT = rttSample + } + if c.hsSamples >= hsMinSamples && c.hsLastMinRTT > 0 { + eta := c.hsLastMinRTT / 8 + if eta < hsEtaMin { + eta = hsEtaMin + } + if eta > hsEtaMax { + eta = hsEtaMax + } + if c.hsCurrMinRTT >= c.hsLastMinRTT+eta { + c.SSThresh = c.CongWin + } + } + } + } + // Congestion-window deflation and fast-retransmit on ACKs in/after fast // recovery (RFC 6582 §3). // diff --git a/pkg/daemon/zz_transport_throughput_fixes_test.go b/pkg/daemon/zz_transport_throughput_fixes_test.go index d8dd787e..736d8574 100644 --- a/pkg/daemon/zz_transport_throughput_fixes_test.go +++ b/pkg/daemon/zz_transport_throughput_fixes_test.go @@ -289,14 +289,67 @@ func TestOOOBufferCoversFullCongestionWindow(t *testing.T) { } } -// TestInitialSSThreshBoundsSlowStartBurst pins the slow-start exit point: -// slow start doubling must hand over to congestion avoidance well before -// the burst reaches the whole-path collapse regime observed at ~550 KB. +// TestInitialSSThreshBoundsSlowStartBurst pins the slow-start hard stop: +// with HyStart RTT-rise detection as the primary (adaptive) exit, the +// static threshold is only a backstop — but it must still exist, below +// the full window, for paths that give no RTT signal before tail-drop. func TestInitialSSThreshBoundsSlowStartBurst(t *testing.T) { t.Parallel() - if InitialSSThresh > MaxCongWin/4 { - t.Fatalf("InitialSSThresh (%d) > MaxCongWin/4 (%d): slow start may "+ - "double straight into a path-collapsing burst before congestion "+ - "avoidance takes over", InitialSSThresh, MaxCongWin/4) + if InitialSSThresh > MaxCongWin/2 { + t.Fatalf("InitialSSThresh (%d) > MaxCongWin/2 (%d): slow start could "+ + "double a full MaxCongWin into path queues in one RTT with no "+ + "hard stop before the ceiling", InitialSSThresh, MaxCongWin/2) + } +} + +// TestHyStartExitsSlowStartOnRTTRise: while in slow start, a round whose +// min RTT rises by >= eta over the previous round's min (with enough +// samples) must set SSThresh = CongWin — exiting at discovered capacity +// instead of doubling to the static threshold (RFC 9406 rationale). +func TestHyStartExitsSlowStartOnRTTRise(t *testing.T) { + t.Parallel() + c := newAckTestConn(t) + c.RetxSend = func(*protocol.Packet) {} + const mss = MaxSegmentSize + c.CongWin = 20 * mss + c.SSThresh = InitialSSThresh + c.LastAck = 1000 + + // Feed ACK rounds with controlled RTT samples: origSentAt in the past + // determines the sample. Round 1: ~30 ms baseline. Round 2: ~60 ms + // (queue building). Each entry acked individually = one sample each. + seq := uint32(1000) + feedRound := func(rtt time.Duration) { + for i := 0; i < hsMinSamples+1; i++ { + c.Unacked = []*retxEntry{{ + seq: seq, data: make([]byte, mss), attempts: 1, + sentAt: time.Now(), origSentAt: time.Now().Add(-rtt), + }} + // Force round boundaries to line up: hsRoundEnd is snd_nxt at + // round start; keep SendSeq one flight ahead. + c.Mu.Lock() + c.SendSeq = seq + uint32((hsMinSamples+1))*mss + c.Mu.Unlock() + seq += mss + c.ProcessAck(seq, true) + } + } + feedRound(30 * time.Millisecond) // establishes hsLastMinRTT ≈ 30 ms + feedRound(30 * time.Millisecond) // stable round — must NOT exit + if c.SSThresh != InitialSSThresh { + t.Fatalf("stable RTT triggered HyStart exit: SSThresh=%d", c.SSThresh) + } + // ACK rounds don't align with feed batches (rollover happens mid-batch + // and the first raised-RTT round inherits earlier low samples), so feed + // several rounds of elevated RTT — at least one full 8-sample round of + // pure 60 ms measurements must occur and trigger the exit. + feedRound(60 * time.Millisecond) + feedRound(60 * time.Millisecond) + feedRound(60 * time.Millisecond) // RTT rise >> eta — must exit by now + if c.SSThresh == InitialSSThresh { + t.Fatalf("60ms-over-30ms RTT rise did not trigger HyStart slow-start exit") + } + if c.SSThresh > c.CongWin { + t.Fatalf("HyStart exit set SSThresh=%d above CongWin=%d", c.SSThresh, c.CongWin) } }