Skip to main content

proxima_primitives/pipe/
body.rs

1//! Spine payload types after the `Body` collapse (yank-body).
2//!
3//! The `Pipe` spine carries raw `Bytes` for buffered bodies. Two
4//! sibling concerns ride beside the bytes, each its own type so the
5//! buffered 80% case pays nothing for their existence:
6//!
7//! - [`Carry`] — an erased `Arc<dyn Any + Send + Sync>` for in-process
8//!   typed payloads (telemetry records) that must NOT serialize to
9//!   bytes. Clone is one atomic Arc bump — the zero-copy fan-out.
10//! - [`ResponseStream`] / [`RequestStream`] — explicit streaming bodies
11//!   built on a `Send`-bounded [`ChunkStream`]. Streaming is a separate
12//!   contract, not a body variant; the `+ Send` decision is contained
13//!   here in [`ChunkStream`], not smeared across every buffered RPC.
14//!
15//! Response trailers (RFC 7230 §4.1.2) are stream-completion metadata
16//! and live on [`ResponseStream`] behind `std`. Cancellation is
17//! per-request and lives on `RequestContext.cancel`, not on the body.
18
19#![cfg(feature = "alloc")]
20
21use core::any::Any;
22use core::pin::Pin;
23
24use alloc::boxed::Box;
25use alloc::vec::Vec;
26
27use bytes::Bytes;
28use futures::stream::Stream;
29use futures::stream::StreamExt;
30
31#[cfg(feature = "std")]
32use alloc::sync::Arc;
33#[cfg(feature = "std")]
34use proxima_core::signal::Signal;
35
36#[cfg(feature = "std")]
37use crate::sync::blocking::Mutex;
38
39#[cfg(not(feature = "std"))]
40use alloc::sync::Arc;
41
42#[cfg(feature = "std")]
43use crate::pipe::header_list::HeaderList;
44use proxima_core::ProximaError;
45
46/// A `Send`-bounded stream of body chunks. The single named home of the
47/// streaming `+ Send` decision (yank-body): a streamed body yields owned
48/// `Bytes` chunks, so producer-side `!Send` state stays behind whatever
49/// channel feeds this. Reused by [`ResponseStream`] and [`RequestStream`].
50pub type ChunkStream = Pin<Box<dyn Stream<Item = Result<Bytes, ProximaError>> + Send>>;
51
52/// Trailers slot — published by a chunked producer at stream end.
53/// std-only: needs [`crate::sync::blocking::Mutex`]. Lives on
54/// [`ResponseStream`], never on the buffered spine (trailers are meaningless
55/// for a complete `Bytes` body).
56#[cfg(feature = "std")]
57pub type TrailersSlot = Arc<Mutex<Option<HeaderList>>>;
58
59/// In-process typed payload — an erased `Arc<dyn Any + Send + Sync>`.
60/// Telemetry records (`SpanRecord`, `LogRecord`, …) ride here without
61/// serializing to `Bytes`; the in-process Pipe chain downcasts them via
62/// [`Carry::downcast_ref`]. Clone is one atomic Arc increment — the
63/// zero-copy fan-out the drainer depends on.
64#[derive(Clone)]
65pub struct Carry(Arc<dyn Any + Send + Sync>);
66
67impl Carry {
68    /// Wrap a value, allocating one `Arc`. Producer side.
69    #[must_use]
70    pub fn new<T>(value: T) -> Self
71    where
72        T: Any + Send + Sync + 'static,
73    {
74        Self(Arc::new(value))
75    }
76
77    /// Wrap an already-shared `Arc`. Zero allocation beyond the
78    /// `Arc<T> -> Arc<dyn Any>` coercion (a move, not a clone) — used
79    /// when the caller already shares the Arc with other consumers
80    /// (the drainer emitting one batch into multiple Pipe chains).
81    #[must_use]
82    pub fn from_arc<T>(value: Arc<T>) -> Self
83    where
84        T: Any + Send + Sync + 'static,
85    {
86        Self(value)
87    }
88
89    /// Borrow the payload if it is of type `T`. Cost is one `TypeId`
90    /// equality check; no clone, no refcount bump.
91    #[must_use]
92    pub fn downcast_ref<T>(&self) -> Option<&T>
93    where
94        T: Any + Send + Sync + 'static,
95    {
96        self.0.downcast_ref::<T>()
97    }
98}
99
100impl core::fmt::Debug for Carry {
101    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
102        formatter.debug_struct("Carry").finish_non_exhaustive()
103    }
104}
105
106/// A streamed response body: chunks plus optional trailers. The
107/// explicit streaming contract that replaced `Body::Stream`. Ride it on
108/// `Response.stream`; the buffered case leaves that field `None`.
109pub struct ResponseStream {
110    stream: ChunkStream,
111    /// Trailers published at stream end. std-only.
112    #[cfg(feature = "std")]
113    trailers: Option<TrailersSlot>,
114}
115
116impl ResponseStream {
117    /// Build from any `Send` chunk stream.
118    #[must_use]
119    pub fn new<S>(stream: S) -> Self
120    where
121        S: Stream<Item = Result<Bytes, ProximaError>> + Send + 'static,
122    {
123        Self {
124            stream: Box::pin(stream),
125            #[cfg(feature = "std")]
126            trailers: None,
127        }
128    }
129
130    /// Build from an already-boxed [`ChunkStream`].
131    #[must_use]
132    pub fn from_chunk_stream(stream: ChunkStream) -> Self {
133        Self {
134            stream,
135            #[cfg(feature = "std")]
136            trailers: None,
137        }
138    }
139
140    /// One-chunk stream wrapping a complete `Bytes` — the buffered ->
141    /// streaming bridge.
142    #[must_use]
143    pub fn once(bytes: Bytes) -> Self {
144        Self::new(futures::stream::once(async move { Ok(bytes) }))
145    }
146
147    /// Attach a trailers slot a chunked producer will populate at end.
148    #[cfg(feature = "std")]
149    #[must_use]
150    pub fn with_trailers_slot(mut self, slot: TrailersSlot) -> Self {
151        self.trailers = Some(slot);
152        self
153    }
154
155    /// Attach trailers known up front.
156    #[cfg(feature = "std")]
157    #[must_use]
158    pub fn with_trailers(mut self, trailers: HeaderList) -> Self {
159        self.trailers = Some(Arc::new(Mutex::new(Some(trailers))));
160        self
161    }
162
163    /// Read captured trailers, if any. `None` until a stream-end
164    /// producer has populated the slot.
165    #[cfg(feature = "std")]
166    #[must_use]
167    pub fn trailers(&self) -> Option<HeaderList> {
168        self.trailers.as_ref()?.lock().clone()
169    }
170
171    /// Borrow the trailers slot — handed to a producer (e.g. the H1
172    /// chunked-decoder pump) before the stream completes.
173    #[cfg(feature = "std")]
174    #[must_use]
175    pub fn trailers_slot(&self) -> Option<&TrailersSlot> {
176        self.trailers.as_ref()
177    }
178
179    /// Take the underlying chunk stream (drops trailers metadata).
180    pub fn into_chunk_stream(self) -> ChunkStream {
181        self.stream
182    }
183
184    /// Borrow the chunk stream mutably (listener pump path).
185    #[must_use]
186    pub fn chunk_stream_mut(&mut self) -> &mut ChunkStream {
187        &mut self.stream
188    }
189
190    /// Consume the body stream without materializing any `Bytes` — read and
191    /// discard all chunks until the stream ends. The framing decoder still
192    /// runs (the caller must exhaust the body to reach the keep-alive
193    /// message boundary); only the per-chunk allocation and copy are skipped.
194    pub async fn drain(self) -> Result<(), ProximaError> {
195        let mut stream = self.stream;
196        while let Some(item) = stream.next().await {
197            item?;
198        }
199        Ok(())
200    }
201
202    /// Drain the stream to a single `Bytes`. Under std, races the
203    /// supplied request-scoped cancel token; the first to resolve wins.
204    pub async fn collect(
205        self,
206        #[cfg(feature = "std")] cancel: Option<&Signal>,
207    ) -> Result<Bytes, ProximaError> {
208        let mut stream = self.stream;
209        let mut chunks: Vec<Bytes> = Vec::new();
210        let mut total = 0usize;
211        loop {
212            #[cfg(feature = "std")]
213            let next = match cancel {
214                // cancel polls first (select is left-biased); the sticky
215                // level makes a fire that raced registration visible here
216                Some(token) => match futures::future::select(token.fired(), stream.next()).await {
217                    futures::future::Either::Left(((), _)) => {
218                        return Err(ProximaError::Body("body cancelled".into()));
219                    }
220                    futures::future::Either::Right((item, _)) => item,
221                },
222                None => stream.next().await,
223            };
224            #[cfg(not(feature = "std"))]
225            let next = stream.next().await;
226            match next {
227                Some(item) => {
228                    let chunk = item?;
229                    total += chunk.len();
230                    chunks.push(chunk);
231                }
232                None => break,
233            }
234        }
235        if chunks.len() == 1 {
236            return Ok(chunks.remove(0));
237        }
238        let mut buffer = bytes::BytesMut::with_capacity(total);
239        for chunk in chunks {
240            buffer.extend_from_slice(&chunk);
241        }
242        Ok(buffer.freeze())
243    }
244}
245
246impl core::fmt::Debug for ResponseStream {
247    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
248        formatter
249            .debug_struct("ResponseStream")
250            .finish_non_exhaustive()
251    }
252}
253
254/// A streamed request body (uploads, WebSocket-inbound relay). Symmetric
255/// to [`ResponseStream`]: carries an optional trailers slot a chunked
256/// decoder populates at body-end (HTTP/1.1 request trailers, RFC 7230
257/// §4.1.2). [`Request::body_bytes`](crate::pipe::request::Request::body_bytes) folds those into `Request.headers`
258/// after draining — "request trailers fold into headers at chunked-decode
259/// end".
260pub struct RequestStream {
261    stream: ChunkStream,
262    /// Trailers published at stream end. std-only.
263    #[cfg(feature = "std")]
264    trailers: Option<TrailersSlot>,
265}
266
267impl RequestStream {
268    #[must_use]
269    pub fn new<S>(stream: S) -> Self
270    where
271        S: Stream<Item = Result<Bytes, ProximaError>> + Send + 'static,
272    {
273        Self {
274            stream: Box::pin(stream),
275            #[cfg(feature = "std")]
276            trailers: None,
277        }
278    }
279
280    #[must_use]
281    pub fn from_chunk_stream(stream: ChunkStream) -> Self {
282        Self {
283            stream,
284            #[cfg(feature = "std")]
285            trailers: None,
286        }
287    }
288
289    /// Attach a trailers slot a chunked decoder populates at body-end.
290    #[cfg(feature = "std")]
291    #[must_use]
292    pub fn with_trailers_slot(mut self, slot: TrailersSlot) -> Self {
293        self.trailers = Some(slot);
294        self
295    }
296
297    /// Read captured trailers, if any. `None` until a stream-end producer
298    /// populates the slot.
299    #[cfg(feature = "std")]
300    #[must_use]
301    pub fn trailers(&self) -> Option<HeaderList> {
302        self.trailers.as_ref()?.lock().clone()
303    }
304
305    /// Borrow the trailers slot — handed to a producer (the chunked
306    /// decoder pump) before the stream completes.
307    #[cfg(feature = "std")]
308    #[must_use]
309    pub fn trailers_slot(&self) -> Option<&TrailersSlot> {
310        self.trailers.as_ref()
311    }
312
313    pub fn into_chunk_stream(self) -> ChunkStream {
314        self.stream
315    }
316
317    #[must_use]
318    pub fn chunk_stream_mut(&mut self) -> &mut ChunkStream {
319        &mut self.stream
320    }
321
322    /// Drain to a single `Bytes`.
323    pub async fn collect(self) -> Result<Bytes, ProximaError> {
324        let mut stream = self.stream;
325        let mut chunks: Vec<Bytes> = Vec::new();
326        let mut total = 0usize;
327        while let Some(item) = stream.next().await {
328            let chunk = item?;
329            total += chunk.len();
330            chunks.push(chunk);
331        }
332        if chunks.len() == 1 {
333            return Ok(chunks.remove(0));
334        }
335        let mut buffer = bytes::BytesMut::with_capacity(total);
336        for chunk in chunks {
337            buffer.extend_from_slice(&chunk);
338        }
339        Ok(buffer.freeze())
340    }
341}
342
343impl core::fmt::Debug for RequestStream {
344    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
345        formatter
346            .debug_struct("RequestStream")
347            .finish_non_exhaustive()
348    }
349}
350
351// `#[proxima::test]` and inline `tokio::spawn`/`tokio::time` pull in the
352// `proxima` / `tokio` dev-dependencies, which the loom build keeps out of
353// the graph (see `[target.'cfg(not(loom))'.dev-dependencies]` in
354// Cargo.toml); these tests are unrelated to the Notify/watch loom protocol.
355#[cfg(all(test, not(loom)))]
356#[allow(clippy::unwrap_used, clippy::expect_used)]
357mod tests {
358    use super::*;
359
360    #[derive(Debug, PartialEq, Eq)]
361    struct DummyRecord {
362        name: alloc::string::String,
363        count: u32,
364    }
365
366    #[test]
367    fn carry_new_then_downcast_returns_value() {
368        let carry = Carry::new(DummyRecord {
369            name: "carried".into(),
370            count: 3,
371        });
372        let view = carry
373            .downcast_ref::<DummyRecord>()
374            .expect("downcast succeeds");
375        assert_eq!(view.name, "carried");
376        assert_eq!(view.count, 3);
377    }
378
379    #[test]
380    fn carry_wrong_type_downcast_returns_none() {
381        let carry = Carry::new(DummyRecord {
382            name: "x".into(),
383            count: 1,
384        });
385        assert!(carry.downcast_ref::<u64>().is_none());
386    }
387
388    #[test]
389    fn carry_from_arc_shares_one_allocation() {
390        let shared = Arc::new(DummyRecord {
391            name: "shared".into(),
392            count: 9,
393        });
394        let strong_before = Arc::strong_count(&shared);
395        let carry = Carry::from_arc(Arc::clone(&shared));
396        assert_eq!(Arc::strong_count(&shared), strong_before + 1);
397        assert_eq!(
398            carry
399                .downcast_ref::<DummyRecord>()
400                .expect("downcast succeeds")
401                .count,
402            9
403        );
404    }
405
406    #[test]
407    fn carry_clone_is_one_arc_bump_not_a_record_copy() {
408        // the fan-out contract: cloning a Carry (Tee/Diff branching) is a
409        // single atomic increment of the SAME allocation, never a per-record
410        // duplication. Proven by strong_count tracking one shared backing Arc.
411        let shared = Arc::new(DummyRecord {
412            name: "fanout".into(),
413            count: 5,
414        });
415        let strong_before = Arc::strong_count(&shared);
416        let carry = Carry::from_arc(Arc::clone(&shared));
417        assert_eq!(Arc::strong_count(&shared), strong_before + 1);
418        let branch_a = carry.clone();
419        let branch_b = carry.clone();
420        assert_eq!(Arc::strong_count(&shared), strong_before + 3);
421        assert_eq!(
422            branch_a.downcast_ref::<DummyRecord>().expect("a").count,
423            branch_b.downcast_ref::<DummyRecord>().expect("b").count
424        );
425    }
426
427    #[proxima::test]
428    async fn response_stream_once_collects_to_original_bytes() {
429        let stream = ResponseStream::once(Bytes::from_static(b"hello"));
430        let collected = stream
431            .collect(
432                #[cfg(feature = "std")]
433                None,
434            )
435            .await
436            .expect("collect succeeds");
437        assert_eq!(&collected[..], b"hello");
438    }
439
440    #[proxima::test]
441    async fn response_stream_multi_chunk_concatenates_in_order() {
442        let stream = ResponseStream::new(futures::stream::iter([
443            Ok(Bytes::from_static(b"hel")),
444            Ok(Bytes::from_static(b"lo")),
445        ]));
446        let collected = stream
447            .collect(
448                #[cfg(feature = "std")]
449                None,
450            )
451            .await
452            .expect("collect succeeds");
453        assert_eq!(&collected[..], b"hello");
454    }
455
456    #[proxima::test]
457    async fn response_stream_propagates_inner_error() {
458        let stream = ResponseStream::new(futures::stream::iter([Err::<Bytes, _>(
459            ProximaError::Body("upstream broken".into()),
460        )]));
461        let outcome = stream
462            .collect(
463                #[cfg(feature = "std")]
464                None,
465            )
466            .await;
467        assert!(matches!(outcome, Err(ProximaError::Body(_))));
468    }
469
470    #[cfg(feature = "std")]
471    #[proxima::test]
472    async fn response_stream_collect_aborts_on_cancel() {
473        use proxima_core::signal::Signal;
474        let cancel = Signal::new();
475        let pending = futures::stream::poll_fn(|_cx| {
476            core::task::Poll::<Option<Result<Bytes, ProximaError>>>::Pending
477        });
478        let stream = ResponseStream::new(pending);
479        let cancel_for_task = cancel.clone();
480        tokio::spawn(async move {
481            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
482            cancel_for_task.fire();
483        });
484        let outcome = tokio::time::timeout(
485            std::time::Duration::from_secs(1),
486            stream.collect(Some(&cancel)),
487        )
488        .await
489        .expect("collect returns before outer timeout");
490        assert!(matches!(outcome, Err(ProximaError::Body(ref msg)) if msg.contains("cancelled")));
491    }
492
493    #[cfg(feature = "std")]
494    #[proxima::test]
495    async fn response_stream_trailers_publish_after_construction() {
496        let slot: TrailersSlot = Arc::new(Mutex::new(None));
497        let stream =
498            ResponseStream::once(Bytes::from_static(b"x")).with_trailers_slot(slot.clone());
499        assert!(stream.trailers().is_none());
500        let mut headers = HeaderList::new();
501        headers.insert(Bytes::from_static(b"X-Done"), Bytes::from_static(b"true"));
502        *slot.lock() = Some(headers);
503        assert_eq!(
504            stream
505                .trailers()
506                .expect("trailers present")
507                .get_str("x-done"),
508            Some("true")
509        );
510    }
511
512    #[proxima::test]
513    #[case::ascii("hello")]
514    #[case::unicode("héllo")]
515    async fn request_stream_roundtrips_via_collect(#[case] input: &'static str) {
516        let stream = RequestStream::new(futures::stream::once({
517            let bytes = Bytes::copy_from_slice(input.as_bytes());
518            async move { Ok(bytes) }
519        }));
520        let collected = stream.collect().await.expect("collect succeeds");
521        assert_eq!(&collected[..], input.as_bytes());
522    }
523}