Skip to main content

rama_net/proxy/
forward.rs

1use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
2use std::time::{Duration, Instant};
3
4use crate::std::sync::Arc;
5
6use super::IdleGuard;
7
8use rama_core::graceful::ShutdownGuard;
9use rama_core::rt::Executor;
10use rama_core::telemetry::tracing;
11use rama_core::{
12    Service,
13    io::{BridgeIo, Io},
14};
15use rama_utils::macros::generate_set_and_with;
16use rama_utils::octets::kib;
17
18use tokio::io::{AsyncReadExt, AsyncWriteExt};
19use tokio::sync::Notify;
20
21// `BridgeCloseReason` is shared with the frame-oriented bridge in
22// `rama-core::stream::forward`. Re-exported here for convience.
23#[doc(inline)]
24pub use rama_core::stream::BridgeCloseReason;
25
26/// Direction tag used internally by [`run_bridge`] to disambiguate
27/// per-direction errors when classifying I/O failures.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29enum CopyDirection {
30    LeftToRight,
31    RightToLeft,
32}
33
34/// Anchor from which the response first-byte window
35/// (see [`IoForwardService::with_first_byte_timeout`]) is measured.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
37#[non_exhaustive]
38pub enum FirstByteTimeoutStart {
39    /// Count from the moment the bridge opens.
40    ///
41    /// Best for server-speaks-first protocols (SMTP/FTP/SSH) where the origin
42    /// is expected to greet unprompted. On client-speaks-first protocols
43    /// (HTTP/TLS) it can cut a slow-to-speak client, since the origin has no
44    /// reason to respond until the client's request arrives.
45    BridgeOpen,
46    /// Count from the client's first sent byte, true time-to-first-response-byte.
47    ///
48    /// Isolates a genuinely silent origin (asked, but not answering) without
49    /// penalising a client that is merely slow to send. A silent origin on a
50    /// server-speaks-first protocol is not caught by this anchor, total mutual
51    /// silence stays [`idle_timeout`](IoForwardService::with_idle_timeout)'s job.
52    ///
53    /// This is the default.
54    #[default]
55    ClientFirstByte,
56}
57
58// 16 KiB is a middle ground: large enough to roughly halve the per-chunk
59// copy/syscall count vs the classic 8 KiB on bulk transfers, while keeping the
60// per-direction reused buffer small enough that holding two of them per live
61// flow stays cheap under high connection concurrency. Override per service via
62// [`IoForwardService::with_buf_size`] when a workload wants a different point on
63// that throughput-vs-resident-memory curve.
64const DEFAULT_BUF_SIZE: usize = kib(16);
65const DEFAULT_SHUTDOWN_GRACE: Duration = Duration::from_millis(50);
66
67/// A proxy [`Service`] which takes a [`BridgeIo`]
68/// and copies the bytes of both the source and target [`Io`]s
69/// bidirectionally.
70///
71/// The service observes shutdown via the [`ShutdownGuard`] of the
72/// [`Executor`] passed at construction (if any), enforces an optional
73/// idle timeout that closes the bridge when neither direction has made
74/// byte progress within the configured window, and emits a single
75/// structured close event when the bridge ends.
76#[derive(Debug, Clone)]
77pub struct IoForwardService {
78    executor: Executor,
79    idle_timeout: Option<Duration>,
80    first_byte_timeout: Option<Duration>,
81    first_byte_timeout_start: FirstByteTimeoutStart,
82    shutdown_grace: Duration,
83    buf_size: usize,
84}
85
86impl Default for IoForwardService {
87    fn default() -> Self {
88        Self::new(Executor::default())
89    }
90}
91
92impl IoForwardService {
93    /// Create a new [`IoForwardService`] using the given [`Executor`].
94    #[must_use]
95    pub fn new(executor: Executor) -> Self {
96        Self {
97            executor,
98            idle_timeout: None,
99            first_byte_timeout: None,
100            first_byte_timeout_start: FirstByteTimeoutStart::default(),
101            shutdown_grace: DEFAULT_SHUTDOWN_GRACE,
102            buf_size: DEFAULT_BUF_SIZE,
103        }
104    }
105
106    generate_set_and_with! {
107        /// Per-direction idle timeout. When set, the bridge closes with reason
108        /// [`BridgeCloseReason::IdleTimeout`] if no byte progress is observed
109        /// in either direction within `timeout`.
110        ///
111        /// `None` (the default) disables idle detection.
112        pub fn idle_timeout(mut self, timeout: Option<Duration>) -> Self {
113            self.idle_timeout = timeout;
114            self
115        }
116    }
117
118    generate_set_and_with! {
119        /// Response first-byte timeout. When set, the bridge closes with reason
120        /// [`BridgeCloseReason::FirstByteTimeout`] if the upstream (right /
121        /// egress) half writes no byte within `timeout` of the window's start
122        /// (see [`first_byte_timeout_start`](Self::with_first_byte_timeout_start)).
123        ///
124        /// This targets a silent origin that accepts the connection but never
125        /// responds: the client's bytes still flow toward the upstream, so the
126        /// flow never looks idle. It keys off the upstream -> client direction
127        /// only, once the first upstream byte arrives the timer disarms
128        /// permanently and [`idle_timeout`](Self::with_idle_timeout) takes over.
129        ///
130        /// Where the window is anchored (bridge open vs the client's first sent
131        /// byte) is controlled by
132        /// [`first_byte_timeout_start`](Self::with_first_byte_timeout_start).
133        ///
134        /// `None` (the default) disables first-byte detection.
135        pub fn first_byte_timeout(mut self, timeout: Option<Duration>) -> Self {
136            self.first_byte_timeout = timeout;
137            self
138        }
139    }
140
141    generate_set_and_with! {
142        /// Anchor for the response first-byte window: see
143        /// [`FirstByteTimeoutStart`]. Only meaningful when
144        /// [`first_byte_timeout`](Self::with_first_byte_timeout) is set.
145        ///
146        /// Default: [`FirstByteTimeoutStart::ClientFirstByte`].
147        pub fn first_byte_timeout_start(mut self, start: FirstByteTimeoutStart) -> Self {
148            self.first_byte_timeout_start = start;
149            self
150        }
151    }
152
153    generate_set_and_with! {
154        /// Per-half cap on graceful shutdown. When the bridge unwinds it calls
155        /// `shutdown()` on each write half bounded by this duration; if the
156        /// inner type blocks (e.g. a TLS layer waiting for `close_notify`),
157        /// the shutdown is abandoned and the half is dropped.
158        ///
159        /// Default: 50ms.
160        pub fn shutdown_grace(mut self, grace: Duration) -> Self {
161            self.shutdown_grace = grace;
162            self
163        }
164    }
165
166    generate_set_and_with! {
167        /// Per-direction copy buffer size (in bytes).
168        ///
169        /// Default: 8 KiB.
170        pub fn buf_size(mut self, size: usize) -> Self {
171            self.buf_size = size.max(1);
172            self
173        }
174    }
175
176    /// The shutdown guard wired through the [`Executor`], if any.
177    fn shutdown_guard(&self) -> Option<ShutdownGuard> {
178        self.executor.guard().cloned()
179    }
180}
181
182impl<S, T> Service<BridgeIo<S, T>> for IoForwardService
183where
184    S: Io + Unpin,
185    T: Io + Unpin,
186{
187    type Output = IoForwardOutcome;
188    type Error = IoForwardError;
189
190    async fn serve(
191        &self,
192        BridgeIo(left, right): BridgeIo<S, T>,
193    ) -> Result<Self::Output, Self::Error> {
194        #[cfg(feature = "dial9")]
195        super::dial9::record_bridge_opened(
196            self.idle_timeout
197                .map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
198                .unwrap_or(0),
199            self.executor.guard().is_some(),
200        );
201
202        let outcome = run_bridge(
203            left,
204            right,
205            self.shutdown_guard(),
206            self.idle_timeout,
207            self.first_byte_timeout,
208            self.first_byte_timeout_start,
209            self.shutdown_grace,
210            self.buf_size,
211        )
212        .await;
213
214        emit_close_event(&outcome);
215
216        #[cfg(feature = "dial9")]
217        {
218            let age_ms = u64::try_from(outcome.age.as_millis()).unwrap_or(u64::MAX);
219            super::dial9::record_bridge_closed(
220                outcome.reason,
221                age_ms,
222                outcome.bytes_l_to_r,
223                outcome.bytes_r_to_l,
224                outcome.fatal_error.as_ref(),
225            );
226        }
227
228        // The outcome is returned either way; the `Result` variant signals a
229        // clean vs errored close (an errored close wraps the outcome in
230        // [`IoForwardError`], which still exposes it).
231        let errored = outcome
232            .fatal_error
233            .as_ref()
234            .is_some_and(|err| !crate::conn::is_connection_error(err));
235        if errored {
236            Err(IoForwardError(outcome))
237        } else {
238            Ok(outcome)
239        }
240    }
241}
242
243/// The result of an [`IoForwardService`] bridge, describing why and how the
244/// forward ended.
245///
246/// Returned as the service [`Output`](IoForwardService) on a clean or benign
247/// close. A genuine (non-connection) error close instead yields an
248/// [`IoForwardError`], which carries this same outcome. Either way callers get
249/// the full picture: benign peer disconnects (connection resets/aborts) are
250/// reported as `Ok`, still carrying the classified [`reason`](Self::reason) and
251/// [`fatal_error`](Self::fatal_error).
252#[derive(Debug)]
253pub struct IoForwardOutcome {
254    reason: BridgeCloseReason,
255    bytes_l_to_r: u64,
256    bytes_r_to_l: u64,
257    age: Duration,
258    fatal_error: Option<std::io::Error>,
259}
260
261impl IoForwardOutcome {
262    /// Why the bridge closed.
263    #[must_use]
264    pub fn reason(&self) -> BridgeCloseReason {
265        self.reason
266    }
267
268    /// Bytes copied from the left (ingress) half to the right (egress) half.
269    #[must_use]
270    pub fn bytes_l_to_r(&self) -> u64 {
271        self.bytes_l_to_r
272    }
273
274    /// Bytes copied from the right (egress) half to the left (ingress) half.
275    #[must_use]
276    pub fn bytes_r_to_l(&self) -> u64 {
277        self.bytes_r_to_l
278    }
279
280    /// Total bytes copied in both directions.
281    #[must_use]
282    pub fn bytes_total(&self) -> u64 {
283        self.bytes_l_to_r.saturating_add(self.bytes_r_to_l)
284    }
285
286    /// How long the bridge was open.
287    #[must_use]
288    pub fn age(&self) -> Duration {
289        self.age
290    }
291
292    /// The fatal I/O error that ended the bridge, if any.
293    #[must_use]
294    pub fn fatal_error(&self) -> Option<&std::io::Error> {
295        self.fatal_error.as_ref()
296    }
297}
298
299impl std::fmt::Display for IoForwardOutcome {
300    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
301        write!(
302            f,
303            "(proxy) I/O forwarder closed: reason={}, bytes_l_to_r={}, bytes_r_to_l={}, age_ms={}",
304            self.reason,
305            self.bytes_l_to_r,
306            self.bytes_r_to_l,
307            u64::try_from(self.age.as_millis()).unwrap_or(u64::MAX),
308        )?;
309        if let Some(err) = &self.fatal_error {
310            write!(f, ", error={err}")?;
311        }
312        Ok(())
313    }
314}
315
316/// The [`Error`](IoForwardService) returned by [`IoForwardService`] when the
317/// bridge ended on a genuine (non-connection) I/O error.
318///
319/// Wraps the full [`IoForwardOutcome`] of the closed bridge: [`Deref`] or
320/// [`outcome`](Self::outcome) to inspect the reason, byte counts, age, and the
321/// underlying error.
322///
323/// [`Deref`]: std::ops::Deref
324#[derive(Debug)]
325pub struct IoForwardError(IoForwardOutcome);
326
327impl IoForwardError {
328    /// The outcome of the bridge that errored.
329    #[must_use]
330    pub fn outcome(&self) -> &IoForwardOutcome {
331        &self.0
332    }
333
334    /// Consume this error, returning the underlying [`IoForwardOutcome`].
335    #[must_use]
336    pub fn into_outcome(self) -> IoForwardOutcome {
337        self.0
338    }
339}
340
341impl std::ops::Deref for IoForwardError {
342    type Target = IoForwardOutcome;
343
344    fn deref(&self) -> &Self::Target {
345        &self.0
346    }
347}
348
349impl std::fmt::Display for IoForwardError {
350    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
351        std::fmt::Display::fmt(&self.0, f)
352    }
353}
354
355impl std::error::Error for IoForwardError {
356    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
357        self.0
358            .fatal_error()
359            .map(|err| err as &(dyn std::error::Error + 'static))
360    }
361}
362
363#[expect(clippy::too_many_arguments)]
364async fn run_bridge<S, T>(
365    left: S,
366    right: T,
367    guard: Option<ShutdownGuard>,
368    idle_timeout: Option<Duration>,
369    first_byte_timeout: Option<Duration>,
370    first_byte_timeout_start: FirstByteTimeoutStart,
371    shutdown_grace: Duration,
372    buf_size: usize,
373) -> IoForwardOutcome
374where
375    S: Io + Unpin,
376    T: Io + Unpin,
377{
378    let opened_at = Instant::now();
379    let bytes_l_to_r = Arc::new(AtomicU64::new(0));
380    let bytes_r_to_l = Arc::new(AtomicU64::new(0));
381    let progress = Arc::new(AtomicU64::new(0));
382
383    let first_byte_seen = Arc::new(AtomicBool::new(false));
384    let upstream_eof_seen = Arc::new(AtomicBool::new(false));
385
386    let client_first_byte_seen = Arc::new(AtomicBool::new(false));
387    let client_spoke = Arc::new(Notify::new());
388
389    let (mut left_r, mut left_w) = tokio::io::split(left);
390    let (mut right_r, mut right_w) = tokio::io::split(right);
391
392    // Tracks whether `copy_one_way` has already half-closed the write
393    // side it owns. The inline half-close fires immediately on EOF so
394    // the peer sees FIN promptly; we then skip the outer post-loop
395    // shutdown for that side. Calling `shutdown` twice on a TLS writer
396    // (boring/rustls) is implementation-defined and can panic — the
397    // flag stops that here.
398    let left_w_shut = Arc::new(AtomicBool::new(false));
399    let right_w_shut = Arc::new(AtomicBool::new(false));
400
401    let (reason, fatal_error) = {
402        let l_to_r = std::pin::pin!(copy_one_way(
403            &mut left_r,
404            &mut right_w,
405            bytes_l_to_r.clone(),
406            progress.clone(),
407            buf_size,
408            shutdown_grace,
409            right_w_shut.clone(),
410            Some(client_first_byte_seen.clone()),
411            Some(client_spoke.clone()),
412            None,
413        ));
414        let r_to_l = std::pin::pin!(copy_one_way(
415            &mut right_r,
416            &mut left_w,
417            bytes_r_to_l.clone(),
418            progress.clone(),
419            buf_size,
420            shutdown_grace,
421            left_w_shut.clone(),
422            Some(first_byte_seen.clone()),
423            None,
424            Some(upstream_eof_seen.clone()),
425        ));
426
427        run_select_loop(
428            l_to_r,
429            r_to_l,
430            guard.as_ref(),
431            idle_timeout,
432            first_byte_timeout,
433            first_byte_timeout_start,
434            &progress,
435            &first_byte_seen,
436            &upstream_eof_seen,
437            &client_spoke,
438        )
439        .await
440        // l_to_r and r_to_l drop here, releasing borrows on the halves.
441    };
442
443    // Close both write halves concurrently rather than sequentially — TLS
444    // close_notify can take the full grace window per side, and serializing
445    // the two doubles the worst-case bridge unwind time. Skip a side that
446    // `copy_one_way` already shut down inline so we don't double-shutdown
447    // a TLS writer.
448    let left_pending_shutdown = !left_w_shut.load(Ordering::Acquire);
449    let right_pending_shutdown = !right_w_shut.load(Ordering::Acquire);
450    match (left_pending_shutdown, right_pending_shutdown) {
451        (true, true) => {
452            _ = tokio::join!(
453                tokio::time::timeout(shutdown_grace, left_w.shutdown()),
454                tokio::time::timeout(shutdown_grace, right_w.shutdown()),
455            );
456        }
457        (true, false) => {
458            _ = tokio::time::timeout(shutdown_grace, left_w.shutdown()).await;
459        }
460        (false, true) => {
461            _ = tokio::time::timeout(shutdown_grace, right_w.shutdown()).await;
462        }
463        (false, false) => {}
464    }
465
466    IoForwardOutcome {
467        reason,
468        bytes_l_to_r: bytes_l_to_r.load(Ordering::Relaxed),
469        bytes_r_to_l: bytes_r_to_l.load(Ordering::Relaxed),
470        age: opened_at.elapsed(),
471        fatal_error,
472    }
473}
474
475enum FirstByteWindow {
476    /// Nothing configured or this timeout has been disarmed
477    Inert,
478    /// Waiting for client to send bytes first
479    PendingClient(Duration),
480    /// Actively counting down to the deadline
481    Armed(std::pin::Pin<Box<tokio::time::Sleep>>),
482}
483
484#[expect(clippy::too_many_arguments)]
485async fn run_select_loop<F1, F2>(
486    mut l_to_r: std::pin::Pin<&mut F1>,
487    mut r_to_l: std::pin::Pin<&mut F2>,
488    guard: Option<&ShutdownGuard>,
489    idle_timeout: Option<Duration>,
490    first_byte_timeout: Option<Duration>,
491    first_byte_timeout_start: FirstByteTimeoutStart,
492    progress: &AtomicU64,
493    first_byte_seen: &AtomicBool,
494    upstream_eof_seen: &AtomicBool,
495    client_spoke: &Notify,
496) -> (BridgeCloseReason, Option<std::io::Error>)
497where
498    F1: Future<Output = Result<(), std::io::Error>>,
499    F2: Future<Output = Result<(), std::io::Error>>,
500{
501    let mut idle = idle_timeout.map(IdleGuard::new);
502    let mut first_byte = match (first_byte_timeout, first_byte_timeout_start) {
503        (None, _) => FirstByteWindow::Inert,
504        (Some(d), FirstByteTimeoutStart::BridgeOpen) => {
505            FirstByteWindow::Armed(Box::pin(tokio::time::sleep(d)))
506        }
507        (Some(d), FirstByteTimeoutStart::ClientFirstByte) => FirstByteWindow::PendingClient(d),
508    };
509    let mut last_progress: u64 = 0;
510    let mut l_to_r_done = false;
511    let mut r_to_l_done = false;
512    // The reason of whichever arm finished first — that's the one
513    // that initiated the close. The second arm is just draining what
514    // the peer had already buffered before its half-close. Without
515    // this, a flow whose left side EOFed first followed by the right
516    // side draining its buffer would be reported as `PeerEofRight`
517    // (last to finish), which is misleading on the analysis side.
518    let mut first_eof: Option<BridgeCloseReason> = None;
519
520    loop {
521        if l_to_r_done && r_to_l_done {
522            return (first_eof.unwrap_or(BridgeCloseReason::PeerEofLeft), None);
523        }
524
525        // Settle the first-byte window for good once the upstream direction is
526        // resolved: either the upstream wrote its first byte (`first_byte_seen`),
527        // or it reached a clean EOF without ever writing (`upstream_eof_seen`).
528        // The explicit EOF signal is set before graceful writer shutdown, because
529        // `r_to_l_done` is not observable until that shutdown has completed.
530        if !matches!(first_byte, FirstByteWindow::Inert)
531            && (first_byte_seen.load(Ordering::Relaxed)
532                || upstream_eof_seen.load(Ordering::Relaxed))
533        {
534            first_byte = FirstByteWindow::Inert;
535        }
536
537        let pending_client_window = match &first_byte {
538            FirstByteWindow::PendingClient(d) => Some(*d),
539            _ => None,
540        };
541
542        let cancelled = async {
543            match guard {
544                Some(g) => g.cancelled().await,
545                None => std::future::pending().await,
546            }
547        };
548
549        tokio::select! {
550            biased;
551            () = cancelled => return (BridgeCloseReason::Shutdown, None),
552            _ = async {
553                match idle.as_mut() {
554                    Some(g) => g.tick().await,
555                    None => std::future::pending().await,
556                }
557            } => {
558                let cur = progress.load(Ordering::Relaxed);
559                if cur != last_progress {
560                    last_progress = cur;
561                    if let Some(g) = idle.as_mut() {
562                        g.reset();
563                    }
564                    continue;
565                }
566                return (BridgeCloseReason::IdleTimeout, None);
567            }
568            _ = async {
569                match &mut first_byte {
570                    FirstByteWindow::Armed(s) => s.as_mut().await,
571                    _ => std::future::pending().await,
572                }
573            } => {
574                // re-check here is needed in case it changed during the await
575                if first_byte_seen.load(Ordering::Relaxed)
576                    || upstream_eof_seen.load(Ordering::Relaxed)
577                {
578                    first_byte = FirstByteWindow::Inert;
579                    continue;
580                }
581                return (BridgeCloseReason::FirstByteTimeout, None);
582            }
583            _ = async {
584                match pending_client_window {
585                    Some(_) => client_spoke.notified().await,
586                    None => std::future::pending().await,
587                }
588            } => {
589                // Client just sent its first byte: start the window from here
590                if let Some(d) = pending_client_window {
591                    first_byte = FirstByteWindow::Armed(Box::pin(tokio::time::sleep(d)));
592                }
593            }
594            res = l_to_r.as_mut(), if !l_to_r_done => match res {
595                Ok(()) => {
596                    l_to_r_done = true;
597                    if first_eof.is_none() {
598                        first_eof = Some(BridgeCloseReason::PeerEofLeft);
599                    }
600                    if !r_to_l_done {
601                        continue;
602                    }
603                    return (
604                        first_eof.unwrap_or(BridgeCloseReason::PeerEofLeft),
605                        None,
606                    );
607                }
608                Err(e) => {
609                    let reason = classify_copy_error(&e, CopyDirection::LeftToRight);
610                    return (reason, Some(e));
611                }
612            },
613            res = r_to_l.as_mut(), if !r_to_l_done => match res {
614                Ok(()) => {
615                    r_to_l_done = true;
616                    if first_eof.is_none() {
617                        first_eof = Some(BridgeCloseReason::PeerEofRight);
618                    }
619                    if !l_to_r_done {
620                        continue;
621                    }
622                    return (
623                        first_eof.unwrap_or(BridgeCloseReason::PeerEofRight),
624                        None,
625                    );
626                }
627                Err(e) => {
628                    let reason = classify_copy_error(&e, CopyDirection::RightToLeft);
629                    return (reason, Some(e));
630                }
631            },
632        }
633    }
634}
635
636#[expect(clippy::too_many_arguments)]
637async fn copy_one_way<R, W>(
638    reader: &mut R,
639    writer: &mut W,
640    bytes: Arc<AtomicU64>,
641    progress: Arc<AtomicU64>,
642    buf_size: usize,
643    shutdown_grace: Duration,
644    write_side_shut: Arc<AtomicBool>,
645    first_byte_seen: Option<Arc<AtomicBool>>,
646    first_byte_notify: Option<Arc<Notify>>,
647    eof_seen: Option<Arc<AtomicBool>>,
648) -> Result<(), std::io::Error>
649where
650    R: tokio::io::AsyncRead + Unpin,
651    W: tokio::io::AsyncWrite + Unpin,
652{
653    let mut buf = vec![0u8; buf_size];
654    let mut copy_err: Option<std::io::Error> = None;
655    loop {
656        match reader.read(&mut buf).await {
657            Ok(0) => {
658                if let Some(seen) = &eof_seen {
659                    seen.store(true, Ordering::Relaxed);
660                }
661                break;
662            }
663            Ok(n) => {
664                // Record the first byte for this direction before write_all, so
665                // backpressure on the far side never counts against the window.
666                // `swap` detects the first transition so we notify exactly once
667                if let Some(seen) = &first_byte_seen
668                    && !seen.swap(true, Ordering::Relaxed)
669                    && let Some(notify) = &first_byte_notify
670                {
671                    notify.notify_one();
672                }
673                if let Err(err) = writer.write_all(&buf[..n]).await {
674                    copy_err = Some(err);
675                    break;
676                }
677                bytes.fetch_add(n as u64, Ordering::Relaxed);
678                progress.fetch_add(1, Ordering::Relaxed);
679            }
680            Err(err) => {
681                copy_err = Some(err);
682                break;
683            }
684        }
685    }
686
687    // Single shutdown path for clean EOF, read errors, and write
688    // errors alike: one bounded shutdown attempt, mark the side shut
689    // so the outer `run_bridge` post-loop shutdown skips us, swallow
690    // any shutdown error (the writer may already be poisoned by a
691    // prior write error — that's expected). Bounded by
692    // `shutdown_grace` so a TLS writer waiting on the peer's
693    // close_notify can't wedge this future indefinitely.
694    _ = tokio::time::timeout(shutdown_grace, writer.shutdown()).await;
695    write_side_shut.store(true, Ordering::Release);
696
697    match copy_err {
698        Some(err) => Err(err),
699        None => Ok(()),
700    }
701}
702
703fn classify_copy_error(err: &std::io::Error, direction: CopyDirection) -> BridgeCloseReason {
704    use std::io::ErrorKind;
705
706    // Rough split: connection / EOF errors on the read side; other kinds on the
707    // write side. We can't always tell which side surfaced an error from the
708    // io::Error alone, so this is best-effort.
709    let read_side = matches!(
710        err.kind(),
711        ErrorKind::UnexpectedEof
712            | ErrorKind::ConnectionReset
713            | ErrorKind::ConnectionAborted
714            | ErrorKind::NotConnected
715            | ErrorKind::BrokenPipe
716    );
717    match (direction, read_side) {
718        (CopyDirection::LeftToRight, true) => BridgeCloseReason::ReadErrorLeft,
719        (CopyDirection::LeftToRight, false) => BridgeCloseReason::WriteErrorRight,
720        (CopyDirection::RightToLeft, true) => BridgeCloseReason::ReadErrorRight,
721        (CopyDirection::RightToLeft, false) => BridgeCloseReason::WriteErrorLeft,
722    }
723}
724
725fn emit_close_event(outcome: &IoForwardOutcome) {
726    let age_ms = u64::try_from(outcome.age.as_millis()).unwrap_or(u64::MAX);
727    if outcome.fatal_error.is_some() {
728        tracing::debug!(
729            target: "rama_net::proxy::forward",
730            reason = %outcome.reason,
731            bytes_l_to_r = outcome.bytes_l_to_r,
732            bytes_r_to_l = outcome.bytes_r_to_l,
733            age_ms,
734            error = ?outcome.fatal_error,
735            "io forward bridge closed",
736        );
737    } else {
738        tracing::trace!(
739            target: "rama_net::proxy::forward",
740            reason = %outcome.reason,
741            bytes_l_to_r = outcome.bytes_l_to_r,
742            bytes_r_to_l = outcome.bytes_r_to_l,
743            age_ms,
744            "io forward bridge closed",
745        );
746    }
747}
748
749#[cfg(test)]
750mod tests {
751    use std::time::Duration;
752
753    use super::*;
754
755    use rama_core::graceful::Shutdown;
756
757    use tokio::io::{AsyncReadExt, AsyncWriteExt, duplex};
758
759    async fn run_default<S, T>(left: S, right: T) -> IoForwardOutcome
760    where
761        S: Io + Unpin,
762        T: Io + Unpin,
763    {
764        let svc = IoForwardService::default();
765        svc.serve(BridgeIo(left, right)).await.unwrap()
766    }
767
768    #[tokio::test]
769    async fn forward_basic_bidirectional_traffic() {
770        let (a_user, a_proxy) = duplex(64);
771        let (b_user, b_proxy) = duplex(64);
772
773        let svc_task = tokio::spawn(async move {
774            run_default(a_proxy, b_proxy).await;
775        });
776
777        let mut a = a_user;
778        let mut b = b_user;
779
780        a.write_all(b"hello").await.unwrap();
781        let mut buf = [0u8; 5];
782        b.read_exact(&mut buf).await.unwrap();
783        assert_eq!(&buf, b"hello");
784
785        b.write_all(b"world!").await.unwrap();
786        let mut buf = [0u8; 6];
787        a.read_exact(&mut buf).await.unwrap();
788        assert_eq!(&buf, b"world!");
789
790        // Closing one side should let the bridge wind down.
791        drop(a);
792        drop(b);
793        svc_task.await.unwrap();
794    }
795
796    async fn shutdown_pair() -> (Shutdown, tokio::sync::oneshot::Sender<()>) {
797        let (tx, rx) = tokio::sync::oneshot::channel::<()>();
798        let shutdown = Shutdown::new(async move {
799            _ = rx.await;
800        });
801        (shutdown, tx)
802    }
803
804    #[tokio::test]
805    async fn forward_shutdown_drops_idle_bridge() {
806        let (shutdown, trigger) = shutdown_pair().await;
807        let guard = shutdown.guard();
808        let svc = IoForwardService::new(Executor::graceful(guard));
809
810        let (_a_user, a_proxy) = duplex(64);
811        let (_b_user, b_proxy) = duplex(64);
812
813        let task = tokio::spawn(async move {
814            svc.serve(BridgeIo(a_proxy, b_proxy)).await.unwrap();
815        });
816
817        tokio::time::sleep(Duration::from_millis(10)).await;
818
819        let started = Instant::now();
820        trigger.send(()).unwrap();
821        tokio::time::timeout(Duration::from_secs(2), task)
822            .await
823            .expect("bridge did not unwind within 2s")
824            .unwrap();
825        let elapsed = started.elapsed();
826        assert!(
827            elapsed < Duration::from_millis(500),
828            "bridge took {elapsed:?} to unwind on shutdown",
829        );
830        drop(shutdown);
831    }
832
833    #[tokio::test]
834    async fn forward_shutdown_drops_active_bridge() {
835        let (shutdown, trigger) = shutdown_pair().await;
836        let guard = shutdown.guard();
837        let svc = IoForwardService::new(Executor::graceful(guard));
838
839        let (mut a_user, a_proxy) = duplex(64);
840        let (mut b_user, b_proxy) = duplex(64);
841
842        let task = tokio::spawn(async move {
843            svc.serve(BridgeIo(a_proxy, b_proxy)).await.unwrap();
844        });
845
846        a_user.write_all(b"hello").await.unwrap();
847        let mut buf = [0u8; 5];
848        b_user.read_exact(&mut buf).await.unwrap();
849        assert_eq!(&buf, b"hello");
850
851        let started = Instant::now();
852        trigger.send(()).unwrap();
853        tokio::time::timeout(Duration::from_secs(2), task)
854            .await
855            .expect("bridge did not unwind within 2s")
856            .unwrap();
857        let elapsed = started.elapsed();
858        assert!(
859            elapsed < Duration::from_millis(500),
860            "bridge took {elapsed:?} to unwind on shutdown",
861        );
862        drop(shutdown);
863    }
864
865    #[tokio::test]
866    async fn forward_idle_timeout_fires_when_no_progress() {
867        let svc = IoForwardService::default().with_idle_timeout(Duration::from_millis(100));
868
869        let (_a_user, a_proxy) = duplex(64);
870        let (_b_user, b_proxy) = duplex(64);
871
872        let started = Instant::now();
873        let outcome = tokio::time::timeout(
874            Duration::from_secs(2),
875            svc.serve(BridgeIo(a_proxy, b_proxy)),
876        )
877        .await
878        .expect("idle bridge did not unwind within 2s")
879        .unwrap();
880        assert_eq!(outcome.reason(), BridgeCloseReason::IdleTimeout);
881        let elapsed = started.elapsed();
882        assert!(
883            elapsed >= Duration::from_millis(80),
884            "idle bridge unwound too early: {elapsed:?}",
885        );
886        assert!(
887            elapsed < Duration::from_millis(800),
888            "idle bridge unwound too late: {elapsed:?}",
889        );
890    }
891
892    #[tokio::test(start_paused = true)]
893    async fn forward_first_byte_timeout_fires_when_upstream_silent() {
894        let svc = IoForwardService::default().with_first_byte_timeout(Duration::from_millis(100));
895
896        let (mut a_user, a_proxy) = duplex(64);
897        let (_b_user, b_proxy) = duplex(64);
898
899        a_user.write_all(b"hello").await.unwrap();
900
901        let started = tokio::time::Instant::now();
902        tokio::time::timeout(
903            Duration::from_secs(5),
904            svc.serve(BridgeIo(a_proxy, b_proxy)),
905        )
906        .await
907        .expect("silent-upstream bridge did not unwind")
908        .unwrap();
909
910        assert_eq!(
911            started.elapsed(),
912            Duration::from_millis(100),
913            "first-byte timeout should fire exactly at its deadline",
914        );
915    }
916
917    #[tokio::test(start_paused = true)]
918    async fn forward_first_byte_survives_when_upstream_speaks() {
919        let svc = IoForwardService::default()
920            .with_first_byte_timeout(Duration::from_millis(100))
921            .with_first_byte_timeout_start(FirstByteTimeoutStart::BridgeOpen)
922            .with_idle_timeout(Duration::from_millis(200));
923
924        let (mut a_user, a_proxy) = duplex(64);
925        let (mut b_user, b_proxy) = duplex(64);
926
927        let task = tokio::spawn(async move {
928            svc.serve(BridgeIo(a_proxy, b_proxy)).await.unwrap();
929        });
930
931        let started = tokio::time::Instant::now();
932
933        b_user.write_all(b"x").await.unwrap();
934        let mut buf = [0u8; 1];
935        a_user.read_exact(&mut buf).await.unwrap();
936
937        tokio::time::timeout(Duration::from_secs(5), task)
938            .await
939            .expect("bridge did not unwind")
940            .unwrap();
941        assert!(
942            started.elapsed() > Duration::from_millis(100),
943            "bridge closed inside the first-byte window ({:?}); the upstream byte should have disarmed it",
944            started.elapsed(),
945        );
946    }
947
948    #[tokio::test(start_paused = true)]
949    async fn forward_first_byte_disarmed_on_upstream_eof_before_byte() {
950        // A clean upstream EOF (no byte ever written) is a `PeerEofRight`, not a
951        // silent origin: the first-byte timer must disarm so it neither cuts the
952        // client-half drain short nor misreports `FirstByteTimeout`. Anchored
953        // from bridge open so the window is armed despite the silent client.
954        let svc = IoForwardService::default()
955            .with_first_byte_timeout(Duration::from_millis(10))
956            .with_first_byte_timeout_start(FirstByteTimeoutStart::BridgeOpen)
957            .with_shutdown_grace(Duration::from_millis(100));
958
959        struct PendingShutdownIo {
960            inner: tokio::io::DuplexStream,
961        }
962
963        impl tokio::io::AsyncRead for PendingShutdownIo {
964            fn poll_read(
965                mut self: std::pin::Pin<&mut Self>,
966                cx: &mut std::task::Context<'_>,
967                buf: &mut tokio::io::ReadBuf<'_>,
968            ) -> std::task::Poll<std::io::Result<()>> {
969                tokio::io::AsyncRead::poll_read(std::pin::Pin::new(&mut self.inner), cx, buf)
970            }
971        }
972
973        impl tokio::io::AsyncWrite for PendingShutdownIo {
974            fn poll_write(
975                mut self: std::pin::Pin<&mut Self>,
976                cx: &mut std::task::Context<'_>,
977                buf: &[u8],
978            ) -> std::task::Poll<std::io::Result<usize>> {
979                tokio::io::AsyncWrite::poll_write(std::pin::Pin::new(&mut self.inner), cx, buf)
980            }
981
982            fn poll_flush(
983                mut self: std::pin::Pin<&mut Self>,
984                cx: &mut std::task::Context<'_>,
985            ) -> std::task::Poll<std::io::Result<()>> {
986                tokio::io::AsyncWrite::poll_flush(std::pin::Pin::new(&mut self.inner), cx)
987            }
988
989            fn poll_shutdown(
990                self: std::pin::Pin<&mut Self>,
991                _: &mut std::task::Context<'_>,
992            ) -> std::task::Poll<std::io::Result<()>> {
993                std::task::Poll::Pending
994            }
995        }
996
997        let (a_user, a_proxy) = duplex(64);
998        let a_proxy = PendingShutdownIo { inner: a_proxy };
999        let (b_user, b_proxy) = duplex(64);
1000
1001        let task = tokio::spawn(async move {
1002            svc.serve(BridgeIo(a_proxy, b_proxy)).await.unwrap();
1003        });
1004
1005        // Upstream accepts, then EOFs immediately without ever writing.
1006        drop(b_user);
1007
1008        // The upstream EOF is observed immediately, but half-closing the client
1009        // writer remains pending until shutdown_grace. Advance past the
1010        // first-byte deadline but not the shutdown grace: the bridge must still
1011        // be alive rather than misreporting FirstByteTimeout.
1012        tokio::time::sleep(Duration::from_millis(50)).await;
1013        assert!(
1014            !task.is_finished(),
1015            "first-byte timer fired while graceful shutdown followed a clean upstream EOF",
1016        );
1017
1018        // Client EOFs too -> bridge winds down cleanly.
1019        drop(a_user);
1020        tokio::time::timeout(Duration::from_secs(5), task)
1021            .await
1022            .expect("bridge did not unwind after client EOF")
1023            .unwrap();
1024    }
1025
1026    #[tokio::test(start_paused = true)]
1027    async fn forward_first_byte_survives_client_backpressure() {
1028        let svc = IoForwardService::default()
1029            .with_first_byte_timeout(Duration::from_millis(100))
1030            .with_first_byte_timeout_start(FirstByteTimeoutStart::BridgeOpen)
1031            .with_idle_timeout(Duration::from_millis(200));
1032
1033        // The upstream response is larger than the client-side buffer, so the
1034        // right-to-left write cannot complete. Reading the response from the
1035        // upstream must still disarm the first byte timer.
1036        let (_a_user, a_proxy) = duplex(1);
1037        let (mut b_user, b_proxy) = duplex(64);
1038
1039        let task = tokio::spawn(async move {
1040            svc.serve(BridgeIo(a_proxy, b_proxy)).await.unwrap();
1041        });
1042
1043        let started = tokio::time::Instant::now();
1044        b_user.write_all(b"response").await.unwrap();
1045
1046        tokio::time::timeout(Duration::from_secs(5), task)
1047            .await
1048            .expect("bridge did not unwind")
1049            .unwrap();
1050        assert_eq!(
1051            started.elapsed(),
1052            Duration::from_millis(200),
1053            "upstream response should disarm first-byte timeout even when the client is backpressured",
1054        );
1055    }
1056
1057    #[tokio::test(start_paused = true)]
1058    async fn forward_first_byte_client_start_anchors_on_client_byte() {
1059        // Default `ClientFirstByte` anchor: the window must not start until the
1060        // client has sent, then it counts from that byte. A client that is slow
1061        // to speak is never cut while the origin waits to be asked.
1062        let svc = IoForwardService::default().with_first_byte_timeout(Duration::from_millis(100));
1063
1064        let (mut a_user, a_proxy) = duplex(64);
1065        let (_b_user, b_proxy) = duplex(64);
1066
1067        let task = tokio::spawn(async move {
1068            svc.serve(BridgeIo(a_proxy, b_proxy)).await.unwrap();
1069        });
1070
1071        // Client stays silent well past the window; the upstream is silent too.
1072        // With the anchor at the client's first byte the window has not started,
1073        // so the bridge must survive.
1074        tokio::time::sleep(Duration::from_millis(300)).await;
1075        assert!(
1076            !task.is_finished(),
1077            "first-byte window started before the client sent anything",
1078        );
1079
1080        // Now the client speaks, the window starts from here. The upstream
1081        // stays silent, so it must fire exactly one window later.
1082        let spoke_at = tokio::time::Instant::now();
1083        a_user.write_all(b"hello").await.unwrap();
1084
1085        tokio::time::timeout(Duration::from_secs(5), task)
1086            .await
1087            .expect("silent-upstream bridge did not unwind")
1088            .unwrap();
1089        assert_eq!(
1090            spoke_at.elapsed(),
1091            Duration::from_millis(100),
1092            "first-byte window should be measured from the client's first byte",
1093        );
1094    }
1095
1096    #[tokio::test]
1097    async fn forward_idle_timeout_resets_on_progress() {
1098        let svc = IoForwardService::default().with_idle_timeout(Duration::from_millis(150));
1099
1100        let (mut a_user, a_proxy) = duplex(64);
1101        let (mut b_user, b_proxy) = duplex(64);
1102
1103        let task = tokio::spawn(async move {
1104            svc.serve(BridgeIo(a_proxy, b_proxy)).await.unwrap();
1105        });
1106
1107        // Push a byte every 50ms for ~400ms; idle is 150ms so it should never
1108        // fire even though cumulative time exceeds the idle window.
1109        for _ in 0..8 {
1110            a_user.write_all(b"x").await.unwrap();
1111            let mut buf = [0u8; 1];
1112            b_user.read_exact(&mut buf).await.unwrap();
1113            tokio::time::sleep(Duration::from_millis(50)).await;
1114        }
1115
1116        drop(a_user);
1117        drop(b_user);
1118        tokio::time::timeout(Duration::from_secs(2), task)
1119            .await
1120            .expect("bridge did not unwind on EOF within 2s")
1121            .unwrap();
1122    }
1123
1124    #[tokio::test]
1125    async fn forward_outcome_reports_reason_and_byte_counts() {
1126        let (mut a_user, a_proxy) = duplex(64);
1127        let (mut b_user, b_proxy) = duplex(64);
1128
1129        let task = tokio::spawn(async move { run_default(a_proxy, b_proxy).await });
1130
1131        a_user.write_all(b"abc").await.unwrap();
1132        let mut buf = [0u8; 3];
1133        b_user.read_exact(&mut buf).await.unwrap();
1134        b_user.write_all(b"defgh").await.unwrap();
1135        let mut buf = [0u8; 5];
1136        a_user.read_exact(&mut buf).await.unwrap();
1137
1138        drop(a_user);
1139        drop(b_user);
1140        let outcome = task.await.unwrap();
1141
1142        assert!(
1143            matches!(
1144                outcome.reason(),
1145                BridgeCloseReason::PeerEofLeft | BridgeCloseReason::PeerEofRight
1146            ),
1147            "unexpected reason: {:?}",
1148            outcome.reason(),
1149        );
1150        assert_eq!(outcome.bytes_l_to_r(), 3);
1151        assert_eq!(outcome.bytes_r_to_l(), 5);
1152        assert_eq!(outcome.bytes_total(), 8);
1153        assert!(outcome.fatal_error().is_none());
1154    }
1155
1156    #[tokio::test]
1157    async fn forward_default_executor_means_no_shutdown_observation() {
1158        // Without a graceful executor, the bridge does not observe an
1159        // external shutdown signal and only ends on EOF/error/idle.
1160        let svc = IoForwardService::default();
1161
1162        let (a_user, a_proxy) = duplex(64);
1163        let (b_user, b_proxy) = duplex(64);
1164
1165        let task = tokio::spawn(async move {
1166            svc.serve(BridgeIo(a_proxy, b_proxy)).await.unwrap();
1167        });
1168
1169        tokio::time::sleep(Duration::from_millis(100)).await;
1170        assert!(!task.is_finished(), "bridge ended without an EOF signal");
1171
1172        drop(a_user);
1173        drop(b_user);
1174        tokio::time::timeout(Duration::from_secs(2), task)
1175            .await
1176            .expect("bridge did not unwind on EOF within 2s")
1177            .unwrap();
1178    }
1179
1180    /// `copy_one_way` must call `writer.shutdown()` exactly once
1181    /// regardless of whether the loop exited via clean EOF, a read
1182    /// error, or a write error. Without this the outer `run_bridge`
1183    /// post-loop shutdown would re-enter `shutdown` on a writer that
1184    /// already errored — fine for current TLS impls, fragile against
1185    /// future ones. Pin the contract.
1186    #[tokio::test]
1187    async fn copy_one_way_calls_shutdown_once_on_write_error() {
1188        use std::sync::atomic::AtomicUsize;
1189        use std::task::{Context, Poll};
1190
1191        use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
1192
1193        struct ReadOnce {
1194            done: bool,
1195        }
1196        impl AsyncRead for ReadOnce {
1197            fn poll_read(
1198                mut self: std::pin::Pin<&mut Self>,
1199                _: &mut Context<'_>,
1200                buf: &mut ReadBuf<'_>,
1201            ) -> Poll<std::io::Result<()>> {
1202                if self.done {
1203                    return Poll::Ready(Ok(()));
1204                }
1205                self.done = true;
1206                buf.put_slice(b"hi");
1207                Poll::Ready(Ok(()))
1208            }
1209        }
1210
1211        struct CountingWriter {
1212            shutdown_calls: Arc<AtomicUsize>,
1213            fail_write: bool,
1214        }
1215        impl AsyncWrite for CountingWriter {
1216            fn poll_write(
1217                self: std::pin::Pin<&mut Self>,
1218                _: &mut Context<'_>,
1219                buf: &[u8],
1220            ) -> Poll<std::io::Result<usize>> {
1221                if self.fail_write {
1222                    Poll::Ready(Err(std::io::Error::new(
1223                        std::io::ErrorKind::BrokenPipe,
1224                        "test",
1225                    )))
1226                } else {
1227                    Poll::Ready(Ok(buf.len()))
1228                }
1229            }
1230            fn poll_flush(
1231                self: std::pin::Pin<&mut Self>,
1232                _: &mut Context<'_>,
1233            ) -> Poll<std::io::Result<()>> {
1234                Poll::Ready(Ok(()))
1235            }
1236            fn poll_shutdown(
1237                self: std::pin::Pin<&mut Self>,
1238                _: &mut Context<'_>,
1239            ) -> Poll<std::io::Result<()>> {
1240                self.shutdown_calls.fetch_add(1, Ordering::Relaxed);
1241                Poll::Ready(Ok(()))
1242            }
1243        }
1244
1245        let shutdown_calls = Arc::new(AtomicUsize::new(0));
1246        let mut reader = ReadOnce { done: false };
1247        let mut writer = CountingWriter {
1248            shutdown_calls: shutdown_calls.clone(),
1249            fail_write: true,
1250        };
1251        let bytes = Arc::new(AtomicU64::new(0));
1252        let progress = Arc::new(AtomicU64::new(0));
1253        let write_side_shut = Arc::new(AtomicBool::new(false));
1254        let res = copy_one_way(
1255            &mut reader,
1256            &mut writer,
1257            bytes,
1258            progress,
1259            64,
1260            Duration::from_millis(50),
1261            write_side_shut.clone(),
1262            None,
1263            None,
1264            None,
1265        )
1266        .await;
1267        assert!(res.is_err(), "expected write error to propagate");
1268        assert_eq!(
1269            shutdown_calls.load(Ordering::Relaxed),
1270            1,
1271            "shutdown must be called exactly once even on the write-error path",
1272        );
1273        assert!(
1274            write_side_shut.load(Ordering::Acquire),
1275            "write_side_shut flag must be set so run_bridge skips a duplicate shutdown",
1276        );
1277    }
1278
1279    /// An [`Io`] whose reader either errors once with a configured kind, or
1280    /// pends forever; its writer always accepts. Used to drive the bridge into
1281    /// a specific terminal error reason.
1282    struct ScriptedIo {
1283        read_err: Option<std::io::ErrorKind>,
1284        errored: bool,
1285    }
1286
1287    impl ScriptedIo {
1288        fn erroring(kind: std::io::ErrorKind) -> Self {
1289            Self {
1290                read_err: Some(kind),
1291                errored: false,
1292            }
1293        }
1294
1295        fn pending() -> Self {
1296            Self {
1297                read_err: None,
1298                errored: false,
1299            }
1300        }
1301    }
1302
1303    impl tokio::io::AsyncRead for ScriptedIo {
1304        fn poll_read(
1305            mut self: std::pin::Pin<&mut Self>,
1306            _: &mut std::task::Context<'_>,
1307            _buf: &mut tokio::io::ReadBuf<'_>,
1308        ) -> std::task::Poll<std::io::Result<()>> {
1309            match self.read_err {
1310                Some(kind) if !self.errored => {
1311                    self.errored = true;
1312                    std::task::Poll::Ready(Err(std::io::Error::new(kind, "scripted")))
1313                }
1314                // Never yields data or EOF: keeps this direction open so the
1315                // bridge closes on the other direction's error.
1316                _ => std::task::Poll::Pending,
1317            }
1318        }
1319    }
1320
1321    impl tokio::io::AsyncWrite for ScriptedIo {
1322        fn poll_write(
1323            self: std::pin::Pin<&mut Self>,
1324            _: &mut std::task::Context<'_>,
1325            buf: &[u8],
1326        ) -> std::task::Poll<std::io::Result<usize>> {
1327            std::task::Poll::Ready(Ok(buf.len()))
1328        }
1329
1330        fn poll_flush(
1331            self: std::pin::Pin<&mut Self>,
1332            _: &mut std::task::Context<'_>,
1333        ) -> std::task::Poll<std::io::Result<()>> {
1334            std::task::Poll::Ready(Ok(()))
1335        }
1336
1337        fn poll_shutdown(
1338            self: std::pin::Pin<&mut Self>,
1339            _: &mut std::task::Context<'_>,
1340        ) -> std::task::Poll<std::io::Result<()>> {
1341            std::task::Poll::Ready(Ok(()))
1342        }
1343    }
1344
1345    #[tokio::test]
1346    async fn forward_genuine_error_surfaces_as_err_outcome() {
1347        // `InvalidData` is not a connection error, so it propagates as `Err`.
1348        let left = ScriptedIo::erroring(std::io::ErrorKind::InvalidData);
1349        let right = ScriptedIo::pending();
1350
1351        let svc = IoForwardService::default();
1352        let err = svc
1353            .serve(BridgeIo(left, right))
1354            .await
1355            .expect_err("genuine (non-connection) error must surface as Err");
1356
1357        assert!(err.fatal_error().is_some());
1358        assert!(
1359            matches!(
1360                err.outcome().reason(),
1361                BridgeCloseReason::ReadErrorLeft | BridgeCloseReason::WriteErrorRight
1362            ),
1363            "unexpected reason: {:?}",
1364            err.reason(),
1365        );
1366    }
1367
1368    #[tokio::test]
1369    async fn forward_connection_error_stays_ok_but_is_exposed() {
1370        // `ConnectionReset` is a benign peer disconnect: swallowed to `Ok`, but
1371        // the error and reason are still exposed on the outcome.
1372        let left = ScriptedIo::erroring(std::io::ErrorKind::ConnectionReset);
1373        let right = ScriptedIo::pending();
1374
1375        let svc = IoForwardService::default();
1376        let outcome = svc
1377            .serve(BridgeIo(left, right))
1378            .await
1379            .expect("connection reset must stay Ok");
1380
1381        assert_eq!(outcome.reason(), BridgeCloseReason::ReadErrorLeft);
1382        assert!(outcome.fatal_error().is_some());
1383    }
1384}