parser: Transfer-Encoding: chunked no longer 411s — it sets
ParsedHead.chunked and the conn actor decodes. Two hard rejections at
parse time, both Malformed/400: chunked together with Content-Length
(the request-smuggling ambiguity; RFC 7230 §3.3.3 permits rejection)
and chunked on HTTP/1.0 (TE is a 1.1 mechanism). ParseError::Unsupported
is now unconstructed; kept for future framings.
conn_actor: read_chunked_body decodes incrementally from buf[head_len..],
reading on the SAME request deadline the head came in under (a stalled
chunked body dies at request_timeout exactly like a stalled CL body).
Chunk extensions are ignored; trailers are consumed and discarded.
Bounds: decoded size capped at max_body_bytes -> 413 the moment the cap
would be crossed (the CL pre-check can't see a chunked body's size up
front); size lines capped at 128 bytes and the trailer section at 8 KiB
-> 400, so framing spam can't grow the read buffer unboundedly.
Returns (decoded, raw_consumed_past_head): the keep-alive drain at the
loop bottom now drops head_len + RAW framing length (not decoded length),
so pipelined requests behind a chunked body land exactly — covered by
the trailers+pipelining test.
Tests: 3 parser unit (flagging, CL+TE reject, 1.0 reject; the old
chunked-is-Unsupported test replaced) + 6 integration (decode with
extensions, trailers + pipelined next request, 413 over decoded cap,
400 malformed size line, 400 CL+TE on the wire, stalled chunked body
killed at request_timeout with silent close).
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).
Two wall-clock budgets in the conn read path, both via
smarm::wait_readable_timeout:
- keep_alive_timeout: idle budget between requests — how long we park
waiting for the FIRST byte of a request (including the first request on
a fresh connection). Expiry closes silently; nothing is owed to a
client that isn't talking.
- request_timeout: per-request budget as an Instant deadline from the
first byte of a request until the request (head + body) is fully read.
Pipelined leftovers in the buffer at cycle start count as a started
request. The deadline is returned from read_head and threaded into
read_body so head and body share one budget. Pipeline run time is NOT
covered. Each read wait uses whichever budget is currently active;
deadlines are Instants, so EAGAIN retries don't reset the clock.
Expiry mid-head gets a best-effort 408 via try_write_once: a single
non-parking write syscall — a client that stalls its read side must not
defeat the timeout by making the 408 write park forever. Expiry mid-body
just closes.
examples/crud.rs gains stdin-Enter graceful shutdown (plain OS thread on
read_line -> handle.shutdown(); no signal crate). Doing so surfaced a
pattern worth knowing: crud's lazily-spawned store actor parked forever
in recv(), which blocks smarm's AllDone, so serve_with_shutdown never
returned (gdb: scheduler idle in poll_wake, store the only live actor).
It can't be messaged awake from the stdin thread either — a cross-thread
send's unpark is a no-op without runtime TLS, the same limitation behind
SHUTDOWN_POLL. Fix: store_loop recv_timeout(250ms) + a SHUTTING_DOWN
AtomicBool set by the stdin thread before handle.shutdown(). This poll
dies with the cross-thread-unpark limitation; recorded in smarm docs.
Tests: idle keep-alive conn reaped at a small keep_alive_timeout;
slowloris partial-head stall killed at request_timeout with the 408
observed. Timeout+shutdown subset hammered 30x clean; full suite 3x;
smarm-trace build and suite clean.
Shutdown is drain-then-force-stop: flag.store(true) -> sup_h.join() (the
no-more-accepts barrier) -> cast BeginDrain -> poll ConnCount every 50ms
until 0 or drain_timeout; past the deadline each tick casts ForceStopConns
and re-sweeps, so conns that registered after the deadline are still
caught.
Listener shutdown is redesigned vs chunk 1: Restart::Permanent ->
Transient, wait-failure return -> panic (Transient restarts it), and a
shared Arc<AtomicBool> flag checked at loop top with a 250ms
wait_readable_timeout tick instead of request_stop — no request_stop on
the supervisor or listeners anywhere. Historical note: the redesign was
originally forced by smarm's then-lossy stop against QUEUED actors (fixed
upstream in 7bab4d2); it is kept because flag-based listener shutdown is
simpler and stop-semantics-free.
Conns self-register with the registry (ConnStarted from the conn actor,
not a listener cast): per-sender FIFO ordering is the only thing that
prevents ConnEnded overtaking ConnStarted; see conn_registry module docs.
conn_actor: in the catch_unwind(pipeline.run) Err branch,
smarm::preempt::check_cancelled() runs BEFORE composing the 500 — smarm's
stop is an undowncastable panic_any(StopSentinel), so the catch_unwind
would otherwise swallow a stop and 500-and-keep-running. The stop flag is
persistent, so check_cancelled re-raises cleanly.
Handle.shutdown() wakes the root via a 100ms try_recv poll
(SHUTDOWN_POLL): a cross-thread Sender::send's unpark is a
try_with_runtime no-op without runtime TLS on the sending thread (still
true on smarm 8e5b754; cross-thread unpark is a recorded smarm roadmap
candidate — this poll dies with it).
Two smarm bugs were found during this chunk and fixed upstream: lossy
stop against QUEUED actors (7bab4d2) and the terminal-wake shutdown
stall/hang (eddf3fe); post-mortem in artefact smarm-bug-terminal-wake.md.