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:
Claude
2026-06-12 04:53:27 +00:00
parent 8065ea561e
commit 42a2464743
6 changed files with 448 additions and 25 deletions
+61 -6
View File
@@ -187,8 +187,9 @@ pub fn build_conn(head: ParsedHead, body: Body) -> Conn {
pub fn serialise_response(conn: &Conn, keep_alive: bool) -> Vec<u8> {
// Pre-size: status line ~30 + headers ~50/each + body. Good enough.
let body_len = conn.resp_body.len_hint();
let mut out = Vec::with_capacity(64 + conn.resp_headers.len() * 40 + body_len);
let body_len = conn.resp_body.len_hint();
let is_stream = matches!(conn.resp_body, RespBody::Stream(_));
let mut out = Vec::with_capacity(64 + conn.resp_headers.len() * 40 + body_len);
let status = conn.status.unwrap_or(200);
let reason = reason_phrase(status);
@@ -202,11 +203,17 @@ pub fn serialise_response(conn: &Conn, keep_alive: bool) -> Vec<u8> {
out.extend_from_slice(b"\r\n");
// User headers — written first so subsequent injection can skip them.
// For Stream bodies WE own the framing: a user `content-length` or
// `transfer-encoding` is dropped rather than emitted (the combination
// of content-length + chunked is a smuggling vector, and a stream has
// no length to promise anyway).
let mut wrote_content_length = false;
let mut wrote_connection = false;
for (name, value) in conn.resp_headers.iter() {
match name {
"content-length" if is_stream => continue,
"transfer-encoding" if is_stream => continue,
"content-length" => wrote_content_length = true,
"connection" => wrote_connection = true,
_ => {}
@@ -217,7 +224,15 @@ pub fn serialise_response(conn: &Conn, keep_alive: bool) -> Vec<u8> {
out.extend_from_slice(b"\r\n");
}
if !wrote_content_length {
if is_stream {
// HTTP/1.1: chunked framing, connection reusable afterwards.
// HTTP/1.0: no chunked TE exists; the body is raw bytes delimited
// by EOF — the caller passes keep_alive = false and we emit
// `connection: close` below.
if conn.version == HttpVersion::Http11 {
out.extend_from_slice(b"transfer-encoding: chunked\r\n");
}
} else if !wrote_content_length {
out.extend_from_slice(b"content-length: ");
out.extend_from_slice(body_len.to_string().as_bytes());
out.extend_from_slice(b"\r\n");
@@ -229,10 +244,12 @@ pub fn serialise_response(conn: &Conn, keep_alive: bool) -> Vec<u8> {
out.extend_from_slice(b"\r\n");
// Body.
// Fixed bodies are written inline with the head; a Stream body is
// pumped by the connection actor after this head goes on the wire.
match &conn.resp_body {
RespBody::Empty => {}
RespBody::Bytes(b) => out.extend_from_slice(b),
RespBody::Empty => {}
RespBody::Bytes(b) => out.extend_from_slice(b),
RespBody::Stream(_) => {}
}
out
@@ -366,6 +383,44 @@ mod tests {
assert!(s.contains("connection: close"));
}
#[test]
fn serialise_stream_http11_is_chunked_no_content_length() {
let (_tx, rx) = smarm::channel::<Vec<u8>>();
let conn = Conn::new().put_status(200).put_body(RespBody::from(rx));
let bytes = serialise_response(&conn, true);
let s = std::str::from_utf8(&bytes).unwrap();
assert!(s.contains("transfer-encoding: chunked"));
assert!(!s.contains("content-length"));
assert!(s.ends_with("\r\n\r\n")); // head only, no body bytes
}
#[test]
fn serialise_stream_strips_user_framing_headers() {
let (_tx, rx) = smarm::channel::<Vec<u8>>();
let conn = Conn::new().put_status(200)
.put_header("content-length", "999")
.put_header("transfer-encoding", "gzip")
.put_body(RespBody::from(rx));
let bytes = serialise_response(&conn, true);
let s = std::str::from_utf8(&bytes).unwrap();
assert!(!s.contains("content-length"));
assert!(!s.contains("gzip"));
assert!(s.contains("transfer-encoding: chunked"));
}
#[test]
fn serialise_stream_http10_no_te_and_closes() {
let (_tx, rx) = smarm::channel::<Vec<u8>>();
let mut conn = Conn::new().put_status(200).put_body(RespBody::from(rx));
conn.version = HttpVersion::Http10;
// The conn actor forces keep_alive=false for a 1.0 stream.
let bytes = serialise_response(&conn, false);
let s = std::str::from_utf8(&bytes).unwrap();
assert!(!s.contains("transfer-encoding"));
assert!(!s.contains("content-length"));
assert!(s.contains("connection: close"));
}
#[test]
fn serialise_user_content_length_is_respected() {
let conn = Conn::new().put_status(200)