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}