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::sync::{
5    Arc,
6    atomic::{AtomicUsize, Ordering},
7};
8
9use crossfire::mpsc;
10use futures::{
11    AsyncRead, AsyncReadExt, AsyncWrite, FutureExt, StreamExt,
12    channel::mpsc::{Receiver, Sender, channel},
13};
14use hopr_api::network::NetworkStreamControl;
15use libp2p::PeerId;
16use tokio_util::{
17    codec::{Decoder, Encoder, FramedRead, FramedWrite},
18    compat::{FuturesAsyncReadCompatExt, FuturesAsyncWriteCompatExt},
19};
20
21#[cfg(all(feature = "telemetry", not(test)))]
22lazy_static::lazy_static! {
23    static ref METRIC_RING_BUFFER_DROPPED: hopr_api::types::telemetry::SimpleCounter =
24        hopr_api::types::telemetry::SimpleCounter::new(
25            "hopr_egress_ring_buffer_dropped",
26            "Number of packets dropped due to per-peer egress channel overflow (drop-newest)",
27        )
28        .unwrap();
29}
30
31/// Per-peer egress buffer: a bounded MPSC crossfire channel.
32///
33/// Only the sender is stored in the cache entry; the receiver is owned exclusively by
34/// the write pump spawned when the outgoing stream opens.  This is functionally SPSC
35/// (one drain-loop producer, one write-pump consumer) even though the sender type is
36/// Clone (required by `moka::sync::Cache`).
37///
38/// When the channel is full the drain loop drops the incoming packet (drop-newest)
39/// and yields via `yield_now()` so the write pump has a chance to drain items and
40/// free space before the next send attempt, preventing task starvation under high
41/// producer rates.
42///
43/// `token` is a unique identity for this sink instance. Pump and opener tasks that
44/// hold a clone compare it against the current cache entry before calling
45/// `cache.invalidate`, so that a stale task finishing after the sink was replaced
46/// (e.g. by an inbound stream arriving during an outgoing open) does not wipe the
47/// newer entry.
48#[derive(Clone)]
49struct PeerSink<T: Send + 'static> {
50    tx: crossfire::MAsyncTx<mpsc::Array<T>>,
51    token: Arc<()>,
52}
53
54impl<T: Send + 'static> PeerSink<T> {
55    fn new(tx: crossfire::MAsyncTx<mpsc::Array<T>>) -> Self {
56        Self {
57            tx,
58            token: Arc::new(()),
59        }
60    }
61}
62
63type PeerStreamCache<T> = moka::sync::Cache<PeerId, PeerSink<T>>;
64
65/// Spawn the write and read pump tasks for an open peer stream.
66///
67/// The write pump drains `rx` into the framed stream writer; the read pump
68/// forwards decoded frames to `ingress_from_peers`. Both tasks invalidate
69/// `cache[peer]` when they complete, but only if the cache entry still holds
70/// the same `token` — this prevents a stale task from wiping a newer sink.
71#[allow(clippy::too_many_arguments)]
72fn spawn_stream_pumps<S, C>(
73    peer: PeerId,
74    stream: S,
75    rx: crossfire::AsyncRx<mpsc::Array<<C as Decoder>::Item>>,
76    cache: PeerStreamCache<<C as Decoder>::Item>,
77    token: Arc<()>,
78    codec: C,
79    ingress_from_peers: Sender<(PeerId, <C as Decoder>::Item)>,
80    frame_writer_backpressure_bytes: usize,
81) where
82    S: AsyncRead + AsyncWrite + Send + 'static,
83    C: Encoder<<C as Decoder>::Item> + Decoder + Send + Sync + Clone + 'static,
84    <C as Encoder<<C as Decoder>::Item>>::Error: std::fmt::Debug + std::fmt::Display + Send + Sync + 'static,
85    <C as Decoder>::Error: std::fmt::Debug + std::fmt::Display + Send + Sync + 'static,
86    <C as Decoder>::Item: AsRef<[u8]> + Clone + Send + 'static,
87{
88    let (stream_rx, stream_tx) = stream.split();
89    let cache_for_write = cache.clone();
90    let cache_for_read = cache.clone();
91    let token_write = token.clone();
92
93    let mut frame_writer = FramedWrite::new(stream_tx.compat_write(), codec.clone());
94
95    // `set_backpressure_boundary` is a *byte* threshold on `FramedWrite`'s internal
96    // pending-write buffer: once the encoded frames exceed this many bytes, the next
97    // `poll_ready` call will issue a flush. A larger value lets adjacent small frames
98    // coalesce into a single quinn write, significantly reducing the number of
99    // connection-mutex acquisitions and driver wake-ups on the hot path.
100    frame_writer.set_backpressure_boundary(frame_writer_backpressure_bytes);
101
102    // Write pump: drain the per-peer channel into the framed stream writer.
103    hopr_utils::runtime::prelude::spawn(
104        rx.into_stream()
105            .map(Ok)
106            .forward(frame_writer)
107            .inspect(move |res| {
108                tracing::debug!(%peer, ?res, component = "stream", "writing stream with peer finished");
109            })
110            .then(move |_| async move {
111                if cache_for_write
112                    .get(&peer)
113                    .is_some_and(|s| Arc::ptr_eq(&s.token, &token_write))
114                {
115                    cache_for_write.invalidate(&peer);
116                }
117            }),
118    );
119
120    // Read pump: forward decoded frames to the ingress channel.
121    hopr_utils::runtime::prelude::spawn(
122        FramedRead::new(stream_rx.compat(), codec)
123            .filter_map(move |v| {
124                futures::future::ready(match v {
125                    Ok(v) => {
126                        tracing::trace!(%peer, "read message from peer stream");
127                        Some((peer, v))
128                    }
129                    Err(error) => {
130                        tracing::error!(%error, "Error decoding object from the underlying stream");
131                        None
132                    }
133                })
134            })
135            .map(Ok)
136            .forward(ingress_from_peers)
137            .inspect(move |res| match res {
138                Ok(_) => tracing::debug!(%peer, component = "stream", "incoming stream done reading"),
139                Err(error) => {
140                    tracing::error!(%peer, %error, component = "stream", "incoming stream failed on reading")
141                }
142            })
143            .then(move |_| async move {
144                if cache_for_read.get(&peer).is_some_and(|s| Arc::ptr_eq(&s.token, &token)) {
145                    cache_for_read.invalidate(&peer);
146                }
147            }),
148    );
149
150    tracing::trace!(%peer, "created new io for peer");
151}
152
153pub async fn process_stream_protocol<C, V>(
154    codec: C,
155    control: V,
156    stream_cfg: crate::config::StreamProtocolConfig,
157) -> super::errors::Result<(
158    Sender<(PeerId, <C as Decoder>::Item)>, // impl Sink<(PeerId, <C as Decoder>::Item)>,
159    Receiver<(PeerId, <C as Decoder>::Item)>, // impl Stream<Item = (PeerId, <C as Decoder>::Item)>,
160)>
161where
162    C: Encoder<<C as Decoder>::Item> + Decoder + Send + Sync + Clone + 'static,
163    <C as Encoder<<C as Decoder>::Item>>::Error: std::fmt::Debug + std::fmt::Display + Send + Sync + 'static,
164    <C as Decoder>::Error: std::fmt::Debug + std::fmt::Display + Send + Sync + 'static,
165    <C as Decoder>::Item: AsRef<[u8]> + Clone + Send + 'static,
166    V: NetworkStreamControl + Clone + Send + Sync + 'static,
167{
168    let (tx_out, mut rx_out) = channel::<(PeerId, <C as Decoder>::Item)>(100_000);
169    let (tx_in, rx_in) = channel::<(PeerId, <C as Decoder>::Item)>(100_000);
170
171    let cache_out: PeerStreamCache<<C as Decoder>::Item> = moka::sync::Cache::builder()
172        .max_capacity(2000)
173        .eviction_listener(|key: Arc<PeerId>, _, cause| {
174            tracing::trace!(peer = %key.as_ref(), ?cause, "evicting stream for peer");
175        })
176        .build();
177
178    // Bounds the number of in-flight stream-open tasks across all distinct peers.
179    const MAX_CONCURRENT_STREAM_OPENS: usize = 50;
180    let open_task_count = Arc::new(AtomicUsize::new(0));
181
182    let incoming = control
183        .clone()
184        .accept()
185        .map_err(|e| super::errors::ProtocolError::Logic(format!("failed to listen on protocol: {e}")))?;
186
187    let stream_open_timeout = stream_cfg.stream_open_timeout;
188    let frame_writer_backpressure_bytes = stream_cfg.frame_writer_backpressure_bytes;
189    let per_peer_channel_capacity = stream_cfg.per_peer_channel_capacity;
190
191    let open_ctx = Arc::new((control, codec, tx_in));
192
193    let cache_ingress = cache_out.clone();
194    let open_ctx_ingress = open_ctx.clone();
195
196    // terminated when the incoming is dropped
197    let _ingress_process = hopr_utils::runtime::prelude::spawn(
198        incoming
199            .for_each(move |(peer, stream)| {
200                let cache = cache_ingress.clone();
201                let open_ctx = open_ctx_ingress.clone();
202
203                tracing::debug!(%peer, "received incoming peer-to-peer stream");
204                let (_control, codec, tx_in) = (&open_ctx.0, &open_ctx.1, &open_ctx.2);
205
206                let (tx, rx) = mpsc::bounded_async::<<C as Decoder>::Item>(per_peer_channel_capacity);
207                let sink = PeerSink::new(tx);
208                let token = sink.token.clone();
209                spawn_stream_pumps(
210                    peer,
211                    stream,
212                    rx,
213                    cache.clone(),
214                    token,
215                    codec.clone(),
216                    tx_in.clone(),
217                    frame_writer_backpressure_bytes,
218                );
219                cache.insert(peer, sink);
220
221                futures::future::ready(())
222            })
223            .inspect(|_| {
224                tracing::info!(
225                    task = "ingress stream processing",
226                    "long-running background task finished"
227                )
228            }),
229    );
230
231    // Egress drain: reads outgoing packets from `rx_out` and enqueues them into
232    // the per-peer crossfire channel.
233    //
234    // The drain is non-blocking: `try_send` never awaits. On overflow the incoming
235    // packet is dropped (drop-newest) and `yield_now()` is called to let the write
236    // pump task drain the channel before the next send attempt, preventing task
237    // starvation under high producer rates.
238    //
239    // On cache miss, `get_with` atomically creates the per-peer channel and spawns
240    // exactly one opener task. In-flight opens are bounded by `open_task_count`
241    // (MAX_CONCURRENT_STREAM_OPENS = 50) to prevent resource exhaustion.
242    let _egress_process = hopr_utils::runtime::prelude::spawn(async move {
243        use futures::StreamExt as _;
244
245        while let Some((peer, msg)) = rx_out.next().await {
246            tracing::trace!(%peer, "trying to deliver message to peer");
247
248            let sink = if let Some(s) = cache_out.get(&peer) {
249                s
250            } else {
251                let cache2 = cache_out.clone();
252                let open_ctx2 = open_ctx.clone();
253                let open_count2 = open_task_count.clone();
254                cache_out.get_with(peer, move || {
255                    let (tx, rx) = mpsc::bounded_async::<<C as Decoder>::Item>(per_peer_channel_capacity);
256                    let sink = PeerSink::new(tx);
257                    let token = sink.token.clone();
258
259                    if open_count2.fetch_add(1, Ordering::Relaxed) < MAX_CONCURRENT_STREAM_OPENS {
260                        hopr_utils::runtime::prelude::spawn(async move {
261                            tracing::trace!(%peer, "peer is not in cache, opening new stream");
262                            use futures_time::future::FutureExt as TimeExt;
263                            let (control, codec, tx_in) = (&open_ctx2.0, &open_ctx2.1, &open_ctx2.2);
264
265                            let stream = control
266                                .clone()
267                                .open(peer)
268                                .timeout(futures_time::time::Duration::from(stream_open_timeout))
269                                .await
270                                .map_err(|_| anyhow::anyhow!("timeout trying to open stream to {peer}"))
271                                .and_then(|s| {
272                                    s.map_err(|e| anyhow::anyhow!("could not open outgoing peer-to-peer stream: {e}"))
273                                });
274
275                            open_count2.fetch_sub(1, Ordering::Relaxed);
276
277                            match stream {
278                                Ok(stream) => {
279                                    tracing::debug!(%peer, "opening outgoing peer-to-peer stream");
280                                    spawn_stream_pumps(
281                                        peer,
282                                        stream,
283                                        rx,
284                                        cache2.clone(),
285                                        token,
286                                        codec.clone(),
287                                        tx_in.clone(),
288                                        frame_writer_backpressure_bytes,
289                                    );
290                                }
291                                Err(error) => {
292                                    tracing::debug!(
293                                        %peer, %error,
294                                        "stream open failed/timed out; dropping buffered packets"
295                                    );
296                                    if cache2.get(&peer).is_some_and(|s| Arc::ptr_eq(&s.token, &token)) {
297                                        cache2.invalidate(&peer);
298                                    }
299                                }
300                            }
301                        });
302                    } else {
303                        open_count2.fetch_sub(1, Ordering::Relaxed);
304                        tracing::debug!(%peer, "stream-open concurrency limit reached; dropping buffered packets");
305                        hopr_utils::runtime::prelude::spawn(async move {
306                            if cache2.get(&peer).is_some_and(|s| Arc::ptr_eq(&s.token, &token)) {
307                                cache2.invalidate(&peer);
308                            }
309                        });
310                    }
311
312                    sink
313                })
314            };
315
316            match sink.tx.try_send(msg) {
317                Ok(()) => tracing::trace!(%peer, "message queued to peer channel"),
318                Err(crossfire::TrySendError::Full(_msg)) => {
319                    // Channel full: drop the newest packet and yield so the write
320                    // pump task can drain space before the next send.
321                    #[cfg(all(feature = "telemetry", not(test)))]
322                    METRIC_RING_BUFFER_DROPPED.increment();
323                    tracing::debug!(%peer, "per-peer egress channel full; dropping newest packet");
324                    hopr_utils::runtime::prelude::yield_now().await;
325                }
326                Err(crossfire::TrySendError::Disconnected(_)) => {
327                    // Receiver dropped (write pump died): invalidate and reopen on next send.
328                    // Guard with the token so we don't wipe a replacement sink that was
329                    // inserted (e.g. from an inbound stream) between our cache.get() above
330                    // and this branch.
331                    tracing::debug!(%peer, "peer sink disconnected; invalidating cache");
332                    if cache_out.get(&peer).is_some_and(|s| Arc::ptr_eq(&s.token, &sink.token)) {
333                        cache_out.invalidate(&peer);
334                    }
335                }
336            }
337        }
338
339        tracing::info!(
340            task = "egress stream processing",
341            "long-running background task finished"
342        );
343    });
344
345    Ok((tx_out, rx_in))
346}
347
348#[cfg(test)]
349mod tests {
350    use std::{
351        pin::Pin,
352        sync::{
353            Arc,
354            atomic::{AtomicUsize, Ordering},
355        },
356        task::{Context as TaskContext, Poll, Waker},
357    };
358
359    use anyhow::Context;
360    use async_trait::async_trait;
361    use futures::{SinkExt, Stream};
362    use parking_lot::Mutex;
363    use tokio_util::{bytes::BytesMut, codec::BytesCodec};
364
365    use super::*;
366
367    #[derive(Clone, Default, Debug)]
368    struct CountingControl {
369        open_calls: Arc<AtomicUsize>,
370    }
371
372    impl CountingControl {
373        fn open_calls(&self) -> usize {
374            self.open_calls.load(Ordering::Relaxed)
375        }
376    }
377
378    #[derive(Default)]
379    struct StalledWriteIo;
380
381    impl AsyncRead for StalledWriteIo {
382        fn poll_read(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>, _buf: &mut [u8]) -> Poll<std::io::Result<usize>> {
383            Poll::Pending
384        }
385    }
386
387    impl AsyncWrite for StalledWriteIo {
388        fn poll_write(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>, _buf: &[u8]) -> Poll<std::io::Result<usize>> {
389            Poll::Pending
390        }
391
392        fn poll_flush(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll<std::io::Result<()>> {
393            Poll::Pending
394        }
395
396        fn poll_close(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll<std::io::Result<()>> {
397            Poll::Ready(Ok(()))
398        }
399    }
400
401    #[async_trait]
402    impl hopr_api::network::traits::NetworkStreamControl for CountingControl {
403        fn accept(
404            self,
405        ) -> Result<impl Stream<Item = (PeerId, impl AsyncRead + AsyncWrite + Send)> + Send, impl std::error::Error>
406        {
407            Ok::<_, std::io::Error>(futures::stream::empty::<(PeerId, StalledWriteIo)>())
408        }
409
410        async fn open(self, _peer: PeerId) -> Result<impl AsyncRead + AsyncWrite + Send, impl std::error::Error> {
411            self.open_calls.fetch_add(1, Ordering::Relaxed);
412            Ok::<_, std::io::Error>(StalledWriteIo)
413        }
414    }
415
416    struct AsyncBinaryStreamChannel {
417        read: async_channel_io::ChannelReader,
418        write: async_channel_io::ChannelWriter,
419    }
420
421    impl AsyncBinaryStreamChannel {
422        pub fn new() -> Self {
423            let (write, read) = async_channel_io::pipe();
424            Self { read, write }
425        }
426    }
427
428    impl AsyncRead for AsyncBinaryStreamChannel {
429        fn poll_read(
430            self: std::pin::Pin<&mut Self>,
431            cx: &mut std::task::Context<'_>,
432            buf: &mut [u8],
433        ) -> std::task::Poll<std::io::Result<usize>> {
434            let mut pinned = std::pin::pin!(&mut self.get_mut().read);
435            pinned.as_mut().poll_read(cx, buf)
436        }
437    }
438
439    impl AsyncWrite for AsyncBinaryStreamChannel {
440        fn poll_write(
441            self: std::pin::Pin<&mut Self>,
442            cx: &mut std::task::Context<'_>,
443            buf: &[u8],
444        ) -> std::task::Poll<std::io::Result<usize>> {
445            let mut pinned = std::pin::pin!(&mut self.get_mut().write);
446            pinned.as_mut().poll_write(cx, buf)
447        }
448
449        fn poll_flush(
450            self: std::pin::Pin<&mut Self>,
451            cx: &mut std::task::Context<'_>,
452        ) -> std::task::Poll<std::io::Result<()>> {
453            let pinned = std::pin::pin!(&mut self.get_mut().write);
454            pinned.poll_flush(cx)
455        }
456
457        fn poll_close(
458            self: std::pin::Pin<&mut Self>,
459            cx: &mut std::task::Context<'_>,
460        ) -> std::task::Poll<std::io::Result<()>> {
461            let pinned = std::pin::pin!(&mut self.get_mut().write);
462            pinned.poll_close(cx)
463        }
464    }
465
466    #[tokio::test]
467    async fn split_codec_should_always_produce_correct_data() -> anyhow::Result<()> {
468        let stream = AsyncBinaryStreamChannel::new();
469        let codec = tokio_util::codec::BytesCodec::new();
470
471        let expected = [0u8, 1u8, 2u8, 3u8, 4u8, 5u8];
472        let value = tokio_util::bytes::BytesMut::from(expected.as_ref());
473
474        let (stream_rx, stream_tx) = stream.split();
475        let (mut tx, rx) = (
476            FramedWrite::new(stream_tx.compat_write(), codec),
477            FramedRead::new(stream_rx.compat(), codec),
478        );
479        tx.send(value)
480            .await
481            .map_err(|_| anyhow::anyhow!("should not fail on send"))?;
482
483        futures::pin_mut!(rx);
484
485        assert_eq!(
486            rx.next().await.context("Value must be present")??,
487            tokio_util::bytes::BytesMut::from(expected.as_ref())
488        );
489
490        Ok(())
491    }
492
493    // ---------------------------------------------------------------------------
494    // FlaggedStream — models a LivenessStream whose connection has been killed.
495    // ---------------------------------------------------------------------------
496
497    struct DeadSignal {
498        read_dead: std::sync::atomic::AtomicBool,
499        write_dead: std::sync::atomic::AtomicBool,
500        read_waker: Mutex<Option<Waker>>,
501        write_waker: Mutex<Option<Waker>>,
502    }
503
504    impl std::fmt::Debug for DeadSignal {
505        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
506            f.debug_struct("DeadSignal")
507                .field("read_dead", &self.read_dead.load(Ordering::Relaxed))
508                .field("write_dead", &self.write_dead.load(Ordering::Relaxed))
509                .finish_non_exhaustive()
510        }
511    }
512
513    impl DeadSignal {
514        fn new() -> Arc<Self> {
515            Arc::new(Self {
516                read_dead: std::sync::atomic::AtomicBool::new(false),
517                write_dead: std::sync::atomic::AtomicBool::new(false),
518                read_waker: Mutex::new(None),
519                write_waker: Mutex::new(None),
520            })
521        }
522
523        fn kill_read(self: &Arc<Self>) {
524            self.read_dead.store(true, Ordering::Release);
525            let waker = self.read_waker.lock().take();
526            if let Some(w) = waker {
527                w.wake();
528            }
529        }
530
531        fn kill_write(self: &Arc<Self>) {
532            self.write_dead.store(true, Ordering::Release);
533            let waker = self.write_waker.lock().take();
534            if let Some(w) = waker {
535                w.wake();
536            }
537        }
538    }
539
540    struct FlaggedStream {
541        signal: Arc<DeadSignal>,
542    }
543
544    impl AsyncRead for FlaggedStream {
545        fn poll_read(self: Pin<&mut Self>, cx: &mut TaskContext<'_>, _buf: &mut [u8]) -> Poll<std::io::Result<usize>> {
546            *self.signal.read_waker.lock() = Some(cx.waker().clone());
547            if self.signal.read_dead.load(Ordering::Acquire) {
548                return Poll::Ready(Err(std::io::Error::from(std::io::ErrorKind::ConnectionAborted)));
549            }
550            Poll::Pending
551        }
552    }
553
554    impl AsyncWrite for FlaggedStream {
555        fn poll_write(self: Pin<&mut Self>, cx: &mut TaskContext<'_>, _buf: &[u8]) -> Poll<std::io::Result<usize>> {
556            *self.signal.write_waker.lock() = Some(cx.waker().clone());
557            if self.signal.write_dead.load(Ordering::Acquire) {
558                return Poll::Ready(Err(std::io::Error::from(std::io::ErrorKind::ConnectionAborted)));
559            }
560            Poll::Pending
561        }
562
563        fn poll_flush(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll<std::io::Result<()>> {
564            Poll::Ready(Ok(()))
565        }
566
567        fn poll_close(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll<std::io::Result<()>> {
568            Poll::Ready(Ok(()))
569        }
570    }
571
572    #[derive(Clone, Debug, Default)]
573    struct ScriptedControl {
574        open_calls: Arc<AtomicUsize>,
575        signals: Arc<Mutex<Vec<Arc<DeadSignal>>>>,
576    }
577
578    impl ScriptedControl {
579        fn open_calls(&self) -> usize {
580            self.open_calls.load(Ordering::Relaxed)
581        }
582
583        fn signal(&self, index: usize) -> Option<Arc<DeadSignal>> {
584            self.signals.lock().get(index).cloned()
585        }
586    }
587
588    #[async_trait]
589    impl hopr_api::network::traits::NetworkStreamControl for ScriptedControl {
590        fn accept(
591            self,
592        ) -> Result<impl Stream<Item = (PeerId, impl AsyncRead + AsyncWrite + Send)> + Send, impl std::error::Error>
593        {
594            Ok::<_, std::io::Error>(futures::stream::empty::<(PeerId, FlaggedStream)>())
595        }
596
597        async fn open(self, _peer: PeerId) -> Result<impl AsyncRead + AsyncWrite + Send, impl std::error::Error> {
598            let signal = DeadSignal::new();
599            self.signals.lock().push(signal.clone());
600            self.open_calls.fetch_add(1, Ordering::Relaxed);
601            Ok::<_, std::io::Error>(FlaggedStream { signal })
602        }
603    }
604
605    async fn wait_for(secs: u64, condition: impl Fn() -> bool) -> bool {
606        let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(secs);
607        while !condition() && tokio::time::Instant::now() < deadline {
608            tokio::time::sleep(std::time::Duration::from_millis(25)).await;
609        }
610        condition()
611    }
612
613    #[tokio::test]
614    async fn dead_stream_should_be_detected_on_read_path_and_allow_next_send_to_reopen() -> anyhow::Result<()> {
615        let control = ScriptedControl::default();
616
617        let (mut tx_out, _rx_in) = process_stream_protocol(
618            BytesCodec::new(),
619            control.clone(),
620            crate::config::StreamProtocolConfig {
621                per_peer_channel_capacity: 64,
622                ..Default::default()
623            },
624        )
625        .await?;
626
627        let peer = PeerId::random();
628        let msg = BytesMut::from(&b"probe"[..]);
629
630        tx_out
631            .send((peer, msg.clone()))
632            .await
633            .context("first send should succeed")?;
634
635        assert!(
636            wait_for(2, || control.open_calls() >= 1).await,
637            "stream was never opened"
638        );
639        let signal = control.signal(0).context("signal for stream #1 must exist")?;
640
641        signal.kill_read();
642
643        tx_out
644            .send((peer, msg.clone()))
645            .await
646            .context("second send into egress queue should succeed")?;
647
648        assert!(
649            wait_for(2, || control.open_calls() >= 2).await,
650            "stream was not reopened after connection kill (open_calls={})",
651            control.open_calls()
652        );
653
654        Ok(())
655    }
656
657    #[tokio::test]
658    async fn dead_stream_should_be_detected_on_write_path_and_allow_next_send_to_reopen() -> anyhow::Result<()> {
659        let control = ScriptedControl::default();
660
661        let (mut tx_out, _rx_in) = process_stream_protocol(
662            BytesCodec::new(),
663            control.clone(),
664            crate::config::StreamProtocolConfig {
665                per_peer_channel_capacity: 128,
666                ..Default::default()
667            },
668        )
669        .await?;
670
671        let peer = PeerId::random();
672        let msg = BytesMut::from(&b"payload"[..]);
673
674        tx_out.send((peer, msg.clone())).await.context("initial send")?;
675        assert!(wait_for(2, || control.open_calls() >= 1).await, "stream not opened");
676
677        let signal = control.signal(0).context("signal #1 must exist")?;
678
679        signal.kill_write();
680
681        let mut drained = 0usize;
682        while control.open_calls() < 2 && drained < 128 {
683            tx_out
684                .send((peer, msg.clone()))
685                .await
686                .with_context(|| format!("drain send {drained} into egress queue should succeed"))?;
687            drained += 1;
688        }
689
690        assert!(
691            wait_for(3, || control.open_calls() >= 2).await,
692            "stream was not reopened after writer kill (open_calls={})",
693            control.open_calls()
694        );
695
696        Ok(())
697    }
698
699    /// Verifies that a full per-peer channel does not invalidate the cache or
700    /// trigger a pathological reopen.
701    ///
702    /// `CountingControl` returns a `StalledWriteIo` whose `poll_write` always
703    /// returns `Pending`. The write pump stalls; the channel fills. The drain loop
704    /// drops newest packets (no-op eviction path) and yields, but must never call
705    /// `cache.invalidate` — so the stream must not reopen.
706    #[tokio::test]
707    async fn per_peer_stream_should_not_reopen_pathologically_on_send_failures() -> anyhow::Result<()> {
708        let control = CountingControl::default();
709        let (mut tx_out, _rx_in) = process_stream_protocol(
710            BytesCodec::new(),
711            control.clone(),
712            crate::config::StreamProtocolConfig {
713                per_peer_channel_capacity: 16,
714                ..Default::default()
715            },
716        )
717        .await?;
718
719        let peer = PeerId::random();
720        let msg = BytesMut::from(&b"x"[..]);
721
722        for _ in 0..1200 {
723            tx_out
724                .send((peer, msg.clone()))
725                .await
726                .context("egress queue should accept test packet")?;
727        }
728
729        // Wait for at least one stream open (first cache miss).
730        let open_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1);
731        while control.open_calls() < 1 && tokio::time::Instant::now() < open_deadline {
732            tokio::time::sleep(std::time::Duration::from_millis(25)).await;
733        }
734        assert!(
735            control.open_calls() >= 1,
736            "stream was never opened — egress task did not process any packet"
737        );
738
739        // Give it another second; pathological reopen must not occur.
740        let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1);
741        while control.open_calls() < 2 && tokio::time::Instant::now() < deadline {
742            tokio::time::sleep(std::time::Duration::from_millis(25)).await;
743        }
744
745        assert!(
746            control.open_calls() <= 1,
747            "pathological reopen churn detected for same peer under send failures (open_calls={})",
748            control.open_calls()
749        );
750
751        Ok(())
752    }
753
754    #[derive(Clone, Debug)]
755    struct BimodalOpenControl {
756        slow_peer: PeerId,
757        open_delay: std::time::Duration,
758        slow_open_calls: Arc<AtomicUsize>,
759        fast_open_calls: Arc<AtomicUsize>,
760    }
761
762    impl BimodalOpenControl {
763        #[allow(dead_code)]
764        fn slow_open_calls(&self) -> usize {
765            self.slow_open_calls.load(Ordering::Relaxed)
766        }
767
768        fn fast_open_calls(&self) -> usize {
769            self.fast_open_calls.load(Ordering::Relaxed)
770        }
771    }
772
773    #[async_trait]
774    impl hopr_api::network::traits::NetworkStreamControl for BimodalOpenControl {
775        fn accept(
776            self,
777        ) -> Result<impl Stream<Item = (PeerId, impl AsyncRead + AsyncWrite + Send)> + Send, impl std::error::Error>
778        {
779            Ok::<_, std::io::Error>(futures::stream::empty::<(PeerId, StalledWriteIo)>())
780        }
781
782        async fn open(self, peer: PeerId) -> Result<impl AsyncRead + AsyncWrite + Send, impl std::error::Error> {
783            if peer == self.slow_peer {
784                self.slow_open_calls.fetch_add(1, Ordering::Relaxed);
785                tokio::time::sleep(self.open_delay).await;
786                return Err::<AsyncBinaryStreamChannel, _>(std::io::Error::other("slow peer cannot connect"));
787            }
788            self.fast_open_calls.fetch_add(1, Ordering::Relaxed);
789            Ok::<_, std::io::Error>(AsyncBinaryStreamChannel::new())
790        }
791    }
792
793    #[tokio::test]
794    async fn egress_should_not_hol_block_fast_peer_behind_slow_opens() -> anyhow::Result<()> {
795        let slow_peer = PeerId::random();
796        let fast_peer = PeerId::random();
797
798        let control = BimodalOpenControl {
799            slow_peer,
800            open_delay: std::time::Duration::from_millis(5_000),
801            slow_open_calls: Default::default(),
802            fast_open_calls: Default::default(),
803        };
804
805        let (mut tx_out, _rx_in) = process_stream_protocol(
806            BytesCodec::new(),
807            control.clone(),
808            crate::config::StreamProtocolConfig {
809                stream_open_timeout: std::time::Duration::from_millis(2_000),
810                ..Default::default()
811            },
812        )
813        .await?;
814
815        let msg = BytesMut::from(&b"x"[..]);
816
817        for _ in 0..3 {
818            tx_out
819                .send((slow_peer, msg.clone()))
820                .await
821                .context("egress queue must accept slow-peer packet")?;
822        }
823        tx_out
824            .send((fast_peer, msg.clone()))
825            .await
826            .context("egress queue must accept fast-peer packet")?;
827
828        let deadline = tokio::time::Instant::now() + std::time::Duration::from_millis(1_000);
829        while control.fast_open_calls() < 1 && tokio::time::Instant::now() < deadline {
830            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
831        }
832
833        assert!(
834            control.fast_open_calls() >= 1,
835            "fast peer's stream open was not called within 1 s — egress drain is likely head-of-line blocked by \
836             slow-peer opens"
837        );
838
839        Ok(())
840    }
841
842    #[derive(Clone, Debug)]
843    struct DelayedControl {
844        open_delay: std::time::Duration,
845        open_calls: Arc<AtomicUsize>,
846    }
847
848    #[async_trait]
849    impl hopr_api::network::traits::NetworkStreamControl for DelayedControl {
850        fn accept(
851            self,
852        ) -> Result<impl Stream<Item = (PeerId, impl AsyncRead + AsyncWrite + Send)> + Send, impl std::error::Error>
853        {
854            Ok::<_, std::io::Error>(futures::stream::empty::<(PeerId, AsyncBinaryStreamChannel)>())
855        }
856
857        async fn open(self, _peer: PeerId) -> Result<impl AsyncRead + AsyncWrite + Send, impl std::error::Error> {
858            self.open_calls.fetch_add(1, Ordering::Relaxed);
859            tokio::time::sleep(self.open_delay).await;
860            Ok::<_, std::io::Error>(AsyncBinaryStreamChannel::new())
861        }
862    }
863
864    /// Verifies that packets sent while the opener is in flight are buffered and
865    /// all delivered once the stream opens — zero loss below channel capacity.
866    #[tokio::test]
867    async fn egress_buffers_during_slow_open_then_drains() -> anyhow::Result<()> {
868        let open_calls = Arc::new(AtomicUsize::new(0));
869        let control = DelayedControl {
870            open_delay: std::time::Duration::from_millis(100),
871            open_calls: open_calls.clone(),
872        };
873
874        let (mut tx_out, mut rx_in) = process_stream_protocol(
875            BytesCodec::new(),
876            control,
877            crate::config::StreamProtocolConfig {
878                per_peer_channel_capacity: 64,
879                ..Default::default()
880            },
881        )
882        .await?;
883
884        let peer = PeerId::random();
885        let msg = BytesMut::from(&b"hello"[..]);
886
887        let n = 10usize;
888        let expected_bytes = n * msg.len();
889        for _ in 0..n {
890            tx_out
891                .send((peer, msg.clone()))
892                .await
893                .context("send into egress queue should succeed")?;
894        }
895
896        assert!(
897            wait_for(2, || open_calls.load(Ordering::Relaxed) >= 1).await,
898            "stream was never opened"
899        );
900
901        let mut received_bytes = 0usize;
902        let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
903        while received_bytes < expected_bytes && tokio::time::Instant::now() < deadline {
904            if let Ok(Some((_, bytes))) =
905                tokio::time::timeout(std::time::Duration::from_millis(100), rx_in.next()).await
906            {
907                received_bytes += bytes.len();
908            }
909        }
910
911        assert!(
912            received_bytes >= expected_bytes,
913            "expected at least {expected_bytes} bytes to be delivered after stream open; got {received_bytes}"
914        );
915
916        Ok(())
917    }
918}