Skip to content

tcp: do not leak SACKed-then-ACKed segments into Outstanding outside recovery - #14101

Open
davidbell217 wants to merge 1 commit into
google:masterfrom
davidbell217:fix-outstanding-sack-leak-outside-recovery
Open

davidbell217 wants to merge 1 commit into
google:masterfrom
davidbell217:fix-outstanding-sack-leak-outside-recovery

Conversation

@davidbell217

Copy link
Copy Markdown

Fixes #14092.

The cumulative-ACK removal loop in handleRcvdSegment skips the Outstanding decrement for previously-SACKed segments, on the premise that SetPipe() 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 into Outstanding permanently. On long-lived bulk transfers over reordering-but-lossless paths the phantoms accumulate until sendData's Outstanding < SndCwnd gate 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 wherever Outstanding is 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:

  • Decrement unconditionally in the removal loop's SACKed branch (the shape originally suggested in the issue): double-subtracts segments SetPipe() already accounted for — SACKed during recovery, cumulatively ACKed at/after exit, where leaveRecovery has already run before the removal loop. Breaks TestSACKRecovery (post-recovery pacing sends beyond the expected window).
  • Decrement at SACK arrival (in walkSACK): frees congestion-window slots before the cumulative ACK — effectively unbounded limited transmit — and only covers RACK-enabled senders. Breaks TestRACKWithWindowFull (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

  • New regression test TestSACKedThenAckedSegmentLeavesOutstanding (both RACK on and off): fails before the fix with Outstanding = 3, want 2 — exactly one phantom packet after a SACKed-then-cumulatively-ACKed segment — and passes with it.
  • tcp_sack_test.go and tcp_rack_test.go e2e suites pass with the fix.
  • In the reordering testbench from the issue (195 ms RTT, ±15 ms jitter, 3 % reordering, zero loss, netstack-to-netstack): wedged transfers drop from 12/12 to 3-4/12 with this fix alone (the residual is the separate spurious-recovery-response gap, filed separately).

Assisted-by: Claude Code

…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
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()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

tamird added a commit to tamird/gvisor that referenced this pull request Nov 8, 2026
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
@adios-404

Copy link
Copy Markdown
Contributor

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 sb.Start). Only tcp_sack_test.go conflicted.

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 Outstanding after the timeout had zeroed it. The walk now stops at writeNext, with a test. And the regression test runs on a manual clock: on master its RACK case failed without the fix in only 22 of 30 runs.

bazel test //pkg/tcpip/transport/tcp/... passes (27 of 27).

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?

@davidbell217

Copy link
Copy Markdown
Author

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 sb.Start). Only tcp_sack_test.go conflicted.

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 Outstanding after the timeout had zeroed it. The walk now stops at writeNext, with a test. And the regression test runs on a manual clock: on master its RACK case failed without the fix in only 22 of 30 runs.

bazel test //pkg/tcpip/transport/tcp/... passes (27 of 27).

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.

@adios-404

Copy link
Copy Markdown
Contributor

Opened as #17684, with you as the author. Thanks, David.

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.

netstack: TCP sender permanently leaks SACKed-then-cumulatively-ACKed packets into Outstanding outside recovery, throttling bulk sends under reordering

3 participants