srt_runtime/filecc.rs
1//! SRT File Transfer Congestion Control (FileCC) — window-based congestion
2//! control, `draft-sharabayko-srt-01` §5.2 (curated at
3//! `specs/rules/srt-congestion.md`).
4//!
5//! This is the **file/bulk-transfer mode** sibling of [`crate::livecc::LiveCC`]
6//! (§5.1, the **live/streaming mode**'s pacing-only model). Unlike LiveCC,
7//! which only paces `PKT_SND_PERIOD` from a configured `MAX_BW`, FileCC is "a
8//! hybrid Additive Increase Multiplicative Decrease (AIMD) algorithm"
9//! (`specs/rules/srt-congestion.md` L3294-3295) that also grows/shrinks a
10//! congestion window (`CWND_SIZE`), in two strictly-sequential phases: Slow
11//! Start (§5.2.1.1), then Congestion Avoidance (§5.2.1.2) — see [`Phase`].
12//! `PKT_SND_PERIOD` and `CWND_SIZE` are computed independently of and do not
13//! touch LiveCC's state — the two controllers are additive alternatives, not
14//! layered.
15//!
16//! ## Usage
17//!
18//! ```rust
19//! use srt_runtime::filecc::{FileCc, Phase};
20//! use core::time::Duration;
21//!
22//! let mut cc = FileCc::new(0);
23//! assert_eq!(cc.phase(), Phase::SlowStart);
24//!
25//! // On each full ACK, feed the receiver-reported rate/RTT samples:
26//! cc.on_ack(Duration::from_millis(10), 16, 5_000, 1_000, Duration::from_millis(50));
27//!
28//! // A NAK (loss report) ends slow start on the first loss (rule 4/9):
29//! cc.on_loss(8, 16, 0.5);
30//! assert_eq!(cc.phase(), Phase::CongestionAvoidance);
31//! ```
32//!
33//! ## Sans-IO contract
34//!
35//! Like every other engine in this crate, [`FileCc`] never reads a wall
36//! clock: [`FileCc::on_ack`] takes an explicit `now: Duration`; [`FileCc::on_loss`]
37//! and [`FileCc::on_timeout`] are driven purely by caller-reported events.
38//! [`FileCc::tick`] is provided for forward compatibility (mirrors
39//! [`crate::livecc::LiveCC::tick`]) but this section defines no periodic,
40//! time-only state transition beyond the three events already covered.
41//!
42//! ## Shared state, not redefined here
43//!
44//! Per `specs/rules/srt-congestion.md`'s header note, the following are
45//! cross-referenced, not redefined by this module:
46//! - `RC_INTERVAL` / `SYN` = 10 ms — reused from [`crate::arq::FULL_ACK_PERIOD`]
47//! (`specs/rules/srt-arq.md` rule 11), which is the same 10 ms value
48//! (L3421-3425).
49//! - The initial `RTT` estimate (100 ms) — reused from
50//! [`crate::arq::rtt::INITIAL_RTT`] (`specs/rules/srt-arq.md` rule 31)
51//! before the first ACK sample is fed.
52//!
53//! ## Two implementation-defined gaps, flagged (not fabricated)
54//!
55//! `specs/rules/srt-congestion.md` explicitly flags two points the draft
56//! text leaves unspecified. Both are resolved here with a documented choice:
57//!
58//! 1. **EWMA weight for `RECEIVING_RATE`/`EST_LINK_CAPACITY` smoothing**
59//! (gap, L3678-3681 / doc's "Gaps" section). Chosen: **this engine does
60//! not smooth them itself** — [`FileCc::on_ack`] takes `receiving_rate_pps`
61//! and `est_link_capacity_pps` as already-current values and stores them
62//! verbatim, exactly the same treatment this section already gives `RTT`
63//! (cross-ref rule/L3409-3411: "receiver-reported and sender-smoothed",
64//! with the smoothing defined *elsewhere*, not here). This keeps the
65//! engine's formulas directly hand-computable from fed inputs (no hidden
66//! internal averaging to reverse-engineer), and leaves the actual
67//! smoothing decision to the caller/sender loop — consistent with how RTT
68//! is already layered in this crate ([`crate::arq::rtt::RttEstimator`] is
69//! a separate, explicit component, not folded into the ARQ sender).
70//! 2. **Packet-pairs probing mechanics** (gap, L3686-3689). Out of scope:
71//! this module consumes `RECEIVING_RATE`/`EST_LINK_CAPACITY` as inputs: it
72//! does not implement the receiver-side inter-arrival-time measurement or
73//! packet-pairs probing described in §5.2.1.3 (rules 30-37) that would
74//! *produce* those inputs on a real receiver. That measurement pipeline is
75//! receiver-side and is not part of this sender-side congestion-control
76//! state machine.
77//!
78//! A third, smaller implementation choice not flagged as a spec gap: the
79//! `DecRandom` *distribution* (uniform in `[1, AvgNAKNum]`, clamped to 1) is
80//! given (rule 23), but no source of randomness is specified. This module
81//! uses a small internal, deterministic xorshift PRNG instead of pulling in
82//! a `rand` dependency for a `no_std` crate — true entropy is not required
83//! for correctness here, `DecRandom` only staggers repeat-decrease timing
84//! across congestion periods (rule 24). `DecRandom` is rounded to the
85//! nearest whole number (`FileCc::next_dec_random`, internal): Step 4's gate
86//! (`NAKCount == DecCount * DecRandom`) compares two integer counters, so a
87//! fractional draw makes that equality unsatisfiable after the first check.
88//!
89//! ## A quirk of the draft's own Step 4 pseudocode (verified, not a bug)
90//!
91//! `NAKCount`/`DecCount` reset to 1 at the start of a congestion period and
92//! are ONLY incremented again *inside* Step 4's own conditional (rule 28) —
93//! never unconditionally per NAK. So once the immediate post-reset check
94//! (`1 == 1*DecRandom`) fails — i.e. whenever the drawn `DecRandom != 1` —
95//! neither counter ever moves again for the rest of the period, and Step 4
96//! goes silent until the next congestion period redraws `DecRandom`. This is
97//! a property of the draft text as transcribed (`specs/rules/srt-congestion.md`,
98//! confirmed against the actual reference implementation, `libsrt`
99//! `congctl.cpp`, which uses a different formulation — `NAKCount % DecRandom
100//! == 0` with both counters incrementing on every same-period NAK
101//! regardless of outcome — that does not share this one-shot property).
102//! Adopting libsrt's formulation would be a spec-posture departure this
103//! crate is not designated for; this implementation stays literal to the
104//! curated draft text. See `filecc::tests::repeat_decrease_is_a_one_shot_per_period_once_dec_random_exceeds_one`.
105
106use core::time::Duration;
107
108use crate::arq::FULL_ACK_PERIOD;
109use crate::arq::rtt::INITIAL_RTT;
110use crate::arq::seq::{seq_diff, seq_gt};
111
112/// `RC_INTERVAL` = `SYN` = 10 ms (`specs/rules/srt-congestion.md` L3421-3425),
113/// reused from [`crate::arq::FULL_ACK_PERIOD`] (same value, `srt-arq.md` rule
114/// 11) rather than redefined here.
115const RC_INTERVAL: Duration = FULL_ACK_PERIOD;
116
117/// `S` — "the SRT packet size (in terms of IP payload) in bytes. SRT treats
118/// 1500 bytes as a standard packet size." (`specs/rules/srt-congestion.md`
119/// L3539-3540, verbatim). A different quantity from LiveCC's EWMA-derived
120/// `PktSize` — see the module doc's header note.
121const S_BYTES: f64 = 1500.0;
122
123/// Slow start's fixed `PKT_SND_PERIOD` — "1 microsecond ... in order to send
124/// packets as fast as possible, but not at an infinite rate"
125/// (`specs/rules/srt-congestion.md` L3338-3340, verbatim; rule 6).
126const SLOW_START_PKT_SND_PERIOD_US: f64 = 1.0;
127
128/// Slow start's initial `CWND_SIZE` — 16 packets (`specs/rules/srt-congestion.md`
129/// L3340-3341, rule 7).
130const INITIAL_CWND_SIZE: f64 = 16.0;
131
132/// `LastDecPeriod`'s initial value — 1 microsecond (`specs/rules/srt-congestion.md`
133/// L3521-3524).
134const INITIAL_LAST_DEC_PERIOD_US: f64 = 1.0;
135
136/// Default `MAX_CWND_SIZE` — the spec suggests "the maximum receiver buffer
137/// size (12 MB)" as the threshold (`specs/rules/srt-congestion.md` L3344-3345,
138/// rule 8), worded as settable/recommended, not a hardwired constant.
139/// Expressed here in packets at the standard packet size `S` (1500 bytes):
140/// `12_000_000 / 1500 = 8000` packets. Override with [`FileCc::set_max_cwnd_size`].
141const DEFAULT_MAX_CWND_SIZE: f64 = 8_000.0;
142
143/// The NAK-tolerance loss-ratio threshold — "less than 2%"
144/// (`specs/rules/srt-congestion.md` L3572-3579, rule 16).
145const LOSS_RATIO_TOLERANCE: f64 = 0.02;
146
147/// The repeat-decrease rate-backoff multiplier — `1.03`
148/// (`specs/rules/srt-congestion.md` L3602-3604 / L3652-3654, rules 19 & 27).
149const RATE_BACKOFF_FACTOR: f64 = 1.03;
150
151/// The `AvgNAKNum` EWMA weights — `0.97`/`0.03`
152/// (`specs/rules/srt-congestion.md` L3606-3608, rule 20, verbatim).
153const AVG_NAK_NUM_OLD_WEIGHT: f64 = 0.97;
154const AVG_NAK_NUM_NEW_WEIGHT: f64 = 0.03;
155
156/// The repeat-decrease `DecCount` ceiling — decreases stop once `DecCount`
157/// exceeds 5 within a congestion period (`specs/rules/srt-congestion.md`
158/// L3652-3658, rule "Step 4").
159const MAX_DEC_COUNT: u32 = 5;
160
161/// `inc`'s multiplier in the Step 5 rate-increase formula (`specs/rules/srt-congestion.md`
162/// L3498-3517, verbatim: `0.0000015`).
163const INC_SCALE: f64 = 0.0000015;
164
165/// Microseconds per second — the units conversion every `PKT_SND_PERIOD`/rate
166/// formula in this module divides or multiplies by (`specs/rules/srt-congestion.md`,
167/// recurring throughout §5.2.1.2's Steps 3/5/6, sourced from the same
168/// L3498-3517 code block as [`INC_SCALE`]).
169const US_PER_SEC: f64 = 1_000_000.0;
170
171/// `lossBandwidth`'s doubling factor in the Step 5 rate-increase formula
172/// (`specs/rules/srt-congestion.md` L3498-3517 code block, verbatim:
173/// `lossBandwidth = 2 * (1000000 / LastDecPeriod)`).
174const LOSS_BANDWIDTH_FACTOR: f64 = 2.0;
175
176/// The `linkCapacity / 9` clamp divisor in the Step 5 rate-increase formula
177/// (`specs/rules/srt-congestion.md` L3498-3517 code block, verbatim:
178/// `if (... && (linkCapacity / 9) < B) B = linkCapacity / 9;`) — a
179/// spec-mandated tuning constant, not independently derived.
180const LINK_CAPACITY_CLAMP_DIVISOR: f64 = 9.0;
181
182/// Bits per byte, used to convert `S` (packet size in bytes) to bits in the
183/// Step 5 rate-increase formula (`specs/rules/srt-congestion.md` L3498-3517
184/// code block, verbatim: `inc = pow(10.0, ceil(log10(B * S * 8))) * 0.0000015 / S`).
185const BITS_PER_BYTE: f64 = 8.0;
186
187/// FileCC algorithm phase (`specs/rules/srt-congestion.md` rules 4-5). Slow
188/// Start (§5.2.1.1) runs exactly once at the start of a connection; it
189/// transitions to Congestion Avoidance (§5.2.1.2) on the first loss,
190/// `CWND_SIZE` exceeding its maximum, or a timeout — and never transitions
191/// back (L3323-3326).
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193#[non_exhaustive]
194pub enum Phase {
195 /// §5.2.1.1 — probes for available bandwidth; runs exactly once.
196 SlowStart,
197 /// §5.2.1.2 — entered once slow start ends; the steady-state AIMD phase.
198 CongestionAvoidance,
199}
200
201impl Phase {
202 /// The §5.2 phase name.
203 pub fn name(&self) -> &'static str {
204 match self {
205 Phase::SlowStart => "SlowStart",
206 Phase::CongestionAvoidance => "CongestionAvoidance",
207 }
208 }
209}
210
211broadcast_common::impl_spec_display!(Phase);
212
213/// A minimal, seedable xorshift64* PRNG — see the module doc's third
214/// implementation-choice note (`DecRandom`'s source of randomness is
215/// unspecified by the draft; only its distribution is given, rule 23).
216#[derive(Debug, Clone, Copy)]
217struct XorShift64(u64);
218
219impl XorShift64 {
220 /// A fixed, non-zero default seed. Deterministic on purpose: reproducible
221 /// test/debug behavior, and true entropy is not required for correctness
222 /// (see the module doc).
223 const DEFAULT_SEED: u64 = 0x9E37_79B9_7F4A_7C15;
224
225 fn new(seed: u64) -> Self {
226 XorShift64(if seed == 0 { Self::DEFAULT_SEED } else { seed })
227 }
228
229 /// Next pseudo-random value, uniform in `[0.0, 1.0)`.
230 fn next_unit(&mut self) -> f64 {
231 let mut x = self.0;
232 x ^= x << 13;
233 x ^= x >> 7;
234 x ^= x << 17;
235 self.0 = x;
236 let r = x.wrapping_mul(0x2545_F491_4F6C_DD1D);
237 // Top 53 bits as a double in [0, 1).
238 (r >> 11) as f64 * (1.0 / (1u64 << 53) as f64)
239 }
240}
241
242/// `min(a, b)` for `f64`, without relying on `f64::min` (kept as a plain
243/// comparison so this module has no dependency on any transcendental-math
244/// support — see [`next_power_of_10`]'s doc for why that matters for the
245/// `no_std` build).
246fn fmin(a: f64, b: f64) -> f64 {
247 if a < b { a } else { b }
248}
249
250/// `max(a, b)` for `f64` — see [`fmin`].
251fn fmax(a: f64, b: f64) -> f64 {
252 if a > b { a } else { b }
253}
254
255/// Round-half-away-from-zero for `f64`, matching `f64::round`'s semantics —
256/// without calling it, since `f64::round` is a `std`-only method (`core`
257/// float types don't expose it; see [`fmin`]'s doc for why this module avoids
258/// any transcendental/libm dependency). `as i64` float-to-int casts are
259/// saturating in Rust, so this is safe for any finite input in `i64` range —
260/// `DecRandom` values are always small and positive.
261fn fround(x: f64) -> f64 {
262 let truncated = x as i64 as f64;
263 let diff = x - truncated;
264 if diff >= 0.5 {
265 truncated + 1.0
266 } else if diff <= -0.5 {
267 truncated - 1.0
268 } else {
269 truncated
270 }
271}
272
273/// `pow(10.0, ceil(log10(x)))` for `x > 0` — the smallest power of 10 that is
274/// `>= x` (`specs/rules/srt-congestion.md` Step 5, L3506, verbatim source:
275/// `pow(10.0, ceil(log10(B * S * 8))) * 0.0000015 / S`).
276///
277/// Implemented via repeated multiplication/division instead of calling
278/// `log10`/`powf` directly: those are `libm` functions not available in
279/// `core` (this crate builds `no_std`, including on `thumbv7em-none-eabi`
280/// with no libm), whereas this loop only uses basic arithmetic and
281/// comparisons. Algebraically identical to the spec's formula for `x > 0`.
282fn next_power_of_10(x: f64) -> f64 {
283 debug_assert!(x > 0.0, "next_power_of_10 is only defined for x > 0");
284 let mut p = 1.0_f64;
285 while p < x {
286 p *= 10.0;
287 }
288 while p / 10.0 >= x {
289 p /= 10.0;
290 }
291 p
292}
293
294/// SRT File Transfer Congestion Control — sender-side window + pacing state
295/// (`draft-sharabayko-srt-01` §5.2). See the module doc for the full formula
296/// mapping, the sans-IO contract, and the two flagged spec gaps.
297#[derive(Debug, Clone)]
298pub struct FileCc {
299 phase: Phase,
300 /// `CWND_SIZE`, in packets (rule 7; Step 3 of both phases).
301 cwnd_size: f64,
302 /// `MAX_CWND_SIZE` (rule 8) — slow start ends once `cwnd_size` exceeds
303 /// this.
304 max_cwnd_size: f64,
305 /// `PKT_SND_PERIOD`, in microseconds.
306 pkt_snd_period_us: f64,
307 /// `LastRCTime` — `None` until the first ACK (rate-control gate always
308 /// passes on the very first call; the draft does not give an initial
309 /// value, this is the implementation-defined resolution).
310 last_rc_time: Option<Duration>,
311 /// `LAST_ACK_SEQNO` — initialized to the caller-supplied `initial_seqno`
312 /// (representing "no data packet acknowledged yet"; the draft does not
313 /// give LAST_ACK_SEQNO's initial value either).
314 last_ack_seqno: u32,
315 /// `bLoss` — initial value `False` (rule 12).
316 b_loss: bool,
317 /// `LastDecPeriod`, in microseconds — initial value 1 microsecond
318 /// (item under Step 5).
319 last_dec_period_us: f64,
320 /// `LastDecSeq` — `None` until the first Congestion-Avoidance-phase NAK
321 /// (so the very first CA-phase loss always starts a new congestion
322 /// period, rule 25).
323 last_dec_seq: Option<u32>,
324 /// `AvgNAKNum` — initial value 0 (rule "State variables", verbatim def).
325 avg_nak_num: f64,
326 /// `NAKCount` — initial value 0.
327 nak_count: u32,
328 /// `DecCount` — initial value 0.
329 dec_count: u32,
330 /// `DecRandom` — computed per congestion period (rule 23).
331 dec_random: f64,
332 /// `RECEIVING_RATE`, packets/sec — see the module doc's gap-1 resolution
333 /// (stored verbatim from [`FileCc::on_ack`], not smoothed here).
334 receiving_rate_pps: u64,
335 /// `EST_LINK_CAPACITY`, packets/sec — see the module doc's gap-1
336 /// resolution.
337 est_link_capacity_pps: u64,
338 /// `RTT`, sender-smoothed elsewhere (`crate::arq::rtt::RttEstimator`) and
339 /// fed in via [`FileCc::on_ack`]. Initialized to
340 /// [`crate::arq::rtt::INITIAL_RTT`] before the first ACK.
341 rtt: Duration,
342 /// `MAX_BW`, bytes/sec — Step 6 clamp (rule 15: file transfer only uses
343 /// `MAXBW_SET`; `None` means unbounded, the default for file transfer).
344 max_bw_bytes_per_sec: Option<u64>,
345 rng: XorShift64,
346}
347
348impl FileCc {
349 /// A fresh FileCC engine in Slow Start (rule 4: "runs exactly once at
350 /// the beginning of a connection").
351 ///
352 /// `initial_seqno` seeds `LAST_ACK_SEQNO` (see the field doc) — pass the
353 /// connection's initial sequence number (ISN) minus one, or the ISN
354 /// itself if no data has been sent yet; either way the first ACK's
355 /// `CWND_SIZE` growth (Step 3) reflects exactly the packets actually
356 /// acknowledged since then.
357 pub fn new(initial_seqno: u32) -> Self {
358 FileCc {
359 phase: Phase::SlowStart,
360 cwnd_size: INITIAL_CWND_SIZE,
361 max_cwnd_size: DEFAULT_MAX_CWND_SIZE,
362 pkt_snd_period_us: SLOW_START_PKT_SND_PERIOD_US,
363 last_rc_time: None,
364 last_ack_seqno: initial_seqno,
365 b_loss: false,
366 last_dec_period_us: INITIAL_LAST_DEC_PERIOD_US,
367 last_dec_seq: None,
368 avg_nak_num: 0.0,
369 nak_count: 0,
370 dec_count: 0,
371 dec_random: 1.0,
372 receiving_rate_pps: 0,
373 est_link_capacity_pps: 0,
374 rtt: INITIAL_RTT,
375 max_bw_bytes_per_sec: None,
376 rng: XorShift64::new(XorShift64::DEFAULT_SEED),
377 }
378 }
379
380 /// The current algorithm phase.
381 pub fn phase(&self) -> Phase {
382 self.phase
383 }
384
385 /// The current `CWND_SIZE`, in packets.
386 pub fn cwnd_size(&self) -> f64 {
387 self.cwnd_size
388 }
389
390 /// The current `PKT_SND_PERIOD`, in microseconds (as an `f64` — the
391 /// Congestion Avoidance formulas are inherently fractional).
392 pub fn pkt_snd_period_us(&self) -> f64 {
393 self.pkt_snd_period_us
394 }
395
396 /// The current `PKT_SND_PERIOD` as a [`Duration`], for a sender to
397 /// consult before transmitting the next packet (same role as
398 /// [`crate::livecc::LiveCC::on_ack_received`]'s return value).
399 ///
400 /// Truncates the microsecond value toward zero (consistent with this
401 /// crate's existing integer-truncation convention, e.g. LiveCC's
402 /// `PKT_SND_PERIOD`).
403 pub fn pkt_snd_period(&self) -> Duration {
404 Duration::from_micros(self.pkt_snd_period_us as u64)
405 }
406
407 /// The current `MAX_CWND_SIZE` (rule 8), in packets.
408 pub fn max_cwnd_size(&self) -> f64 {
409 self.max_cwnd_size
410 }
411
412 /// Reconfigure `MAX_CWND_SIZE` — rule 8 calls the 12 MB-derived default
413 /// a settable/recommended value, not a hardwired constant.
414 pub fn set_max_cwnd_size(&mut self, packets: f64) {
415 self.max_cwnd_size = packets;
416 }
417
418 /// The current `MAX_BW` clamp, in bytes/sec (`None` = unbounded).
419 pub fn max_bw_bytes_per_sec(&self) -> Option<u64> {
420 self.max_bw_bytes_per_sec
421 }
422
423 /// Reconfigure `MAX_BW` (rule 15: `MAXBW_SET` mode only applies to file
424 /// transfer; there is no default, `None` is unbounded).
425 pub fn set_max_bw_bytes_per_sec(&mut self, max_bw: Option<u64>) {
426 self.max_bw_bytes_per_sec = max_bw;
427 }
428
429 /// The last `RECEIVING_RATE` fed via [`FileCc::on_ack`], packets/sec.
430 pub fn receiving_rate_pps(&self) -> u64 {
431 self.receiving_rate_pps
432 }
433
434 /// The last `EST_LINK_CAPACITY` fed via [`FileCc::on_ack`], packets/sec.
435 pub fn est_link_capacity_pps(&self) -> u64 {
436 self.est_link_capacity_pps
437 }
438
439 /// The last `RTT` fed via [`FileCc::on_ack`] (or the initial 100 ms
440 /// default before the first ACK).
441 pub fn rtt(&self) -> Duration {
442 self.rtt
443 }
444
445 /// `bLoss` — `true` if a loss has been reported since the last rate
446 /// increase (rule 11-12).
447 pub fn b_loss(&self) -> bool {
448 self.b_loss
449 }
450
451 /// `AvgNAKNum` — the average number of NAKs per congestion period
452 /// (rule 20's EWMA).
453 pub fn avg_nak_num(&self) -> f64 {
454 self.avg_nak_num
455 }
456
457 /// `NAKCount` — NAKs received so far in the current congestion period.
458 pub fn nak_count(&self) -> u32 {
459 self.nak_count
460 }
461
462 /// `DecCount` — rate decreases applied so far in the current congestion
463 /// period.
464 pub fn dec_count(&self) -> u32 {
465 self.dec_count
466 }
467
468 /// `LastDecSeq` — the largest sent sequence number at the last rate
469 /// decrease / congestion-period boundary, or `None` if no
470 /// Congestion-Avoidance-phase loss has occurred yet.
471 pub fn last_dec_seq(&self) -> Option<u32> {
472 self.last_dec_seq
473 }
474
475 /// `LastDecPeriod`, in microseconds (item under Step 5; initial value 1
476 /// microsecond).
477 pub fn last_dec_period_us(&self) -> f64 {
478 self.last_dec_period_us
479 }
480
481 /// On full-ACK packet reception (`specs/rules/srt-congestion.md`,
482 /// §5.2.1.1 "(1) On ACK packet reception" / §5.2.1.2 "(1) On ACK packet
483 /// reception"). Only full ACKs trigger a rate increase (rule 3,
484 /// L3313-3314) — a light ACK must not be passed to this method.
485 ///
486 /// `now` — the current time (Step 1's `currTime`).
487 /// `ack_seqno` — the ACK's acknowledged sequence number (`ACK_SEQNO`).
488 /// `receiving_rate_pps` / `est_link_capacity_pps` — the ACK-carried,
489 /// receiver-reported rate estimates (§5.2.1.3); see the module doc's
490 /// gap-1 resolution for why these are stored verbatim, not smoothed
491 /// here.
492 /// `rtt` — the current (already-smoothed elsewhere) RTT estimate.
493 pub fn on_ack(
494 &mut self,
495 now: Duration,
496 ack_seqno: u32,
497 receiving_rate_pps: u64,
498 est_link_capacity_pps: u64,
499 rtt: Duration,
500 ) {
501 self.receiving_rate_pps = receiving_rate_pps;
502 self.est_link_capacity_pps = est_link_capacity_pps;
503 self.rtt = rtt;
504
505 // Step 1 (identical gate in both phases, L3365-3371 / L3455-3461):
506 // if (currTime - LastRCTime < RC_INTERVAL) { keep; stop; }
507 if let Some(last) = self.last_rc_time
508 && now.saturating_sub(last) < RC_INTERVAL
509 {
510 return;
511 }
512 // Step 2: LastRCTime = currTime.
513 self.last_rc_time = Some(now);
514
515 match self.phase {
516 Phase::SlowStart => self.on_ack_slow_start(ack_seqno),
517 Phase::CongestionAvoidance => self.on_ack_congestion_avoidance(),
518 }
519 }
520
521 /// Slow start's ACK handling, Steps 3-5 (L3381-3401).
522 fn on_ack_slow_start(&mut self, ack_seqno: u32) {
523 // Step 3: CWND_SIZE += ACK_SEQNO - LAST_ACK_SEQNO (wrap-safe delta —
524 // reusing arq::seq, not redefined here, per the crate's existing
525 // sequence-arithmetic convention).
526 let delta = seq_diff(ack_seqno, self.last_ack_seqno);
527 self.cwnd_size += f64::from(delta);
528 // Step 4: LAST_ACK_SEQNO = ACK_SEQNO.
529 self.last_ack_seqno = ack_seqno;
530
531 // Step 5: CWND_SIZE exceeding MAX_CWND_SIZE ends slow start.
532 if self.cwnd_size > self.max_cwnd_size {
533 self.end_slow_start();
534 }
535 }
536
537 /// Congestion Avoidance's ACK handling, Steps 3-6 (L3479-3559).
538 fn on_ack_congestion_avoidance(&mut self) {
539 // Step 3: CWND_SIZE = RECEIVING_RATE*(RTT+RC_INTERVAL)/1000000 + 16
540 // (recomputed directly, not incrementally, unlike slow start).
541 self.cwnd_size = self.receiving_rate_pps as f64 * self.rtt_plus_rc_interval_us()
542 / US_PER_SEC
543 + INITIAL_CWND_SIZE;
544
545 // Step 4: loss-in-flight guard.
546 if self.b_loss {
547 self.b_loss = false;
548 return;
549 }
550
551 // Step 5: rate-increase formula (L3498-3517, verbatim).
552 let loss_bandwidth = LOSS_BANDWIDTH_FACTOR * (US_PER_SEC / self.last_dec_period_us);
553 let link_capacity = fmin(loss_bandwidth, self.est_link_capacity_pps as f64);
554 let mut b = link_capacity - US_PER_SEC / self.pkt_snd_period_us;
555 if self.pkt_snd_period_us > self.last_dec_period_us
556 && (link_capacity / LINK_CAPACITY_CLAMP_DIVISOR) < b
557 {
558 b = link_capacity / LINK_CAPACITY_CLAMP_DIVISOR;
559 }
560 let inc = if b <= 0.0 {
561 1.0 / S_BYTES
562 } else {
563 let raw = next_power_of_10(b * S_BYTES * BITS_PER_BYTE) * INC_SCALE / S_BYTES;
564 fmax(raw, 1.0 / S_BYTES)
565 };
566 let rc_interval_us = RC_INTERVAL.as_micros() as f64;
567 self.pkt_snd_period_us = (self.pkt_snd_period_us * rc_interval_us)
568 / (self.pkt_snd_period_us * inc + rc_interval_us);
569
570 // Step 6: MAX_BW clamp, if configured (rule 15).
571 if let Some(max_bw) = self.max_bw_bytes_per_sec {
572 let min_period_us = US_PER_SEC / (max_bw as f64 / S_BYTES);
573 if self.pkt_snd_period_us < min_period_us {
574 self.pkt_snd_period_us = min_period_us;
575 }
576 }
577 }
578
579 /// `RTT + RC_INTERVAL`, in microseconds — shared by the CWND_SIZE
580 /// formula (Step 3, both event handlers that use it).
581 fn rtt_plus_rc_interval_us(&self) -> f64 {
582 self.rtt.as_micros() as f64 + RC_INTERVAL.as_micros() as f64
583 }
584
585 /// Ends slow start, computing `PKT_SND_PERIOD` per the shared Step 5
586 /// formula (L3392-3401, reused verbatim by rules 9 and 10 — NAK and RTO
587 /// during slow start).
588 fn end_slow_start(&mut self) {
589 self.phase = Phase::CongestionAvoidance;
590 self.pkt_snd_period_us = if self.receiving_rate_pps > 0 {
591 US_PER_SEC / self.receiving_rate_pps as f64
592 } else {
593 self.cwnd_size / self.rtt_plus_rc_interval_us()
594 };
595 }
596
597 /// On a loss report (NAK) packet reception.
598 ///
599 /// - During Slow Start (rule 9): ends slow start; `PKT_SND_PERIOD` is
600 /// set exactly as in the ACK Step 5 formula. `lost_seqno`,
601 /// `largest_sent_seqno`, and `loss_ratio` are not consulted by this
602 /// phase's handling (the draft's rule 9 gives no further formula).
603 /// - During Congestion Avoidance (§5.2.1.2 "(2)", L3568-3658): runs the
604 /// full bLoss/loss-ratio-tolerance/congestion-period/repeat-decrease
605 /// state machine.
606 ///
607 /// `lost_seqno` — the sequence number reported lost by this NAK.
608 /// `largest_sent_seqno` — the largest sequence number sent so far
609 /// (recorded as the new `LastDecSeq` on a decrease, rules 22/29).
610 /// `loss_ratio` — the sender's current estimated loss ratio (rule 16's
611 /// "less than 2%" tolerance check), e.g. lost/sent over a recent window.
612 pub fn on_loss(&mut self, lost_seqno: u32, largest_sent_seqno: u32, loss_ratio: f64) {
613 match self.phase {
614 Phase::SlowStart => self.end_slow_start(),
615 Phase::CongestionAvoidance => {
616 self.on_loss_congestion_avoidance(lost_seqno, largest_sent_seqno, loss_ratio)
617 }
618 }
619 }
620
621 /// Congestion Avoidance's NAK handling (L3568-3658).
622 fn on_loss_congestion_avoidance(
623 &mut self,
624 lost_seqno: u32,
625 largest_sent_seqno: u32,
626 loss_ratio: f64,
627 ) {
628 // Step 1: bLoss = True.
629 self.b_loss = true;
630
631 // Step 2: loss-ratio tolerance (rule 16-17).
632 if loss_ratio < LOSS_RATIO_TOLERANCE {
633 self.last_dec_period_us = self.pkt_snd_period_us;
634 return;
635 }
636
637 // Step 3: new congestion period? (rule 25: the lost seq is greater
638 // than LastDecSeq).
639 let is_new_period = match self.last_dec_seq {
640 None => true,
641 Some(last) => seq_gt(lost_seqno, last),
642 };
643
644 if is_new_period {
645 self.last_dec_period_us = self.pkt_snd_period_us;
646 self.pkt_snd_period_us *= RATE_BACKOFF_FACTOR;
647 // rule 20: AvgNAKNum = 0.97*AvgNAKNum + 0.03*NAKCount (using the
648 // just-finished period's NAKCount, before it is reset below).
649 self.avg_nak_num = AVG_NAK_NUM_OLD_WEIGHT * self.avg_nak_num
650 + AVG_NAK_NUM_NEW_WEIGHT * self.nak_count as f64;
651 self.nak_count = 1;
652 self.dec_count = 1;
653 self.last_dec_seq = Some(largest_sent_seqno);
654 self.dec_random = self.next_dec_random();
655 return;
656 }
657
658 // Step 4: repeat decrease within the same congestion period
659 // (rule "Step 4": DecCount<=5 && NAKCount==DecCount*DecRandom).
660 if self.dec_count <= MAX_DEC_COUNT
661 && self.nak_count as f64 == self.dec_count as f64 * self.dec_random
662 {
663 self.pkt_snd_period_us *= RATE_BACKOFF_FACTOR;
664 self.dec_count += 1;
665 self.nak_count += 1;
666 self.last_dec_seq = Some(largest_sent_seqno);
667 }
668 }
669
670 /// `DecRandom` — "a random number between 1 and the average number of
671 /// NAKs per congestion period (AvgNAKNum)", clamped to 1 if the draw is
672 /// below 1 (rule 23). See the module doc for the PRNG source note.
673 ///
674 /// Rounded to the nearest whole number: Step 4's repeat-decrease gate
675 /// (`NAKCount == DecCount * DecRandom`) compares against the integer
676 /// counters `NAKCount`/`DecCount`, so it only means "every `DecRandom`-th
677 /// NAK" — and is satisfiable — when `DecRandom` is itself integer-valued.
678 /// A raw fractional draw (`AvgNAKNum > 1`) makes that equality a
679 /// measure-zero float comparison that (almost) never holds again after
680 /// the congestion period's first decrease, silently disabling repeat
681 /// decrease for the rest of the period.
682 fn next_dec_random(&mut self) -> f64 {
683 let hi = fmax(self.avg_nak_num, 1.0);
684 let r = self.rng.next_unit();
685 let val = fround(1.0 + r * (hi - 1.0));
686 fmax(val, 1.0)
687 }
688
689 /// On a retransmission timeout (RTO) event.
690 ///
691 /// During Slow Start (rule 10): ends slow start, `PKT_SND_PERIOD` set
692 /// exactly as in the ACK Step 5 formula (same as [`FileCc::on_loss`]'s
693 /// slow-start handling). Once in Congestion Avoidance, this section does
694 /// not describe an RTO-specific FileCC reaction (RTO-driven
695 /// retransmission itself is the ARQ engine's concern, `srt-arq.md`) —
696 /// calling this while already in Congestion Avoidance is a no-op.
697 pub fn on_timeout(&mut self) {
698 if self.phase == Phase::SlowStart {
699 self.end_slow_start();
700 }
701 }
702
703 /// Time-driven tick — currently a no-op (mirrors
704 /// [`crate::livecc::LiveCC::tick`]). Provided for forward compatibility:
705 /// this section's algorithm is driven entirely by the three named
706 /// events (send / ACK / timeout, rule 5); there is no additional
707 /// periodic-only state transition to run here.
708 pub fn tick(&mut self, _now: Duration) {}
709}
710
711impl Default for FileCc {
712 /// A fresh engine with `initial_seqno = 0` (see [`FileCc::new`]).
713 fn default() -> Self {
714 FileCc::new(0)
715 }
716}
717
718#[cfg(test)]
719mod tests {
720 use super::*;
721
722 #[test]
723 fn starts_in_slow_start_with_spec_initial_values() {
724 let cc = FileCc::new(0);
725 assert_eq!(cc.phase(), Phase::SlowStart);
726 assert_eq!(cc.cwnd_size(), 16.0);
727 assert_eq!(cc.pkt_snd_period_us(), 1.0);
728 assert_eq!(cc.max_cwnd_size(), 8_000.0);
729 assert!(!cc.b_loss());
730 assert_eq!(cc.avg_nak_num(), 0.0);
731 assert_eq!(cc.nak_count(), 0);
732 assert_eq!(cc.dec_count(), 0);
733 assert_eq!(cc.last_dec_seq(), None);
734 assert_eq!(cc.last_dec_period_us(), 1.0);
735 assert_eq!(cc.rtt(), Duration::from_millis(100));
736 assert_eq!(cc.max_bw_bytes_per_sec(), None);
737 }
738
739 #[test]
740 fn slow_start_cwnd_grows_by_ack_seqno_delta() {
741 let mut cc = FileCc::new(0);
742 cc.on_ack(
743 Duration::from_millis(10),
744 5,
745 0,
746 0,
747 Duration::from_millis(100),
748 );
749 assert_eq!(cc.cwnd_size(), 21.0); // 16 + (5 - 0)
750 assert_eq!(
751 cc.pkt_snd_period_us(),
752 1.0,
753 "fixed at 1us during slow start"
754 );
755 }
756
757 #[test]
758 fn rate_control_gate_blocks_updates_within_rc_interval() {
759 let mut cc = FileCc::new(0);
760 cc.on_ack(
761 Duration::from_millis(10),
762 5,
763 0,
764 0,
765 Duration::from_millis(100),
766 );
767 assert_eq!(cc.cwnd_size(), 21.0);
768 // Only 1ms later (< RC_INTERVAL = 10ms): must be a no-op.
769 cc.on_ack(
770 Duration::from_millis(11),
771 999,
772 0,
773 0,
774 Duration::from_millis(100),
775 );
776 assert_eq!(
777 cc.cwnd_size(),
778 21.0,
779 "gate must block an ACK inside RC_INTERVAL"
780 );
781 }
782
783 #[test]
784 fn loss_during_slow_start_transitions_to_congestion_avoidance() {
785 let mut cc = FileCc::new(0);
786 assert_eq!(cc.phase(), Phase::SlowStart);
787 cc.on_loss(10, 10, 0.9);
788 assert_eq!(cc.phase(), Phase::CongestionAvoidance);
789 }
790
791 #[test]
792 fn timeout_during_slow_start_transitions_to_congestion_avoidance() {
793 let mut cc = FileCc::new(0);
794 cc.on_timeout();
795 assert_eq!(cc.phase(), Phase::CongestionAvoidance);
796 }
797
798 #[test]
799 fn timeout_during_congestion_avoidance_is_a_no_op() {
800 let mut cc = FileCc::new(0);
801 cc.on_loss(10, 10, 0.9);
802 assert_eq!(cc.phase(), Phase::CongestionAvoidance);
803 let period = cc.pkt_snd_period_us();
804 cc.on_timeout();
805 assert_eq!(cc.pkt_snd_period_us(), period);
806 }
807
808 #[test]
809 fn cwnd_exceeding_max_ends_slow_start() {
810 let mut cc = FileCc::new(0);
811 cc.set_max_cwnd_size(20.0);
812 cc.on_ack(
813 Duration::from_millis(10),
814 10,
815 0,
816 0,
817 Duration::from_millis(100),
818 );
819 // CWND = 16 + 10 = 26 > 20 -> slow start ends.
820 assert_eq!(cc.phase(), Phase::CongestionAvoidance);
821 }
822
823 #[test]
824 fn max_bw_clamp_floors_pkt_snd_period() {
825 let mut cc = FileCc::new(0);
826 cc.on_loss(1, 1, 0.9); // -> Congestion Avoidance
827 cc.set_max_bw_bytes_per_sec(Some(1)); // absurdly low MAX_BW
828 cc.on_ack(
829 Duration::from_millis(10),
830 1,
831 1_000,
832 1_000,
833 Duration::from_millis(50),
834 );
835 // MIN_PERIOD = 1_000_000 / (1 / 1500) = 1_500_000_000 us.
836 assert!(cc.pkt_snd_period_us() >= 1_500_000_000.0);
837 }
838
839 #[test]
840 fn next_power_of_10_matches_known_values() {
841 assert_eq!(next_power_of_10(1.0), 1.0);
842 assert_eq!(next_power_of_10(9.9), 10.0);
843 assert_eq!(next_power_of_10(10.0), 10.0);
844 assert_eq!(next_power_of_10(10.1), 100.0);
845 assert_eq!(next_power_of_10(100.0), 100.0);
846 assert_eq!(next_power_of_10(0.05), 0.1);
847 }
848
849 #[test]
850 fn xorshift_produces_values_in_unit_range() {
851 let mut rng = XorShift64::new(1);
852 for _ in 0..100 {
853 let v = rng.next_unit();
854 assert!((0.0..1.0).contains(&v), "value out of range: {v}");
855 }
856 }
857
858 #[test]
859 fn dec_random_is_one_when_avg_nak_num_is_zero() {
860 let mut cc = FileCc::new(0);
861 assert_eq!(cc.avg_nak_num(), 0.0);
862 assert_eq!(cc.next_dec_random(), 1.0);
863 }
864
865 /// `DecRandom` must be integer-valued once `AvgNAKNum > 1` (a fractional
866 /// draw makes Step 4's `NAKCount == DecCount * DecRandom` gate a
867 /// measure-zero float comparison that — after the congestion period's
868 /// first decrease — essentially never holds again, silently disabling
869 /// repeat decrease for the rest of the period). This is the bug a
870 /// pre-tag audit found: the un-rounded draw only ever produced an
871 /// integer in the degenerate `AvgNAKNum <= 1` case, which is the only
872 /// case the pre-existing test suite exercised.
873 #[test]
874 fn dec_random_is_integer_valued_once_avg_nak_num_exceeds_one() {
875 let mut cc = FileCc::new(0);
876 cc.avg_nak_num = 6.0;
877 for _ in 0..200 {
878 let v = cc.next_dec_random();
879 assert_eq!(
880 v,
881 v.round(),
882 "DecRandom must be a whole number (got {v}) so Step 4's \
883 NAKCount == DecCount * DecRandom gate is actually satisfiable"
884 );
885 assert!((1.0..=6.0).contains(&v), "DecRandom out of range: {v}");
886 }
887 }
888
889 /// Documents a genuine quirk of the draft's own literal Step 4
890 /// pseudocode (verified against `specs/rules/srt-congestion.md`, not
891 /// assumed): `NAKCount`/`DecCount` are reset to 1 on a new congestion
892 /// period and are ONLY ever touched again *inside* Step 4's own
893 /// `NAKCount == DecCount * DecRandom` conditional (rule 28: "Increase
894 /// DecCount and NAKCount each by 1" — inside the `if`, not unconditional
895 /// per-NAK). So the very next same-period NAK checks `1 == 1*DecRandom`;
896 /// if `DecRandom != 1` that fails, and since neither counter has moved,
897 /// the SAME false check repeats for every subsequent NAK in the period —
898 /// Step 4 cannot fire again until the next congestion period redraws
899 /// `DecRandom`. This is a property of the literal spec text as
900 /// transcribed, not a Rust-side bug: the reference implementation
901 /// (libsrt `congctl.cpp`) uses a different formulation (`NAKCount %
902 /// DecRandom == 0`, incrementing both counters on every same-period NAK
903 /// regardless of the check's outcome) that does NOT have this one-shot
904 /// property — but adopting that would be a spec-posture departure this
905 /// crate isn't designated for (see `CLAUDE.md`), so this implementation
906 /// stays literal to the curated draft text. Confirmed here with a fixed
907 /// `dec_random` (bypassing the RNG) so the assertion is deterministic.
908 #[test]
909 fn repeat_decrease_is_a_one_shot_per_period_once_dec_random_exceeds_one() {
910 let mut cc = FileCc::new(0);
911 cc.on_loss(10, 10, 0.9); // end slow start
912 cc.on_loss(20, 20, 0.9); // new congestion period, big decrease
913 assert_eq!(cc.dec_count(), 1);
914
915 // Force a specific integer DecRandom > 1 for this period (bypassing
916 // the RNG so the check below is deterministic, not seed-dependent).
917 cc.dec_random = 3.0;
918 let after_first = cc.pkt_snd_period_us();
919
920 // Many same-period NAKs: per the literal spec pseudocode, NONE of
921 // them can fire Step 4 again, because NAKCount/DecCount are frozen
922 // at (1, 1) and 1 == 1*3 is false — and stays false forever, since
923 // nothing outside Step 4's own conditional advances either counter.
924 for sent in 21..=60u32 {
925 cc.on_loss(15, sent, 0.9);
926 }
927
928 assert_eq!(
929 cc.dec_count(),
930 1,
931 "with the literal spec's Step 4 pseudocode, DecCount must stay \
932 frozen at 1 for the rest of the period once the immediate \
933 post-reset check (NAKCount==DecCount*DecRandom, i.e. 1==1*3) \
934 fails — this is the draft's own one-shot property, not a bug"
935 );
936 assert_eq!(
937 cc.pkt_snd_period_us(),
938 after_first,
939 "PKT_SND_PERIOD must not change further once Step 4 has gone \
940 silent for the rest of the period"
941 );
942 }
943
944 /// The complementary, working case: when the drawn integer `DecRandom`
945 /// rounds to exactly `1`, the immediate post-reset check `1 == 1*1`
946 /// holds, so Step 4 fires on every subsequent same-period NAK (this is
947 /// the case the pre-existing integration test
948 /// `repeated_decrease_backs_off_by_1_03_bounded_by_dec_count` already
949 /// covers end-to-end with `AvgNAKNum == 0`; this unit test additionally
950 /// proves it holds even when `AvgNAKNum` is nonzero but rounds to 1).
951 #[test]
952 fn repeat_decrease_fires_repeatedly_when_dec_random_rounds_to_one() {
953 let mut cc = FileCc::new(0);
954 cc.avg_nak_num = 1.2; // rounds to 1 via next_dec_random's `.round()`
955 cc.on_loss(10, 10, 0.9);
956 cc.on_loss(20, 20, 0.9);
957 assert_eq!(cc.dec_random, 1.0);
958 assert_eq!(cc.dec_count(), 1);
959
960 for sent in 21..=25u32 {
961 cc.on_loss(15, sent, 0.9);
962 }
963 assert_eq!(
964 cc.dec_count(),
965 6,
966 "DecRandom==1 must let Step 4 fire on every same-period NAK"
967 );
968 }
969
970 #[test]
971 fn default_matches_new_zero() {
972 let a = FileCc::default();
973 let b = FileCc::new(0);
974 assert_eq!(a.cwnd_size(), b.cwnd_size());
975 assert_eq!(a.phase(), b.phase());
976 }
977}