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
+111 -12
View File
@@ -13,7 +13,7 @@
//! `wait_readable` between bytes and `wait_writable` during slow writes;
//! during those parks, other connection actors progress freely.
use crate::conn::{Body, Conn, RespBody};
use crate::conn::{Body, Conn, HttpVersion, RespBody, StreamBody};
use crate::conn_registry::{Cast, ConnRegistry, DeregisterGuard};
use crate::net::OwnedFd;
use crate::parser::{self, ParseError};
@@ -50,7 +50,15 @@ pub struct ConnLimits {
/// request until the request (head + body) is fully read. Expiry
/// mid-head gets a best-effort 408; expiry mid-body just closes.
/// Pipeline run time is NOT covered — that's the handler's business.
/// Covers the READ phase only; the write phase has its own
/// per-write budget (`write_timeout`) so a streaming response can
/// legitimately outlive any whole-request clock.
pub request_timeout: Duration,
/// Per-write budget for response bytes: every `write_all` (the fixed
/// head+body, and each streamed chunk) must complete within this.
/// A client that stops reading mid-response is dropped when its
/// socket buffer fills and a write stalls past the budget.
pub write_timeout: Duration,
}
impl Default for ConnLimits {
@@ -62,6 +70,7 @@ impl Default for ConnLimits {
max_body_bytes: 16 * 1024 * 1024,
keep_alive_timeout: Duration::from_secs(60),
request_timeout: Duration::from_secs(30),
write_timeout: Duration::from_secs(30),
}
}
}
@@ -117,7 +126,7 @@ pub fn run_connection(
return;
}
Err(ReadHeadErr::Parse(e)) => {
emit_error_response(raw, &e);
emit_error_response(raw, &e, Instant::now() + limits.write_timeout);
return;
}
};
@@ -126,7 +135,11 @@ pub fn run_connection(
// ----- 2. Read body. -----
let body_len = parsed.content_length.unwrap_or(0);
if body_len > limits.max_body_bytes {
let _ = write_all(raw, b"HTTP/1.1 413 Payload Too Large\r\ncontent-length: 0\r\nconnection: close\r\n\r\n");
let _ = write_all(
raw,
b"HTTP/1.1 413 Payload Too Large\r\ncontent-length: 0\r\nconnection: close\r\n\r\n",
Instant::now() + limits.write_timeout,
);
return;
}
@@ -134,7 +147,7 @@ pub fn run_connection(
// body. RFC 7231 §5.1.1. We don't gate on app logic here; v1 always
// accepts.
if parsed.expect_100 {
if write_all(raw, b"HTTP/1.1 100 Continue\r\n\r\n").is_err() {
if write_all(raw, b"HTTP/1.1 100 Continue\r\n\r\n", Instant::now() + limits.write_timeout).is_err() {
return;
}
}
@@ -187,11 +200,29 @@ pub fn run_connection(
}
// ----- 4. Write the response. -----
let bytes = parser::serialise_response(&response_conn, keep_alive);
if write_all(raw, &bytes).is_err() {
// A Stream body on HTTP/1.0 has no chunked framing: the body is
// delimited by EOF, so keep-alive is forced off for this response
// (and `connection: close` goes on the wire).
let is_stream = matches!(response_conn.resp_body, RespBody::Stream(_));
let keep_alive = keep_alive
&& !(is_stream && version == HttpVersion::Http10);
let head_bytes = parser::serialise_response(&response_conn, keep_alive);
if write_all(raw, &head_bytes, Instant::now() + limits.write_timeout).is_err() {
return;
}
if let RespBody::Stream(stream) = response_conn.resp_body {
let chunked = version == HttpVersion::Http11;
if pump_stream(raw, stream, chunked, limits.write_timeout).is_err() {
return;
}
if !chunked {
// EOF delimits the HTTP/1.0 stream body.
return;
}
}
// ----- 5. Loop or close. -----
if !keep_alive {
return;
@@ -399,13 +430,24 @@ fn try_write_once(fd: RawFd, buf: &[u8]) {
}
// ---------------------------------------------------------------------------
// write_all — robust write loop.
// write_all — robust write loop, bounded by a deadline.
// ---------------------------------------------------------------------------
//
// Mirrors read_some: each writability park is bounded by the remaining
// budget. `ErrorKind::TimedOut` when the deadline passes before the bytes
// are down — a client that stops reading must not pin this actor in
// wait_writable forever (the write-side twin of slowloris).
fn write_all(fd: RawFd, mut buf: &[u8]) -> io::Result<()> {
fn write_all(fd: RawFd, mut buf: &[u8], deadline: Instant) -> io::Result<()> {
while !buf.is_empty() {
// Park on writability before each syscall.
smarm::wait_writable(fd)?;
// Park on writability before each syscall, bounded by the budget.
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(io::Error::new(ErrorKind::TimedOut, "write deadline elapsed"));
}
if !smarm::wait_writable_timeout(fd, remaining)? {
return Err(io::Error::new(ErrorKind::TimedOut, "write deadline elapsed"));
}
let n = unsafe {
libc::write(fd, buf.as_ptr() as *const _, buf.len())
@@ -425,11 +467,68 @@ fn write_all(fd: RawFd, mut buf: &[u8]) -> io::Result<()> {
Ok(())
}
// ---------------------------------------------------------------------------
// pump_stream — drive a RespBody::Stream onto the wire.
// ---------------------------------------------------------------------------
//
// The pull side of the v0.3 streaming design: the handler's producer actor
// owns the Sender; this conn actor owns the socket and every write
// deadline. We park in `recv()` between chunks — that park is stoppable
// (a draining registry's `request_stop` unwinds us out of `park_current`
// via the stop sentinel; fd + registry guards clean up), so an infinite
// stream is force-stoppable at the drain deadline like any other in-flight
// request. End of stream is the channel closing: every Sender dropped.
//
// Each chunk gets a FRESH write_timeout budget — a stream is expected to
// outlive any whole-response clock; what is not tolerated is a single
// write stalling. On write failure we return Err: the conn loop drops the
// Receiver, and the producer's next `send` observes the closed channel and
// should exit (that is the documented producer contract).
//
// `chunked` selects HTTP/1.1 chunked framing (hex-length CRLF payload
// CRLF, terminated by a 0-chunk) vs HTTP/1.0 raw writes (EOF-delimited;
// caller closes). Empty chunks are skipped — a zero-length chunk would
// terminate the framing early.
fn pump_stream(
fd: RawFd,
stream: StreamBody,
chunked: bool,
write_timeout: Duration,
) -> io::Result<()> {
loop {
match stream.rx.recv() {
Ok(chunk) => {
if chunk.is_empty() {
continue;
}
let deadline = Instant::now() + write_timeout;
if chunked {
let mut framed = Vec::with_capacity(chunk.len() + 20);
framed.extend_from_slice(format!("{:x}\r\n", chunk.len()).as_bytes());
framed.extend_from_slice(&chunk);
framed.extend_from_slice(b"\r\n");
write_all(fd, &framed, deadline)?;
} else {
write_all(fd, &chunk, deadline)?;
}
}
Err(_) => {
// All senders dropped: end of stream.
if chunked {
write_all(fd, b"0\r\n\r\n", Instant::now() + write_timeout)?;
}
return Ok(());
}
}
}
}
// ---------------------------------------------------------------------------
// Error responses for unparseable / malformed requests.
// ---------------------------------------------------------------------------
fn emit_error_response(fd: RawFd, err: &ParseError) {
fn emit_error_response(fd: RawFd, err: &ParseError, deadline: Instant) {
let resp: &[u8] = match err {
ParseError::TooManyHeaders =>
b"HTTP/1.1 431 Request Header Fields Too Large\r\ncontent-length: 0\r\nconnection: close\r\n\r\n",
@@ -442,5 +541,5 @@ fn emit_error_response(fd: RawFd, err: &ParseError) {
_ =>
b"HTTP/1.1 400 Bad Request\r\ncontent-length: 0\r\nconnection: close\r\n\r\n",
};
let _ = write_all(fd, resp);
let _ = write_all(fd, resp, deadline);
}