docs: README streaming/SSE sections, ROADMAP v0.3 done, crud SSE ticker (v0.3 chunk 4)

README: write_timeout in the Config sample, read/write timeout-split
semantics spelled out, new Streaming Responses and Server-Sent Events
sections (producer contract, 1.0 fallback, heartbeat liveness), roadmap
summary bumped. ROADMAP: v0.3 marked done with the as-landed decisions
(pull shape per the user's fork answer; Q3 timeout split as proposed)
and verified exit criteria. crud example: GET /ticker SSE route — one
event/second, producer exits on SseClosed or the example's shutdown
flag (so an open ticker doesn't block AllDone past the drain).
This commit is contained in:
Claude
2026-06-12 04:59:39 +00:00
parent d14fb7c331
commit 43bd5a91a4
3 changed files with 126 additions and 18 deletions
+33 -12
View File
@@ -119,22 +119,43 @@ panicking listener restarts under load without dropped accepts (test each).
---
## v0.3 — Streaming bodies + SSE
## v0.3 — Streaming bodies + SSE ✅ (landed 2026-06, chunks 1-3)
The slipped v1 goal, and the WebSocket prerequisite (an upgraded connection
is "a response that never ends").
1. `RespBody::Stream(...)` — handler hands back a writer callback or a
`Receiver<Vec<u8>>`; conn actor switches to chunked transfer-encoding
(HTTP/1.1) and pumps until the stream closes. Keep-alive after a
completed chunked response.
2. Chunked **request** bodies (`parser.rs` currently returns 411) — same
cycle, the codec knowledge is fresh.
3. SSE sugar: `Conn::sse()` → an `EventSender` (`.send(event, data)`),
sets `content-type: text/event-stream`, disables buffering, heartbeats
via comment lines on a timer. An SSE conn is just a Stream body with
the per-read deadline disabled (or set to the heartbeat interval) —
liveness comes from the keep-alive ping, not `request_timeout`.
1. `RespBody::Stream(StreamBody)` ✅ — **decided: pull** (user call at the
v0.3 design fork): the handler hands back a `smarm::Receiver<Vec<u8>>`
and the producer is an actor the handler spawned; the conn actor pumps
(`pump_stream`) and keeps sole ownership of the socket and write
deadlines. The recv() park between chunks is stoppable by the draining
registry, so infinite streams die at the drain deadline with no
polling. HTTP/1.1: chunked TE, keep-alive after the terminator;
HTTP/1.0: raw bytes, forced close, EOF-delimited. End of stream =
every Sender dropped = 0-chunk. Timeout split (handoff Q3, as
proposed): `request_timeout` covers the READ phase only; new
`Config.write_timeout` (default 30s) bounds every response write —
fresh budget per streamed chunk, and the fixed-body + error-response
writes went deadline-bounded too (write-side slowloris closed).
2. Chunked **request** bodies ✅ — `read_chunked_body` decodes
incrementally on the request deadline; extensions ignored, trailers
consumed + discarded, decoded size capped at max_body_bytes (413),
size-line/trailer spam capped (400). CL + TE: chunked, or chunked on
HTTP/1.0, rejected 400 at parse (smuggling ambiguity). Keep-alive
drain accounts RAW framing length, so pipelined requests behind a
chunked body land exactly.
3. SSE sugar ✅ — `Conn::sse()` / `sse_with_heartbeat``EventSender`
(`send(event, data)` / `data` / `comment`, Clone, `Err(SseClosed)`
once the stream is gone). Heartbeat lives in the pump as leaned: with
`StreamBody.heartbeat` set, the chunk wait is `recv_timeout(interval)`
and expiry writes a `: keep-alive` comment chunk. Liveness from the
ping (dead client = heartbeat write stalls past write_timeout), not
from any request clock.
**Exit criteria (verified):** chunked round-trips both directions with
keep-alive and pipelining intact; an infinite SSE stream heartbeats while
silent, is killed by write_timeout when the client stops reading, and is
force-stopped at the drain deadline on shutdown; full suite green.
---