feat(conn): streaming response bodies — RespBody::Stream + chunked TE (v0.3 chunk 1)
Design decisions (per handoff Q1 user answer + Q3 proposal): - Producer shape is PULL: the handler returns a smarm Receiver<Vec<u8>> (RespBody::Stream / StreamBody, From<Receiver<Vec<u8>>> for ergonomics); the producer is an actor the handler spawned. The conn actor pumps in pump_stream(): it keeps sole ownership of the socket and of write deadlines, and the recv() park between chunks is stoppable by the draining registry's request_stop (stop sentinel unwinds out of park_current; fd + registry guards clean up) — so an infinite stream is force-stoppable at the drain deadline like any in-flight request, with no polling. End of stream = every Sender dropped = terminating 0-chunk. Producer contract: a send() error means the conn died; exit. - Framing: HTTP/1.1 gets transfer-encoding: chunked and the connection stays reusable after the terminator (keep-alive after chunked). HTTP/1.0 has no chunked TE: bytes go raw, keep-alive is forced off, EOF delimits. User-set content-length/transfer-encoding headers are DROPPED for stream bodies — we own the framing, and CL+chunked is a smuggling vector. Empty producer chunks are skipped (a 0-length chunk would terminate the framing early). - request_timeout stays READ-phase only (unchanged). NEW Config/ConnLimits field write_timeout (default 30s) gives every response write a per-write budget: the fixed head+body write, each streamed chunk, and the error paths (413/100-continue/4xx) all go through the now deadline-bounded write_all (wait_writable_timeout, mirroring read_some). Each chunk gets a FRESH budget — streams may outlive any whole-response clock; a single stalled write may not (write-side slowloris). Naming/default were flagged as a user call in the handoff: veto here if write_timeout(30s) isn't it. - RespBody loses derive(Clone) (Receiver isn't Clone; Clone was unused) and gets a manual Debug. Tests: 3 serialiser unit tests (chunked head shape, user-framing-header stripping, 1.0 fallback) + 5 integration (chunked round-trip with decoder, keep-alive after chunked, 1.0 EOF-delimited, shutdown force-stops an infinite stream at drain deadline, stalled reader killed at write_timeout with producer observing the closed channel).
This commit is contained in:
+54
-6
@@ -146,21 +146,61 @@ impl Body {
|
||||
// RespBody — what plugs put on the wire.
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Enum, not a trait, so the connection actor can pattern-match. Leaves room
|
||||
// for future variants like Sse, Stream, Chunked without touching the plug
|
||||
// API.
|
||||
// Enum, not a trait, so the connection actor can pattern-match.
|
||||
//
|
||||
// `Stream` is the pull-shaped streaming body (v0.3 design decision): the
|
||||
// handler hands back a `smarm::Receiver<Vec<u8>>` — typically the read end
|
||||
// of a channel whose `Sender` lives in a producer actor the handler
|
||||
// spawned — and the connection actor pumps it onto the wire. The conn
|
||||
// actor keeps sole ownership of the socket and of write deadlines; the
|
||||
// producer never touches the fd. End-of-stream is signalled by dropping
|
||||
// every `Sender` (the conn actor then emits the terminating 0-chunk).
|
||||
// Empty `Vec`s are skipped by the pump (a zero-length chunk would
|
||||
// terminate chunked framing early), so they are safe to send but useless.
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RespBody {
|
||||
Empty,
|
||||
Bytes(Vec<u8>),
|
||||
Stream(StreamBody),
|
||||
}
|
||||
|
||||
/// A streaming response body. Construct with [`StreamBody::new`] (or
|
||||
/// `RespBody::from(rx)`) and hand it to [`Conn::put_body`].
|
||||
///
|
||||
/// On HTTP/1.1 the connection actor sends it with
|
||||
/// `Transfer-Encoding: chunked` and the connection stays reusable after
|
||||
/// the stream completes. On HTTP/1.0 (no chunked framing) the bytes are
|
||||
/// written raw and the connection closes at end of stream to delimit the
|
||||
/// body.
|
||||
pub struct StreamBody {
|
||||
pub rx: smarm::Receiver<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl StreamBody {
|
||||
pub fn new(rx: smarm::Receiver<Vec<u8>>) -> Self {
|
||||
Self { rx }
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for RespBody {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
RespBody::Empty => f.write_str("Empty"),
|
||||
RespBody::Bytes(b) => f.debug_tuple("Bytes").field(&b.len()).finish(),
|
||||
RespBody::Stream(_) => f.write_str("Stream(..)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RespBody {
|
||||
/// Body length for fixed bodies; 0 for `Stream` (a stream has no
|
||||
/// length up front — that is the point — and is never serialised with
|
||||
/// a `content-length`).
|
||||
pub fn len_hint(&self) -> usize {
|
||||
match self {
|
||||
RespBody::Empty => 0,
|
||||
RespBody::Bytes(b) => b.len(),
|
||||
RespBody::Empty => 0,
|
||||
RespBody::Bytes(b) => b.len(),
|
||||
RespBody::Stream(_) => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -326,3 +366,11 @@ impl From<&'static str> for RespBody {
|
||||
impl From<&[u8]> for RespBody {
|
||||
fn from(s: &[u8]) -> Self { RespBody::Bytes(s.to_vec()) }
|
||||
}
|
||||
impl From<StreamBody> for RespBody {
|
||||
fn from(s: StreamBody) -> Self { RespBody::Stream(s) }
|
||||
}
|
||||
impl From<smarm::Receiver<Vec<u8>>> for RespBody {
|
||||
fn from(rx: smarm::Receiver<Vec<u8>>) -> Self {
|
||||
RespBody::Stream(StreamBody::new(rx))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user