Skip to main content

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}