fix(protocol): don't abandon slow start over a single isolated packet loss

Root cause of "connection takes 20-30s, sometimes 1-2 minutes, to reach
stable throughput" (trickle of KB for a while, then a sudden jump to full
speed): on_loss during SlowStart unconditionally halved cwnd AND
permanently switched to ProbeBandwidth's linear (+1 MTU/RTT) growth on the
very FIRST loss. Real mobile/Wi-Fi links have a non-zero background loss
rate from ordinary wireless noise and handover blips that has nothing to
do with congestion; on such a link the first RTT or two of slow start would
hit a loss, get knocked into linear growth from a still-small window, and
take an enormous number of RTTs to claw back up to full speed - directly
contradicting the module's own stated BBR-inspired design intent, since
real BBR is deliberately loss-tolerant during startup instead of treating
any loss as a hard congestion signal.

Fix: track losses within a short (500ms) window and only pay the full
exit-slow-start-and-halve cost once SLOW_START_LOSS_TOLERANCE (3) losses
land within it - sustained loss is still treated as real congestion.  A
single isolated loss now takes a mild, temporary haircut (cwnd *= 0.8) but
stays in slow start, so exponential growth continues instead of being
abandoned over a one-off dropped packet.
This commit is contained in:
ospab 2026-07-21 18:08:04 +03:00
parent e46c863ef0
commit e31c4b2268
1 changed files with 91 additions and 5 deletions

View File

@ -43,6 +43,11 @@ pub struct CongestionController {
mtu: u64,
/// Min RTT expiry: re-probe after 10 seconds
min_rtt_stamp: Instant,
/// Loss events counted toward SLOW_START_LOSS_TOLERANCE within the
/// current SLOW_START_LOSS_WINDOW (see on_loss's SlowStart arm).
slow_start_losses: u32,
/// Start of the current loss-tolerance window.
slow_start_loss_window_start: Instant,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -67,6 +72,24 @@ const RTO_MAX: Duration = Duration::from_secs(16);
/// Will be replaced by first real measurement within milliseconds.
const INITIAL_RTT: Duration = Duration::from_millis(30);
/// Isolated packet loss during slow start (a single dropped frame from
/// wireless noise, a brief LTE handover blip, etc.) is normal on real
/// mobile/Wi-Fi links and does NOT mean the link is congested. The previous
/// behavior exited slow start and halved cwnd on the very FIRST loss, which
/// on any link with a non-zero background loss rate permanently downgrades
/// the session from exponential growth to linear (+1 MTU/RTT) ProbeBandwidth
/// growth within the first few RTTs - turning what should be a sub-second
/// ramp-up into tens of seconds to minutes before throughput opens up
/// (observed as: a trickle of KB/s, then a sudden jump once cwnd finally
/// claws back up). Only treat loss as a real congestion signal - and pay
/// the full slow-start-exit + halving cost - once this many losses land
/// within SLOW_START_LOSS_WINDOW.
const SLOW_START_LOSS_TOLERANCE: u32 = 3;
/// Window within which SLOW_START_LOSS_TOLERANCE losses must land to count
/// as sustained (rather than isolated) loss. Roughly a few RTTs on a
/// well-connected link, generous on a slow one.
const SLOW_START_LOSS_WINDOW: Duration = Duration::from_millis(500);
impl CongestionController {
pub fn new(mtu: u64) -> Self {
let now = Instant::now();
@ -88,6 +111,8 @@ impl CongestionController {
pacing_rate: initial_pacing,
mtu,
min_rtt_stamp: now,
slow_start_losses: 0,
slow_start_loss_window_start: now,
}
}
@ -197,11 +222,28 @@ impl CongestionController {
match self.phase {
Phase::SlowStart => {
// Exit slow start, set ssthresh to half of cwnd
let now = Instant::now();
if now.duration_since(self.slow_start_loss_window_start) > SLOW_START_LOSS_WINDOW {
// Previous window's losses have aged out - this loss starts a fresh count.
self.slow_start_losses = 0;
self.slow_start_loss_window_start = now;
}
self.slow_start_losses += 1;
if self.slow_start_losses >= SLOW_START_LOSS_TOLERANCE {
// Sustained loss within the window: treat as real congestion.
// Exit slow start, set ssthresh to half of cwnd.
self.ssthresh = self.cwnd / 2;
self.cwnd = self.ssthresh.max(MIN_CWND_PACKETS * self.mtu);
self.phase = Phase::ProbeBandwidth;
tracing::debug!(cwnd = self.cwnd, ssthresh = self.ssthresh, "congestion: loss during slow start");
tracing::debug!(cwnd = self.cwnd, ssthresh = self.ssthresh, "congestion: sustained loss during slow start, exiting");
} else {
// Isolated loss: likely non-congestive noise. Take a mild,
// temporary haircut but keep exponential growth going -
// don't throw away slow start over a single dropped frame.
self.cwnd = (self.cwnd * 8 / 10).max(MIN_CWND_PACKETS * self.mtu);
tracing::debug!(cwnd = self.cwnd, count = self.slow_start_losses, "congestion: isolated loss during slow start, staying in slow start");
}
}
Phase::ProbeBandwidth => {
// Multiplicative decrease: cwnd *= 0.7 (BBR-style, less aggressive than Cubic's 0.5)
@ -290,6 +332,50 @@ mod tests {
assert!(cc.cwnd() < initial);
}
#[test]
fn test_isolated_slow_start_loss_does_not_exit_slow_start() {
// A single dropped packet (wireless noise, a brief handover blip) is
// normal on real links and must not permanently downgrade the
// session from exponential to linear growth.
let mut cc = CongestionController::new(1200);
cc.on_loss(1200);
assert_eq!(cc.phase, Phase::SlowStart, "one isolated loss must not exit slow start");
// It should still shrink the window somewhat (not ignored entirely),
// just far less punishing than the sustained-congestion case.
let after_one = cc.cwnd();
assert!(after_one < INITIAL_CWND_PACKETS * 1200);
}
#[test]
fn test_sustained_slow_start_loss_exits_slow_start() {
// Losses landing close together (within SLOW_START_LOSS_WINDOW) are
// a real congestion signal and must still trigger the harsher
// exit-slow-start + halve response.
let mut cc = CongestionController::new(1200);
for _ in 0..SLOW_START_LOSS_TOLERANCE {
cc.on_loss(1200);
}
assert_eq!(cc.phase, Phase::ProbeBandwidth, "sustained loss must exit slow start");
}
#[test]
fn test_slow_start_loss_window_resets_after_expiry() {
// Two losses far enough apart (window expired between them) must
// each be treated as isolated, not accumulated toward the sustained-
// loss threshold.
let mut cc = CongestionController::new(1200);
cc.on_loss(1200);
assert_eq!(cc.phase, Phase::SlowStart);
// Simulate the window having expired by resetting its start
// directly (std::thread::sleep in a unit test would be flaky/slow).
cc.slow_start_loss_window_start = Instant::now() - SLOW_START_LOSS_WINDOW - Duration::from_millis(1);
cc.on_loss(1200);
assert_eq!(cc.phase, Phase::SlowStart, "a loss after the window expired must restart the count, not accumulate");
assert_eq!(cc.slow_start_losses, 1);
}
#[test]
fn test_can_send_limits() {
let mut cc = CongestionController::new(1200);