Repository navigation
tcp: do not leak SACKed-then-ACKed segments into Outstanding outside recovery - #14101
davidbell217 wants to merge 1 commit into
Conversation
…recovery The cumulative-ACK removal loop in handleRcvdSegment skips the Outstanding decrement for segments that were previously SACKed, on the premise that SetPipe() already accounted for them. That premise only holds during loss recovery: SetPipe() returns immediately when recovery is not active, and nothing else removes those packets. A segment SACKed outside recovery (benign reordering) and then cumulatively ACKed therefore leaks its packet count into Outstanding permanently. On a long-lived connection over a reordering-but-lossless path the phantoms accumulate until the sender believes it is cwnd-limited while almost nothing is in flight, throttling bulk transfers (observed: a wedged sender with Outstanding=86, cwnd=86 and ~5 packets actually in flight, and a completed transfer running at ~8% of its believed window). Track packets newly SACKed while no recovery is in progress in a sender-local counter, and consume it when those segments are cumulatively ACKed. The counter resets wherever Outstanding is recomputed or reset wholesale (recovery entry, RTO, full window drain), so recovery-path accounting is unchanged. Two simpler shapes were measured and rejected: - decrementing Outstanding unconditionally in the removal loop's SACKed branch double-subtracts segments SetPipe() already accounted for (SACKed during recovery, cumulatively ACKed at/after exit) and breaks TestSACKRecovery's post-recovery pacing; - decrementing at SACK arrival (walkSACK) frees congestion window slots before the cumulative ACK, effectively unbounded limited transmit, and breaks TestRACKWithWindowFull with a transmission after a zero-window ACK. Fixes google#14092 Assisted-by: Claude Code
208f3b2 to
df000e6
Compare
Adds results/2026-10-31/reorder: 20 MB fetches with Tailscale's netstack settings on gVisor master, alone and with google/gvisor#14101, google/gvisor#14458 or both, under the seeded reordering mode, with RACK off and on and with and without client timestamps.
| var newlyCovered []*segment | ||
| if !s.FastRecovery.Active { | ||
| for seg := s.writeList.Front(); seg != nil && seg.sequenceNumber.LessThan(sb.End); seg = seg.Next() { | ||
| if seg.xmitCount != 0 && !s.ep.scoreboard.IsSACKED(seg.sackBlock()) { |
There was a problem hiding this comment.
Could we skip segments that end at or before sb.Start here? Inserting this SACK block cannot change whether those segments are covered. The sender still walks past them to find the block, but it can skip both the append and the second scoreboard lookup. A partially overlapping segment still needs both checks because adjacent SACK blocks can merge.
I timed one new SACK near the end of a 128-segment send queue in handleRcvdSegment, with RACK disabled. stock is this PR and fixed adds the overlap check.
│ stock │ fixed │
│ sec/op │ sec/op vs base │
SACKAccountingQualification/queue=128/sack=far 3604.5n ± 1% 937.2n ± 5% -74.00% (p=0.000 n=8)
Allocations fell from seven to two per ACK. Both versions gave the same Outstanding count for duplicate and partially overlapping blocks, including across sequence-number wrap. One-line change · Benchmark and controls.
Posted by Codex on behalf of @tamird.
TCP can report a negative count of packets the peer selectively acknowledged. Send eight packets in one GSO entry, SACK packet 3, cumulatively ACK 100 bytes, SACK packets 6–8 to enter recovery, then ACK the flight. Linux reports 1, 1, 4, 0 [1]. Stock gVisor reports 0, 0, 0, −3. With RACK enabled, one packet trace [2] waited for the 25 ms reordering window instead of retransmitting immediately. Record the MSS and sequence origin at transmission. A cumulative ACK can trim a queue entry, recovery can split it, and the path MTU can fall without changing which bytes were sent together. Credit a packet only when all its bytes have been acknowledged. The queue entry containing its last byte owns the credit. A timeout or restore clears the count along with the scoreboard. Track RACK’s delivery observation separately from this count. A peer may discard previously SACKed data, so the sender must be able to retransmit it after a timeout. Repeating the same SACK for an original transmission gives RACK no new delivery to observe. Keep that observation across timeout and restore. Clear it when the segment is successfully sent again. The related google#14101 corrects the flight estimate (Outstanding). Both changes visit incoming SACKs. google#14101 can use the credit change here to identify newly acknowledged packets without a second queue scan. The corrected wire test [3] matches Linux’s four counts. After a path MTU reduction [4], Linux still counted a packet once while retransmitting the preceding hole with a smaller MSS. Restoring a live socket [5] dropped the count, credited the peer’s repeated SACK once, and returned to zero after a full ACK. At 7d7f552 all 79 package and packet cases passed [6], along with both owning analyzers on AMD64 and ARM64. Stock failed the nine new wire cases. The new timeout case reports false reordering on 7d7f552. Linux and f96afe4 leave TCP_INFO.ReordSeen clear. With google#16738 included, the sender still retransmits a previously SACKed segment after the receiver discards it and detects genuine reordering. On live restore, replaying an earlier SACK restores packet credit once and leaves the flag clear. The TCP, RACK, SACK and packetimpact suites passed [8]. Skip unchanged SACK ranges. When new bytes join a stored range, search at most one transmitted packet’s length beyond the new bytes. Eight paired runs on one AMD64 CPU [7] measured one SACK walk, RACK window update and loss scan per operation. Four timings changed significantly. ```text │ stock.bench │ candidate.bench │ │ sec/op │ sec/op vs base │ RACKSparseCoverage/ordinary-128/duplicate 2.173µ ± 1% 1.796µ ± 1% -17.37% (p=0.000 n=8) RACKSparseCoverage/ordinary-128/new 2.172µ ± 0% 2.067µ ± 0% -4.83% (p=0.000 n=8) RACKSparseCoverage/tail-4096/duplicate 108.66µ ± 5% 83.11µ ± 6% -23.52% (p=0.000 n=8) RACKSparseCoverage/partial-GSO-4096/duplicate 284.0µ ± 1% 257.0µ ± 1% -9.52% (p=0.000 n=8) ``` The fixture uses 32-byte packets. Each GSO entry contains eight packets. The partial-GSO fixture starts with 60 disjoint SACK ranges. All six workloads fell from two allocations (32 bytes) per operation to zero. The TCP segment remains 248 bytes on AMD64. These timings measure ACK processing. [1]: https://github.com/tamird/gvisor/actions/runs/40993662609/artifacts/12949958196 [2]: https://github.com/tamird/gvisor/actions/runs/40996472066/artifacts/12952002021 [3]: https://github.com/tamird/gvisor/actions/runs/40995772154/artifacts/12951391345 [4]: https://github.com/tamird/gvisor/actions/runs/40991952786/artifacts/12949399355 [5]: https://github.com/tamird/gvisor/actions/runs/40996828397/artifacts/12951757104 [6]: https://github.com/tamird/gvisor/actions/runs/41001085658/artifacts/12954091545 [7]: https://github.com/tamird/gvisor/actions/runs/40997495310/artifacts/12951717490 [8]: https://github.com/tamird/gvisor/actions/runs/41091511027/artifacts/12994247763
|
In my reordering runs (RACK on, 25% of data packets held 15 µs, no loss) this PR cut the median 20 MB fetch from 2.47 s to 0.254 s. I rebased it onto master 5f7e73a and added tamird's review change (skip segments that end at or before Two more changes. After a timeout the scoreboard is empty, so the peer's SACKs for earlier segments look new, and they were subtracted from
David, will you update this PR, or are you happy for me to open the rebased copy with you kept as the author and tamird credited? |
Thanks @adios-404, and thanks @tamird for the review. Please go ahead and open the rebased copy. Link it here and I'll close this one once it's up. |
|
Opened as #17684, with you as the author. Thanks, David. |
Fixes #14092.
The cumulative-ACK removal loop in
handleRcvdSegmentskips theOutstandingdecrement for previously-SACKed segments, on the premise thatSetPipe()already accounted for them. That premise only holds during loss recovery —SetPipe()is a no-op otherwise — so a segment SACKed outside recovery (benign reordering, RACK correctly declaring no loss) and then cumulatively ACKed leaks its packet count intoOutstandingpermanently. On long-lived bulk transfers over reordering-but-lossless paths the phantoms accumulate untilsendData'sOutstanding < SndCwndgate throttles a healthy connection (details and repro in the issue).The fix: track packets newly SACKed while no recovery is in progress in a sender-local counter (
sackedOutsideRecovery), consume it when those segments are cumulatively ACKed, and reset it whereverOutstandingis recomputed or reset wholesale (recovery entry, RTO, full window drain). Recovery-path accounting is unchanged.Why not something simpler — both obvious one-line shapes were implemented and measured, and each breaks an existing test for a legitimate reason:
SetPipe()already accounted for — SACKed during recovery, cumulatively ACKed at/after exit, whereleaveRecoveryhas already run before the removal loop. BreaksTestSACKRecovery(post-recovery pacing sends beyond the expected window).walkSACK): frees congestion-window slots before the cumulative ACK — effectively unbounded limited transmit — and only covers RACK-enabled senders. BreaksTestRACKWithWindowFull(a transmission observed after a zero-window ACK).The counter shape keeps the conservative cumulative-ACK release point, never touches segments
SetPipe()accounted for, and works with RACK on or off.Verification
TestSACKedThenAckedSegmentLeavesOutstanding(both RACK on and off): fails before the fix withOutstanding = 3, want 2— exactly one phantom packet after a SACKed-then-cumulatively-ACKed segment — and passes with it.tcp_sack_test.goandtcp_rack_test.goe2e suites pass with the fix.Assisted-by: Claude Code