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:
+61
-6
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user