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}