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:
@@ -556,3 +556,219 @@ fn slowloris_partial_head_killed_at_request_timeout() {
|
||||
assert!(!resp.is_empty(), "expected a best-effort 408 before close");
|
||||
assert_eq!(http_status(&resp), 408);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Streaming responses (v0.3 chunk 1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Router with a fixed route plus a 3-chunk streaming route. The producer
|
||||
/// is a separate actor (the pull design): the handler spawns it, hands the
|
||||
/// Receiver to urus, and returns. Dropping the Sender ends the stream.
|
||||
fn streaming_pipeline(chunk_gap: Duration, chunks: usize) -> Pipeline {
|
||||
Pipeline::new().plug(
|
||||
Router::new()
|
||||
.get("/", |c: Conn, _n: Next| c.put_status(200).put_body("hello"))
|
||||
.get("/stream", move |c: Conn, _n: Next| {
|
||||
let (tx, rx) = smarm::channel::<Vec<u8>>();
|
||||
smarm::spawn(move || {
|
||||
for i in 0..chunks {
|
||||
if tx.send(format!("part{i}").into_bytes()).is_err() {
|
||||
return; // conn died; stop producing
|
||||
}
|
||||
smarm::sleep(chunk_gap);
|
||||
}
|
||||
});
|
||||
c.put_status(200).put_body(rx)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// Read from `s` until the chunked-encoding terminator arrives, then
|
||||
/// return (head, decoded_body).
|
||||
fn read_chunked_response(s: &mut TcpStream) -> (String, Vec<u8>) {
|
||||
let mut raw = Vec::new();
|
||||
let mut buf = [0u8; 4096];
|
||||
loop {
|
||||
match s.read(&mut buf) {
|
||||
Ok(0) => panic!("connection closed before chunked terminator"),
|
||||
Ok(n) => raw.extend_from_slice(&buf[..n]),
|
||||
Err(e) => panic!("read failed mid-stream: {e}"),
|
||||
}
|
||||
if raw.windows(5).any(|w| w == b"0\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let head_end = raw.windows(4).position(|w| w == b"\r\n\r\n").unwrap() + 4;
|
||||
let head = String::from_utf8_lossy(&raw[..head_end]).to_string();
|
||||
|
||||
// Decode the chunked body.
|
||||
let mut body = Vec::new();
|
||||
let mut rest = &raw[head_end..];
|
||||
loop {
|
||||
let line_end = rest.windows(2).position(|w| w == b"\r\n").unwrap();
|
||||
let size = usize::from_str_radix(
|
||||
std::str::from_utf8(&rest[..line_end]).unwrap().trim(),
|
||||
16,
|
||||
)
|
||||
.unwrap();
|
||||
rest = &rest[line_end + 2..];
|
||||
if size == 0 {
|
||||
break;
|
||||
}
|
||||
body.extend_from_slice(&rest[..size]);
|
||||
assert_eq!(&rest[size..size + 2], b"\r\n", "chunk not CRLF-terminated");
|
||||
rest = &rest[size + 2..];
|
||||
}
|
||||
(head, body)
|
||||
}
|
||||
|
||||
/// A streaming handler produces a chunked HTTP/1.1 response whose decoded
|
||||
/// body is the concatenation of the producer's sends, with no
|
||||
/// content-length on the wire.
|
||||
#[test]
|
||||
fn streaming_response_chunked() {
|
||||
let port = spawn_server(streaming_pipeline(Duration::from_millis(10), 3));
|
||||
let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
s.write_all(b"GET /stream HTTP/1.1\r\nHost: x\r\n\r\n").unwrap();
|
||||
|
||||
let (head, body) = read_chunked_response(&mut s);
|
||||
assert!(head.starts_with("HTTP/1.1 200 OK\r\n"), "head: {head}");
|
||||
assert!(head.contains("transfer-encoding: chunked"), "head: {head}");
|
||||
assert!(!head.contains("content-length"), "head: {head}");
|
||||
assert_eq!(body, b"part0part1part2");
|
||||
}
|
||||
|
||||
/// The connection survives a completed chunked response: a second request
|
||||
/// on the same socket gets a normal fixed response.
|
||||
#[test]
|
||||
fn keep_alive_after_chunked_response() {
|
||||
let port = spawn_server(streaming_pipeline(Duration::from_millis(5), 3));
|
||||
let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
|
||||
s.write_all(b"GET /stream HTTP/1.1\r\nHost: x\r\n\r\n").unwrap();
|
||||
let (_head, body) = read_chunked_response(&mut s);
|
||||
assert_eq!(body, b"part0part1part2");
|
||||
|
||||
// Same socket, second request.
|
||||
s.write_all(b"GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n")
|
||||
.unwrap();
|
||||
let mut resp = Vec::new();
|
||||
s.read_to_end(&mut resp).unwrap();
|
||||
assert_eq!(http_status(&resp), 200);
|
||||
assert_eq!(http_body(&resp), b"hello");
|
||||
}
|
||||
|
||||
/// HTTP/1.0 has no chunked framing: a stream body goes out raw, the
|
||||
/// response carries `connection: close` and no transfer-encoding, and EOF
|
||||
/// delimits the body.
|
||||
#[test]
|
||||
fn http10_stream_is_eof_delimited() {
|
||||
let port = spawn_server(streaming_pipeline(Duration::from_millis(5), 3));
|
||||
let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
s.write_all(b"GET /stream HTTP/1.0\r\nHost: x\r\n\r\n").unwrap();
|
||||
|
||||
let mut resp = Vec::new();
|
||||
s.read_to_end(&mut resp).unwrap(); // EOF = end of body
|
||||
let head = String::from_utf8_lossy(&resp[..]).to_string();
|
||||
assert!(head.starts_with("HTTP/1.0 200 OK\r\n"), "head: {head}");
|
||||
assert!(!head.contains("transfer-encoding"), "head: {head}");
|
||||
assert!(head.contains("connection: close"), "head: {head}");
|
||||
assert_eq!(http_body(&resp), b"part0part1part2");
|
||||
}
|
||||
|
||||
/// An infinite stream is an in-flight request: graceful shutdown waits for
|
||||
/// the drain deadline, then force-stops the conn actor parked in the pump's
|
||||
/// recv(). serve returns; the client sees EOF; the orphaned producer's next
|
||||
/// send fails (closed channel) and it exits, letting the runtime wind down.
|
||||
#[test]
|
||||
fn shutdown_force_stops_active_stream() {
|
||||
let pipe = Pipeline::new().plug(Router::new().get(
|
||||
"/infinite",
|
||||
|c: Conn, _n: Next| {
|
||||
let (tx, rx) = smarm::channel::<Vec<u8>>();
|
||||
smarm::spawn(move || loop {
|
||||
if tx.send(b"tick".to_vec()).is_err() {
|
||||
return;
|
||||
}
|
||||
smarm::sleep(Duration::from_millis(20));
|
||||
});
|
||||
c.put_status(200).put_body(rx)
|
||||
},
|
||||
));
|
||||
let (port, handle, done_rx) =
|
||||
spawn_server_with_handle(pipe, Duration::from_millis(300));
|
||||
|
||||
let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
s.write_all(b"GET /infinite HTTP/1.1\r\nHost: x\r\n\r\n").unwrap();
|
||||
|
||||
// Confirm the stream is live before shutting down.
|
||||
let mut buf = [0u8; 1024];
|
||||
let n = s.read(&mut buf).unwrap();
|
||||
assert!(n > 0);
|
||||
|
||||
handle.shutdown();
|
||||
done_rx
|
||||
.recv_timeout(Duration::from_secs(5))
|
||||
.expect("serve did not return: streaming conn not force-stopped at drain deadline");
|
||||
|
||||
// Client side drains to EOF.
|
||||
let mut rest = Vec::new();
|
||||
let _ = s.read_to_end(&mut rest); // EOF or reset; both fine
|
||||
}
|
||||
|
||||
/// A client that stops reading mid-stream is dropped once a chunk write
|
||||
/// stalls past write_timeout; the producer observes the closed channel.
|
||||
#[test]
|
||||
fn stalled_reader_killed_at_write_timeout() {
|
||||
let (dead_tx, dead_rx) = std::sync::mpsc::channel::<()>();
|
||||
let pipe = Pipeline::new().plug(Router::new().get(
|
||||
"/firehose",
|
||||
move |c: Conn, _n: Next| {
|
||||
let (tx, rx) = smarm::channel::<Vec<u8>>();
|
||||
let dead_tx = dead_tx.clone();
|
||||
smarm::spawn(move || loop {
|
||||
if tx.send(vec![0u8; 64 * 1024]).is_err() {
|
||||
let _ = dead_tx.send(()); // conn actor dropped the Receiver
|
||||
return;
|
||||
}
|
||||
smarm::sleep(Duration::from_millis(1));
|
||||
});
|
||||
c.put_status(200).put_body(rx)
|
||||
},
|
||||
));
|
||||
|
||||
let port = free_port();
|
||||
let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap();
|
||||
std::thread::spawn(move || {
|
||||
let cfg = Config {
|
||||
listener_pool: 2,
|
||||
scheduler_threads: Some(2),
|
||||
write_timeout: Duration::from_millis(300),
|
||||
..Config::new(addr)
|
||||
};
|
||||
serve_with(cfg, pipe).unwrap();
|
||||
});
|
||||
for _ in 0..50 {
|
||||
if TcpStream::connect(addr).is_ok() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
|
||||
let mut s = TcpStream::connect(addr).unwrap();
|
||||
s.write_all(b"GET /firehose HTTP/1.1\r\nHost: x\r\n\r\n").unwrap();
|
||||
// Read a little to confirm liveness, then stall: never read again.
|
||||
let mut buf = [0u8; 1024];
|
||||
let _ = s.read(&mut buf).unwrap();
|
||||
|
||||
// Socket buffers fill, a chunk write stalls, write_timeout (300ms)
|
||||
// expires, the conn actor exits, the Receiver drops, the producer's
|
||||
// send fails. Generous wall budget for slow CI.
|
||||
dead_rx
|
||||
.recv_timeout(Duration::from_secs(10))
|
||||
.expect("producer never observed conn death: write_timeout not enforced");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user