netstack: spurious loss recovery is detected (RFC 3522) but never responded to (no RFC 4015 undo) — under path jitter the sender ratchets its own cwnd down while diagnosing every recovery as spurious
- Dominant language
- Go
- Stars
- 19.3k
- Forks
- 2k
- Avg merge
- 3d 5h
- Merged PRs (30d)
- 264
Description
## Description
`sender.detectSpuriousRecovery` (`pkg/tcpip/transport/tcp/snd.go`) implements RFC 3522 Eifel *detection*: it sets `s.spuriousRecovery` and increments `Stats().TCP.SpuriousRecovery`. Nothing consumes that signal. There is no RFC 4015 (or Linux-undo-style) *response*: `leaveRecovery` unconditionally deflates `SndCwnd = s.Ssthresh`, so a recovery the sender itself has proven spurious still costs a permanent multiplicative decrease.
Two aggravating details make this bite hard on jittery paths:
- RACK's reorder window is `minRTT/4` (`rack.go updateRACKReorderWindow`), a thin margin. We observed segments marked lost at `age = 250.6 ms` against a tolerance of `RACK.rtt 198.6 ms + reoWnd 49.5 ms = 248.1 ms` — 2.4 ms over, on a path whose reordering delay (30 ms) was comfortably inside `reoWnd`. Modest ACK jitter makes such marks recur indefinitely.
- Reno's cut is `Ssthresh = Outstanding/2` (`reno.go reduceSlowStartThreshold`), and `Outstanding` immediately after ACK processing can be far below cwnd, so a single spurious episode can slash ssthresh to single digits (observed: cut to 5 from cwnd ≈ 30).
Each spurious episode is therefore a permanent cut, regrowth between episodes is Reno's +1 MSS/RTT, and at WAN RTTs the sender pins: it keeps entering recovery, keeps concluding "nothing was lost", and keeps paying anyway.
**Expected:** a recovery the sender itself flags spurious (RFC 3522) triggers the RFC 4015 congestion-control response, so transient reordering/jitter does not permanently crush throughput.
**Observed:** on a production-like Windows deployment sending bulk data over a real WAN path: inside stall windows, 175/175 recovery episodes self-diagnosed spurious, zero RTOs, one DSACK per retransmit, throughput pinned at ~165 KB/s on a ~40 Mbit/s path.
Suggested fix — RFC 4015's response, on exiting a recovery flagged spurious:
```go
// in leaveRecovery, before the SndCwnd = Ssthresh deflate:
if s.spuriousRecovery {
if prevCwnd > s.Ssthresh { // cwnd captured at enterRecovery entry, pre-cut
s.Ssthresh = prevCwnd // pipe_prev
}
s.SndCwnd = s.Outstanding + InitialCwnd // FlightSize + IW; slow-start climbs back
s.cc.PostRecovery()
return
}
```
(`SndCwnd` at `enterRecovery` entry is the pre-loss value: `HandleLossDetected` only cuts ssthresh. `spuriousRecovery` is reset per-episode in `enterRecovery`, so the undo cannot leak into a later, genuine recovery.)
**Warning for implementers — do not do the obvious simpler thing.** We measured a direct restore first: `SndCwnd = prevCwnd` (with or without restoring ssthresh) collapsed the same test fixture 24/24. `Outstanding` is small at recovery exit, and `sendData` emits the entire restored-cwnd difference as one line-rate burst, causing real mass loss. RFC 4015's `FlightSize + IW` form exists precisely to prevent that burst.
Prior art in this tracker: #477 ("RFC 4015 - Eiffel Algorithm to detect spurious retransmissions", 2019) covered this area and was closed by the stale-bot in Dec 2023 with no body and no implementation of the *response* — the RFC 3522 *detection* half is what exists in the tree today. This issue is the missing half, with a measured consequence.
Minor related observation: `tcpip.TCPRACKStaticReoWnd` is declared in `tcpip.go` but consulted nowhere in the tcp package — a dead knob. Happy to split that into its own issue.
Happy to send this as a PR with a regression test (CLA in place — see #14101 for the #14092 fix).
## Steps to reproduce
Netstack as a library, two `stack.Stack` instances (SACK on, RACK default, Reno, `channel` endpoints), bulk TCP send through a UDP relay impairing the path with 195 ms RTT, ±15 ms per-leg jitter, and 3 % of packets delayed +30 ms (no loss), 50 Mbit/s cap. To isolate this defect, the `Outstanding` accounting leak (#14092) is fixed first so it is not the limiter. 24 runs/arm:
| | pinned (>15 s) | median | recovery episodes |
|---|---|---|---|
| detection only (as shipped) | 6/24 | 8.0–10.4 s | ~6.5/run, 100 % flagged spurious, 0 RTO |
| + RFC 4015 response above | 0/24 | ~3.1 s | ~5/run, still spurious — no longer compounding |
The episodes keep firing either way (the RACK margin above); the response merely stops them compounding — which is the RFC's intent.
## runsc version
```
N/A — netstack used as a library (gvisor.dev/gvisor Go module), no runsc involved.
```
## docker version (if using docker)
```
N/A
```
## uname
Darwin 25.4.0 arm64 (repro); pinned-throughput fingerprint also measured on windows/amd64 — platform-independent netstack code.
## kubectl (if using Kubernetes)
```
N/A
```
## repo state (if built from source)
gvisor.dev/gvisor v0.0.0-20260701204157-69c2d17aea96; `detectSpuriousRecovery` and `leaveRecovery` are unchanged on master as of 2026-08-12.
## runsc debug logs (if available)
```
N/A
```
Assisted-by: Claude Code
Contributor guide
Assessment
This issue has not been assessed yet.