ostp/ostp-core/src/congestion.rs

458 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Congestion control for the OSTP protocol.
//!
//! Implements a simplified BBR-inspired algorithm that estimates bottleneck
//! bandwidth and minimum RTT to determine the optimal sending rate.
//! This replaces the fixed `retransmit_budget = 8` with an adaptive
//! congestion window that responds to network conditions.
//!
//! RTO calculation follows RFC 6298:
//! SRTT = (1 - α) * SRTT + α * RTT (α = 1/8)
//! RTTVAR = (1 - β) * RTTVAR + β * |SRTT - RTT| (β = 1/4)
//! RTO = SRTT + 4 * RTTVAR
//! clamped to [RTO_MIN, RTO_MAX]
use std::time::{Duration, Instant};
/// Congestion control state for a single OSTP session.
pub struct CongestionController {
/// Current congestion window in bytes (how much can be in-flight)
cwnd: u64,
/// Slow-start threshold in bytes
ssthresh: u64,
/// Current phase
phase: Phase,
/// Minimum RTT observed (for BBR-style bandwidth estimation)
min_rtt: Duration,
/// Smoothed RTT (RFC 6298 SRTT)
srtt: Duration,
/// RTT variance (RFC 6298 RTTVAR)
rttvar: Duration,
/// Whether we have received a first RTT sample
rtt_initialized: bool,
/// Bytes currently in flight (unacknowledged)
bytes_in_flight: u64,
/// Total bytes acknowledged (for bandwidth estimation)
total_acked: u64,
/// Last time we received an ACK
last_ack_time: Instant,
/// Number of loss events in the current window
loss_count: u32,
/// Pacing rate: bytes per second
pacing_rate: u64,
/// MTU estimate (used for cwnd → packet count conversion)
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)]
enum Phase {
/// Exponential growth until loss or ssthresh
SlowStart,
/// Probe bandwidth: additive increase
ProbeBandwidth,
}
/// Initial congestion window: 32 packets × MTU (IW10 is too conservative for modern links)
const INITIAL_CWND_PACKETS: u64 = 32;
/// Minimum cwnd: 2 packets
const MIN_CWND_PACKETS: u64 = 2;
/// Min RTT expiry window (after which we re-probe)
const MIN_RTT_EXPIRY: Duration = Duration::from_secs(10);
/// Minimum RTO (RFC 6298: 1s in TCP; we use 50ms since we own the protocol)
const RTO_MIN: Duration = Duration::from_millis(50);
/// Maximum RTO
const RTO_MAX: Duration = Duration::from_secs(16);
/// Initial RTT estimate — 30 ms is reasonable for a well-connected VPN server.
/// 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();
let initial_cwnd = INITIAL_CWND_PACKETS * mtu;
// Initial pacing: deliver cwnd in ~2 RTTs to fill the pipe quickly
let initial_pacing = initial_cwnd * 1_000_000 / INITIAL_RTT.as_micros().max(1) as u64;
Self {
cwnd: initial_cwnd,
ssthresh: u64::MAX,
phase: Phase::SlowStart,
min_rtt: INITIAL_RTT,
srtt: INITIAL_RTT,
rttvar: INITIAL_RTT / 2,
rtt_initialized: false,
bytes_in_flight: 0,
total_acked: 0,
last_ack_time: now,
loss_count: 0,
pacing_rate: initial_pacing,
mtu,
min_rtt_stamp: now,
slow_start_losses: 0,
slow_start_loss_window_start: now,
}
}
/// Returns the current congestion window in bytes.
pub fn cwnd(&self) -> u64 {
self.cwnd
}
/// Returns the current congestion window in packets.
pub fn cwnd_packets(&self) -> usize {
(self.cwnd / self.mtu).max(MIN_CWND_PACKETS) as usize
}
/// Returns the current pacing rate in bytes/sec.
pub fn pacing_rate(&self) -> u64 {
self.pacing_rate
}
/// Returns the smoothed RTT estimate (SRTT).
pub fn smoothed_rtt(&self) -> Duration {
self.srtt
}
/// Returns the adaptive RTO computed per RFC 6298:
/// RTO = SRTT + 4 * RTTVAR, clamped to [RTO_MIN, RTO_MAX].
///
/// This replaces the static `rto_ms` field in ProtocolMachine so that
/// retransmit timers automatically track changing network conditions.
pub fn rto(&self) -> Duration {
let rttvar4 = self.rttvar.saturating_mul(4);
let rto = self.srtt.saturating_add(rttvar4);
rto.clamp(RTO_MIN, RTO_MAX)
}
/// Returns how many bytes can still be sent.
pub fn available_cwnd(&self) -> u64 {
self.cwnd.saturating_sub(self.bytes_in_flight)
}
/// Returns the recommended retransmit budget per tick.
pub fn retransmit_budget(&self) -> usize {
// Allow retransmitting up to 1/4 of the cwnd in packets per tick
let budget = (self.cwnd_packets() / 4).max(2);
budget.min(64) // cap at 64 to prevent burst
}
/// Check whether we can send more data.
pub fn can_send(&self) -> bool {
self.bytes_in_flight < self.cwnd
}
/// Record that we sent `bytes` of data.
pub fn on_send(&mut self, bytes: u64) {
self.bytes_in_flight = self.bytes_in_flight.saturating_add(bytes);
}
/// Record that `bytes` were acknowledged but WITHOUT a usable RTT sample
/// (e.g. every acked frame was retransmitted, so Karn's algorithm forbids
/// measuring RTT from it). The window still advances; only the RTT estimator
/// is left untouched.
pub fn on_ack_no_rtt(&mut self, bytes: u64) {
let now = Instant::now();
self.bytes_in_flight = self.bytes_in_flight.saturating_sub(bytes);
self.total_acked = self.total_acked.saturating_add(bytes);
self.grow_window(bytes);
self.update_pacing_rate();
self.last_ack_time = now;
}
/// Record that `bytes` were acknowledged with the given RTT sample.
pub fn on_ack(&mut self, bytes: u64, rtt: Duration) {
let now = Instant::now();
self.bytes_in_flight = self.bytes_in_flight.saturating_sub(bytes);
self.total_acked = self.total_acked.saturating_add(bytes);
// Update RTT measurements
self.update_rtt(rtt, now);
self.grow_window(bytes);
self.update_pacing_rate();
self.last_ack_time = now;
}
/// Congestion-window growth shared by both ACK paths (slow start / probe).
fn grow_window(&mut self, bytes: u64) {
// State machine
match self.phase {
Phase::SlowStart => {
// Exponential growth: increase cwnd by acked bytes (doubles per RTT)
self.cwnd = self.cwnd.saturating_add(bytes);
if self.cwnd >= self.ssthresh {
self.phase = Phase::ProbeBandwidth;
tracing::debug!(cwnd = self.cwnd, "congestion: exiting slow start");
}
}
Phase::ProbeBandwidth => {
// TCP Reno Additive Increase: increase cwnd by ~1 MTU per RTT
self.cwnd = self.cwnd.saturating_add(bytes * self.mtu / self.cwnd.max(1));
}
}
}
/// Record a loss event.
pub fn on_loss(&mut self, bytes_lost: u64) {
self.bytes_in_flight = self.bytes_in_flight.saturating_sub(bytes_lost);
self.loss_count += 1;
match self.phase {
Phase::SlowStart => {
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: 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)
self.cwnd = (self.cwnd * 7 / 10).max(MIN_CWND_PACKETS * self.mtu);
tracing::debug!(cwnd = self.cwnd, "congestion: loss, cwnd reduced");
}
}
self.update_pacing_rate();
}
// ── Private ──────────────────────────────────────────────────────────────
fn update_rtt(&mut self, rtt: Duration, now: Instant) {
// Update windowed minimum RTT (for pacing)
if rtt < self.min_rtt || now.duration_since(self.min_rtt_stamp) >= MIN_RTT_EXPIRY {
self.min_rtt = rtt;
self.min_rtt_stamp = now;
}
// Update SRTT and RTTVAR per RFC 6298
if !self.rtt_initialized {
// First measurement: initialize directly
self.srtt = rtt;
self.rttvar = rtt / 2;
self.rtt_initialized = true;
} else {
// RTTVAR = (3/4) * RTTVAR + (1/4) * |SRTT - R|
let diff = if rtt > self.srtt {
rtt - self.srtt
} else {
self.srtt - rtt
};
// Integer-safe: RTTVAR = RTTVAR - RTTVAR/4 + diff/4
self.rttvar = self.rttvar
.saturating_sub(self.rttvar / 4)
.saturating_add(diff / 4);
// SRTT = (7/8) * SRTT + (1/8) * R
self.srtt = self.srtt
.saturating_sub(self.srtt / 8)
.saturating_add(rtt / 8);
}
tracing::trace!(
srtt_ms = self.srtt.as_millis(),
rttvar_ms = self.rttvar.as_millis(),
rto_ms = self.rto().as_millis(),
"congestion: RTT updated"
);
}
fn update_pacing_rate(&mut self) {
// Pacing rate = cwnd / min_rtt (delivery rate target)
let rtt_us = self.min_rtt.as_micros().max(1) as u64;
self.pacing_rate = self.cwnd * 1_000_000 / rtt_us;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_initial_state() {
let cc = CongestionController::new(1200);
assert_eq!(cc.cwnd(), 32 * 1200); // 32 * 1200
assert!(cc.can_send());
assert_eq!(cc.cwnd_packets(), 32);
}
#[test]
fn test_slow_start_growth() {
let mut cc = CongestionController::new(1200);
let initial = cc.cwnd();
cc.on_send(1200);
cc.on_ack(1200, Duration::from_millis(50));
assert!(cc.cwnd() > initial);
}
#[test]
fn test_loss_reduces_cwnd() {
let mut cc = CongestionController::new(1200);
let initial = cc.cwnd();
cc.on_loss(1200);
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);
// Send until cwnd is exhausted
for _ in 0..32 {
cc.on_send(1200);
}
assert!(!cc.can_send()); // cwnd exhausted
}
#[test]
fn test_retransmit_budget() {
let cc = CongestionController::new(1200);
let budget = cc.retransmit_budget();
assert!(budget >= 2);
assert!(budget <= 64);
}
#[test]
fn test_rtt_tracking_first_sample() {
let mut cc = CongestionController::new(1200);
cc.on_send(1200);
cc.on_ack(1200, Duration::from_millis(25));
// After first sample: SRTT = 25ms, RTTVAR = 12ms
assert_eq!(cc.smoothed_rtt(), Duration::from_millis(25));
}
#[test]
fn test_rto_rfc6298() {
let mut cc = CongestionController::new(1200);
// After first sample with RTT=50ms: SRTT=50ms, RTTVAR=25ms, RTO=150ms
cc.on_send(1200);
cc.on_ack(1200, Duration::from_millis(50));
let rto = cc.rto();
// RTO = 50 + 4*25 = 150ms; clamped to [50ms, 16s]
assert!(rto >= RTO_MIN);
assert!(rto <= RTO_MAX);
assert_eq!(rto, Duration::from_millis(150));
}
#[test]
fn test_on_ack_no_rtt_grows_window_without_touching_srtt() {
let mut cc = CongestionController::new(1200);
// Establish a known SRTT with a real sample.
cc.on_send(1200);
cc.on_ack(1200, Duration::from_millis(40));
let srtt_before = cc.smoothed_rtt();
let cwnd_before = cc.cwnd();
// A Karn's-algorithm ACK (all acked frames were retransmitted): window
// must advance, RTT estimate must be untouched.
cc.on_send(1200);
cc.on_ack_no_rtt(1200);
assert!(cc.cwnd() > cwnd_before, "cwnd should still grow on a no-RTT ack");
assert_eq!(cc.smoothed_rtt(), srtt_before, "SRTT must not move on a no-RTT ack");
}
#[test]
fn test_rto_clamp_min() {
let cc = CongestionController::new(1200);
// Even with no RTT samples, RTO should not go below RTO_MIN
assert!(cc.rto() >= RTO_MIN);
}
#[test]
fn test_rto_adapts_after_multiple_samples() {
let mut cc = CongestionController::new(1200);
// Feed several consistent RTT samples
for _ in 0..8 {
cc.on_send(1200);
cc.on_ack(1200, Duration::from_millis(20));
}
// After convergence, RTTVAR should be small → RTO close to SRTT + small margin
let rto = cc.rto();
// Should be well below 100ms (the old hardcoded default)
assert!(rto < Duration::from_millis(200));
assert!(rto >= RTO_MIN);
}
}