hopr_protocol_session/flow_control.rs
1//! Client/ENTRY-side send-window flow control for Session sockets.
2//!
3//! A `Segmentation`-only Session has no end-to-end flow control: the ENTRY writer runs to
4//! completion and floods the return path (data + acknowledgements + SURB replenishments), capped
5//! at the EXIT SURB-reply rate. Unpaced, this deterministically stalls.
6//!
7//! This module provides a self-adapting send window that replaces manual pacing. It speeds up
8//! **only** on *proven* delivery and slows down on congestion/loss, so throughput auto-tracks the
9//! drain rate and unpaced send becomes safe.
10//!
11//! # Trust model (why the window is asymmetric)
12//!
13//! The counterparty is **not** assumed cooperative. A SURB carries `PoRValues`, and reply packets
14//! sent with it are paid from the SURB creator's (our) channels along the return path *regardless
15//! of payload* (RFC-0005 §3.2). SURBs are therefore prepaid value a greedy EXIT can harvest
16//! without serving us; the SURB balancer is the anti-grief throttle. Consequently:
17//!
18//! 1. **The window only opens on HONEST delivery** — data proven to have reached us: reliable-mode frame
19//! acknowledgements (the `ack_state` machinery) or application-verified return bytes. Nothing else authorizes
20//! speeding up. See `WindowController::on_delivered`.
21//! 2. **SURB state may only slow us down, never speed us up.** `SupplyConstraint` can shrink or cap the window; it can
22//! never grow it. The SURB `buffer_level` is partly counterparty-reported and dead-reckoned, so a hostile or lossy
23//! peer must not be able to accelerate us or make us overspend.
24//! 3. **A malicious counterparty can only ever make us slower** — never faster, never draining our SURBs beyond the
25//! client-configured ceiling.
26//! 4. **1–20 % path loss is expected and recovered** (reliable mode retransmits; the window multiplicatively decreases
27//! and re-grows on delivery).
28//! 5. **The anti-grief ⇄ throughput trade is the client's dial** (`FlowControlConfig`), set explicitly — SURB supply is
29//! never silently widened for speed.
30
31use std::{
32 sync::{
33 Arc,
34 atomic::{AtomicU64, Ordering},
35 },
36 time::Duration,
37};
38
39/// How the send window learns that data was delivered (its "honest clock").
40#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
41pub enum FlowControlMode {
42 /// Reliable-mode frame acknowledgements drive the window. This is the universal honest clock:
43 /// it is independent of the application payload, so it works for arbitrary carried protocols
44 /// (e.g. a VPN tunnel), and it additionally provides loss recovery via retransmission.
45 #[default]
46 Reliable,
47 /// No end-to-end acknowledgements. The window cannot open on delivery (there is no honest
48 /// signal), so it is governed purely by the [`SupplyConstraint`] ceiling and degrades
49 /// gracefully. A `Segmentation`-only client accepts loss knowingly. For a loopback echo the
50 /// application can still supply a verified-return-byte clock (see the `DeliverySignal` impls).
51 Segmentation,
52}
53
54/// Client-tunable flow-control parameters. Defaults are deliberately conservative
55/// (anti-grief-preserving): the window starts at the floor and only grows on proven delivery, and
56/// the opt-in robustness knobs are off. This is the **clean** profile.
57///
58/// Flow control is enabled per-session by *providing* this config (the transport's
59/// `SessionClientConfig::flow_control` is an `Option` — `None` leaves the session unpaced with
60/// today's behaviour), so there is no separate `enabled` flag.
61#[derive(Clone, Copy, Debug, PartialEq, smart_default::SmartDefault)]
62pub struct FlowControlConfig {
63 /// Honest-clock mode. Default [`FlowControlMode::Reliable`].
64 pub mode: FlowControlMode,
65
66 /// Minimum in-flight bytes that are always admitted, regardless of delivery feedback or SURB
67 /// supply. Guarantees the duplex socket never deadlocks (acks/keep-alives can always flow) and
68 /// is the hard floor a malicious peer can never push the window below — but also never above
69 /// without honest delivery. Keep small (a few frames). Default 4 KiB.
70 #[default(4 * 1024)]
71 pub min_window_size: usize,
72
73 /// Hard ceiling on the send window, seeded from the bandwidth-delay product
74 /// (`drain_rate_hint × rtt`). The window never opens past this even under sustained delivery.
75 /// Default 2 MiB.
76 #[default(2 * 1024 * 1024)]
77 pub max_window_size: usize,
78
79 /// Additive-increase step (bytes added to the window per fully-delivered window) while in
80 /// congestion avoidance. Default 16 KiB.
81 #[default(16 * 1024)]
82 pub ai_step: usize,
83
84 /// Multiplicative-decrease factor applied to the window on loss or a soft backoff hint. Must be
85 /// in `(0.0, 1.0)`. Default 0.5 (classic AIMD).
86 #[default(0.5)]
87 pub md_factor: f64,
88
89 /// In [`FlowControlMode::Segmentation`] there is no honest delivery signal, so admission may
90 /// park indefinitely behind a shrinking SURB ceiling. This deadline bounds how long the writer
91 /// parks before it re-checks the ceiling / makes keep-progress at the floor. Default 250 ms.
92 #[default(Duration::from_millis(250))]
93 pub no_honest_deadline: Duration,
94
95 /// **Persist probe (opt-in robustness).** Consecutive no-progress parks before the writer admits
96 /// a bounded `min_window_size` beyond `cwnd` (still SURB-capped) to break an end-of-stream tail deadlock
97 /// on a slow/throttled return path. `0` **disables** it — the default, i.e. the verified clean
98 /// behaviour. A robust profile (e.g. for deliberately SURB-throttled paths) sets ~8 (≈2 s at the
99 /// default keep-progress deadline). See the `PacedWriter` admission logic.
100 #[default(0)]
101 pub persist_stall_parks: u32,
102
103 /// **Frame retransmission budget under flow control (opt-in robustness).** Applied to the
104 /// reliable socket's `max_outgoing_frame_retries` when flow control is enabled. `2` (the
105 /// original) is the default; a robust profile raises it (~8) so a merely-*delayed* ack on a
106 /// temporarily-starved return path recovers the frame instead of abandoning it (an abandoned
107 /// frame leaves a gap → stream corruption).
108 #[default(2)]
109 pub frame_retries: u32,
110
111 /// **Anti-bufferbloat bound (opt-in).** Maximum age of a data-path frame — from its first send
112 /// on the sending side, from entering the ordering buffer on the receiving side.
113 ///
114 /// Older frames are dropped rather than delivered late, so a stall surfaces as clean loss
115 /// instead of a multi-second latency tail (the burst-drain "sawtooth"). A packet arriving
116 /// seconds late is worthless to a real-time consumer, but the latency it adds is not.
117 ///
118 /// Distinct from `frame_timeout`, which bounds how long a *missing* frame is waited for; this
119 /// bounds how stale a *present* frame may be. `None` (default) keeps the previous behaviour.
120 #[default(None)]
121 pub max_frame_age: Option<Duration>,
122}
123
124impl FlowControlConfig {
125 /// Clamps parameters into their valid ranges (`min_window_size ≤ max_window_size`, `md_factor ∈ (0,1)`,
126 /// `ai_step ≥ 1`). Called by [`WindowController::new`] so out-of-range config cannot violate
127 /// the invariants.
128 fn normalized(self) -> FlowControlConfig {
129 let min_window_size = self.min_window_size.max(1);
130 FlowControlConfig {
131 mode: self.mode,
132 min_window_size,
133 max_window_size: self.max_window_size.max(min_window_size),
134 ai_step: self.ai_step.max(1),
135 md_factor: if self.md_factor.is_finite() {
136 self.md_factor.clamp(0.01, 0.99)
137 } else {
138 0.5
139 },
140 no_honest_deadline: self.no_honest_deadline.max(Duration::from_millis(1)),
141 persist_stall_parks: self.persist_stall_parks,
142 // At least one retry: under reliable-mode flow control an abandoned frame leaves a gap
143 // (stream corruption), so `frame_retries` must never clamp the retry budget to 0.
144 frame_retries: self.frame_retries.max(1),
145 // A zero bound would drop every frame on sight; treat it as "not set".
146 max_frame_age: self.max_frame_age.filter(|age| !age.is_zero()),
147 }
148 }
149
150 /// The **robust** profile: the clean defaults plus the opt-in tail-tolerance bundle (persist
151 /// probe + larger retransmission budget) for deliberately SURB-throttled / high-latency return
152 /// paths. See [`Self::persist_stall_parks`] and [`Self::frame_retries`].
153 ///
154 /// The larger retry budget alone would let a transport stall be absorbed as buffering and
155 /// drained afterwards as a multi-second latency sawtooth, so the profile also bounds frame age
156 /// at 2 seconds: past that a frame surfaces as recoverable loss instead of arriving late.
157 pub fn robust() -> Self {
158 Self {
159 persist_stall_parks: 8,
160 frame_retries: 8,
161 max_frame_age: Some(Duration::from_secs(2)),
162 ..Self::default()
163 }
164 }
165
166 /// Convenience constructor seeding [`max_window_size`](Self::max_window_size) from a bandwidth-delay product.
167 ///
168 /// `drain_rate_bytes_per_sec` is the estimated rate at which the counterparty can drain the
169 /// return path (for a SURB-capped echo: `max_surbs_per_sec / surbs_per_reply_packet ×
170 /// bytes_per_packet`). `rtt` is the round-trip delivery latency.
171 pub fn with_bdp(mut self, drain_rate_bytes_per_sec: u64, rtt: Duration) -> Self {
172 let bdp = (drain_rate_bytes_per_sec as f64 * rtt.as_secs_f64()) as usize;
173 self.max_window_size = bdp.max(self.min_window_size);
174 self
175 }
176}
177
178/// Bytes retired from the in-flight window this observation, split by outcome. Produced by a
179/// [`DeliverySignal`] and fed to [`WindowController::apply_delivery`].
180#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
181pub struct Delivered {
182 /// Bytes proven delivered (honest signal → authorizes additive increase).
183 pub acked_bytes: usize,
184 /// Bytes proven lost (retransmission needed → triggers multiplicative decrease).
185 pub lost_bytes: usize,
186}
187
188impl Delivered {
189 /// Total bytes retired from the in-flight accounting (`acked + lost`).
190 #[inline]
191 pub fn retired(&self) -> usize {
192 self.acked_bytes.saturating_add(self.lost_bytes)
193 }
194}
195
196/// The honest clock. Reports how many in-flight bytes were delivered or lost since last polled.
197///
198/// Impl A (preferred): reliable-mode frame acknowledgements — an acked frame retires its bytes as
199/// `acked_bytes`; a frame whose retransmissions are exhausted retires as `lost_bytes`.
200///
201/// Impl B: application-verified return bytes (e.g. a loopback echo the caller can checksum).
202pub trait DeliverySignal {
203 /// Non-blocking: returns bytes delivered/lost since the previous call (zeroes if none).
204 fn poll_delivered(&mut self) -> Delivered;
205
206 /// Best-effort round-trip delivery latency, used to (re)seed the BDP ceiling. `None` if unknown.
207 fn rtt_hint(&self) -> Option<Duration>;
208}
209
210/// Severity of a SURB-supply backoff request.
211#[derive(Clone, Copy, Debug, PartialEq, Eq)]
212pub enum Backoff {
213 /// Buffer running low (below watermark): multiplicatively decrease.
214 Soft,
215 /// SURB distress / out-of-SURBs: collapse to the floor.
216 Hard,
217}
218
219/// The SURB-supply ceiling. **Down-only**: it may cap or shrink the window, never open it.
220///
221/// This is the anti-grief governor. A healthy buffer returns [`backoff_hint`](Self::backoff_hint)
222/// `None` — it does *not* mean "go faster"; only [`DeliverySignal`] can authorize that.
223pub trait SupplyConstraint {
224 /// Maximum in-flight bytes the current SURB stock can support
225 /// (`buffer_level × bytes_per_reply_packet`). The window is capped to this.
226 fn max_admissible_inflight(&self) -> usize;
227
228 /// Backoff request derived from SURB distress signals. `None` when the buffer is healthy —
229 /// never a signal to open the window.
230 fn backoff_hint(&self) -> Option<Backoff>;
231}
232
233/// Pure AIMD send-window controller. Holds no I/O; every state transition is a plain method so the
234/// invariants are exhaustively unit-testable. Byte-based (not frame-based) so it is agnostic to
235/// frame sizing.
236#[derive(Clone, Debug)]
237pub struct WindowController {
238 cfg: FlowControlConfig,
239 /// Current congestion window in bytes, always within `[min_window_size, max_window_size]`.
240 cwnd: usize,
241 /// Bytes sent but not yet delivered or lost.
242 inflight: usize,
243 /// Bytes delivered toward the next additive-increase step (Reno-style congestion avoidance).
244 ai_accumulated_size: usize,
245}
246
247impl WindowController {
248 /// Creates a controller with the window at the floor (`min_window_size`). The window can only grow from
249 /// here via proven delivery — so before any honest signal, the peer cannot make it exceed
250 /// `min_window_size` (invariant 1 & 3).
251 pub fn new(cfg: FlowControlConfig) -> Self {
252 let cfg = cfg.normalized();
253 Self {
254 cwnd: cfg.min_window_size,
255 inflight: 0,
256 ai_accumulated_size: 0,
257 cfg,
258 }
259 }
260
261 /// Current window size in bytes (for diagnostics/tests).
262 #[inline]
263 pub fn window(&self) -> usize {
264 self.cwnd
265 }
266
267 /// Current in-flight bytes (sent, not yet retired).
268 #[inline]
269 pub fn inflight(&self) -> usize {
270 self.inflight
271 }
272
273 /// The active mode.
274 #[inline]
275 pub fn mode(&self) -> FlowControlMode {
276 self.cfg.mode
277 }
278
279 /// The configured minimum window (duplex floor / persist-probe size).
280 #[inline]
281 pub fn min_window_size(&self) -> usize {
282 self.cfg.min_window_size
283 }
284
285 /// Records `bytes` admitted onto the wire. Increases the in-flight accounting only; never grows
286 /// the window.
287 #[inline]
288 pub fn on_sent(&mut self, bytes: usize) {
289 self.inflight = self.inflight.saturating_add(bytes);
290 }
291
292 /// **The only path that grows the window.** Retires `bytes` of proven-delivered data and
293 /// performs Reno congestion avoidance: roughly `+ai_step` per fully-delivered window, capped at
294 /// `max_window_size`.
295 pub fn on_delivered(&mut self, bytes: usize) {
296 self.inflight = self.inflight.saturating_sub(bytes);
297 if self.cwnd >= self.cfg.max_window_size {
298 return;
299 }
300 self.ai_accumulated_size = self.ai_accumulated_size.saturating_add(bytes);
301 // Emit one additive step per window's worth of delivered bytes.
302 while self.ai_accumulated_size >= self.cwnd {
303 self.ai_accumulated_size -= self.cwnd;
304 self.cwnd = self.cwnd.saturating_add(self.cfg.ai_step).min(self.cfg.max_window_size);
305 if self.cwnd >= self.cfg.max_window_size {
306 self.ai_accumulated_size = 0;
307 break;
308 }
309 }
310 }
311
312 /// Retires `bytes` of proven-lost data and multiplicatively decreases the window (never below
313 /// `min_window_size`). Loss recovery itself (retransmission) is the reliable socket's job.
314 pub fn on_lost(&mut self, bytes: usize) {
315 self.inflight = self.inflight.saturating_sub(bytes);
316 self.decrease(self.cfg.md_factor);
317 }
318
319 /// Applies a batched [`Delivered`] observation from a [`DeliverySignal`].
320 pub fn apply_delivery(&mut self, d: Delivered) {
321 if d.acked_bytes > 0 {
322 self.on_delivered(d.acked_bytes);
323 }
324 if d.lost_bytes > 0 {
325 self.on_lost(d.lost_bytes);
326 }
327 }
328
329 /// Applies a SURB-supply backoff. Down-only: `Soft` multiplicatively decreases, `Hard`
330 /// collapses to the floor. Never grows the window.
331 pub fn apply_backoff(&mut self, b: Backoff) {
332 match b {
333 Backoff::Soft => self.decrease(self.cfg.md_factor),
334 Backoff::Hard => self.cwnd = self.cfg.min_window_size,
335 }
336 }
337
338 /// Effective window against a raw ceiling value (bytes): `min(cwnd, ceiling)`, but never below
339 /// `min_window_size` (duplex floor). The ceiling can only shrink the result — invariant 2 — and it
340 /// does **not** mutate `cwnd`, so a transient dip is fully recovered the instant the ceiling lifts.
341 pub fn effective_window_for(&self, ceiling: usize) -> usize {
342 self.cwnd.min(ceiling).max(self.cfg.min_window_size)
343 }
344
345 /// Bytes admissible now against a raw ceiling value: `effective_window − inflight` (saturating).
346 pub fn admissible_for(&self, ceiling: usize) -> usize {
347 self.effective_window_for(ceiling).saturating_sub(self.inflight)
348 }
349
350 /// Effective window against a [`SupplyConstraint`] (convenience wrapper over
351 /// [`effective_window_for`](Self::effective_window_for)).
352 pub fn effective_window(&self, supply: &impl SupplyConstraint) -> usize {
353 self.effective_window_for(supply.max_admissible_inflight())
354 }
355
356 /// Bytes that may be admitted right now against `supply`. Zero means park until delivery retires
357 /// in-flight bytes (or the ceiling lifts).
358 pub fn admissible(&self, supply: &impl SupplyConstraint) -> usize {
359 self.admissible_for(supply.max_admissible_inflight())
360 }
361
362 /// Multiplicative decrease helper, flooring at `min_window_size`.
363 fn decrease(&mut self, factor: f64) {
364 let reduced = (self.cwnd as f64 * factor) as usize;
365 self.cwnd = reduced.max(self.cfg.min_window_size);
366 self.ai_accumulated_size = 0;
367 }
368}
369
370/// Shared, lock-free honest-clock meter. Producers bump it **in place** with a single atomic add —
371/// the reliable ack machinery on ack / retransmission-exhaustion (impl A), or an
372/// application-return-byte reader (impl B). The window driver reads byte deltas via [`DeliveryClock`].
373/// No channel, no per-frame allocation, no dedup bookkeeping (a duplicate ack merely over-credits by
374/// one frame, which the SURB ceiling and `cwnd` still bound). Modelled on the existing atomic
375/// `BalancerStateValues`.
376#[derive(Clone, Default)]
377pub struct DeliveryMeter(Arc<DeliveryAtomics>);
378
379#[derive(Default)]
380struct DeliveryAtomics {
381 acked_bytes: AtomicU64,
382 lost_bytes: AtomicU64,
383}
384
385impl DeliveryMeter {
386 /// Records `bytes` proven delivered — a received frame ack, or application-verified return bytes.
387 #[inline]
388 pub fn record_acked(&self, bytes: usize) {
389 self.0.acked_bytes.fetch_add(bytes as u64, Ordering::Relaxed);
390 }
391
392 /// Records `bytes` proven lost — sender-side retransmissions exhausted.
393 #[inline]
394 pub fn record_lost(&self, bytes: usize) {
395 self.0.lost_bytes.fetch_add(bytes as u64, Ordering::Relaxed);
396 }
397
398 #[inline]
399 fn load(&self) -> (u64, u64) {
400 (
401 self.0.acked_bytes.load(Ordering::Relaxed),
402 self.0.lost_bytes.load(Ordering::Relaxed),
403 )
404 }
405}
406
407/// Frame-granular tap installed into the reliable socket: it pairs a [`DeliveryMeter`] with a frame's
408/// byte size, so the ack machinery can report deliveries in place without tracking byte counts.
409/// Cheap to clone (`Arc` + `usize`).
410#[derive(Clone)]
411pub struct DeliveryTap {
412 meter: DeliveryMeter,
413 bytes_per_frame: usize,
414}
415
416impl DeliveryTap {
417 /// Pairs `meter` with the socket's frame byte size.
418 pub fn new(meter: DeliveryMeter, bytes_per_frame: usize) -> Self {
419 Self {
420 meter,
421 bytes_per_frame: bytes_per_frame.max(1),
422 }
423 }
424
425 /// The receiver acknowledged a frame — proof of delivery (impl A).
426 #[inline]
427 pub fn on_acked_frame(&self) {
428 self.meter.record_acked(self.bytes_per_frame);
429 }
430
431 /// A frame's sender-side retransmissions were exhausted — given up as lost (impl A).
432 #[inline]
433 pub fn on_lost_frame(&self) {
434 self.meter.record_lost(self.bytes_per_frame);
435 }
436}
437
438/// Delta reader over a shared [`DeliveryMeter`], implementing [`DeliverySignal`]. Each poll returns
439/// bytes delivered/lost since the previous poll. One reader serves either clock — impl A (reliable
440/// acks via [`DeliveryTap`]) or impl B (return bytes via [`DeliveryMeter::record_acked`]) — because
441/// both simply add to the same meter.
442pub struct DeliveryClock {
443 meter: DeliveryMeter,
444 seen_acked: u64,
445 seen_lost: u64,
446 rtt_hint: Option<Duration>,
447}
448
449impl DeliveryClock {
450 /// Creates a reader over `meter`. `rtt_hint` seeds the BDP ceiling when known.
451 pub fn new(meter: DeliveryMeter, rtt_hint: Option<Duration>) -> Self {
452 Self {
453 meter,
454 seen_acked: 0,
455 seen_lost: 0,
456 rtt_hint,
457 }
458 }
459}
460
461impl DeliverySignal for DeliveryClock {
462 fn poll_delivered(&mut self) -> Delivered {
463 let (acked, lost) = self.meter.load();
464 let d = Delivered {
465 acked_bytes: acked.saturating_sub(self.seen_acked) as usize,
466 lost_bytes: lost.saturating_sub(self.seen_lost) as usize,
467 };
468 self.seen_acked = acked;
469 self.seen_lost = lost;
470 d
471 }
472
473 fn rtt_hint(&self) -> Option<Duration> {
474 self.rtt_hint
475 }
476}
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481
482 #[test]
483 fn robust_profile_should_bound_frame_age() {
484 assert_eq!(FlowControlConfig::robust().max_frame_age, Some(Duration::from_secs(2)));
485 assert_eq!(FlowControlConfig::default().max_frame_age, None);
486 }
487
488 /// A `SupplyConstraint` with a fixed ceiling and no distress — the "honest, generous supply"
489 /// baseline used to isolate the delivery clock.
490 struct FixedSupply {
491 ceiling: usize,
492 backoff: Option<Backoff>,
493 }
494 impl FixedSupply {
495 fn generous() -> Self {
496 Self {
497 ceiling: usize::MAX,
498 backoff: None,
499 }
500 }
501 }
502 impl SupplyConstraint for FixedSupply {
503 fn max_admissible_inflight(&self) -> usize {
504 self.ceiling
505 }
506
507 fn backoff_hint(&self) -> Option<Backoff> {
508 self.backoff
509 }
510 }
511
512 fn cfg() -> FlowControlConfig {
513 FlowControlConfig {
514 min_window_size: 1_000,
515 max_window_size: 100_000,
516 ai_step: 1_000,
517 md_factor: 0.5,
518 ..Default::default()
519 }
520 }
521
522 #[test]
523 fn starts_at_floor() {
524 let w = WindowController::new(cfg());
525 assert_eq!(w.window(), 1_000, "window must start at min_window_size, not above");
526 }
527
528 #[test]
529 fn normalization_clamps_out_of_range_config() {
530 let w = WindowController::new(FlowControlConfig {
531 min_window_size: 5_000,
532 max_window_size: 1_000, // below min_window_size → must be raised to min_window_size
533 ai_step: 0, // → 1
534 md_factor: 2.0, // → clamped into (0,1)
535 ..Default::default()
536 });
537 assert_eq!(w.window(), 5_000);
538 assert!(w.cfg.max_window_size >= w.cfg.min_window_size);
539 assert!(w.cfg.md_factor > 0.0 && w.cfg.md_factor < 1.0);
540 assert!(w.cfg.ai_step >= 1);
541 }
542
543 #[test]
544 fn additive_increase_one_step_per_window() {
545 let mut w = WindowController::new(cfg());
546 let start = w.window(); // 1_000
547 w.on_sent(start);
548 w.on_delivered(start); // exactly one window delivered → +ai_step
549 assert_eq!(w.window(), start + 1_000);
550 }
551
552 #[test]
553 fn additive_increase_capped_at_max_win() {
554 let mut w = WindowController::new(cfg());
555 // Deliver far more than needed to reach the ceiling.
556 for _ in 0..1_000 {
557 let win = w.window();
558 w.on_sent(win);
559 w.on_delivered(win);
560 }
561 assert_eq!(w.window(), 100_000, "must not exceed max_window_size");
562 }
563
564 #[test]
565 fn multiplicative_decrease_on_loss() {
566 let mut w = WindowController::new(cfg());
567 // Grow first so the decrease is observable.
568 for _ in 0..10 {
569 let win = w.window();
570 w.on_sent(win);
571 w.on_delivered(win);
572 }
573 let before = w.window();
574 w.on_sent(before);
575 w.on_lost(before);
576 assert_eq!(w.window(), before / 2, "loss must halve the window");
577 }
578
579 #[test]
580 fn decrease_never_below_floor() {
581 let mut w = WindowController::new(cfg());
582 for _ in 0..100 {
583 w.on_lost(0);
584 }
585 assert_eq!(w.window(), 1_000, "must never shrink below min_window_size");
586 }
587
588 // ---- Invariant 1 & 3: adversarial peer cannot open the window ----
589
590 #[test]
591 fn adversarial_healthy_supply_no_delivery_cannot_open_window() {
592 // Peer reports an enormous healthy SURB ceiling but delivers nothing.
593 let mut w = WindowController::new(cfg());
594 let supply = FixedSupply::generous(); // ceiling = usize::MAX, no backoff
595 // Simulate many admission cycles with zero delivery feedback.
596 for _ in 0..1_000 {
597 let can = w.admissible(&supply);
598 w.on_sent(can); // send whatever is admissible into the void
599 // no on_delivered — nothing comes back
600 }
601 assert_eq!(
602 w.window(),
603 1_000,
604 "no honest delivery ⇒ window must stay pinned at min_window_size regardless of reported supply"
605 );
606 // And it must never admit more than one floor-window of unacked data.
607 assert_eq!(
608 w.admissible(&supply),
609 0,
610 "with a full in-flight floor and no delivery, nothing more may be admitted"
611 );
612 }
613
614 #[test]
615 fn generous_ceiling_cannot_raise_effective_window_above_cwnd() {
616 let w = WindowController::new(cfg());
617 let supply = FixedSupply::generous();
618 assert_eq!(
619 w.effective_window(&supply),
620 w.window(),
621 "an over-generous ceiling must not raise the window above cwnd"
622 );
623 }
624
625 // ---- Invariant 2: SURB supply is down-only ----
626
627 #[test]
628 fn supply_ceiling_only_shrinks_window() {
629 let mut w = WindowController::new(cfg());
630 // Grow the honest window to near max.
631 for _ in 0..50 {
632 let win = w.window();
633 w.on_sent(win);
634 w.on_delivered(win);
635 }
636 let cwnd = w.window();
637 let tight = FixedSupply {
638 ceiling: cwnd / 4,
639 backoff: None,
640 };
641 assert_eq!(w.effective_window(&tight), cwnd / 4, "tight ceiling clamps down");
642 let loose = FixedSupply {
643 ceiling: cwnd * 10,
644 backoff: None,
645 };
646 assert_eq!(
647 w.effective_window(&loose),
648 cwnd,
649 "loose ceiling cannot raise above cwnd"
650 );
651 }
652
653 // ---- Hysteresis: a supply-ceiling dip clamps the effective window but PRESERVES cwnd ----
654
655 #[test]
656 fn ceiling_clamp_preserves_cwnd() {
657 let mut w = WindowController::new(cfg());
658 // Grow the honest window well above the floor.
659 for _ in 0..30 {
660 let win = w.window();
661 w.on_sent(win);
662 w.on_delivered(win);
663 }
664 let grown = w.window();
665 assert!(grown > 10_000);
666
667 // A tight ceiling clamps the *effective* window right down...
668 assert_eq!(w.effective_window_for(1_000), 1_000);
669 // ...but must NOT destroy the learned cwnd (this is the whole fix — no cliff collapse).
670 assert_eq!(w.window(), grown, "ceiling clamp must not mutate cwnd");
671
672 // The instant the ceiling lifts, the full learned window is available again — no re-growth.
673 assert_eq!(w.effective_window_for(usize::MAX), grown);
674 assert_eq!(w.admissible_for(usize::MAX), grown);
675 }
676
677 #[test]
678 fn hard_backoff_collapses_to_floor_soft_halves() {
679 let mut w = WindowController::new(cfg());
680 for _ in 0..20 {
681 let win = w.window();
682 w.on_sent(win);
683 w.on_delivered(win);
684 }
685 let grown = w.window();
686 assert!(grown > 2_000);
687
688 let mut soft = w.clone();
689 soft.apply_backoff(Backoff::Soft);
690 assert_eq!(soft.window(), grown / 2);
691
692 w.apply_backoff(Backoff::Hard);
693 assert_eq!(w.window(), 1_000, "hard backoff collapses to floor");
694 }
695
696 // ---- Invariant 4: loss is recovered (window re-grows after decrease) ----
697
698 #[test]
699 fn should_recover_after_loss_burst() {
700 let mut w = WindowController::new(cfg());
701 for _ in 0..30 {
702 let win = w.window();
703 w.on_sent(win);
704 w.on_delivered(win);
705 }
706 let peak = w.window();
707 // 20% loss burst.
708 let chunk = w.window();
709 w.on_sent(chunk);
710 w.on_lost(chunk);
711 assert!(w.window() < peak, "window backs off on loss");
712 // Sustained delivery re-opens it.
713 for _ in 0..30 {
714 let win = w.window();
715 w.on_sent(win);
716 w.on_delivered(win);
717 }
718 assert!(w.window() > peak / 2, "window recovers with renewed delivery");
719 }
720
721 #[test]
722 fn apply_delivery_batches_ack_and_loss() {
723 let mut w = WindowController::new(cfg());
724 w.on_sent(2_000);
725 w.apply_delivery(Delivered {
726 acked_bytes: 1_000,
727 lost_bytes: 1_000,
728 });
729 assert_eq!(w.inflight(), 0, "both acked and lost retire in-flight bytes");
730 }
731
732 #[test]
733 fn inflight_accounting_saturates() {
734 let mut w = WindowController::new(cfg());
735 w.on_sent(500);
736 w.on_delivered(10_000); // more than in-flight → saturates at 0, no underflow
737 assert_eq!(w.inflight(), 0);
738 }
739
740 // ---- DeliveryMeter + DeliveryClock: the in-place atomic honest clock ----
741
742 #[test]
743 fn delivery_clock_reports_byte_deltas() {
744 let meter = DeliveryMeter::default();
745 let mut clock = DeliveryClock::new(meter.clone(), Some(Duration::from_millis(50)));
746 meter.record_acked(2_000);
747 meter.record_lost(1_000);
748 let d = clock.poll_delivered();
749 assert_eq!(d.acked_bytes, 2_000);
750 assert_eq!(d.lost_bytes, 1_000);
751 assert_eq!(clock.rtt_hint(), Some(Duration::from_millis(50)));
752 // Only the delta is reported next poll.
753 assert_eq!(clock.poll_delivered(), Delivered::default());
754 meter.record_acked(300);
755 assert_eq!(clock.poll_delivered().acked_bytes, 300);
756 }
757
758 #[test]
759 fn delivery_tap_reports_whole_frames() {
760 // impl A: the reliable ack tap reports one frame's bytes per event.
761 let meter = DeliveryMeter::default();
762 let tap = DeliveryTap::new(meter.clone(), 1_000);
763 let mut clock = DeliveryClock::new(meter, None);
764 tap.on_acked_frame();
765 tap.on_acked_frame();
766 tap.on_lost_frame();
767 let d = clock.poll_delivered();
768 assert_eq!(d.acked_bytes, 2_000);
769 assert_eq!(d.lost_bytes, 1_000);
770 }
771
772 #[test]
773 fn delivery_clock_drives_window_growth() {
774 let meter = DeliveryMeter::default();
775 let tap = DeliveryTap::new(meter.clone(), 1_000);
776 let mut clock = DeliveryClock::new(meter, None);
777 let mut w = WindowController::new(cfg());
778 let supply = FixedSupply::generous();
779 let start = w.window();
780 // Deliver a full window's worth of frames via the tap.
781 let frames = start / 1_000 + 1;
782 for _ in 0..frames {
783 w.on_sent(1_000);
784 tap.on_acked_frame();
785 }
786 w.apply_delivery(clock.poll_delivered());
787 assert!(w.window() > start, "honest acks must open the window");
788 assert!(w.admissible(&supply) > 0);
789 }
790
791 #[test]
792 fn return_bytes_share_the_same_meter() {
793 // impl B: the application-return-byte reader bumps the same meter directly.
794 let meter = DeliveryMeter::default();
795 let mut clock = DeliveryClock::new(meter.clone(), None);
796 meter.record_acked(1_500);
797 meter.record_acked(500);
798 let d = clock.poll_delivered();
799 assert_eq!(d.acked_bytes, 2_000, "sum of returned bytes since last poll");
800 assert_eq!(d.lost_bytes, 0);
801 }
802
803 #[test]
804 fn bdp_seed_sets_ceiling() {
805 // 700 pkt/s × ~1 KB × 0.1 s ≈ 70 KB ceiling.
806 let rate: u64 = 700 * 1024;
807 let cfg = FlowControlConfig::default().with_bdp(rate, Duration::from_millis(100));
808 assert_eq!(cfg.max_window_size, (rate as f64 * 0.1) as usize);
809 }
810}