Skip to main content

cratefield_core/
stream.rs

1//! Opt-in streamed request and response bodies (issue #585).
2//!
3//! The buffered half of the harness — the [`crate::Json`]/[`crate::Form`]
4//! extractors and the 64 KiB `/v1/*` [`MAX_BODY_BYTES`] ceiling — reads a
5//! whole body into memory before a handler sees it. That is the right
6//! default (it is what lets a route answer problem+json before doing any
7//! work) but it cannot carry a body larger than memory, in either
8//! direction.
9//!
10//! A module opts a route out by naming it in
11//! [`Module::streaming_routes`](crate::Module::streaming_routes). On such a
12//! route:
13//!
14//! - the handler extracts a [`RequestStream`] instead of a buffered body (a
15//!   [`crate::Json`]/[`crate::Form`] extractor would see an empty body), and
16//!   reads it in chunks with the route's [`StreamRoute::max_bytes`] enforced
17//!   as it goes: the chunk that would cross the ceiling is refused with
18//!   [`StreamError::TooLarge`] rather than delivered, and the stream is fused
19//!   and its source dropped;
20//! - a declared `content-length` over the ceiling is refused with the same
21//!   `413 request-too-large` every buffered route gives, answered by the
22//!   router before any byte is read;
23//! - the handler may answer with a [`ResponseStream`], which the runtime
24//!   bridges to the wire without buffering it first.
25//!
26//! Every other route keeps the buffered behaviour it always had.
27
28use std::fmt;
29use std::pin::Pin;
30use std::task::{Context, Poll};
31
32use axum::body::Body;
33use axum::extract::FromRequestParts;
34use axum::http::Method;
35use axum::http::request::Parts;
36use axum::response::{IntoResponse, Response};
37use bytes::Bytes;
38use futures_core::Stream;
39use futures_util::StreamExt;
40
41use crate::problem::Problem;
42use crate::scope::Scope;
43
44/// The shared one-shot cell both stream types wrap: whoever polls the body
45/// or calls [`ResponseStream::take`] first gets the underlying stream, and
46/// the other side then sees an empty stream. `Mutex` because the two sides
47/// live in different parts of one request and the cell has to be `Sync`;
48/// this is request-scoped state carried in the request/response, not the
49/// ambient state ADR 0007 forbids.
50#[allow(clippy::disallowed_types)]
51type Slot =
52    std::sync::Arc<std::sync::Mutex<Option<BoxStream<'static, Result<Bytes, StreamError>>>>>;
53
54/// Builds the shared one-shot cell. The single place the `std::sync::Mutex`
55/// [`Slot`] names is constructed, so the disallowed-types allow lives here
56/// and the two stream types never name it themselves.
57#[allow(clippy::disallowed_types)]
58fn slot(inner: Option<BoxStream<'static, Result<Bytes, StreamError>>>) -> Slot {
59    std::sync::Arc::new(std::sync::Mutex::new(inner))
60}
61
62/// A type-erased, `Send` stream of body chunks: the shape a runtime bridges
63/// to the wire, and what [`ResponseStream::take`] hands one. Same definition
64/// as `futures_util::stream::BoxStream`, written here because core depends
65/// on `futures-core` only.
66pub type BoxStream<'a, T> = Pin<Box<dyn Stream<Item = T> + Send + 'a>>;
67
68/// How many items `pump`'s channel buffers: deep enough that a producer
69/// running ahead of its consumer does not stall on every item, small
70/// enough that an unread stream cannot queue an unbounded pipeline in
71/// memory. The producer awaits the send past this point, which is the
72/// backpressure that ties its pace to the reader's.
73const PUMP_BUFFER: usize = 16;
74
75/// Bridges a producer into a stream of its own lifetime without spawning a
76/// task (issue #859): an owning producer becomes a `'static` stream — the
77/// trick a route handler needs, because [`ResponseStream::new`] wants a
78/// `'static` body and a port call wants a borrow — and a borrowing one, a
79/// stream that borrows its inputs (`run_tool_loop_stream`'s loop future,
80/// which holds the model and the executor), becomes a stream of that same
81/// lifetime.
82///
83/// `produce` receives the channel's sender and runs to completion: it owns
84/// or borrows
85/// whatever it needs (an `Arc` and an owned prompt, say) and pushes items
86/// until its source ends or the receiver goes away. The trick that keeps
87/// this spawn-free: the future travels as a stream that never yields an
88/// item, merged with the receiver, so it is polled whenever — and only
89/// whenever — the output is polled. Dropping the output drops the merged
90/// producer, which drops whatever it owned, which is the cancellation
91/// contract; a producer that finds the receiver gone on a send stops
92/// early for the same reason.
93pub(crate) fn pump<'a, T, P, Fut>(produce: P) -> BoxStream<'a, T>
94where
95    T: Send + 'a,
96    P: FnOnce(futures_channel::mpsc::Sender<T>) -> Fut + Send + 'a,
97    Fut: std::future::Future<Output = ()> + Send + 'a,
98{
99    let (tx, rx) = futures_channel::mpsc::channel::<T>(PUMP_BUFFER);
100    let keepalive = futures_util::stream::once(Box::pin(produce(tx)))
101        .filter_map(|()| futures_util::future::ready(None::<T>));
102    Box::pin(futures_util::stream::select(keepalive, rx))
103}
104
105/// One route a module serves in streaming mode (issue #585). `path` is
106/// relative to the module's `/v1/<name>` mount and uses axum's pattern
107/// syntax: a literal segment, `{param}` for exactly one segment, or
108/// `{*rest}` for the remaining one-or-more (`/upload`, `/files/{id}`,
109/// `/files/{*rest}`).
110///
111/// `max_bytes` is this route's own ceiling, enforced chunk by chunk by
112/// [`RequestStream`] and consulted by the router for the declared
113/// `content-length` pre-check. It replaces [`Module::max_body_bytes`] on
114/// this route, and may be raised or lowered freely: unlike the buffered
115/// ceiling it is the precise per-route enforcer, not a coarse guard in front
116/// of the router.
117///
118/// [`Module::max_body_bytes`]: crate::Module::max_body_bytes
119#[derive(Debug, Clone)]
120pub struct StreamRoute {
121    pub method: Method,
122    pub path: &'static str,
123    pub max_bytes: usize,
124}
125
126impl StreamRoute {
127    /// A `POST` route. `const` so a module's routes are one `static`
128    /// slice, the way its migrations are.
129    #[must_use]
130    pub const fn post(path: &'static str, max_bytes: usize) -> Self {
131        Self {
132            method: Method::POST,
133            path,
134            max_bytes,
135        }
136    }
137
138    /// A `PUT` route.
139    #[must_use]
140    pub const fn put(path: &'static str, max_bytes: usize) -> Self {
141        Self {
142            method: Method::PUT,
143            path,
144            max_bytes,
145        }
146    }
147
148    /// A `GET` route (a streamed response with no request body).
149    #[must_use]
150    pub const fn get(path: &'static str, max_bytes: usize) -> Self {
151        Self {
152            method: Method::GET,
153            path,
154            max_bytes,
155        }
156    }
157}
158
159/// Why a [`RequestStream`] ended early.
160#[derive(Debug, Clone)]
161pub enum StreamError {
162    /// The body crossed the route's [`StreamRoute::max_bytes`]. The chunk
163    /// that would have crossed it is refused, not delivered, and the stream
164    /// is fused from here on.
165    TooLarge,
166    /// The underlying transport failed mid-stream. The text is for logs and
167    /// never reaches a response body.
168    Transport(String),
169}
170
171impl fmt::Display for StreamError {
172    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
173        match self {
174            Self::TooLarge => f.write_str("streamed body exceeded its route ceiling"),
175            Self::Transport(detail) => write!(f, "stream transport error: {detail}"),
176        }
177    }
178}
179
180impl std::error::Error for StreamError {}
181
182/// A mid-stream failure is a [`Problem`]: a ceiling breach is exactly the
183/// `413 request-too-large` a buffered route answers with, and a transport
184/// failure is a server-side fault the caller cannot fix, so it becomes the
185/// generic `500 internal` (the same mapping [`crate::ports::DbError`] gets —
186/// no internals in the body, the error only logged).
187impl From<StreamError> for Problem {
188    fn from(error: StreamError) -> Self {
189        match error {
190            StreamError::TooLarge => Self::request_too_large(),
191            StreamError::Transport(detail) => {
192                tracing::error!(error = %detail, "stream transport error mapped to internal problem");
193                // wasm has no tracing dispatcher (it hangs the isolate), so
194                // also forward to the runtime's sink, as the DbError mapping
195                // does (issue #107).
196                crate::logging::forward_internal_error(&format!(
197                    "stream transport error: {detail}"
198                ));
199                Self::internal()
200            }
201        }
202    }
203}
204
205/// A streamed request body (issue #585). Extracted by a handler on a route
206/// its module declared in
207/// [`Module::streaming_routes`](crate::Module::streaming_routes); the router
208/// has already put it in the request extensions and emptied the buffered
209/// body.
210///
211/// It is `Clone`, and clones share one cursor: the router's request layer
212/// inserts one handle and a handler may clone it, but they read the same
213/// underlying stream. It is one-shot — once the source ends (or is refused),
214/// every later poll answers `None` forever.
215#[derive(Clone)]
216pub struct RequestStream {
217    slot: Slot,
218}
219
220impl RequestStream {
221    /// Wraps `stream`, enforcing `max_bytes` as chunks are read. Accepts any
222    /// `Send` stream of `Result<O, E>` where `O` converts into [`Bytes`] and
223    /// `E` renders — an axum `Body::into_data_stream()`, a runtime's own
224    /// body stream, or a test's synthetic one.
225    #[must_use]
226    pub fn new<S, O, E>(stream: S, max_bytes: usize) -> Self
227    where
228        S: Stream<Item = Result<O, E>> + Send + 'static,
229        O: Into<Bytes> + 'static,
230        E: fmt::Display + 'static,
231    {
232        let capped: BoxStream<'static, Result<Bytes, StreamError>> = Box::pin(Capped {
233            inner: Some(map_stream(stream)),
234            delivered: 0,
235            max: max_bytes,
236        });
237        Self {
238            slot: slot(Some(capped)),
239        }
240    }
241
242    /// A stream that ends immediately. The empty exchange a test or a
243    /// runtime hands a route that has no body of its own.
244    #[must_use]
245    pub fn empty() -> Self {
246        Self { slot: slot(None) }
247    }
248
249    /// The next chunk, or `None` once the source has ended (or was refused).
250    /// A convenience for core callers, who have no `StreamExt`: it is
251    /// [`futures_core::Stream::poll_next`] behind `std::future::poll_fn`.
252    pub async fn next_chunk(&mut self) -> Option<Result<Bytes, StreamError>> {
253        std::future::poll_fn(|cx| Pin::new(&mut *self).poll_next(cx)).await
254    }
255}
256
257impl Stream for RequestStream {
258    type Item = Result<Bytes, StreamError>;
259
260    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
261        let this = self.get_mut();
262        let mut slot = this.slot.lock().expect("stream slot lock uncontended");
263        match slot.as_mut() {
264            Some(stream) => stream.as_mut().poll_next(cx),
265            None => Poll::Ready(None),
266        }
267    }
268}
269
270impl<S: Send + Sync> FromRequestParts<S> for RequestStream {
271    type Rejection = Problem;
272
273    /// Removes the router-inserted stream from the request extensions. A
274    /// route that was not declared in `Module::streaming_routes` has none,
275    /// and extracting here is a programming error — never a client one — so
276    /// it answers a `500 internal` naming the declaration that is missing.
277    // The trait method is `async`; this impl never awaits, which the lint
278    // reads as a mistake but the signature does not allow otherwise.
279    #[allow(clippy::unused_async_trait_impl)]
280    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
281        if let Some(stream) = parts.extensions.remove::<RequestStream>() {
282            return Ok(stream);
283        }
284        let mut problem = Problem::internal().with_detail(
285            "RequestStream is only available on a route declared in the owning module's \
286             streaming_routes()",
287        );
288        if let Some(scope) = parts.extensions.get::<Scope>() {
289            problem = problem.instance(&scope.request_id);
290        }
291        Err(problem)
292    }
293}
294
295/// A streamed response body (issue #585).
296///
297/// Answering with one bridges the chunks to the wire without buffering them:
298/// on native (and in the test kit) the [`axum`] body is polled directly, so
299/// nothing runtime-specific is needed. A runtime that cannot bridge an axum
300/// body — `runtime-cloudflare` — instead calls [`ResponseStream::take`] to
301/// pull the stream out of the response extensions. Whoever takes first wins;
302/// the other side then sees an empty body.
303#[derive(Clone)]
304pub struct ResponseStream {
305    slot: Slot,
306}
307
308impl ResponseStream {
309    /// Wraps `stream` as a response body. No ceiling: a streamed response is
310    /// bounded by the handler that produced it, not by a request limit.
311    #[must_use]
312    pub fn new<S, O, E>(stream: S) -> Self
313    where
314        S: Stream<Item = Result<O, E>> + Send + 'static,
315        O: Into<Bytes> + 'static,
316        E: fmt::Display + 'static,
317    {
318        Self {
319            slot: slot(Some(map_stream(stream))),
320        }
321    }
322
323    /// Takes the underlying stream, for a runtime that bridges it to its own
324    /// response type rather than polling the axum body — `runtime-cloudflare`
325    /// builds a Worker response from it, because converting an axum body into
326    /// a Worker `Response` hangs the isolate. `None` once the body has
327    /// already been polled (or taken).
328    ///
329    /// # Panics
330    ///
331    /// Panics if the slot's lock is poisoned — only reachable if a prior
332    /// poll panicked while holding it.
333    #[must_use]
334    pub fn take(&self) -> Option<BoxStream<'static, Result<Bytes, StreamError>>> {
335        self.slot
336            .lock()
337            .expect("stream slot lock uncontended")
338            .take()
339    }
340}
341
342impl IntoResponse for ResponseStream {
343    fn into_response(self) -> Response {
344        let body = Body::from_stream(BodySource {
345            slot: std::sync::Arc::clone(&self.slot),
346            inner: None,
347        });
348        let mut response = Response::new(body);
349        // The handle rides along on the response so a runtime can `take` the
350        // stream out of it instead of polling the axum body.
351        response.extensions_mut().insert(self);
352        response
353    }
354}
355
356/// The axum-body side of a [`ResponseStream`]: takes the inner stream from
357/// the shared slot the first time it is polled, then delegates to it. If a
358/// runtime took it first the slot is already empty and this yields `None`.
359struct BodySource {
360    slot: Slot,
361    inner: Option<BoxStream<'static, Result<Bytes, StreamError>>>,
362}
363
364impl Stream for BodySource {
365    type Item = Result<Bytes, StreamError>;
366
367    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
368        let this = self.get_mut();
369        if this.inner.is_none() {
370            this.inner = this
371                .slot
372                .lock()
373                .expect("stream slot lock uncontended")
374                .take();
375        }
376        match this.inner.as_mut() {
377            Some(stream) => stream.as_mut().poll_next(cx),
378            None => Poll::Ready(None),
379        }
380    }
381}
382
383/// Enforces a byte ceiling on an already-mapped chunk stream: the chunk that
384/// would cross `max` is replaced by [`StreamError::TooLarge`] (it is not
385/// delivered), the source is dropped, and every later poll answers `None`.
386struct Capped {
387    inner: Option<BoxStream<'static, Result<Bytes, StreamError>>>,
388    delivered: usize,
389    max: usize,
390}
391
392impl Stream for Capped {
393    type Item = Result<Bytes, StreamError>;
394
395    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
396        let this = self.get_mut();
397        let polled = match this.inner.as_mut() {
398            Some(stream) => stream.as_mut().poll_next(cx),
399            None => return Poll::Ready(None),
400        };
401        match polled {
402            Poll::Ready(Some(Ok(chunk))) => {
403                if this.delivered.saturating_add(chunk.len()) > this.max {
404                    // Drop the source and fuse: nothing further is read.
405                    this.inner = None;
406                    Poll::Ready(Some(Err(StreamError::TooLarge)))
407                } else {
408                    this.delivered += chunk.len();
409                    Poll::Ready(Some(Ok(chunk)))
410                }
411            }
412            Poll::Ready(Some(Err(err))) => Poll::Ready(Some(Err(err))),
413            Poll::Ready(None) => {
414                this.inner = None;
415                Poll::Ready(None)
416            }
417            Poll::Pending => Poll::Pending,
418        }
419    }
420}
421
422/// Type-erases a `Stream<Item = Result<O, E>>` into the `BoxStream` shape,
423/// mapping each item into [`Bytes`] and each error into
424/// [`StreamError::Transport`]. The inner stream is boxed first, so the
425/// adapter itself is `Unpin` and needs no `pin_project`.
426fn map_stream<S, O, E>(stream: S) -> BoxStream<'static, Result<Bytes, StreamError>>
427where
428    S: Stream<Item = Result<O, E>> + Send + 'static,
429    O: Into<Bytes> + 'static,
430    E: fmt::Display + 'static,
431{
432    Box::pin(MapStream {
433        inner: Box::pin(stream),
434    })
435}
436
437struct MapStream<T, E> {
438    inner: Pin<Box<dyn Stream<Item = Result<T, E>> + Send + 'static>>,
439}
440
441impl<T, E> Stream for MapStream<T, E>
442where
443    T: Into<Bytes> + 'static,
444    E: fmt::Display + 'static,
445{
446    type Item = Result<Bytes, StreamError>;
447
448    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
449        let this = self.get_mut();
450        match this.inner.as_mut().poll_next(cx) {
451            Poll::Ready(Some(Ok(item))) => Poll::Ready(Some(Ok(item.into()))),
452            Poll::Ready(Some(Err(err))) => {
453                Poll::Ready(Some(Err(StreamError::Transport(err.to_string()))))
454            }
455            Poll::Ready(None) => Poll::Ready(None),
456            Poll::Pending => Poll::Pending,
457        }
458    }
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464
465    // A tiny `Stream` over a `Vec`, written out because the crate has no
466    // `futures-util` and the tests need nothing more than this.
467    struct VecStream<T> {
468        items: std::vec::IntoIter<T>,
469    }
470
471    impl<T: std::marker::Unpin> Stream for VecStream<T> {
472        type Item = T;
473
474        fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<T>> {
475            Poll::Ready(self.get_mut().items.next())
476        }
477    }
478
479    /// A `Send` chunk stream whose items are `Ok(Vec<u8>)`/`Err(String)`,
480    /// the shape a runtime's body stream maps to.
481    fn chunks(
482        items: Vec<Result<&'static [u8], &'static str>>,
483    ) -> impl Stream<Item = Result<Vec<u8>, String>> + Send {
484        VecStream {
485            items: items
486                .into_iter()
487                .map(|item| item.map(<[u8]>::to_vec).map_err(str::to_owned))
488                .collect::<Vec<_>>()
489                .into_iter(),
490        }
491    }
492
493    #[pollster::test]
494    async fn the_crossing_chunk_is_refused_and_the_stream_fuses() {
495        let mut stream = RequestStream::new(
496            chunks(vec![Ok(b"12345"), Ok(b"67890"), Ok(b"unreachable")]),
497            8,
498        );
499        assert_eq!(stream.next_chunk().await.unwrap().unwrap(), "12345");
500        // 5 + 5 crosses 8 on the second chunk: refused, not delivered.
501        assert!(matches!(
502            stream.next_chunk().await.unwrap(),
503            Err(StreamError::TooLarge)
504        ));
505        assert!(stream.next_chunk().await.is_none(), "fused after refusal");
506    }
507
508    #[pollster::test]
509    async fn a_body_exactly_at_the_ceiling_is_allowed() {
510        let mut stream = RequestStream::new(chunks(vec![Ok(b"1234"), Ok(b"5678")]), 8);
511        assert_eq!(stream.next_chunk().await.unwrap().unwrap(), "1234");
512        assert_eq!(stream.next_chunk().await.unwrap().unwrap(), "5678");
513        assert!(
514            stream.next_chunk().await.is_none(),
515            "clean end at the limit"
516        );
517    }
518
519    #[pollster::test]
520    async fn a_transport_error_is_mapped_not_swallowed() {
521        let mut stream = RequestStream::new(chunks(vec![Ok(b"hi"), Err("boom")]), 1024);
522        assert_eq!(stream.next_chunk().await.unwrap().unwrap(), "hi");
523        match stream.next_chunk().await.unwrap() {
524            Err(StreamError::Transport(detail)) => assert!(detail.contains("boom"), "{detail}"),
525            other => panic!("expected a transport error, got {other:?}"),
526        }
527    }
528
529    #[pollster::test]
530    async fn an_empty_stream_ends_at_once() {
531        let mut stream = RequestStream::empty();
532        assert!(stream.next_chunk().await.is_none());
533    }
534
535    #[pollster::test]
536    async fn all_response_chunks_reach_the_body() {
537        let response =
538            ResponseStream::new(chunks(vec![Ok(b"one"), Ok(b"two"), Ok(b"three")])).into_response();
539        let body = axum::body::to_bytes(response.into_parts().1, 1024)
540            .await
541            .expect("body reads");
542        assert_eq!(&body[..], b"onetwothree");
543    }
544
545    #[pollster::test]
546    async fn take_after_the_body_was_consumed_answers_none() {
547        let stream = ResponseStream::new(chunks(vec![Ok(b"one")]));
548        let response = stream.clone().into_response();
549        let body = axum::body::to_bytes(response.into_parts().1, 1024)
550            .await
551            .expect("body reads");
552        assert_eq!(&body[..], b"one");
553        assert!(stream.take().is_none(), "the body already took the stream");
554    }
555
556    #[pollster::test]
557    async fn take_first_leaves_the_axum_body_empty() {
558        let stream = ResponseStream::new(chunks(vec![Ok(b"one")]));
559        assert!(stream.take().is_some(), "the runtime takes the stream");
560        let response = stream.into_response();
561        let body = axum::body::to_bytes(response.into_parts().1, 1024)
562            .await
563            .expect("body reads");
564        assert!(body.is_empty(), "whoever takes first wins");
565    }
566}