Skip to main content

proxima_primitives/pipe/
stream_bridge.rs

1//! `AsStream`/`AsSink` — the two orphan-rule-forced newtypes bridging
2//! proxima's async source/sink vocabulary ([`UnpinPipe`], [`DrainSink`])
3//! onto `futures::Stream`/`futures::Sink`, plus the std-tier `.into_reader()`/
4//! `.into_writer()` lift onto `tokio_util::io::{StreamReader, SinkWriter,
5//! CopyToBytes}` (never a hand-rolled `PipeReader`/`PipeWriter` — that would
6//! reinvent what `tokio-util` already ships, violating P1/RISC).
7//!
8//! ## Scoping: `UnpinPipe`/`DrainSink` only, never arbitrary `Pipe::call`
9//!
10//! These bridges attach to the exhaustible source/sink vocabulary
11//! ([`UnpinPipe::call`] with `Err = Exhausted`, the sink-shaped
12//! [`DrainSink::accept`]), not to arbitrary `Pipe<In, Out>::call`. A
13//! request/response `Pipe` link is not a stream anyway: per
14//! `docs/pipe-to-metal/edges.md`'s reshape ruling, "the stream character lives
15//! in the driver loop, not inside `Pipe`." There is no `into_stream` on
16//! arbitrary `Pipe` here, by design — only on the exhaustible `UnpinPipe`
17//! source shape and `DrainSink`.
18//!
19//! ## Why the newtype, not a blanket impl
20//!
21//! `futures::Stream`/`futures::Sink` are foreign traits; `UnpinPipe`/
22//! `DrainSink` are ours, but a bare `impl<S: UnpinPipe<..>> Stream for S` is
23//! rejected by the orphan rule (E0210 — no local type in the impl, `S` is an
24//! unconstrained parameter). `AsStream<S>`/`AsSink<S>` are the minimal local
25//! newtype that satisfies the rule; both are pure forwarding wrappers, adding
26//! no buffering, no state, no behavior of their own.
27//!
28//! ## Busy-poll caveat (shared with every T0 source/sink in this module)
29//!
30//! `UnpinPipe`/`DrainSink` implementors at the T0 floor (`FanIn`, `RingSink`)
31//! register no [`core::task::Waker`] when they have nothing ready — the
32//! `docs/pipe-to-metal/edges.md` ruling names this explicitly: "true poll-mode
33//! ... can't be wake-driven, only busy-polled." `AsStream`/`AsSink` forward
34//! that characteristic unchanged: a `Poll::Pending` here is not a broken
35//! contract, it is the inherited caller-re-drives model of the wrapped
36//! source/sink.
37
38use core::future::Future;
39use core::ops::ControlFlow;
40use core::pin::Pin;
41use core::task::{Context, Poll};
42
43use bytes::Bytes;
44
45use crate::pipe::drain_sink::DrainSink;
46use crate::pipe::fan_in::Exhausted;
47use crate::pipe::primitives::UnpinPipe;
48
49/// Forwards an [`UnpinPipe`] source's `call` straight through as
50/// `futures::Stream` — the orphan-forced newtype (see module doc). `Ok(item)`
51/// becomes `Some(item)`; [`Exhausted`] becomes `None`. No `Unpin` bound is
52/// needed on `S`: `Pin<&mut Self>` derefs to `&Self` regardless of `S`'s own
53/// `Unpin`-ness, and `UnpinPipe::call` only ever needs `&self`.
54pub struct AsStream<S>(S);
55
56impl<S> AsStream<S> {
57    /// Recover the wrapped source.
58    #[must_use]
59    pub fn into_inner(self) -> S {
60        self.0
61    }
62}
63
64impl<S: UnpinPipe<In = (), Err = Exhausted>> futures::Stream for AsStream<S> {
65    type Item = S::Out;
66
67    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
68        let mut call = self.0.call(());
69        match Pin::new(&mut call).poll(cx) {
70            Poll::Ready(Ok(item)) => Poll::Ready(Some(item)),
71            Poll::Ready(Err(Exhausted)) => Poll::Ready(None),
72            Poll::Pending => Poll::Pending,
73        }
74    }
75}
76
77/// `.into_stream()`/`.as_stream()` — lifts any exhaustible [`UnpinPipe`]
78/// source onto `futures::Stream` by composing [`AsStream`] (no new behavior,
79/// just the orphan-forced wrapper).
80pub trait PollSourceExt: UnpinPipe<In = (), Err = Exhausted> + Sized {
81    /// Owns `self`, wrapping it as a `futures::Stream`.
82    fn into_stream(self) -> AsStream<Self> {
83        AsStream(self)
84    }
85
86    /// Borrows `self` MUTABLY as a `futures::Stream`, so the source can be
87    /// reused once the stream handle is dropped — the same shape as
88    /// `Iterator::by_ref`. `&mut Self` composes via the [`UnpinPipe`]
89    /// blanket impl for `&mut S` below, with no wrapper reinvention.
90    fn as_stream(&mut self) -> AsStream<&mut Self> {
91        AsStream(self)
92    }
93}
94
95impl<S: UnpinPipe<In = (), Err = Exhausted>> PollSourceExt for S {}
96
97// forwarding blanket impl: `&mut S` re-calls the same underlying source, so
98// `as_stream`'s `AsStream<&mut Self>` is a genuine `UnpinPipe`, not a stub.
99impl<S: UnpinPipe<In = (), Err = Exhausted> + ?Sized> UnpinPipe for &mut S {
100    type In = ();
101    type Out = S::Out;
102    type Err = Exhausted;
103
104    fn call(&self, (): ()) -> impl Future<Output = Result<S::Out, Exhausted>> + Unpin {
105        (**self).call(())
106    }
107}
108
109/// The one real error [`AsSink`]'s `futures::Sink::start_send` can report: [`DrainSink::accept`]
110/// only signals backpressure via `ControlFlow::Break` (no richer cause), and
111/// `poll_ready` is expected to prevent this in the well-behaved case.
112#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
113pub enum AsSinkError {
114    /// The sink had no room for the item.
115    #[error("sink rejected the item: no capacity")]
116    Rejected,
117}
118
119// `std::io::Error` has no generic `From<E>` blanket (only the `E: Into<Box<dyn
120// Error+Send+Sync>>`-bounded `Error::other` associated fn), so `SinkWriter`'s
121// `E: Into<io::Error>` bound needs this explicit bridge — `io-bridge`-gated
122// since it exists only to satisfy that std-tier composition.
123#[cfg(feature = "io-bridge")]
124impl From<AsSinkError> for std::io::Error {
125    fn from(error: AsSinkError) -> Self {
126        std::io::Error::other(error)
127    }
128}
129
130/// Forwards [`DrainSink::accept`] straight through as `futures::Sink<Bytes>` —
131/// the sink-side orphan-forced newtype (see module doc), box-free, no extra
132/// buffering.
133pub struct AsSink<S>(S);
134
135impl<S> AsSink<S> {
136    /// Recover the wrapped sink.
137    #[must_use]
138    pub fn into_inner(self) -> S {
139        self.0
140    }
141}
142
143impl<S: DrainSink<Item = [u8]> + Unpin> futures::Sink<Bytes> for AsSink<S> {
144    type Error = AsSinkError;
145
146    fn poll_ready(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
147        // busy-poll model (see module doc): a full sink reports `Pending`
148        // without arming a wake; the caller re-drives.
149        if self.get_mut().0.has_capacity() {
150            Poll::Ready(Ok(()))
151        } else {
152            Poll::Pending
153        }
154    }
155
156    fn start_send(self: Pin<&mut Self>, item: Bytes) -> Result<(), Self::Error> {
157        match self.get_mut().0.accept(&item) {
158            ControlFlow::Continue(()) => Ok(()),
159            ControlFlow::Break(()) => Err(AsSinkError::Rejected),
160        }
161    }
162
163    fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
164        // a `DrainSink` write already lands in its backing storage (the ring
165        // slot); there is no separate flush stage to drive.
166        Poll::Ready(Ok(()))
167    }
168
169    fn poll_close(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
170        Poll::Ready(Ok(()))
171    }
172}
173
174/// `.into_sink()`/`.as_sink()` — lifts any `[u8]`-shaped [`DrainSink`] onto
175/// `futures::Sink<Bytes>` by composing [`AsSink`].
176pub trait DrainSinkExt: DrainSink<Item = [u8]> + Sized {
177    /// Owns `self`, wrapping it as a `futures::Sink<Bytes>`.
178    fn into_sink(self) -> AsSink<Self> {
179        AsSink(self)
180    }
181
182    /// Borrows `self` MUTABLY as a `futures::Sink<Bytes>` (mirrors
183    /// [`PollSourceExt::as_stream`] — `accept` is `&mut self`, so `&self`
184    /// cannot forward it).
185    fn as_sink(&mut self) -> AsSink<&mut Self> {
186        AsSink(self)
187    }
188}
189
190impl<S: DrainSink<Item = [u8]>> DrainSinkExt for S {}
191
192impl<S: DrainSink + ?Sized> DrainSink for &mut S {
193    type Item = S::Item;
194
195    fn accept(&mut self, item: &Self::Item) -> ControlFlow<()> {
196        (**self).accept(item)
197    }
198
199    fn has_capacity(&self) -> bool {
200        (**self).has_capacity()
201    }
202}
203
204/// std-tier `.into_reader()`/`.into_writer()`: lifts [`AsStream`]/[`AsSink`]
205/// onto `futures::io::{AsyncRead, AsyncWrite}` — the workspace's canonical
206/// std-tier byte-stream trait (see `proxima_core::io`'s module doc for the
207/// full tier rule: `prime::os::net::TcpStream` implements it, and every real
208/// std transport in this workspace binds it). The composition itself reuses
209/// `tokio-util`'s own `StreamReader`/`SinkWriter`/`CopyToBytes` (P1 — no
210/// hand-rolled `PipeReader`/`PipeWriter`), then crosses from tokio's native
211/// `AsyncRead`/`AsyncWrite` to the canonical `futures::io` ones via
212/// `tokio_util::compat` — the SAME bridge idiom
213/// `proxima-http/src/listener/mod.rs`'s `socket.compat()` already uses at the
214/// tokio-accept boundary, not a new adapter.
215#[cfg(feature = "io-bridge")]
216mod reader_writer {
217    use bytes::Bytes;
218    use tokio_util::compat::{Compat, TokioAsyncReadCompatExt as _, TokioAsyncWriteCompatExt as _};
219    use tokio_util::io::{CopyToBytes, SinkWriter, StreamReader};
220
221    use super::{AsSink, AsStream, DrainSinkExt as _, Exhausted, PollSourceExt as _};
222    use crate::pipe::drain_sink::DrainSink;
223    use crate::pipe::primitives::UnpinPipe;
224
225    /// `.into_reader()`/`.as_reader()` — wraps `tokio_util::io::StreamReader`
226    /// over [`AsStream`], then `.compat()`s it onto `futures::io::AsyncRead`
227    /// (the canonical std trait). `StreamReader` needs a fallible byte-chunk
228    /// stream (`Item = Result<Bytes, Error>`), so only an [`UnpinPipe`] source
229    /// shaped that way gets a reader; `Error` must convert into
230    /// `std::io::Error` because `StreamReader`'s own `AsyncRead` impl is
231    /// std::io::Error-shaped.
232    pub trait IntoReader<Error>:
233        UnpinPipe<In = (), Out = Result<Bytes, Error>, Err = Exhausted> + Unpin + Sized
234    where
235        Error: Into<std::io::Error>,
236    {
237        /// Owns `self`; composes `StreamReader::new` over
238        /// [`Self::into_stream`](super::PollSourceExt::into_stream), then `.compat()`
239        /// onto `futures::io::AsyncRead`.
240        fn into_reader(self) -> Compat<StreamReader<AsStream<Self>, Bytes>> {
241            StreamReader::new(self.into_stream()).compat()
242        }
243
244        /// Borrows `self` mutably; same composition over
245        /// [`Self::as_stream`](super::PollSourceExt::as_stream).
246        fn as_reader(&mut self) -> Compat<StreamReader<AsStream<&mut Self>, Bytes>> {
247            StreamReader::new(self.as_stream()).compat()
248        }
249    }
250
251    impl<S, Error> IntoReader<Error> for S
252    where
253        S: UnpinPipe<In = (), Out = Result<Bytes, Error>, Err = Exhausted> + Unpin,
254        Error: Into<std::io::Error>,
255    {
256    }
257
258    /// `.into_writer()`/`.as_writer()` — wraps `tokio_util::io::SinkWriter`
259    /// over `CopyToBytes` over [`AsSink`], then `.compat_write()`s it onto
260    /// `futures::io::AsyncWrite` (the canonical std trait): `SinkWriter`
261    /// writes a `Sink<&[u8]>`; `CopyToBytes` is `tokio-util`'s own
262    /// `Sink<Bytes> -> Sink<&[u8]>` adapter (its docs name this exact
263    /// composition), so no new adapter is authored here, only wired.
264    pub trait IntoWriter: DrainSink<Item = [u8]> + Unpin + Sized {
265        /// Owns `self`; composes `SinkWriter::new(CopyToBytes::new(..))` over
266        /// [`Self::into_sink`](super::DrainSinkExt::into_sink), then `.compat_write()`
267        /// onto `futures::io::AsyncWrite`.
268        fn into_writer(self) -> Compat<SinkWriter<CopyToBytes<AsSink<Self>>>> {
269            SinkWriter::new(CopyToBytes::new(self.into_sink())).compat_write()
270        }
271
272        /// Borrows `self` mutably; same composition over
273        /// [`Self::as_sink`](super::DrainSinkExt::as_sink).
274        fn as_writer(&mut self) -> Compat<SinkWriter<CopyToBytes<AsSink<&mut Self>>>> {
275            SinkWriter::new(CopyToBytes::new(self.as_sink())).compat_write()
276        }
277    }
278
279    impl<S: DrainSink<Item = [u8]> + Unpin> IntoWriter for S {}
280}
281
282#[cfg(feature = "io-bridge")]
283pub use reader_writer::{IntoReader, IntoWriter};
284
285#[cfg(test)]
286mod tests {
287    #![allow(clippy::unwrap_used, clippy::expect_used)]
288
289    use core::cell::Cell;
290    use core::task::Waker;
291
292    use futures::{Sink, Stream};
293
294    use super::*;
295    use crate::pipe::drain_sink::RingSink;
296
297    // a captured-shaped HTTP/1 request, split across three poll_next calls —
298    // real bytes, not `b"AAAA"`, matching P9.
299    const REQUEST_LINE: &[u8] = b"GET /orders?id=42 HTTP/1.1\r\n";
300    const HOST_HEADER: &[u8] = b"Host: api.example.internal\r\n";
301    const TAIL: &[u8] = b"Accept: application/json\r\n\r\n";
302
303    struct ThreeChunkSource {
304        chunks: [&'static [u8]; 3],
305        next: Cell<usize>,
306    }
307
308    impl UnpinPipe for ThreeChunkSource {
309        type In = ();
310        type Out = Result<Bytes, std::io::Error>;
311        type Err = Exhausted;
312
313        fn call(&self, (): ()) -> impl Future<Output = Result<Self::Out, Exhausted>> + Unpin {
314            let index = self.next.get();
315            if index >= self.chunks.len() {
316                return core::future::ready(Err(Exhausted));
317            }
318            let chunk = self.chunks[index];
319            self.next.set(index + 1);
320            core::future::ready(Ok(Ok(Bytes::from_static(chunk))))
321        }
322    }
323
324    fn noop_context() -> Context<'static> {
325        Context::from_waker(Waker::noop())
326    }
327
328    #[test]
329    fn as_stream_forwards_poll_next_in_order() {
330        let source = ThreeChunkSource {
331            chunks: [REQUEST_LINE, HOST_HEADER, TAIL],
332            next: Cell::new(0),
333        };
334        let mut stream = source.into_stream();
335        let mut cx = noop_context();
336
337        let first = Pin::new(&mut stream).poll_next(&mut cx);
338        let Poll::Ready(Some(Ok(chunk))) = first else {
339            panic!("expected the first chunk, got {first:?}");
340        };
341        assert_eq!(&chunk[..], REQUEST_LINE);
342
343        let second = Pin::new(&mut stream).poll_next(&mut cx);
344        let Poll::Ready(Some(Ok(chunk))) = second else {
345            panic!("expected the second chunk, got {second:?}");
346        };
347        assert_eq!(&chunk[..], HOST_HEADER);
348    }
349
350    #[test]
351    fn as_stream_drains_to_none_when_source_is_exhausted() {
352        let source = ThreeChunkSource {
353            chunks: [REQUEST_LINE, HOST_HEADER, TAIL],
354            next: Cell::new(3),
355        };
356        let mut stream = source.into_stream();
357        let mut cx = noop_context();
358        let Poll::Ready(None) = Pin::new(&mut stream).poll_next(&mut cx) else {
359            panic!("expected the source to report exhausted");
360        };
361    }
362
363    #[test]
364    fn as_sink_round_trips_real_bytes_through_a_ring() {
365        let mut sink: AsSink<RingSink<4, 64>> = RingSink::new().into_sink();
366        let mut cx = noop_context();
367
368        assert!(matches!(
369            Pin::new(&mut sink).poll_ready(&mut cx),
370            Poll::Ready(Ok(()))
371        ));
372        Pin::new(&mut sink)
373            .start_send(Bytes::from_static(REQUEST_LINE))
374            .expect("ring has room for one frame");
375        Pin::new(&mut sink)
376            .start_send(Bytes::from_static(HOST_HEADER))
377            .expect("ring has room for a second frame");
378
379        let mut ring = sink.into_inner();
380        assert_eq!(ring.pop(), Some(REQUEST_LINE));
381        assert_eq!(ring.pop(), Some(HOST_HEADER));
382    }
383
384    #[test]
385    fn as_sink_reports_rejected_when_frame_too_large_for_the_ring() {
386        let mut sink: AsSink<RingSink<1, 4>> = RingSink::new().into_sink();
387        let error = Pin::new(&mut sink)
388            .start_send(Bytes::from_static(b"toolong"))
389            .expect_err("frame exceeds the 4-byte slot capacity");
390        assert_eq!(error, AsSinkError::Rejected);
391    }
392
393    #[test]
394    fn as_stream_as_stream_borrow_leaves_the_source_usable_after() {
395        let mut source = ThreeChunkSource {
396            chunks: [REQUEST_LINE, HOST_HEADER, TAIL],
397            next: Cell::new(0),
398        };
399        let mut cx = noop_context();
400        {
401            let mut borrowed = source.as_stream();
402            let first = Pin::new(&mut borrowed).poll_next(&mut cx);
403            assert!(first.is_ready());
404        }
405        // the borrow was dropped; `source` itself still owns its cursor state
406        // and can be polled directly (or wrapped again) afterward.
407        assert_eq!(source.next.get(), 1);
408    }
409}
410
411#[cfg(all(test, feature = "io-bridge"))]
412mod io_bridge_tests {
413    #![allow(clippy::unwrap_used, clippy::expect_used)]
414
415    use bytes::Bytes;
416    use core::cell::Cell;
417    use core::future::Future;
418    use futures::io::{AsyncReadExt, AsyncWriteExt};
419
420    use super::{Exhausted, IntoReader as _, IntoWriter as _};
421    use crate::pipe::drain_sink::RingSink;
422    use crate::pipe::primitives::UnpinPipe;
423
424    // the same captured-shaped HTTP/1 request as the `AsStream`/`AsSink`
425    // tests above, split across three chunks — real bytes, P9.
426    const REQUEST_LINE: &[u8] = b"GET /orders?id=42 HTTP/1.1\r\n";
427    const HOST_HEADER: &[u8] = b"Host: api.example.internal\r\n";
428    const TAIL: &[u8] = b"Accept: application/json\r\n\r\n";
429
430    struct ChunkedRequestSource {
431        chunks: [&'static [u8]; 3],
432        next: Cell<usize>,
433    }
434
435    impl UnpinPipe for ChunkedRequestSource {
436        type In = ();
437        type Out = Result<Bytes, std::io::Error>;
438        type Err = Exhausted;
439
440        fn call(&self, (): ()) -> impl Future<Output = Result<Self::Out, Exhausted>> + Unpin {
441            let index = self.next.get();
442            if index >= self.chunks.len() {
443                return core::future::ready(Err(Exhausted));
444            }
445            let chunk = self.chunks[index];
446            self.next.set(index + 1);
447            core::future::ready(Ok(Ok(Bytes::from_static(chunk))))
448        }
449    }
450
451    #[test]
452    fn into_reader_round_trips_a_captured_http_request() {
453        let source = ChunkedRequestSource {
454            chunks: [REQUEST_LINE, HOST_HEADER, TAIL],
455            next: Cell::new(0),
456        };
457        let mut reader = source.into_reader();
458
459        let mut collected = Vec::new();
460        futures::executor::block_on(async {
461            reader
462                .read_to_end(&mut collected)
463                .await
464                .expect("an in-memory UnpinPipe stream never errors")
465        });
466
467        let mut expected = Vec::new();
468        expected.extend_from_slice(REQUEST_LINE);
469        expected.extend_from_slice(HOST_HEADER);
470        expected.extend_from_slice(TAIL);
471        assert_eq!(collected, expected, "reader output equals the fed bytes");
472    }
473
474    #[test]
475    fn into_writer_round_trips_bytes_into_a_ring_sink() {
476        let mut writer = RingSink::<4, 64>::new().into_writer();
477
478        futures::executor::block_on(async {
479            writer
480                .write_all(REQUEST_LINE)
481                .await
482                .expect("ring has room for the request line");
483            writer
484                .write_all(HOST_HEADER)
485                .await
486                .expect("ring has room for the host header");
487            writer
488                .close()
489                .await
490                .expect("close is a no-op success for a RingSink");
491        });
492
493        let mut ring = writer.into_inner().into_inner().into_inner().into_inner();
494        assert_eq!(ring.pop(), Some(REQUEST_LINE), "first frame written");
495        assert_eq!(ring.pop(), Some(HOST_HEADER), "second frame written");
496    }
497}