Skip to content

tcp: implement the RFC 4015 response to spurious loss recovery - #17022

Open
adios-404 wants to merge 1 commit into
google:masterfrom
adios-404:carry-14458-spurious-recovery
Open

adios-404 wants to merge 1 commit into
google:masterfrom
adios-404:carry-14458-spurious-recovery

Conversation

@adios-404

@adios-404 adios-404 commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

This is #14458 by @davidbell217, rebased onto master with David kept as author; besides review changes, only tcp_sack_test.go changed, to fit #16601's table-driven test. kerumeto approved #14458; David has since closed it in favour of this PR.

When Eifel detection finds a recovery was spurious, the sender restores ssthresh and slow-starts back toward it (RFC 4015 step 9). Detection needs timestamps.

With RACK off and 1% of data packets held 200 µs, a 20 MB fetch took 1.33 s on master and 0.268 s with this (medians of 16 runs, results). Retransmissions rose from 95.5 to 156.5, with no drops or timeouts. In juxuanu's model of a lossy Wi-Fi path, a similar undo gained about 6% (tailscale/tailscale#18307).

This is rebased onto #16851 (detector fixes), now merged. #16258 stops other connections' D-SACKs from triggering this; TCP tests pass with it.

Fixes #14102

// have been decimated by the ACKs that triggered the detection. An
// Ssthresh that was never reduced (InitialSsthresh) holds no pipe
// estimate to restore and is skipped.
s.pipePrev = s.SndCwnd

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 timeout recovery use the number of packets in flight here? In the packet test, an earlier timeout set the slow-start threshold to 2. ACKs then grew the congestion window to 10. Only two packets were in flight when the next timeout fired. Once the sender detected that timeout was spurious, this assignment restored the threshold to 10. RFC 4015 §3.1, step 0 gives max(2, 2) = 2.

Changing the timeout capture to use the outstanding count kept the threshold at 2. The control that echoed the retransmission’s timestamp also stayed at 2 on both versions. Could you add this application-limited case to the timeout tests? Packet results

Posted by Codex on behalf of @tamird.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks, yes. A timeout now captures the packets in flight (Outstanding) as FlightSize. ACK-driven detection (fast retransmit, RACK, TLP) still captures cwnd, as those ACKs may already have cut Outstanding.

I added your packet test as TestEifelFiniteApplicationFlight, minus the logging, with you as co-author. It fails on the previous head (threshold 10, want 2) and passes now. One change: with #17023 (step 8) a spurious timeout doesn't resend the second packet, so only repair_ack requires that resend.

This also moves the existing RTO test, which times out 5 packets with a window of 10: ssthresh is now restored to 5, not 10. RFC 4015 would restore the initial, unlimited ssthresh there, but this PR has always skipped that value. Happy to follow the RFC there too.

All 11 TCP packages pass with go test, alone and stacked with #17023, #16851 and #16258, and Bazel (nogo included) passes on Linux.

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.

Yes, I’d restore InitialSsthresh in the timeout case. RFC 4015 §3.1 step (0) saves max(FlightSize, ssthresh), so a false alarm with five packets in flight should preserve the initial threshold. Step (9) bounds the immediate send separately through cwnd. Could you update the existing five-packet RTO assertion? The finite-threshold two-packet case now matches the trace.

Posted by Codex on behalf of @tamird.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. A timeout now captures max(FlightSize, ssthresh) as is, so a spurious timeout restores an untouched InitialSsthresh, and the five-packet RTO test now expects that. Step (9) still sets cwnd, so that test still expects 4. The ACK-driven paths are back to #14458's code unchanged, so they still skip an untouched InitialSsthresh; only the timeout has its own capture. I also merged the two doc comments on TestEifelFiniteApplicationFlight.

The five-packet test fails on the previous head (threshold 5, want InitialSsthresh) and passes now, with your two-packet case still at 2. All 11 TCP packages pass with go test, alone and stacked with #17023, #16851 and #16258, and Bazel (nogo included) passes on Linux.

@adios-404
adios-404 force-pushed the carry-14458-spurious-recovery branch from 963e83a to d0439e8 Compare November 5, 2026 15:46
@davidbell217

Copy link
Copy Markdown

Thanks @adios-404 for carrying this forward and @tamird for the fix and test. Happy for this PR to supersede #14458. I'll close the original so review can continue here.

@adios-404
adios-404 force-pushed the carry-14458-spurious-recovery branch 2 times, most recently from c02d094 to 7e3d55e Compare November 6, 2026 12:31
@adios-404

Copy link
Copy Markdown
Contributor Author

Two more changes, so the RFC 4015 response can't reverse a loss response that must stand.

With #16851, which keeps the detection state through recovery, the response to a loss found after Eifel has flagged the recovery as spurious would be undone when recovery ends. capturePipePrev and captureTimeoutPipePrev now cancel the pending response inside recovery once spuriousRecovery is set. With #16851, two spurious timeouts before the detection still get the restore.

A loss found by a tail loss probe could be undone too: enterRecovery records RetransmitTS an RTT after the probe went out, so its ACK passes Eifel once a DSACK has been seen. detectTLPRecovery now leaves nothing to restore; #16596 stops entering recovery there.

The two new tests fail without these changes (the first needs #16851), and removing any part fails its own case. All 11 TCP packages and Bazel pass, alone and stacked with #17023, #16851 and #16258.

@adios-404

Copy link
Copy Markdown
Contributor Author

One more interaction, with #16081, which keeps timeout recovery from entering RACK recovery. With both applied, a loss that RACK finds after Eifel has flagged the timeout as spurious gets no response at all: #16081 skips the RACK entry, so the pending response is never cancelled, and the end of recovery restores ssthresh (unlimited in my test, want 2).

In a local stack, cancelling the pending response wherever RACK finds a loss inside a recovery already flagged as spurious, whether or not it enters recovery, fixes it. The "rack" case of TestEifelGenuineLossAfterSpuriousDetection should then check only the final ssthresh, since #16081 keeps the sender in timeout recovery. I can fold that into this PR with your next review, or leave it to whichever lands second.

tamird added a commit to tamird/gvisor that referenced this pull request Nov 6, 2026
The four-way packet replay now covers the revised google#17022 source, both alone and with google#16851. Keep the previous replies in history and send reviewers to the current results.

@tamird tamird left a comment

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.

@adios-404, please fold the RACK case into this PR. With #16081 applied, I reproduced ssthresh going back to unlimited at the end of recovery. Clearing the pending Eifel response when RACK marks the packet lost kept ssthresh at 2, both on this PR alone and with #16081 applied.

I’d keep the check that Eifel detected one spurious timeout and the final ssthresh assertion. The intermediate recovery state legitimately differs between the two stacks. The original-timestamp and retransmission-timestamp controls passed on both. Tested change · Packet test results

— Codex, on behalf of @tamird

@adios-404
adios-404 force-pushed the carry-14458-spurious-recovery branch from 7e3d55e to 62277fb Compare November 7, 2026 04:54
@adios-404

Copy link
Copy Markdown
Contributor Author

Folded in your change as is: RACK now cancels the pending response where it marks the packet lost, and the "rack" case checks only the spurious-timeout count and the final ssthresh. The commit message says why.

Each cancel guards its own case. With #16081, removing the RACK one leaves ssthresh unlimited in "rack" (want 2); with #16851, removing the one in the capture functions does the same in "timeout". All 11 TCP packages pass alone, merged with master, with #16081, and with #16081 and #16851; Bazel passes on Linux.

One more interaction: with #17023 as well, #16081's timeout-recovery guard blocks the fast retransmit that #17023 allows after a spurious timeout, so two #17023 tests and the "rack" case wait for another timeout. Letting that guard through once step (8) has run fixes all three; I'll take that to #17023.

@kerumeto
kerumeto requested review from kerumeto and removed request for nybidari November 12, 2026 01:59
@adios-404
adios-404 force-pushed the carry-14458-spurious-recovery branch from 62277fb to f38afb6 Compare November 13, 2026 10:54
@adios-404

Copy link
Copy Markdown
Contributor Author

Rebased onto master now that #16851 has merged. The only conflict was in the timeout path, where recordRetransmitTS() became startSpuriousRecoveryDetection(); the timeout capture now follows it. The code is otherwise unchanged, and the commit message now treats #16851 as merged.
On Linux (Bazel), all 27 TCP targets pass, alone and with #16081 or #16258. The new tests still catch the bug: without the cancellations, both cases of TestEifelGenuineLossAfterSpuriousDetection fail.
This conflicts with #16596 (export #17514) in detectTLPRecovery. As #16670 suggests, the combination keeps #16596's direct response to a confirmed tail-probe loss and drops this PR's pipePrev = 0 there; the TCP tests pass with it too. If #16596 lands first, I'll rebase this again.

RFC 3522 Eifel detection sets sender.spuriousRecovery and the
SpuriousRecovery metrics, but nothing consumed the signal: leaveRecovery
unconditionally deflated SndCwnd to Ssthresh, so a recovery the sender
itself proved spurious still cost a permanent multiplicative decrease,
and under sustained path jitter the sender ratchets its throughput down
episode after episode.

Capture pipe_prev (RFC 4015 Section 3 step (0)) at every loss-recovery
entry point, before the congestion controller reduces Ssthresh, and on
exit from a recovery detected spurious restore Ssthresh to it and
slow-start back from FlightSize + min(bytes_acked, IW) (step (9))
instead of keeping the deflation. The response is applied at the single
point every recovery exits through — the Open transition in
handleRcvdSegment — after the ACK-removal loop, when Outstanding is the
true FlightSize, so the send it allows is bounded by IW. The capture is
consumed on use, so the response applies at most once per episode.

A timeout captures RFC 4015's max(FlightSize, ssthresh) as is, with
the packets in flight as FlightSize: no ACK triggered the timeout, so
none has just taken packets out of that count, and an
application-limited sender can have far fewer packets in flight than
cwnd. A spurious timeout thus restores an untouched, unlimited ssthresh.
Loss detected from ACKs uses cwnd as FlightSize instead and skips an
untouched ssthresh, as the sender is cwnd-limited then and those ACKs
may already have taken packets out of the count.

detectSpuriousRecovery also ignores ACKs without a usable timestamp
echo. Since google#16601 its caller checks this too; the guard keeps the
response safe if another caller is added.

Eifel cannot judge a loss found after it has flagged the recovery as
spurious, so treat that loss as genuine: cancel the pending response, so
that the end of recovery does not reverse the loss's reduction. RACK
cancels it where it marks the packet lost, before either caller decides
whether to enter fast recovery, so the cancellation holds even when the
sender stays in timeout recovery (google#16081). Since google#16851, recovery keeps
the detection state, as RFC 3522 requires.

A loss confirmed by a tail loss probe has nothing to restore either:
since google#16596 its response reduces the window without entering recovery,
so nothing is captured for it and no recovery exit can reverse it.

Extend the two spurious-recovery e2e tests to drive the connection out
of recovery on each exit path and assert Ssthresh/SndCwnd are restored;
both extensions fail without the fix. TestEifelFiniteApplicationFlight
times out an application-limited flight of two packets after an earlier
timeout left ssthresh at 2: the response must restore ssthresh to 2,
not to cwnd. TestEifelGenuineLossAfterSpuriousDetection follows a
spurious timeout with a genuine loss, found by a second timeout or by
RACK, and checks that its reduction survives the end of recovery; both
cases fail without the cancellations, and with google#16081 applied, the RACK
case fails without RACK's.
TestTailLossProbeResponseIsKept repairs a lost tail with a probe after
an earlier DSACK and checks that its reduction is kept; since google#16596 it
passes without this change too.

Fixes google#14102

Co-authored-by: Tamir Duberstein <tamird@gmail.com>
@adios-404
adios-404 force-pushed the carry-14458-spurious-recovery branch from f38afb6 to 5f4e4e2 Compare November 14, 2026 14:53
@adios-404

Copy link
Copy Markdown
Contributor Author

Rebased onto master now that #16596 has merged. The only conflict was in detectTLPRecovery. As planned, it keeps #16596's direct response to a confirmed tail-probe loss and drops this PR's pipePrev = 0 there: that response no longer enters recovery, so the RFC 4015 response has nothing to reverse. One code comment, one test comment and the commit message now say so; the code is otherwise unchanged.

On Linux (Bazel), all 27 TCP targets pass, alone and with #16081 or #16258. Both extended spurious-recovery tests still fail without the fix, and each case of TestEifelGenuineLossAfterSpuriousDetection still fails without its cancellation (the RACK case with #16081 applied).

TestTailLossProbeResponseIsKept now passes without this change too, because of #16596. I kept it as a check on the two together and can drop it if you prefer.

Related: #17665 fixes a false spurious verdict that this response would act on.

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

Labels

None yet

Projects

None yet

3 participants