Skip to main content

hopr_transport/protocol/
stream.rs

1//! Infrastructure supporting converting a collection of `PeerId` split `libp2p_stream` managed
2//! individual peer-to-peer `libp2p::swarm::Stream`s.
3
4use std::{
5    pin::Pin,
6    sync::{
7        Arc,
8        atomic::{AtomicBool, AtomicUsize, Ordering},
9    },
10    task::{Context, Poll},
11    time::Duration,
12};
13
14use crossfire::mpsc;
15use futures::{
16    AsyncRead, AsyncReadExt, AsyncWrite, FutureExt, Sink, StreamExt,
17    channel::mpsc::{Receiver, Sender, channel},
18};
19use futures_timer::Delay;
20use hopr_api::network::NetworkStreamControl;
21use libp2p::PeerId;
22use tokio_util::{
23    codec::{Decoder, Encoder, FramedRead, FramedWrite},
24    compat::{FuturesAsyncReadCompatExt, FuturesAsyncWriteCompatExt},
25};
26
27#[cfg(all(feature = "telemetry", not(test)))]
28lazy_static::lazy_static! {
29    static ref METRIC_RING_BUFFER_DROPPED: hopr_api::types::telemetry::SimpleCounter =
30        hopr_api::types::telemetry::SimpleCounter::new(
31            "hopr_egress_ring_buffer_dropped",
32            "Number of packets dropped due to per-peer egress channel overflow (drop-newest)",
33        )
34        .unwrap();
35}
36
37/// Per-peer egress buffer: a bounded MPSC crossfire channel.
38///
39/// Only the sender is stored in the cache entry; the receiver is owned exclusively by
40/// the write pump spawned when the outgoing stream opens.  This is functionally SPSC
41/// (one drain-loop producer, one write-pump consumer) even though the sender type is
42/// Clone (required by `moka::sync::Cache`).
43///
44/// When the channel is full the drain loop drops the incoming packet (drop-newest)
45/// and yields via `yield_now()` so the write pump has a chance to drain items and
46/// free space before the next send attempt, preventing task starvation under high
47/// producer rates.
48///
49/// `token` is a unique identity for this sink instance. Pump and opener tasks that
50/// hold a clone compare it against the current cache entry before calling
51/// `cache.invalidate`, so that a stale task finishing after the sink was replaced
52/// (e.g. by an inbound stream arriving during an outgoing open) does not wipe the
53/// newer entry.
54#[derive(Clone)]
55struct PeerSink<T: Send + 'static> {
56    tx: crossfire::MAsyncTx<mpsc::Array<T>>,
57    token: Arc<()>,
58    /// `false` while the outgoing stream is still being opened, `true` once the write pump
59    /// is draining the channel. The egress drain only applies blocking backpressure on a full
60    /// channel when the stream is `ready`; while still opening it uses non-blocking drop-newest,
61    /// so a slow open for one peer never head-of-line-blocks other peers.
62    ready: Arc<AtomicBool>,
63}
64
65impl<T: Send + 'static> PeerSink<T> {
66    fn new(tx: crossfire::MAsyncTx<mpsc::Array<T>>) -> Self {
67        Self {
68            tx,
69            token: Arc::new(()),
70            ready: Arc::new(AtomicBool::new(false)),
71        }
72    }
73}
74
75type PeerStreamCache<T> = moka::sync::Cache<PeerId, PeerSink<T>>;
76
77/// Error produced by the per-peer write pump ([`StallGuardSink`]).
78#[derive(Debug)]
79enum EgressWriteError<E> {
80    /// The wrapped framed-writer sink returned an error of its own.
81    Sink(E),
82    /// The wrapped sink made no forward progress within the stall timeout.
83    Stalled { timeout: Duration },
84}
85
86impl<E: std::fmt::Display> std::fmt::Display for EgressWriteError<E> {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        match self {
89            EgressWriteError::Sink(e) => write!(f, "egress sink error: {e}"),
90            EgressWriteError::Stalled { timeout } => {
91                write!(f, "egress write pump made no progress within {timeout:?}")
92            }
93        }
94    }
95}
96
97/// A [`Sink`] adapter that fails the per-peer write pump once the inner sink stops making progress.
98///
99/// The write pump is `rx.forward(frame_writer)`, where `frame_writer` writes to the peer's quinn
100/// substream. When a remote stops reading, quinn's `poll_write` parks (`Pending`) indefinitely
101/// under flow control, so the framed writer's `poll_ready`/`poll_flush` never resolve and the pump
102/// task parks forever — never completing, so the cache-eviction closure attached to it never runs.
103/// The stalled peer's channel then stays full and the shared egress drain burns one backpressure
104/// timeout per packet for that peer, head-of-line-blocking every other peer until the unbounded
105/// mixer queue exhausts memory (the 2026-08-27 `jura-dev` OOM).
106///
107/// This adapter arms an idle deadline the first time the inner sink returns `Pending` on a
108/// readiness/flush/close poll, and clears it the moment the inner sink returns `Ready`. If the
109/// deadline elapses while the inner sink is still `Pending`, the poll resolves to
110/// [`EgressWriteError::Stalled`], driving the pump to completion so its cache entry is evicted and
111/// the next send reopens the stream. A merely-slow peer that keeps making progress within the
112/// timeout is never killed — the deadline resets on every `Ready`.
113struct StallGuardSink<S> {
114    inner: S,
115    timeout: Duration,
116    /// Persistent idle timer, `reset` in place when a stall starts (the same persistent-`Delay`
117    /// pattern as the mixer's `MixerSink`) so a busy-but-healthy peer that oscillates
118    /// `Pending`/`Ready` does not churn a fresh `Delay` per flush cycle.
119    timer: Delay,
120    /// `true` while a readiness/flush/close poll is outstanding (inner last returned `Pending`).
121    /// While set, the `timer` is polled without resetting, so a continuous stall is measured from
122    /// its start rather than restarted on every poll.
123    armed: bool,
124}
125
126impl<S> StallGuardSink<S> {
127    fn new(inner: S, timeout: Duration) -> Self {
128        Self {
129            inner,
130            timeout,
131            timer: Delay::new(timeout),
132            armed: false,
133        }
134    }
135
136    /// Poll the inner sink, arming/clearing the idle timer and mapping a stall to an error.
137    fn poll_guarded<T, E>(
138        &mut self,
139        cx: &mut Context<'_>,
140        poll_inner: impl FnOnce(&mut S, &mut Context<'_>) -> Poll<Result<T, E>>,
141    ) -> Poll<Result<T, EgressWriteError<E>>> {
142        match poll_inner(&mut self.inner, cx) {
143            Poll::Ready(Ok(v)) => {
144                self.armed = false;
145                Poll::Ready(Ok(v))
146            }
147            Poll::Ready(Err(e)) => {
148                self.armed = false;
149                Poll::Ready(Err(EgressWriteError::Sink(e)))
150            }
151            Poll::Pending => {
152                if !self.armed {
153                    self.timer.reset(self.timeout);
154                    self.armed = true;
155                }
156                match self.timer.poll_unpin(cx) {
157                    Poll::Ready(()) => {
158                        self.armed = false;
159                        Poll::Ready(Err(EgressWriteError::Stalled { timeout: self.timeout }))
160                    }
161                    Poll::Pending => Poll::Pending,
162                }
163            }
164        }
165    }
166}
167
168impl<S, T> Sink<T> for StallGuardSink<S>
169where
170    S: Sink<T> + Unpin,
171{
172    type Error = EgressWriteError<S::Error>;
173
174    fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
175        self.get_mut().poll_guarded(cx, |s, cx| Pin::new(s).poll_ready(cx))
176    }
177
178    fn start_send(self: Pin<&mut Self>, item: T) -> Result<(), Self::Error> {
179        Pin::new(&mut self.get_mut().inner)
180            .start_send(item)
181            .map_err(EgressWriteError::Sink)
182    }
183
184    fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
185        self.get_mut().poll_guarded(cx, |s, cx| Pin::new(s).poll_flush(cx))
186    }
187
188    fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
189        self.get_mut().poll_guarded(cx, |s, cx| Pin::new(s).poll_close(cx))
190    }
191}
192
193/// Spawn the write and read pump tasks for an open peer stream.
194///
195/// The write pump drains `rx` into the framed stream writer; the read pump
196/// forwards decoded frames to `ingress_from_peers`. Both tasks invalidate
197/// `cache[peer]` when they complete, but only if the cache entry still holds
198/// the same `token` — this prevents a stale task from wiping a newer sink.
199#[allow(clippy::too_many_arguments)]
200fn spawn_stream_pumps<S, C>(
201    peer: PeerId,
202    stream: S,
203    rx: crossfire::AsyncRx<mpsc::Array<<C as Decoder>::Item>>,
204    cache: PeerStreamCache<<C as Decoder>::Item>,
205    token: Arc<()>,
206    codec: C,
207    ingress_from_peers: Sender<(PeerId, <C as Decoder>::Item)>,
208    frame_writer_backpressure_bytes: usize,
209    write_stall_timeout: Duration,
210    ready: Arc<AtomicBool>,
211) where
212    S: AsyncRead + AsyncWrite + Send + 'static,
213    C: Encoder<<C as Decoder>::Item> + Decoder + Send + Sync + Clone + 'static,
214    <C as Encoder<<C as Decoder>::Item>>::Error: std::fmt::Debug + std::fmt::Display + Send + Sync + 'static,
215    <C as Decoder>::Error: std::fmt::Debug + std::fmt::Display + Send + Sync + 'static,
216    <C as Decoder>::Item: AsRef<[u8]> + Clone + Send + 'static,
217{
218    let (stream_rx, stream_tx) = stream.split();
219    let cache_for_write = cache.clone();
220    let cache_for_read = cache.clone();
221    let token_write = token.clone();
222
223    let mut frame_writer = FramedWrite::new(stream_tx.compat_write(), codec.clone());
224
225    // `set_backpressure_boundary` is a *byte* threshold on `FramedWrite`'s internal
226    // pending-write buffer: once the encoded frames exceed this many bytes, the next
227    // `poll_ready` call will issue a flush. A larger value lets adjacent small frames
228    // coalesce into a single quinn write, significantly reducing the number of
229    // connection-mutex acquisitions and driver wake-ups on the hot path.
230    frame_writer.set_backpressure_boundary(frame_writer_backpressure_bytes);
231
232    // Guard the framed writer so a remote that stops reading (quinn `poll_write` parked forever)
233    // fails the pump after `write_stall_timeout` instead of parking it indefinitely. On failure the
234    // pump completes and the eviction closure below removes the cache entry, so the next send reopens.
235    let frame_writer = StallGuardSink::new(frame_writer, write_stall_timeout);
236
237    // Write pump: drain the per-peer channel into the framed stream writer.
238    hopr_utils::runtime::prelude::spawn(
239        futures::future::lazy(move |_| {
240            // Flip `ready` only once this task is actually running and about to drain the
241            // channel, so the egress drain never applies blocking backpressure on a peer whose
242            // write pump has not started draining yet.
243            ready.store(true, Ordering::Relaxed);
244        })
245        .then(move |_| rx.into_stream().map(Ok).forward(frame_writer))
246        .inspect(move |res| {
247            tracing::debug!(%peer, ?res, component = "stream", "writing stream with peer finished");
248        })
249        .then(move |_| async move {
250            if cache_for_write
251                .get(&peer)
252                .is_some_and(|s| Arc::ptr_eq(&s.token, &token_write))
253            {
254                cache_for_write.invalidate(&peer);
255            }
256        }),
257    );
258
259    // Read pump: forward decoded frames to the ingress channel.
260    hopr_utils::runtime::prelude::spawn(
261        FramedRead::new(stream_rx.compat(), codec)
262            .filter_map(move |v| {
263                futures::future::ready(match v {
264                    Ok(v) => {
265                        tracing::trace!(%peer, "read message from peer stream");
266                        Some((peer, v))
267                    }
268                    Err(error) => {
269                        tracing::error!(%error, "Error decoding object from the underlying stream");
270                        None
271                    }
272                })
273            })
274            .map(Ok)
275            .forward(ingress_from_peers)
276            .inspect(move |res| match res {
277                Ok(_) => tracing::debug!(%peer, component = "stream", "incoming stream done reading"),
278                Err(error) => {
279                    tracing::error!(%peer, %error, component = "stream", "incoming stream failed on reading")
280                }
281            })
282            .then(move |_| async move {
283                if cache_for_read.get(&peer).is_some_and(|s| Arc::ptr_eq(&s.token, &token)) {
284                    cache_for_read.invalidate(&peer);
285                }
286            }),
287    );
288
289    tracing::trace!(%peer, "created new io for peer");
290}
291
292pub async fn process_stream_protocol<C, V>(
293    codec: C,
294    control: V,
295    stream_cfg: crate::config::StreamProtocolConfig,
296) -> super::errors::Result<(
297    Sender<(PeerId, <C as Decoder>::Item)>, // impl Sink<(PeerId, <C as Decoder>::Item)>,
298    Receiver<(PeerId, <C as Decoder>::Item)>, // impl Stream<Item = (PeerId, <C as Decoder>::Item)>,
299)>
300where
301    C: Encoder<<C as Decoder>::Item> + Decoder + Send + Sync + Clone + 'static,
302    <C as Encoder<<C as Decoder>::Item>>::Error: std::fmt::Debug + std::fmt::Display + Send + Sync + 'static,
303    <C as Decoder>::Error: std::fmt::Debug + std::fmt::Display + Send + Sync + 'static,
304    // `Unpin` is required so the egress drain can `await` a crossfire `send` (backpressure path).
305    <C as Decoder>::Item: AsRef<[u8]> + Clone + Send + Unpin + 'static,
306    V: NetworkStreamControl + Clone + Send + Sync + 'static,
307{
308    let (tx_out, mut rx_out) = channel::<(PeerId, <C as Decoder>::Item)>(100_000);
309    let (tx_in, rx_in) = channel::<(PeerId, <C as Decoder>::Item)>(100_000);
310
311    let cache_out: PeerStreamCache<<C as Decoder>::Item> = moka::sync::Cache::builder()
312        .max_capacity(2000)
313        .eviction_listener(|key: Arc<PeerId>, _, cause| {
314            tracing::trace!(peer = %key.as_ref(), ?cause, "evicting stream for peer");
315        })
316        .build();
317
318    // Bounds the number of in-flight stream-open tasks across all distinct peers.
319    const MAX_CONCURRENT_STREAM_OPENS: usize = 50;
320    let open_task_count = Arc::new(AtomicUsize::new(0));
321
322    let incoming = control
323        .clone()
324        .accept()
325        .map_err(|e| super::errors::ProtocolError::Logic(format!("failed to listen on protocol: {e}")))?;
326
327    let stream_open_timeout = stream_cfg.stream_open_timeout;
328    let frame_writer_backpressure_bytes = stream_cfg.frame_writer_backpressure_bytes;
329    let per_peer_channel_capacity = stream_cfg.per_peer_channel_capacity;
330    let egress_backpressure_timeout = stream_cfg.egress_backpressure_timeout;
331
332    let open_ctx = Arc::new((control, codec, tx_in));
333
334    let cache_ingress = cache_out.clone();
335    let open_ctx_ingress = open_ctx.clone();
336
337    // terminated when the incoming is dropped
338    let _ingress_process = hopr_utils::runtime::prelude::spawn(
339        incoming
340            .for_each(move |(peer, stream)| {
341                let cache = cache_ingress.clone();
342                let open_ctx = open_ctx_ingress.clone();
343
344                tracing::debug!(%peer, "received incoming peer-to-peer stream");
345                let (_control, codec, tx_in) = (&open_ctx.0, &open_ctx.1, &open_ctx.2);
346
347                let (tx, rx) = mpsc::bounded_async::<<C as Decoder>::Item>(per_peer_channel_capacity);
348                let sink = PeerSink::new(tx);
349                let token = sink.token.clone();
350                let ready = sink.ready.clone();
351                spawn_stream_pumps(
352                    peer,
353                    stream,
354                    rx,
355                    cache.clone(),
356                    token,
357                    codec.clone(),
358                    tx_in.clone(),
359                    frame_writer_backpressure_bytes,
360                    egress_backpressure_timeout,
361                    ready,
362                );
363                cache.insert(peer, sink);
364
365                futures::future::ready(())
366            })
367            .inspect(|_| {
368                tracing::info!(
369                    task = "ingress stream processing",
370                    "long-running background task finished"
371                )
372            }),
373    );
374
375    // Egress drain: reads outgoing packets from `rx_out` and enqueues them into
376    // the per-peer crossfire channel.
377    //
378    // The drain is non-blocking: `try_send` never awaits. On overflow the incoming
379    // packet is dropped (drop-newest) and `yield_now()` is called to let the write
380    // pump task drain the channel before the next send attempt, preventing task
381    // starvation under high producer rates.
382    //
383    // On cache miss, `get_with` atomically creates the per-peer channel and spawns
384    // exactly one opener task. In-flight opens are bounded by `open_task_count`
385    // (MAX_CONCURRENT_STREAM_OPENS = 50) to prevent resource exhaustion.
386    let _egress_process = hopr_utils::runtime::prelude::spawn(async move {
387        use futures::StreamExt as _;
388
389        while let Some((peer, msg)) = rx_out.next().await {
390            tracing::trace!(%peer, "trying to deliver message to peer");
391
392            let sink = if let Some(s) = cache_out.get(&peer) {
393                s
394            } else {
395                let cache2 = cache_out.clone();
396                let open_ctx2 = open_ctx.clone();
397                let open_count2 = open_task_count.clone();
398                cache_out.get_with(peer, move || {
399                    let (tx, rx) = mpsc::bounded_async::<<C as Decoder>::Item>(per_peer_channel_capacity);
400                    let sink = PeerSink::new(tx);
401                    let token = sink.token.clone();
402                    let ready = sink.ready.clone();
403
404                    if open_count2.fetch_add(1, Ordering::Relaxed) < MAX_CONCURRENT_STREAM_OPENS {
405                        hopr_utils::runtime::prelude::spawn(async move {
406                            tracing::trace!(%peer, "peer is not in cache, opening new stream");
407                            use futures_time::future::FutureExt as TimeExt;
408                            let (control, codec, tx_in) = (&open_ctx2.0, &open_ctx2.1, &open_ctx2.2);
409
410                            let stream = control
411                                .clone()
412                                .open(peer)
413                                .timeout(futures_time::time::Duration::from(stream_open_timeout))
414                                .await
415                                .map_err(|_| anyhow::anyhow!("timeout trying to open stream to {peer}"))
416                                .and_then(|s| {
417                                    s.map_err(|e| anyhow::anyhow!("could not open outgoing peer-to-peer stream: {e}"))
418                                });
419
420                            open_count2.fetch_sub(1, Ordering::Relaxed);
421
422                            match stream {
423                                Ok(stream) => {
424                                    tracing::debug!(%peer, "opening outgoing peer-to-peer stream");
425                                    spawn_stream_pumps(
426                                        peer,
427                                        stream,
428                                        rx,
429                                        cache2.clone(),
430                                        token,
431                                        codec.clone(),
432                                        tx_in.clone(),
433                                        frame_writer_backpressure_bytes,
434                                        egress_backpressure_timeout,
435                                        ready,
436                                    );
437                                }
438                                Err(error) => {
439                                    tracing::debug!(
440                                        %peer, %error,
441                                        "stream open failed/timed out; dropping buffered packets"
442                                    );
443                                    if cache2.get(&peer).is_some_and(|s| Arc::ptr_eq(&s.token, &token)) {
444                                        cache2.invalidate(&peer);
445                                    }
446                                }
447                            }
448                        });
449                    } else {
450                        open_count2.fetch_sub(1, Ordering::Relaxed);
451                        tracing::debug!(%peer, "stream-open concurrency limit reached; dropping buffered packets");
452                        hopr_utils::runtime::prelude::spawn(async move {
453                            if cache2.get(&peer).is_some_and(|s| Arc::ptr_eq(&s.token, &token)) {
454                                cache2.invalidate(&peer);
455                            }
456                        });
457                    }
458
459                    sink
460                })
461            };
462
463            match sink.tx.try_send(msg) {
464                Ok(()) => tracing::trace!(%peer, "message queued to peer channel"),
465                Err(crossfire::TrySendError::Full(msg)) => {
466                    if sink.ready.load(Ordering::Relaxed) {
467                        // Stream is open and draining; a full channel means the wire is slower
468                        // than the producer. Wait (bounded) for space so wire-rate backpressure
469                        // propagates upstream — through the mixer, the outgoing CrossfireSink and
470                        // the session socket — to the application writer, instead of dropping.
471                        // Fall back to drop-newest only if the peer stays full past the timeout,
472                        // so a stalled peer cannot head-of-line-block other peers forever.
473                        use futures_time::future::FutureExt as _;
474                        match async { sink.tx.send(msg).await }
475                            .timeout(futures_time::time::Duration::from(egress_backpressure_timeout))
476                            .await
477                        {
478                            Ok(Ok(())) => {
479                                tracing::trace!(%peer, "message queued to peer channel after backpressure")
480                            }
481                            Ok(Err(_disconnected)) => {
482                                tracing::debug!(%peer, "peer sink disconnected while awaiting space; invalidating cache");
483                                if cache_out.get(&peer).is_some_and(|s| Arc::ptr_eq(&s.token, &sink.token)) {
484                                    cache_out.invalidate(&peer);
485                                }
486                            }
487                            Err(_timeout) => {
488                                #[cfg(all(feature = "telemetry", not(test)))]
489                                METRIC_RING_BUFFER_DROPPED.increment();
490                                tracing::debug!(
491                                    %peer,
492                                    "per-peer egress channel full past backpressure timeout; dropping newest packet"
493                                );
494                            }
495                        }
496                    } else {
497                        // Stream still opening: never block — a slow open for this peer must not
498                        // head-of-line-block delivery to other peers. Drop newest and yield so the
499                        // opener task can make progress.
500                        #[cfg(all(feature = "telemetry", not(test)))]
501                        METRIC_RING_BUFFER_DROPPED.increment();
502                        tracing::debug!(%peer, "per-peer egress channel full during open; dropping newest packet");
503                        hopr_utils::runtime::prelude::yield_now().await;
504                    }
505                }
506                Err(crossfire::TrySendError::Disconnected(_)) => {
507                    // Receiver dropped (write pump died): invalidate and reopen on next send.
508                    // Guard with the token so we don't wipe a replacement sink that was
509                    // inserted (e.g. from an inbound stream) between our cache.get() above
510                    // and this branch.
511                    tracing::debug!(%peer, "peer sink disconnected; invalidating cache");
512                    if cache_out.get(&peer).is_some_and(|s| Arc::ptr_eq(&s.token, &sink.token)) {
513                        cache_out.invalidate(&peer);
514                    }
515                }
516            }
517        }
518
519        tracing::info!(
520            task = "egress stream processing",
521            "long-running background task finished"
522        );
523    });
524
525    Ok((tx_out, rx_in))
526}
527
528#[cfg(test)]
529mod tests {
530    use std::{
531        pin::Pin,
532        sync::{
533            Arc,
534            atomic::{AtomicUsize, Ordering},
535        },
536        task::{Context as TaskContext, Poll, Waker},
537    };
538
539    use anyhow::Context;
540    use async_trait::async_trait;
541    use futures::{SinkExt, Stream};
542    use parking_lot::Mutex;
543    use tokio_util::{bytes::BytesMut, codec::BytesCodec};
544
545    use super::*;
546
547    #[derive(Clone, Default, Debug)]
548    struct CountingControl {
549        open_calls: Arc<AtomicUsize>,
550    }
551
552    impl CountingControl {
553        fn open_calls(&self) -> usize {
554            self.open_calls.load(Ordering::Relaxed)
555        }
556    }
557
558    #[derive(Default)]
559    struct StalledWriteIo;
560
561    impl AsyncRead for StalledWriteIo {
562        fn poll_read(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>, _buf: &mut [u8]) -> Poll<std::io::Result<usize>> {
563            Poll::Pending
564        }
565    }
566
567    impl AsyncWrite for StalledWriteIo {
568        fn poll_write(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>, _buf: &[u8]) -> Poll<std::io::Result<usize>> {
569            Poll::Pending
570        }
571
572        fn poll_flush(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll<std::io::Result<()>> {
573            Poll::Pending
574        }
575
576        fn poll_close(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll<std::io::Result<()>> {
577            Poll::Ready(Ok(()))
578        }
579    }
580
581    #[async_trait]
582    impl hopr_api::network::traits::NetworkStreamControl for CountingControl {
583        fn accept(
584            self,
585        ) -> Result<impl Stream<Item = (PeerId, impl AsyncRead + AsyncWrite + Send)> + Send, impl std::error::Error>
586        {
587            Ok::<_, std::io::Error>(futures::stream::empty::<(PeerId, StalledWriteIo)>())
588        }
589
590        async fn open(self, _peer: PeerId) -> Result<impl AsyncRead + AsyncWrite + Send, impl std::error::Error> {
591            self.open_calls.fetch_add(1, Ordering::Relaxed);
592            Ok::<_, std::io::Error>(StalledWriteIo)
593        }
594    }
595
596    struct AsyncBinaryStreamChannel {
597        read: async_channel_io::ChannelReader,
598        write: async_channel_io::ChannelWriter,
599    }
600
601    impl AsyncBinaryStreamChannel {
602        pub fn new() -> Self {
603            let (write, read) = async_channel_io::pipe();
604            Self { read, write }
605        }
606    }
607
608    impl AsyncRead for AsyncBinaryStreamChannel {
609        fn poll_read(
610            self: std::pin::Pin<&mut Self>,
611            cx: &mut std::task::Context<'_>,
612            buf: &mut [u8],
613        ) -> std::task::Poll<std::io::Result<usize>> {
614            let mut pinned = std::pin::pin!(&mut self.get_mut().read);
615            pinned.as_mut().poll_read(cx, buf)
616        }
617    }
618
619    impl AsyncWrite for AsyncBinaryStreamChannel {
620        fn poll_write(
621            self: std::pin::Pin<&mut Self>,
622            cx: &mut std::task::Context<'_>,
623            buf: &[u8],
624        ) -> std::task::Poll<std::io::Result<usize>> {
625            let mut pinned = std::pin::pin!(&mut self.get_mut().write);
626            pinned.as_mut().poll_write(cx, buf)
627        }
628
629        fn poll_flush(
630            self: std::pin::Pin<&mut Self>,
631            cx: &mut std::task::Context<'_>,
632        ) -> std::task::Poll<std::io::Result<()>> {
633            let pinned = std::pin::pin!(&mut self.get_mut().write);
634            pinned.poll_flush(cx)
635        }
636
637        fn poll_close(
638            self: std::pin::Pin<&mut Self>,
639            cx: &mut std::task::Context<'_>,
640        ) -> std::task::Poll<std::io::Result<()>> {
641            let pinned = std::pin::pin!(&mut self.get_mut().write);
642            pinned.poll_close(cx)
643        }
644    }
645
646    #[tokio::test]
647    async fn split_codec_should_always_produce_correct_data() -> anyhow::Result<()> {
648        let stream = AsyncBinaryStreamChannel::new();
649        let codec = tokio_util::codec::BytesCodec::new();
650
651        let expected = [0u8, 1u8, 2u8, 3u8, 4u8, 5u8];
652        let value = tokio_util::bytes::BytesMut::from(expected.as_ref());
653
654        let (stream_rx, stream_tx) = stream.split();
655        let (mut tx, rx) = (
656            FramedWrite::new(stream_tx.compat_write(), codec),
657            FramedRead::new(stream_rx.compat(), codec),
658        );
659        tx.send(value)
660            .await
661            .map_err(|_| anyhow::anyhow!("should not fail on send"))?;
662
663        futures::pin_mut!(rx);
664
665        assert_eq!(
666            rx.next().await.context("Value must be present")??,
667            tokio_util::bytes::BytesMut::from(expected.as_ref())
668        );
669
670        Ok(())
671    }
672
673    // ---------------------------------------------------------------------------
674    // FlaggedStream — models a LivenessStream whose connection has been killed.
675    // ---------------------------------------------------------------------------
676
677    struct DeadSignal {
678        read_dead: std::sync::atomic::AtomicBool,
679        write_dead: std::sync::atomic::AtomicBool,
680        read_waker: Mutex<Option<Waker>>,
681        write_waker: Mutex<Option<Waker>>,
682    }
683
684    impl std::fmt::Debug for DeadSignal {
685        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
686            f.debug_struct("DeadSignal")
687                .field("read_dead", &self.read_dead.load(Ordering::Relaxed))
688                .field("write_dead", &self.write_dead.load(Ordering::Relaxed))
689                .finish_non_exhaustive()
690        }
691    }
692
693    impl DeadSignal {
694        fn new() -> Arc<Self> {
695            Arc::new(Self {
696                read_dead: std::sync::atomic::AtomicBool::new(false),
697                write_dead: std::sync::atomic::AtomicBool::new(false),
698                read_waker: Mutex::new(None),
699                write_waker: Mutex::new(None),
700            })
701        }
702
703        fn kill_read(self: &Arc<Self>) {
704            self.read_dead.store(true, Ordering::Release);
705            let waker = self.read_waker.lock().take();
706            if let Some(w) = waker {
707                w.wake();
708            }
709        }
710
711        fn kill_write(self: &Arc<Self>) {
712            self.write_dead.store(true, Ordering::Release);
713            let waker = self.write_waker.lock().take();
714            if let Some(w) = waker {
715                w.wake();
716            }
717        }
718    }
719
720    struct FlaggedStream {
721        signal: Arc<DeadSignal>,
722    }
723
724    impl AsyncRead for FlaggedStream {
725        fn poll_read(self: Pin<&mut Self>, cx: &mut TaskContext<'_>, _buf: &mut [u8]) -> Poll<std::io::Result<usize>> {
726            *self.signal.read_waker.lock() = Some(cx.waker().clone());
727            if self.signal.read_dead.load(Ordering::Acquire) {
728                return Poll::Ready(Err(std::io::Error::from(std::io::ErrorKind::ConnectionAborted)));
729            }
730            Poll::Pending
731        }
732    }
733
734    impl AsyncWrite for FlaggedStream {
735        fn poll_write(self: Pin<&mut Self>, cx: &mut TaskContext<'_>, _buf: &[u8]) -> Poll<std::io::Result<usize>> {
736            *self.signal.write_waker.lock() = Some(cx.waker().clone());
737            if self.signal.write_dead.load(Ordering::Acquire) {
738                return Poll::Ready(Err(std::io::Error::from(std::io::ErrorKind::ConnectionAborted)));
739            }
740            Poll::Pending
741        }
742
743        fn poll_flush(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll<std::io::Result<()>> {
744            Poll::Ready(Ok(()))
745        }
746
747        fn poll_close(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll<std::io::Result<()>> {
748            Poll::Ready(Ok(()))
749        }
750    }
751
752    #[derive(Clone, Debug, Default)]
753    struct ScriptedControl {
754        open_calls: Arc<AtomicUsize>,
755        signals: Arc<Mutex<Vec<Arc<DeadSignal>>>>,
756    }
757
758    impl ScriptedControl {
759        fn open_calls(&self) -> usize {
760            self.open_calls.load(Ordering::Relaxed)
761        }
762
763        fn signal(&self, index: usize) -> Option<Arc<DeadSignal>> {
764            self.signals.lock().get(index).cloned()
765        }
766    }
767
768    #[async_trait]
769    impl hopr_api::network::traits::NetworkStreamControl for ScriptedControl {
770        fn accept(
771            self,
772        ) -> Result<impl Stream<Item = (PeerId, impl AsyncRead + AsyncWrite + Send)> + Send, impl std::error::Error>
773        {
774            Ok::<_, std::io::Error>(futures::stream::empty::<(PeerId, FlaggedStream)>())
775        }
776
777        async fn open(self, _peer: PeerId) -> Result<impl AsyncRead + AsyncWrite + Send, impl std::error::Error> {
778            let signal = DeadSignal::new();
779            self.signals.lock().push(signal.clone());
780            self.open_calls.fetch_add(1, Ordering::Relaxed);
781            Ok::<_, std::io::Error>(FlaggedStream { signal })
782        }
783    }
784
785    async fn wait_for(secs: u64, condition: impl Fn() -> bool) -> bool {
786        let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(secs);
787        while !condition() && tokio::time::Instant::now() < deadline {
788            tokio::time::sleep(std::time::Duration::from_millis(25)).await;
789        }
790        condition()
791    }
792
793    #[tokio::test]
794    async fn dead_stream_should_be_detected_on_read_path_and_allow_next_send_to_reopen() -> anyhow::Result<()> {
795        let control = ScriptedControl::default();
796
797        let (mut tx_out, _rx_in) = process_stream_protocol(
798            BytesCodec::new(),
799            control.clone(),
800            crate::config::StreamProtocolConfig {
801                per_peer_channel_capacity: 64,
802                ..Default::default()
803            },
804        )
805        .await?;
806
807        let peer = PeerId::random();
808        let msg = BytesMut::from(&b"probe"[..]);
809
810        tx_out
811            .send((peer, msg.clone()))
812            .await
813            .context("first send should succeed")?;
814
815        assert!(
816            wait_for(2, || control.open_calls() >= 1).await,
817            "stream was never opened"
818        );
819        let signal = control.signal(0).context("signal for stream #1 must exist")?;
820
821        signal.kill_read();
822
823        tx_out
824            .send((peer, msg.clone()))
825            .await
826            .context("second send into egress queue should succeed")?;
827
828        assert!(
829            wait_for(2, || control.open_calls() >= 2).await,
830            "stream was not reopened after connection kill (open_calls={})",
831            control.open_calls()
832        );
833
834        Ok(())
835    }
836
837    #[tokio::test]
838    async fn dead_stream_should_be_detected_on_write_path_and_allow_next_send_to_reopen() -> anyhow::Result<()> {
839        let control = ScriptedControl::default();
840
841        let (mut tx_out, _rx_in) = process_stream_protocol(
842            BytesCodec::new(),
843            control.clone(),
844            crate::config::StreamProtocolConfig {
845                per_peer_channel_capacity: 128,
846                ..Default::default()
847            },
848        )
849        .await?;
850
851        let peer = PeerId::random();
852        let msg = BytesMut::from(&b"payload"[..]);
853
854        tx_out.send((peer, msg.clone())).await.context("initial send")?;
855        assert!(wait_for(2, || control.open_calls() >= 1).await, "stream not opened");
856
857        let signal = control.signal(0).context("signal #1 must exist")?;
858
859        signal.kill_write();
860
861        let mut drained = 0usize;
862        while control.open_calls() < 2 && drained < 128 {
863            tx_out
864                .send((peer, msg.clone()))
865                .await
866                .with_context(|| format!("drain send {drained} into egress queue should succeed"))?;
867            drained += 1;
868        }
869
870        assert!(
871            wait_for(3, || control.open_calls() >= 2).await,
872            "stream was not reopened after writer kill (open_calls={})",
873            control.open_calls()
874        );
875
876        Ok(())
877    }
878
879    /// Reopening a permanently stalled peer must self-heal at the stall-timeout cadence — never as
880    /// tight per-send churn.
881    ///
882    /// `CountingControl` returns a `StalledWriteIo` whose `poll_write`/`poll_flush` always return
883    /// `Pending`. Before the sink-level stall mitigation the write pump parked forever and the entry
884    /// was never evicted (the previous version of this test asserted "must not reopen"). It now
885    /// terminates after the stall timeout so the entry is evicted and the next send reopens — but
886    /// the reopen cadence must stay bounded by the stall timeout, not spin once per send.
887    #[tokio::test]
888    async fn stalled_peer_reopen_cadence_should_be_bounded_by_the_stall_timeout() -> anyhow::Result<()> {
889        const STALL_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(300);
890        const WINDOW: std::time::Duration = std::time::Duration::from_millis(1_500);
891
892        let control = CountingControl::default();
893        let (mut tx_out, _rx_in) = process_stream_protocol(
894            BytesCodec::new(),
895            control.clone(),
896            crate::config::StreamProtocolConfig {
897                per_peer_channel_capacity: 4,
898                frame_writer_backpressure_bytes: 1,
899                egress_backpressure_timeout: STALL_TIMEOUT,
900                ..Default::default()
901            },
902        )
903        .await?;
904
905        let peer = PeerId::random();
906        let msg = BytesMut::from(&b"x"[..]);
907
908        // Keep offering packets throughout the window so every eviction is promptly observable as a
909        // fresh open.
910        let deadline = tokio::time::Instant::now() + WINDOW;
911        while tokio::time::Instant::now() < deadline {
912            let _ = tx_out.send((peer, msg.clone())).await;
913            tokio::time::sleep(std::time::Duration::from_millis(25)).await;
914        }
915
916        let opens = control.open_calls();
917        assert!(
918            opens >= 2,
919            "a permanently stalled peer must self-heal by reopening (open_calls={opens})"
920        );
921
922        // Cadence bound: at most one reopen per stall timeout, plus generous slack for scheduling.
923        // A regression to per-send reopen churn (dozens/hundreds of opens) trips this.
924        let max_expected = (WINDOW.as_millis() / STALL_TIMEOUT.as_millis()) as usize + 3;
925        assert!(
926            opens <= max_expected,
927            "reopen churn detected: {opens} opens in {WINDOW:?} exceeds the ~{max_expected} bounded by the \
928             {STALL_TIMEOUT:?} stall timeout"
929        );
930
931        Ok(())
932    }
933
934    /// Reproduces the 2026-08-27 `jura-dev` OOM at the integration boundary.
935    ///
936    /// A peer whose remote stops reading makes the underlying `poll_write` park (`Pending`)
937    /// forever under flow control. The per-peer write pump (`rx.forward(frame_writer)`) then
938    /// parks with it and never completes, so its cache-eviction closure never runs: the sink is
939    /// never evicted, the peer's channel stays full, and the shared egress drain burns one
940    /// backpressure timeout per packet for that peer — head-of-line-blocking every other peer
941    /// until the unbounded mixer queue exhausts memory.
942    ///
943    /// The fix mitigates the stall *at the sink*: the write pump must fail once it makes no
944    /// progress for `egress_backpressure_timeout`, driving the pump to completion so the entry is
945    /// evicted and a later send reopens the stream. A merely-slow peer that keeps making progress
946    /// within the timeout is never killed.
947    ///
948    /// Under the unfixed code the pump parks forever, the entry is never evicted, and
949    /// `open_calls` stays pinned at 1 — this test fails.
950    #[tokio::test]
951    async fn stalled_write_pump_should_terminate_and_reopen_after_stall_timeout() -> anyhow::Result<()> {
952        const STALL_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500);
953
954        let control = CountingControl::default();
955        let (mut tx_out, _rx_in) = process_stream_protocol(
956            BytesCodec::new(),
957            control.clone(),
958            crate::config::StreamProtocolConfig {
959                per_peer_channel_capacity: 4,
960                // Flush every frame so the pump reaches the stalled writer immediately, rather than
961                // buffering frames inside `FramedWrite` until the byte threshold forces a flush.
962                frame_writer_backpressure_bytes: 1,
963                // Reused as the write-pump stall timeout.
964                egress_backpressure_timeout: STALL_TIMEOUT,
965                ..Default::default()
966            },
967        )
968        .await?;
969
970        let peer = PeerId::random();
971        let msg = BytesMut::from(&b"payload"[..]);
972
973        // First send opens the stream; its writer is permanently stalled (poll_write == Pending).
974        tx_out.send((peer, msg.clone())).await.context("first send")?;
975        assert!(
976            wait_for(2, || control.open_calls() >= 1).await,
977            "stream was never opened"
978        );
979
980        // A merely-slow peer must not be evicted: no reopen before the stall timeout elapses.
981        tokio::time::sleep(STALL_TIMEOUT / 2).await;
982        assert_eq!(
983            control.open_calls(),
984            1,
985            "stream reopened before the stall timeout elapsed — over-eager eviction would kill merely-slow peers"
986        );
987
988        // Past the stall timeout the pump must terminate, evict the cache entry, and a subsequent
989        // send must reopen the stream. Keep sending so an eviction is observable as a fresh open.
990        let mut sends = 0;
991        while control.open_calls() < 2 && sends < 200 {
992            let _ = tx_out.send((peer, msg.clone())).await;
993            sends += 1;
994            tokio::time::sleep(std::time::Duration::from_millis(25)).await;
995        }
996
997        assert!(
998            wait_for(3, || control.open_calls() >= 2).await,
999            "stalled write pump never terminated: the stream was not reopened after the stall timeout (open_calls={}) \
1000             — the pump parked forever and the peer became a permanent silent black hole, exactly the field failure \
1001             mode",
1002            control.open_calls(),
1003        );
1004
1005        Ok(())
1006    }
1007
1008    // -----------------------------------------------------------------------
1009    // StallGuardSink unit tests — isolate the sink-level stall mitigation from
1010    // the surrounding stream-protocol machinery. Each covers one way the wrapped
1011    // sink can stall (or make progress) and asserts the adapter's response.
1012    // -----------------------------------------------------------------------
1013
1014    /// Outcome a [`ScriptedSink`] returns for a given poll.
1015    #[derive(Clone, Copy)]
1016    enum Op {
1017        Ready,
1018        Pending,
1019        Err,
1020    }
1021
1022    fn apply(op: Op) -> Poll<Result<(), std::io::Error>> {
1023        match op {
1024            Op::Ready => Poll::Ready(Ok(())),
1025            Op::Pending => Poll::Pending,
1026            Op::Err => Poll::Ready(Err(std::io::Error::other("scripted sink error"))),
1027        }
1028    }
1029
1030    /// A `Sink` whose `poll_ready`/`poll_flush`/`poll_close` outcomes are fixed per-op. A `Pending`
1031    /// op never registers a waker (modelling a quinn substream whose remote stopped reading) — the
1032    /// wake-up must come from `StallGuardSink`'s own timer.
1033    struct ScriptedSink {
1034        ready: Op,
1035        flush: Op,
1036        close: Op,
1037    }
1038
1039    impl Sink<u8> for ScriptedSink {
1040        type Error = std::io::Error;
1041
1042        fn poll_ready(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll<Result<(), Self::Error>> {
1043            apply(self.ready)
1044        }
1045
1046        fn start_send(self: Pin<&mut Self>, _item: u8) -> Result<(), Self::Error> {
1047            Ok(())
1048        }
1049
1050        fn poll_flush(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll<Result<(), Self::Error>> {
1051            apply(self.flush)
1052        }
1053
1054        fn poll_close(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll<Result<(), Self::Error>> {
1055            apply(self.close)
1056        }
1057    }
1058
1059    /// A `Sink` whose first `poll_flush` parks once (waking itself so it is re-polled immediately),
1060    /// then succeeds — a peer that briefly stalls but recovers well within the timeout.
1061    #[derive(Default)]
1062    struct TransientFlushSink {
1063        flushed_once: bool,
1064    }
1065
1066    impl Sink<u8> for TransientFlushSink {
1067        type Error = std::io::Error;
1068
1069        fn poll_ready(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll<Result<(), Self::Error>> {
1070            Poll::Ready(Ok(()))
1071        }
1072
1073        fn start_send(self: Pin<&mut Self>, _item: u8) -> Result<(), Self::Error> {
1074            Ok(())
1075        }
1076
1077        fn poll_flush(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<Result<(), Self::Error>> {
1078            let this = self.get_mut();
1079            if this.flushed_once {
1080                Poll::Ready(Ok(()))
1081            } else {
1082                this.flushed_once = true;
1083                cx.waker().wake_by_ref();
1084                Poll::Pending
1085            }
1086        }
1087
1088        fn poll_close(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll<Result<(), Self::Error>> {
1089            Poll::Ready(Ok(()))
1090        }
1091    }
1092
1093    #[tokio::test]
1094    async fn stall_guard_sink_should_error_when_poll_ready_stalls_past_timeout() {
1095        let mut sink = StallGuardSink::new(
1096            ScriptedSink {
1097                ready: Op::Pending,
1098                flush: Op::Ready,
1099                close: Op::Ready,
1100            },
1101            Duration::from_millis(150),
1102        );
1103        let res = tokio::time::timeout(Duration::from_secs(2), sink.send(1u8))
1104            .await
1105            .expect("StallGuardSink must resolve on its own timer, not hang");
1106        assert!(
1107            matches!(res, Err(EgressWriteError::Stalled { .. })),
1108            "a sink that never becomes ready must fail with Stalled, got {res:?}"
1109        );
1110    }
1111
1112    #[tokio::test]
1113    async fn stall_guard_sink_should_error_when_poll_flush_stalls_past_timeout() {
1114        let mut sink = StallGuardSink::new(
1115            ScriptedSink {
1116                ready: Op::Ready,
1117                flush: Op::Pending,
1118                close: Op::Ready,
1119            },
1120            Duration::from_millis(150),
1121        );
1122        let res = tokio::time::timeout(Duration::from_secs(2), sink.send(1u8))
1123            .await
1124            .expect("StallGuardSink must resolve on its own timer, not hang");
1125        assert!(
1126            matches!(res, Err(EgressWriteError::Stalled { .. })),
1127            "a sink that accepts but never flushes must fail with Stalled, got {res:?}"
1128        );
1129    }
1130
1131    #[tokio::test]
1132    async fn stall_guard_sink_should_error_when_poll_close_stalls_past_timeout() {
1133        let mut sink = StallGuardSink::new(
1134            ScriptedSink {
1135                ready: Op::Ready,
1136                flush: Op::Ready,
1137                close: Op::Pending,
1138            },
1139            Duration::from_millis(150),
1140        );
1141        let res = tokio::time::timeout(Duration::from_secs(2), sink.close())
1142            .await
1143            .expect("StallGuardSink must resolve on its own timer, not hang");
1144        assert!(
1145            matches!(res, Err(EgressWriteError::Stalled { .. })),
1146            "a sink that never closes must fail with Stalled, got {res:?}"
1147        );
1148    }
1149
1150    #[tokio::test]
1151    async fn stall_guard_sink_should_pass_through_a_healthy_sink_without_error() {
1152        let mut sink = StallGuardSink::new(
1153            ScriptedSink {
1154                ready: Op::Ready,
1155                flush: Op::Ready,
1156                close: Op::Ready,
1157            },
1158            Duration::from_millis(50),
1159        );
1160        for i in 0..100u8 {
1161            sink.send(i)
1162                .await
1163                .expect("a healthy sink must never be failed by the stall guard");
1164        }
1165        sink.close().await.expect("closing a healthy sink must succeed");
1166    }
1167
1168    #[tokio::test]
1169    async fn stall_guard_sink_should_surface_inner_errors_verbatim_not_as_a_stall() {
1170        let mut sink = StallGuardSink::new(
1171            ScriptedSink {
1172                ready: Op::Ready,
1173                flush: Op::Err,
1174                close: Op::Ready,
1175            },
1176            Duration::from_millis(150),
1177        );
1178        let res = sink.send(1u8).await;
1179        assert!(
1180            matches!(res, Err(EgressWriteError::Sink(_))),
1181            "an inner sink error must surface as Sink(_), not be masked as Stalled, got {res:?}"
1182        );
1183    }
1184
1185    #[tokio::test]
1186    async fn stall_guard_sink_should_not_error_on_a_transient_stall_that_recovers_in_time() {
1187        // Timeout far larger than the transient stall: the deadline is armed on the first (parked)
1188        // flush poll and must be cleared when the sink makes progress, so no Stalled error fires.
1189        let mut sink = StallGuardSink::new(TransientFlushSink::default(), Duration::from_secs(10));
1190        tokio::time::timeout(Duration::from_secs(2), sink.send(1u8))
1191            .await
1192            .expect("a sink that recovers before the timeout must not hang")
1193            .expect("a sink that recovers before the timeout must not be failed as Stalled");
1194    }
1195
1196    /// The stalled peer's stream opens exactly once (then its writer is gated shut by the test); any
1197    /// reopen fails. Every other peer opens a writer that accepts immediately.
1198    #[derive(Clone, Debug)]
1199    struct HeadOfLineControl {
1200        stalled_peer: PeerId,
1201        stalled_io: GatedWriteIo,
1202        healthy_io: GatedWriteIo,
1203        open_calls: Arc<AtomicUsize>,
1204        stalled_opens: Arc<AtomicUsize>,
1205    }
1206
1207    #[async_trait]
1208    impl hopr_api::network::traits::NetworkStreamControl for HeadOfLineControl {
1209        fn accept(
1210            self,
1211        ) -> Result<impl Stream<Item = (PeerId, impl AsyncRead + AsyncWrite + Send)> + Send, impl std::error::Error>
1212        {
1213            Ok::<_, std::io::Error>(futures::stream::empty::<(PeerId, GatedWriteIo)>())
1214        }
1215
1216        async fn open(self, peer: PeerId) -> Result<impl AsyncRead + AsyncWrite + Send, impl std::error::Error> {
1217            self.open_calls.fetch_add(1, Ordering::Relaxed);
1218            if peer == self.stalled_peer {
1219                // Only the first open establishes the stream. A permanently-shut peer that could
1220                // reopen would re-stall for another full timeout each cycle, making the burst take an
1221                // unbounded number of ~timeout cycles; failing the reopen bounds the fix's side to a
1222                // single eviction cycle, while the unfixed pump never evicts and so never reopens.
1223                if self.stalled_opens.fetch_add(1, Ordering::Relaxed) == 0 {
1224                    Ok::<GatedWriteIo, std::io::Error>(self.stalled_io.clone())
1225                } else {
1226                    Err(std::io::Error::other("stalled peer refuses reopen"))
1227                }
1228            } else {
1229                Ok(self.healthy_io.clone())
1230            }
1231        }
1232    }
1233
1234    /// End-to-end proof that the sink-level stall mitigation clears the head-of-line block that
1235    /// caused the 2026-08-27 `jura-dev` OOM: a peer whose write pump parks must not hold the shared
1236    /// drain loop hostage while a healthy peer waits behind it.
1237    ///
1238    /// The healthy packet is queued *after* the stalled burst, so it only arrives promptly if the
1239    /// stalled peer's sink is evicted and the loop stops serialising on it. Under the unfixed code
1240    /// the burst holds the loop for one backpressure timeout per packet forever, and the healthy
1241    /// peer is starved.
1242    #[tokio::test]
1243    async fn stalled_peer_must_not_head_of_line_block_a_healthy_peer() -> anyhow::Result<()> {
1244        const STALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1);
1245        const OVERFLOW: usize = 8;
1246
1247        let stalled_peer = PeerId::random();
1248        let healthy_peer = PeerId::random();
1249
1250        // Both writers start open; the stalled peer is gated shut only after its stream is
1251        // established (below), modelling a remote that stops reading mid-stream.
1252        let stalled_io = GatedWriteIo {
1253            open: Arc::new(std::sync::atomic::AtomicBool::new(true)),
1254            ..Default::default()
1255        };
1256        let healthy_io = GatedWriteIo {
1257            open: Arc::new(std::sync::atomic::AtomicBool::new(true)),
1258            ..Default::default()
1259        };
1260
1261        let open_calls = Arc::new(AtomicUsize::new(0));
1262        let control = HeadOfLineControl {
1263            stalled_peer,
1264            stalled_io: stalled_io.clone(),
1265            healthy_io: healthy_io.clone(),
1266            open_calls: open_calls.clone(),
1267            stalled_opens: Arc::new(AtomicUsize::new(0)),
1268        };
1269
1270        let (mut tx_out, _rx_in) = process_stream_protocol(
1271            BytesCodec::new(),
1272            control,
1273            crate::config::StreamProtocolConfig {
1274                per_peer_channel_capacity: 2,
1275                // Flush every frame so the channel fills, not the `FramedWrite` buffer.
1276                frame_writer_backpressure_bytes: 1,
1277                egress_backpressure_timeout: STALL_TIMEOUT,
1278                ..Default::default()
1279            },
1280        )
1281        .await?;
1282
1283        let msg = BytesMut::from(&b"hello"[..]);
1284
1285        // Establish the ready precondition race-free: prime the stalled peer *while it is still
1286        // open* and wait until the byte is actually written to the wire. That positively proves the
1287        // stream opened and its write pump drained (`ready == true`), with no stall or eviction in
1288        // play yet — unlike waiting on a stalled poll, which could race the pump's own stall timeout.
1289        tx_out
1290            .send((stalled_peer, msg.clone()))
1291            .await
1292            .context("egress queue must accept priming stalled-peer packet")?;
1293        assert!(
1294            wait_for(2, || stalled_io.written() >= msg.len()).await,
1295            "priming packet was never written — stalled peer's stream/pump did not establish"
1296        );
1297        assert_eq!(
1298            open_calls.load(Ordering::Relaxed),
1299            1,
1300            "exactly one open expected during priming"
1301        );
1302
1303        // Now the remote stops reading: gate the writer shut so subsequent writes park. Only from
1304        // here does the stall (and its eviction timer) begin.
1305        stalled_io.gate_shut();
1306
1307        // Overflow the ready+full stalled channel. Under the old code each excess packet costs the
1308        // shared drain loop a full backpressure timeout, starving the healthy peer behind it; the fix
1309        // evicts the parked pump after one timeout so the loop moves on.
1310        for _ in 0..OVERFLOW {
1311            tx_out
1312                .send((stalled_peer, msg.clone()))
1313                .await
1314                .context("egress queue must accept stalled-peer packet")?;
1315        }
1316
1317        tx_out
1318            .send((healthy_peer, msg.clone()))
1319            .await
1320            .context("egress queue must accept healthy-peer packet")?;
1321
1322        let delivered = wait_for(3, || healthy_io.written() >= msg.len()).await;
1323
1324        assert_eq!(
1325            stalled_io.written(),
1326            msg.len(),
1327            "only the priming packet should reach the stalled writer; the post-shut burst must not"
1328        );
1329
1330        assert!(
1331            delivered,
1332            "a single stalled peer head-of-line-blocked the shared egress drain: the healthy peer got {} of {} bytes \
1333             within 3s, while {OVERFLOW} overflow packets to the stalled peer each held the loop for {STALL_TIMEOUT:?}",
1334            healthy_io.written(),
1335            msg.len(),
1336        );
1337
1338        Ok(())
1339    }
1340
1341    #[derive(Clone, Debug)]
1342    struct BimodalOpenControl {
1343        slow_peer: PeerId,
1344        open_delay: std::time::Duration,
1345        slow_open_calls: Arc<AtomicUsize>,
1346        fast_open_calls: Arc<AtomicUsize>,
1347    }
1348
1349    impl BimodalOpenControl {
1350        #[allow(dead_code)]
1351        fn slow_open_calls(&self) -> usize {
1352            self.slow_open_calls.load(Ordering::Relaxed)
1353        }
1354
1355        fn fast_open_calls(&self) -> usize {
1356            self.fast_open_calls.load(Ordering::Relaxed)
1357        }
1358    }
1359
1360    #[async_trait]
1361    impl hopr_api::network::traits::NetworkStreamControl for BimodalOpenControl {
1362        fn accept(
1363            self,
1364        ) -> Result<impl Stream<Item = (PeerId, impl AsyncRead + AsyncWrite + Send)> + Send, impl std::error::Error>
1365        {
1366            Ok::<_, std::io::Error>(futures::stream::empty::<(PeerId, StalledWriteIo)>())
1367        }
1368
1369        async fn open(self, peer: PeerId) -> Result<impl AsyncRead + AsyncWrite + Send, impl std::error::Error> {
1370            if peer == self.slow_peer {
1371                self.slow_open_calls.fetch_add(1, Ordering::Relaxed);
1372                tokio::time::sleep(self.open_delay).await;
1373                return Err::<AsyncBinaryStreamChannel, _>(std::io::Error::other("slow peer cannot connect"));
1374            }
1375            self.fast_open_calls.fetch_add(1, Ordering::Relaxed);
1376            Ok::<_, std::io::Error>(AsyncBinaryStreamChannel::new())
1377        }
1378    }
1379
1380    #[tokio::test]
1381    async fn egress_should_not_hol_block_fast_peer_behind_slow_opens() -> anyhow::Result<()> {
1382        let slow_peer = PeerId::random();
1383        let fast_peer = PeerId::random();
1384
1385        let control = BimodalOpenControl {
1386            slow_peer,
1387            open_delay: std::time::Duration::from_millis(5_000),
1388            slow_open_calls: Default::default(),
1389            fast_open_calls: Default::default(),
1390        };
1391
1392        let (mut tx_out, _rx_in) = process_stream_protocol(
1393            BytesCodec::new(),
1394            control.clone(),
1395            crate::config::StreamProtocolConfig {
1396                stream_open_timeout: std::time::Duration::from_millis(2_000),
1397                ..Default::default()
1398            },
1399        )
1400        .await?;
1401
1402        let msg = BytesMut::from(&b"x"[..]);
1403
1404        for _ in 0..3 {
1405            tx_out
1406                .send((slow_peer, msg.clone()))
1407                .await
1408                .context("egress queue must accept slow-peer packet")?;
1409        }
1410        tx_out
1411            .send((fast_peer, msg.clone()))
1412            .await
1413            .context("egress queue must accept fast-peer packet")?;
1414
1415        let deadline = tokio::time::Instant::now() + std::time::Duration::from_millis(1_000);
1416        while control.fast_open_calls() < 1 && tokio::time::Instant::now() < deadline {
1417            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1418        }
1419
1420        assert!(
1421            control.fast_open_calls() >= 1,
1422            "fast peer's stream open was not called within 1 s — egress drain is likely head-of-line blocked by \
1423             slow-peer opens"
1424        );
1425
1426        Ok(())
1427    }
1428
1429    #[derive(Clone, Debug)]
1430    struct DelayedControl {
1431        open_delay: std::time::Duration,
1432        open_calls: Arc<AtomicUsize>,
1433    }
1434
1435    #[async_trait]
1436    impl hopr_api::network::traits::NetworkStreamControl for DelayedControl {
1437        fn accept(
1438            self,
1439        ) -> Result<impl Stream<Item = (PeerId, impl AsyncRead + AsyncWrite + Send)> + Send, impl std::error::Error>
1440        {
1441            Ok::<_, std::io::Error>(futures::stream::empty::<(PeerId, AsyncBinaryStreamChannel)>())
1442        }
1443
1444        async fn open(self, _peer: PeerId) -> Result<impl AsyncRead + AsyncWrite + Send, impl std::error::Error> {
1445            self.open_calls.fetch_add(1, Ordering::Relaxed);
1446            tokio::time::sleep(self.open_delay).await;
1447            Ok::<_, std::io::Error>(AsyncBinaryStreamChannel::new())
1448        }
1449    }
1450
1451    /// Verifies that packets sent while the opener is in flight are buffered and
1452    /// all delivered once the stream opens — zero loss below channel capacity.
1453    #[tokio::test]
1454    async fn egress_buffers_during_slow_open_then_drains() -> anyhow::Result<()> {
1455        let open_calls = Arc::new(AtomicUsize::new(0));
1456        let control = DelayedControl {
1457            open_delay: std::time::Duration::from_millis(100),
1458            open_calls: open_calls.clone(),
1459        };
1460
1461        let (mut tx_out, mut rx_in) = process_stream_protocol(
1462            BytesCodec::new(),
1463            control,
1464            crate::config::StreamProtocolConfig {
1465                per_peer_channel_capacity: 64,
1466                ..Default::default()
1467            },
1468        )
1469        .await?;
1470
1471        let peer = PeerId::random();
1472        let msg = BytesMut::from(&b"hello"[..]);
1473
1474        let n = 10usize;
1475        let expected_bytes = n * msg.len();
1476        for _ in 0..n {
1477            tx_out
1478                .send((peer, msg.clone()))
1479                .await
1480                .context("send into egress queue should succeed")?;
1481        }
1482
1483        assert!(
1484            wait_for(2, || open_calls.load(Ordering::Relaxed) >= 1).await,
1485            "stream was never opened"
1486        );
1487
1488        let mut received_bytes = 0usize;
1489        let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
1490        while received_bytes < expected_bytes && tokio::time::Instant::now() < deadline {
1491            if let Ok(Some((_, bytes))) =
1492                tokio::time::timeout(std::time::Duration::from_millis(100), rx_in.next()).await
1493            {
1494                received_bytes += bytes.len();
1495            }
1496        }
1497
1498        assert!(
1499            received_bytes >= expected_bytes,
1500            "expected at least {expected_bytes} bytes to be delivered after stream open; got {received_bytes}"
1501        );
1502
1503        Ok(())
1504    }
1505
1506    /// A writer whose `poll_write` accepts bytes while `open` and parks (`Pending`) while shut,
1507    /// counting the bytes it accepts. Gating it shut after it has drained a packet models a remote
1508    /// that stops reading only once the stream is established, so a burst can be forced onto the
1509    /// ready+full egress path with no dependence on scheduler/pipe-buffer timing.
1510    #[derive(Clone, Default, Debug)]
1511    struct GatedWriteIo {
1512        open: Arc<std::sync::atomic::AtomicBool>,
1513        written: Arc<AtomicUsize>,
1514        waker: Arc<Mutex<Option<Waker>>>,
1515    }
1516
1517    impl GatedWriteIo {
1518        fn release(&self) {
1519            self.open.store(true, Ordering::Relaxed);
1520            if let Some(waker) = self.waker.lock().take() {
1521                waker.wake();
1522            }
1523        }
1524
1525        /// Gate the writer shut so subsequent `poll_write`s park — models the remote ceasing to read.
1526        fn gate_shut(&self) {
1527            self.open.store(false, Ordering::Relaxed);
1528        }
1529
1530        fn written(&self) -> usize {
1531            self.written.load(Ordering::Relaxed)
1532        }
1533    }
1534
1535    impl AsyncRead for GatedWriteIo {
1536        fn poll_read(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>, _buf: &mut [u8]) -> Poll<std::io::Result<usize>> {
1537            Poll::Pending
1538        }
1539    }
1540
1541    impl AsyncWrite for GatedWriteIo {
1542        fn poll_write(self: Pin<&mut Self>, cx: &mut TaskContext<'_>, buf: &[u8]) -> Poll<std::io::Result<usize>> {
1543            if self.open.load(Ordering::Relaxed) {
1544                self.written.fetch_add(buf.len(), Ordering::Relaxed);
1545                Poll::Ready(Ok(buf.len()))
1546            } else {
1547                *self.waker.lock() = Some(cx.waker().clone());
1548                Poll::Pending
1549            }
1550        }
1551
1552        fn poll_flush(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll<std::io::Result<()>> {
1553            Poll::Ready(Ok(()))
1554        }
1555
1556        fn poll_close(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll<std::io::Result<()>> {
1557            Poll::Ready(Ok(()))
1558        }
1559    }
1560
1561    #[derive(Clone, Debug)]
1562    struct GatedControl {
1563        io: GatedWriteIo,
1564        open_calls: Arc<AtomicUsize>,
1565    }
1566
1567    #[async_trait]
1568    impl hopr_api::network::traits::NetworkStreamControl for GatedControl {
1569        fn accept(
1570            self,
1571        ) -> Result<impl Stream<Item = (PeerId, impl AsyncRead + AsyncWrite + Send)> + Send, impl std::error::Error>
1572        {
1573            Ok::<_, std::io::Error>(futures::stream::empty::<(PeerId, GatedWriteIo)>())
1574        }
1575
1576        async fn open(self, _peer: PeerId) -> Result<impl AsyncRead + AsyncWrite + Send, impl std::error::Error> {
1577            self.open_calls.fetch_add(1, Ordering::Relaxed);
1578            Ok::<_, std::io::Error>(self.io.clone())
1579        }
1580    }
1581
1582    /// Regression guard for the egress backpressure fix.
1583    ///
1584    /// Once the stream is open and draining, a burst that overflows the per-peer channel must apply
1585    /// (bounded) backpressure so the producer is slowed to wire rate — no packet loss. Under the old
1586    /// `try_send` + drop-newest behavior, every packet that arrived while the channel was full was
1587    /// silently dropped.
1588    ///
1589    /// The [`GatedWriteIo`] writer is held shut so the per-peer channel is deterministically full while
1590    /// the stream is open (`ready == true`) — the exact ready+full path — regardless of scheduler
1591    /// timing. The writer is released *before* the backpressure timeout: with backpressure the queued
1592    /// overflow then drains to the wire (zero loss); with drop-newest the overflow was already gone.
1593    #[tokio::test]
1594    async fn egress_backpressures_when_open_channel_full() -> anyhow::Result<()> {
1595        let io = GatedWriteIo::default();
1596        let open_calls = Arc::new(AtomicUsize::new(0));
1597        let control = GatedControl {
1598            io: io.clone(),
1599            open_calls: open_calls.clone(),
1600        };
1601
1602        // Small per-peer channel so a modest burst overflows it while the stream is open.
1603        let (mut tx_out, _rx_in) = process_stream_protocol(
1604            BytesCodec::new(),
1605            control,
1606            crate::config::StreamProtocolConfig {
1607                per_peer_channel_capacity: 4,
1608                // Flush every frame so the per-peer channel (not the FramedWrite buffer) is the
1609                // bottleneck that fills — making the ready+full path unambiguous.
1610                frame_writer_backpressure_bytes: 1,
1611                ..Default::default()
1612            },
1613        )
1614        .await?;
1615
1616        let peer = PeerId::random();
1617        let msg = BytesMut::from(&b"hello"[..]);
1618        let n = 50usize;
1619        let expected_bytes = n * msg.len();
1620
1621        // Burst far beyond the per-peer channel capacity while the writer is gated shut. The stream
1622        // opens and the write pump starts (ready == true) but cannot drain, so the channel fills and the
1623        // drain loop enters the ready+full path for the overflow.
1624        for _ in 0..n {
1625            tx_out
1626                .send((peer, msg.clone()))
1627                .await
1628                .context("send into egress queue should succeed")?;
1629        }
1630        assert!(
1631            wait_for(2, || open_calls.load(Ordering::Relaxed) >= 1).await,
1632            "stream was never opened"
1633        );
1634
1635        // Let the drain loop hit the full channel and enter the (bounded) backpressure wait — well under
1636        // EGRESS_BACKPRESSURE_TIMEOUT. Under drop-newest the overflow is already dropped by now.
1637        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1638
1639        // Release the writer: with backpressure the queued overflow now drains; with drop-newest only
1640        // the few packets that fit the channel were ever kept.
1641        io.release();
1642
1643        // Allow a single frame to remain buffered inside `FramedWrite` (flushed only on the next write
1644        // cycle) — that is a framing artifact, not a drop. Drop-newest would lose the whole overflow
1645        // (only ~`per_peer_channel_capacity` packets ever kept), which is far below this bound.
1646        let min_delivered = expected_bytes - msg.len();
1647        assert!(
1648            wait_for(3, || io.written() >= min_delivered).await,
1649            "essentially all {n} packets ({expected_bytes} bytes) must reach the wire under egress backpressure; a \
1650             regression to drop-newest on a full open channel would lose the overflow (wrote {} bytes, need >= \
1651             {min_delivered})",
1652            io.written()
1653        );
1654
1655        Ok(())
1656    }
1657}