google / google/gvisor

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

Open
#14,102 1 comment 0 reactions 2 assignees Claimed by @kerumeto View on GitHub
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.