Skip to main content

hopr_transport_session/
flow_control.rs

1//! HOPR-transport wiring for the Session send-window flow control.
2//!
3//! The algorithm itself (the AIMD [`WindowController`], the honest-clock [`DeliverySignal`], the
4//! [`SupplyConstraint`] trait) lives in [`hopr_protocol_session::flow_control`]. This module supplies
5//! the two HOPR-specific pieces:
6//!
7//! * [`SurbSupply`] — the anti-grief SURB ceiling, reading the existing atomic [`BalancerStateValues`] as a
8//!   **down-only** clamp (never a signal to speed up — invariant 2).
9//! * [`PacedWriter`] — wraps the Session socket's write half and admits bytes only while the window has room, parking
10//!   on a keep-progress timer otherwise. It never gates the read half, so the duplex socket cannot deadlock.
11
12use std::{
13    future::Future,
14    pin::Pin,
15    sync::Arc,
16    task::{Context, Poll},
17    time::Duration,
18};
19
20use hopr_protocol_session::flow_control::{
21    Backoff, DeliveryClock, DeliverySignal, FlowControlConfig, SupplyConstraint, WindowController,
22};
23
24use crate::balancer::BalancerStateValues;
25
26/// SURB-supply ceiling over the balancer's atomic state. **Down-only**: it can cap or shrink the
27/// window (invariant 2), never open it. A healthy buffer returns no backoff — that is *not* a signal
28/// to go faster; only proven delivery opens the window.
29pub struct SurbSupply {
30    state: Arc<BalancerStateValues>,
31    /// Bytes one reply packet can carry (≈ one SURB consumed per reply). `buffer_level` SURBs thus
32    /// cap the return path to `buffer_level × bytes_per_reply_packet` in-flight bytes.
33    bytes_per_reply_packet: usize,
34    /// Soft-backoff watermark as a fraction of the target buffer size.
35    low_watermark_frac: f64,
36}
37
38impl SurbSupply {
39    /// Creates a ceiling over `state`. `bytes_per_reply_packet` is the session frame/packet payload
40    /// size. Soft backoff triggers below 25 % of the target buffer.
41    pub fn new(state: Arc<BalancerStateValues>, bytes_per_reply_packet: usize) -> Self {
42        Self {
43            state,
44            bytes_per_reply_packet: bytes_per_reply_packet.max(1),
45            low_watermark_frac: 0.25,
46        }
47    }
48
49    /// Raw SURB buffer level (for diagnostics).
50    fn buffer_level(&self) -> u64 {
51        self.state.buffer_level()
52    }
53}
54
55impl SupplyConstraint for SurbSupply {
56    fn max_admissible_inflight(&self) -> usize {
57        // Balancer disabled ⇒ no SURB throttle ⇒ no ceiling (the window is then governed purely by
58        // the honest delivery clock).
59        if self.state.is_disabled() {
60            return usize::MAX;
61        }
62        // Same reasoning while the return path is degraded. The controller stores `0` there to
63        // drive production to the maximum, not because it measured an empty buffer. Reading that
64        // as a ceiling yields zero admissible bytes -- and the persist probe cannot escape it,
65        // since it is itself capped by the ceiling -- so a session that opted into surviving
66        // return-path loss would instead stop sending for the whole degraded window, which is the
67        // opposite of the intent. Defer to the honest delivery clock, which still sees the missing
68        // acknowledgements and throttles on real evidence.
69        if self.state.return_path_estimate_is_stale() {
70            return usize::MAX;
71        }
72        (self.state.buffer_level() as usize).saturating_mul(self.bytes_per_reply_packet)
73    }
74
75    fn backoff_hint(&self) -> Option<Backoff> {
76        if self.state.is_disabled() {
77            return None;
78        }
79        // A degraded-path zero is an instruction to the controller, not an observation, so it is
80        // not evidence of an empty buffer and must not collapse the window to the floor.
81        if self.state.return_path_estimate_is_stale() {
82            return None;
83        }
84        let level = self.state.buffer_level();
85        if level == 0 {
86            // Out of SURBs: collapse to the floor.
87            return Some(Backoff::Hard);
88        }
89        let target = self.state.as_config().target_surb_buffer_size;
90        if target > 0 && (level as f64) < target as f64 * self.low_watermark_frac {
91            Some(Backoff::Soft)
92        } else {
93            // Healthy buffer: NOT a signal to open the window — only delivery does that.
94            None
95        }
96    }
97}
98
99/// Wraps a Session socket, admitting writes only while the [`WindowController`] has room against the
100/// honest delivery clock and the SURB ceiling. Reads are delegated untouched (never gated), so the
101/// duplex socket cannot deadlock; the window always keeps at least `min_window_size` admissible.
102///
103/// `S` is `Unpin` (the boxed Session socket is), so this needs no pin projection.
104pub struct PacedWriter<S> {
105    inner: S,
106    window: WindowController,
107    clock: DeliveryClock,
108    supply: SurbSupply,
109    /// Keep-progress deadline: how long to park when the window is momentarily full before
110    /// re-checking, and the persist-probe interval (see `stalled_parks`).
111    deadline: Duration,
112    /// Pending park timer, recreated per park.
113    park: Option<Pin<Box<dyn Future<Output = ()> + Send>>>,
114    /// Consecutive keep-progress parks that saw **no** honest delivery and admitted **no** bytes.
115    /// Reset to 0 on any admission or any delivery. When it reaches `persist_after` (> 0), the
116    /// persist probe fires (see [`Self::admissible`]).
117    stalled_parks: u32,
118    /// Persist-probe threshold (consecutive no-progress parks). `0` disables the probe — the default
119    /// clean behaviour; a robust profile sets it (~8). Sourced from [`FlowControlConfig`].
120    persist_after: u32,
121    /// Cumulative bytes admitted (diagnostics).
122    sent_total: u64,
123    /// `refresh_window` call counter, for throttling the diagnostic trace.
124    refreshes: u64,
125}
126
127impl<S> PacedWriter<S> {
128    /// Assembles a paced writer. `clock` is the honest delivery clock (fed by the reliable ack tap
129    /// or return bytes); `supply` is the SURB ceiling; `cfg` seeds the window.
130    pub fn new(inner: S, cfg: FlowControlConfig, clock: DeliveryClock, supply: SurbSupply) -> Self {
131        Self {
132            inner,
133            window: WindowController::new(cfg),
134            clock,
135            supply,
136            deadline: cfg.no_honest_deadline,
137            park: None,
138            stalled_parks: 0,
139            persist_after: cfg.persist_stall_parks,
140            sent_total: 0,
141            refreshes: 0,
142        }
143    }
144
145    /// Folds the latest honest delivery into the window. `cwnd` moves **only** on the honest delivery
146    /// clock (up on ack, down on loss); SURB supply never touches `cwnd` — so a transient
147    /// `buffer_level` dip cannot destroy the learned window (it can only clamp the effective window
148    /// down via the ceiling, in [`Self::admissible`]).
149    fn refresh_window(&mut self) {
150        let delivered = self.clock.poll_delivered();
151        self.window.apply_delivery(delivered);
152        if delivered.acked_bytes > 0 || delivered.lost_bytes > 0 {
153            self.stalled_parks = 0;
154        }
155
156        self.refreshes = self.refreshes.wrapping_add(1);
157        if self.refreshes.is_multiple_of(256) {
158            tracing::debug!(
159                target: "hopr_flow_control",
160                sent_total = self.sent_total,
161                cwnd = self.window.window(),
162                inflight = self.window.inflight(),
163                raw_ceiling = self.supply.max_admissible_inflight(),
164                buffer_level = self.supply.buffer_level(),
165                stalled_parks = self.stalled_parks,
166                "flow-control state"
167            );
168        }
169    }
170
171    /// Bytes admissible right now, against the live SURB ceiling.
172    ///
173    /// Normally this is `min(cwnd, surb_ceiling) − inflight`: the AIMD window bounds the send rate to
174    /// the drain rate, and the SURB ceiling clamps it down when supply is low (never up — invariant 2).
175    ///
176    /// **Persist probe (opt-in, anti-deadlock).** At end-of-stream a HOPR session has no half-close,
177    /// so the final frames may be acked slowly (or their retransmissions must exhaust before their
178    /// bytes retire from `inflight`). Meanwhile `inflight` sits at `cwnd`, so the normal formula
179    /// admits 0 and the writer would park forever — the tail deadlock the 5-sample measurement
180    /// showed. When enabled (`persist_after > 0`), after that many consecutive no-progress parks we
181    /// admit a bounded `min_window_size` **beyond `cwnd` but still capped by the SURB ceiling**. This is the
182    /// classic TCP persist-timer escape, and it is invariant-safe: it never spends past the SURB
183    /// ceiling (anti-grief intact), and a peer withholding acks can extract at most `min_window_size` per
184    /// `persist_after × deadline` — it can only ever make us slower, never faster or over-spending.
185    /// `persist_after == 0` (the default) disables it entirely: the verified clean behaviour.
186    fn admissible(&self) -> usize {
187        let ceiling = self.supply.max_admissible_inflight();
188        admit_bytes(
189            self.window.admissible_for(ceiling),
190            self.stalled_parks,
191            self.persist_after,
192            self.window.inflight(),
193            self.window.min_window_size(),
194            ceiling,
195        )
196    }
197}
198
199/// Pure admission decision, including the persist probe. Extracted for unit testing.
200///
201/// * `normal` = the AIMD/ceiling-bounded admissible bytes. If positive, use it (no probe).
202/// * Otherwise, if the persist probe is enabled (`persist_after > 0`) and `stalled_parks` has reached it, admit up to
203///   `min_window_size` **beyond `inflight`**, but never past the SURB `ceiling` — the anti-deadlock persist probe.
204///   `persist_after == 0` disables the probe entirely (the default clean behaviour).
205fn admit_bytes(
206    normal: usize,
207    stalled_parks: u32,
208    persist_after: u32,
209    inflight: usize,
210    min_window_size: usize,
211    ceiling: usize,
212) -> usize {
213    if normal > 0 {
214        return normal;
215    }
216    if persist_after > 0 && stalled_parks >= persist_after {
217        let probe_target = inflight.saturating_add(min_window_size);
218        return probe_target.min(ceiling).saturating_sub(inflight);
219    }
220    0
221}
222
223impl<S: futures::AsyncWrite + Unpin> futures::AsyncWrite for PacedWriter<S> {
224    fn poll_write(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8]) -> Poll<std::io::Result<usize>> {
225        // An empty write carries no bytes and must never be flow-controlled (otherwise it would park
226        // on the timer and return `Pending` forever). Complete it immediately.
227        if buf.is_empty() {
228            return Pin::new(&mut self.get_mut().inner).poll_write(cx, buf);
229        }
230        let this = self.get_mut();
231        loop {
232            this.refresh_window();
233            let admissible = this.admissible();
234            if admissible == 0 {
235                // Window full and not yet stalled long enough to persist: park on the keep-progress
236                // timer, count the stall, then re-check.
237                let deadline = this.deadline;
238                let fut = this.park.get_or_insert_with(|| Box::pin(sleep(deadline)));
239                match fut.as_mut().poll(cx) {
240                    Poll::Ready(()) => {
241                        this.park = None;
242                        this.stalled_parks = this.stalled_parks.saturating_add(1);
243                        continue; // re-evaluate admission (delivery/ceiling/persist may have moved)
244                    }
245                    Poll::Pending => return Poll::Pending,
246                }
247            }
248
249            let to_write = buf.len().min(admissible);
250            return match Pin::new(&mut this.inner).poll_write(cx, &buf[..to_write]) {
251                Poll::Ready(Ok(n)) => {
252                    this.window.on_sent(n);
253                    this.sent_total = this.sent_total.wrapping_add(n as u64);
254                    this.stalled_parks = 0; // forward progress — reset the persist counter
255                    Poll::Ready(Ok(n))
256                }
257                other => other,
258            };
259        }
260    }
261
262    fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
263        Pin::new(&mut self.get_mut().inner).poll_flush(cx)
264    }
265
266    fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
267        Pin::new(&mut self.get_mut().inner).poll_close(cx)
268    }
269}
270
271impl<S: futures::AsyncRead + Unpin> futures::AsyncRead for PacedWriter<S> {
272    fn poll_read(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut [u8]) -> Poll<std::io::Result<usize>> {
273        // Reads are never gated by the send window.
274        Pin::new(&mut self.get_mut().inner).poll_read(cx, buf)
275    }
276}
277
278/// Runtime-agnostic sleep returning a `()`-future (via `futures-time`).
279fn sleep(dur: Duration) -> Pin<Box<dyn Future<Output = ()> + Send>> {
280    Box::pin(async move {
281        let _ = futures_time::task::sleep(dur.into()).await;
282    })
283}
284
285#[cfg(test)]
286mod tests {
287    use futures::AsyncWriteExt;
288    use hopr_protocol_session::flow_control::DeliveryMeter;
289
290    use super::*;
291
292    fn balancer(target: u64, buffer_level: u64) -> Arc<BalancerStateValues> {
293        let cfg = crate::SurbBalancerConfig {
294            target_surb_buffer_size: target,
295            max_surbs_per_sec: 5_000,
296            ..Default::default()
297        };
298        let state = Arc::new(BalancerStateValues::from(cfg));
299        state
300            .buffer_level
301            .store(buffer_level, std::sync::atomic::Ordering::Relaxed);
302        state
303    }
304
305    /// Regression: the controller stores `0` into `buffer_level` while the return path is
306    /// degraded to drive SURB production to the maximum. That zero is an instruction, not a
307    /// measurement — but `SurbSupply` also reads the same atomic as a supply ceiling, so it used
308    /// to yield zero admissible bytes for the whole degraded window, and the persist probe could
309    /// not escape it because the probe is itself capped by the ceiling.
310    ///
311    /// A session that opted into surviving return-path loss would therefore have stopped sending
312    /// entirely — the opposite of the feature's intent, and indistinguishable from a dead session.
313    #[test]
314    fn a_degraded_return_path_should_not_zero_the_supply_ceiling() {
315        let state = balancer(7_000, 0);
316        // Through the config, as a caller would: the opt-in is a configuration decision, and going
317        // via the atomic would couple this test to a representation it has no business knowing.
318        state.update(&crate::SurbBalancerConfig {
319            sustain_on_return_path_loss: true,
320            ..Default::default()
321        });
322        state.mark_return_path_degraded(Duration::from_secs(30));
323        assert!(
324            state.return_path_estimate_is_stale(),
325            "precondition: the estimate must be marked stale"
326        );
327
328        let supply = SurbSupply::new(state.clone(), 1_000);
329
330        assert_eq!(
331            usize::MAX,
332            supply.max_admissible_inflight(),
333            "a degraded-path zero must not cap the window; the delivery clock governs instead"
334        );
335        assert_eq!(
336            None,
337            supply.backoff_hint(),
338            "a degraded-path zero is not evidence of an empty buffer"
339        );
340    }
341
342    /// The inverse: once the mark expires the level is a measurement again, and a genuinely empty
343    /// buffer must still close the window.
344    #[test]
345    fn an_empty_buffer_should_still_cap_the_window_when_not_degraded() {
346        let supply = SurbSupply::new(balancer(7_000, 0), 1_000);
347
348        assert_eq!(0, supply.max_admissible_inflight());
349        assert_eq!(Some(Backoff::Hard), supply.backoff_hint());
350    }
351
352    // ---- Persist probe (anti-deadlock at the un-acked tail), configurable ----
353
354    const MIN_WIN: usize = 4_096;
355    const PERSIST: u32 = 8;
356
357    #[test]
358    fn persist_disabled_by_default_never_fires() {
359        // persist_after == 0 (default clean profile): the probe never fires, however long we stall.
360        assert_eq!(admit_bytes(0, 0, 0, 50_000, MIN_WIN, usize::MAX), 0);
361        assert_eq!(admit_bytes(0, 10_000, 0, 50_000, MIN_WIN, usize::MAX), 0);
362    }
363
364    #[test]
365    fn persist_inactive_when_window_has_room() {
366        // Normal admissible > 0 ⇒ probe never involved, whatever the stall count / profile.
367        assert_eq!(admit_bytes(10_000, 0, PERSIST, 50_000, MIN_WIN, usize::MAX), 10_000);
368        assert_eq!(admit_bytes(10_000, 999, PERSIST, 50_000, MIN_WIN, usize::MAX), 10_000);
369    }
370
371    #[test]
372    fn persist_holds_until_stall_threshold() {
373        // Window full (normal == 0) but not yet stalled long enough ⇒ admit nothing.
374        for parks in 0..PERSIST {
375            assert_eq!(
376                admit_bytes(0, parks, PERSIST, 50_000, MIN_WIN, usize::MAX),
377                0,
378                "parks={parks}"
379            );
380        }
381    }
382
383    #[test]
384    fn persist_fires_after_threshold_bounded_by_min_win() {
385        // At/after the threshold, admit exactly min_window_size (ceiling ample).
386        assert_eq!(admit_bytes(0, PERSIST, PERSIST, 50_000, MIN_WIN, usize::MAX), MIN_WIN);
387        assert_eq!(
388            admit_bytes(0, PERSIST + 5, PERSIST, 50_000, MIN_WIN, usize::MAX),
389            MIN_WIN
390        );
391    }
392
393    #[test]
394    fn persist_never_exceeds_surb_ceiling() {
395        // Anti-grief: the probe is still capped by the SURB ceiling.
396        // inflight 50_000, ceiling 52_000 ⇒ only 2_000 headroom even though min_window_size is 4_096.
397        assert_eq!(admit_bytes(0, PERSIST, PERSIST, 50_000, MIN_WIN, 52_000), 2_000);
398        // inflight already at/over the ceiling ⇒ probe admits nothing (never overspends SURBs).
399        assert_eq!(admit_bytes(0, PERSIST, PERSIST, 50_000, MIN_WIN, 50_000), 0);
400        assert_eq!(admit_bytes(0, PERSIST, PERSIST, 50_000, MIN_WIN, 40_000), 0);
401    }
402
403    #[test]
404    fn surb_supply_ceiling_scales_with_buffer_level() {
405        let supply = SurbSupply::new(balancer(7_000, 1_000), 1_000);
406        assert_eq!(supply.max_admissible_inflight(), 1_000 * 1_000);
407    }
408
409    #[test]
410    fn surb_supply_hard_backoff_when_empty() {
411        let supply = SurbSupply::new(balancer(7_000, 0), 1_000);
412        assert_eq!(supply.backoff_hint(), Some(Backoff::Hard));
413    }
414
415    #[test]
416    fn surb_supply_soft_backoff_below_watermark() {
417        // 10% of 7000 target = 700 < 25% watermark (1750) ⇒ Soft.
418        let supply = SurbSupply::new(balancer(7_000, 700), 1_000);
419        assert_eq!(supply.backoff_hint(), Some(Backoff::Soft));
420    }
421
422    #[test]
423    fn surb_supply_healthy_gives_no_backoff() {
424        // Healthy buffer must NOT be a "go faster" signal.
425        let supply = SurbSupply::new(balancer(7_000, 6_000), 1_000);
426        assert_eq!(supply.backoff_hint(), None);
427    }
428
429    #[test]
430    fn surb_supply_disabled_balancer_has_no_ceiling() {
431        let supply = SurbSupply::new(balancer(0, 0), 1_000);
432        assert_eq!(supply.max_admissible_inflight(), usize::MAX);
433        assert_eq!(supply.backoff_hint(), None);
434    }
435
436    // An in-memory duplex end used as the paced writer's inner: writes accumulate into a buffer.
437    #[derive(Default)]
438    struct Sink(Vec<u8>);
439    impl futures::AsyncWrite for Sink {
440        fn poll_write(mut self: Pin<&mut Self>, _: &mut Context<'_>, buf: &[u8]) -> Poll<std::io::Result<usize>> {
441            self.0.extend_from_slice(buf);
442            Poll::Ready(Ok(buf.len()))
443        }
444
445        fn poll_flush(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<std::io::Result<()>> {
446            Poll::Ready(Ok(()))
447        }
448
449        fn poll_close(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<std::io::Result<()>> {
450            Poll::Ready(Ok(()))
451        }
452    }
453
454    fn small_cfg() -> FlowControlConfig {
455        FlowControlConfig {
456            min_window_size: 1_000,
457            max_window_size: 100_000,
458            ai_step: 1_000,
459            md_factor: 0.5,
460            no_honest_deadline: Duration::from_millis(5),
461            ..Default::default()
462        }
463    }
464
465    #[tokio::test]
466    async fn paced_writer_admits_up_to_the_floor_without_delivery() {
467        let meter = DeliveryMeter::default();
468        let clock = DeliveryClock::new(meter, None);
469        let supply = SurbSupply::new(balancer(0, 0), 1_000); // no ceiling
470        let mut w = PacedWriter::new(Sink::default(), small_cfg(), clock, supply);
471        // The floor is 1000 bytes; a single write is capped to the admissible window.
472        let n = w.write(&[7u8; 10_000]).await.unwrap();
473        assert!(n <= 1_000, "first write cannot exceed the floor window, got {n}");
474        assert!(n > 0);
475    }
476
477    #[tokio::test]
478    async fn paced_writer_reopens_after_delivery() {
479        let meter = DeliveryMeter::default();
480        let clock = DeliveryClock::new(meter.clone(), None);
481        let supply = SurbSupply::new(balancer(0, 0), 1_000);
482        let mut w = PacedWriter::new(Sink::default(), small_cfg(), clock, supply);
483
484        // Fill the floor window.
485        let first = w.write(&[0u8; 10_000]).await.unwrap();
486        assert!(first > 0);
487        // Prove delivery of everything sent so far → window grows and admits more.
488        meter.record_acked(first);
489        let second = w.write(&[0u8; 10_000]).await.unwrap();
490        assert!(second > 0, "delivery must reopen the window");
491    }
492
493    #[tokio::test]
494    async fn paced_writer_empty_write_completes_immediately() {
495        // An empty write must never be flow-controlled: even with the window pinned to the floor and
496        // no delivery, writing an empty slice returns `Ok(0)` right away (no park).
497        let meter = DeliveryMeter::default();
498        let clock = DeliveryClock::new(meter, None);
499        let supply = SurbSupply::new(balancer(0, 0), 1_000);
500        let mut w = PacedWriter::new(Sink::default(), small_cfg(), clock, supply);
501        let n = w.write(&[]).await.unwrap();
502        assert_eq!(n, 0);
503    }
504}