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.