Topology (b) as agreed: ONE actor per connection via smarm RFC 008 fd
arms. After the 101, ws::duplex::run_duplex replaces the HTTP loop:
try_select(&[&outbound_rx, &FdArm::readable(fd)]) per iteration,
outbound at index 0 (priority order = owed writes drain before reads;
honest backpressure). Accepted gap documented: mid-write of a large
outbound frame the actor isn't reading.
Handler API (deferred questions, as answered this session):
- WsHandler { on_message, on_close } runs IN the conn actor's select
loop; concurrency = spawn an actor with a WsSender clone (the SSE
producer pattern). Conn::upgrade(self, handler) flat signature;
WsUpgrade grows from marker to Box<dyn WsHandler> payload.
- WsSender: Clone; send/text/binary/ping/close -> Err(WsClosed).
Unbounded channel like SSE; slow client bounded by write_timeout.
- Control frames invisible v1: auto-pong inline (5.5.2), peer close
auto-echoed (5.5.1; server closes TCP first per 7.1.1) surfacing
only as on_close(Some(code), reason); wordless endings (EOF, write
failure, drain timeout) = on_close(None, ""). on_ping/on_pong can
land later as default methods, non-breaking.
- Server-initiated close (WsSender::close, FrameError->1002/1009/1007,
handler panic->1011): close out, bounded drain (one write_timeout
budget) for the peer echo, data discarded (1.4). Handler panics
re-raise smarm's stop sentinel first (the pipeline catch_unwind
dance, replicated).
- Caps: Config.max_frame_payload (1 MiB) / max_message_bytes (4 MiB).
Conn actor: 101 branch now hands fd + leftover buf (pipelined first
frame carries over; tested) + boxed handler to run_duplex; registry
entry stays Busy for the ws lifetime, so graceful shutdown force-stops
the conn out of the select park at the drain deadline (tested — the
in-runtime request_stop wake covers select parks too).
Tests: chunk-1 EOF test rewritten into a 9-test duplex suite (echo,
pipelined first frame, ping/pong, both close directions, 1002 unmasked,
1009 header-cap, fragmentation with interleaved ping, shutdown
force-stop). Suite 59u+40i+2d. Hammer: 35x lifecycle+ws subset + 3 full
+ 1 full under smarm-trace, all green; no AlreadyExists out of
try_select (the fresh eager-cleanup path held). Validated against
python websocket-client (echo, pong payload, close 1000).
examples/ws_echo.rs: /echo in-actor + /clock producer-spawning.
Design (as agreed in the v0.4 pass):
- Dep #3 spent: sha1_smol 1.0.1 (zero transitive deps) instead of a
vendored SHA-1 — user call: no hand-rolled crypto. Consequence noted
in ROADMAP: the v0.6 wire-format JSON question now costs dep #4.
- Base64 stays in-tree but ENCODE-ONLY (RFC 4648 vectors tested): it is
an encoding, not crypto, and the decode direction (where parsing bugs
live) is deliberately not implemented.
- src/ws/{mod,handshake}.rs: validate() implements §4.2.1 (GET, 1.1,
Host, upgrade/connection token lists case-insensitively, key shape =
base64 of exactly 16 bytes, version 13). Version mismatch -> 426 +
sec-websocket-version: 13 (§4.2.2); everything else -> 400. Rejected
upgrades stay plain HTTP with keep-alive intact (tested: 400 then 101
on the same connection).
- Conn grows pub upgrade: Option<WsUpgrade> (opaque marker; chunk 3
turns it into the duplex/handler handoff payload — shape deliberately
uncommitted while the handler API is open). Conn::upgrade(self) is the
commit point: 101 + accept key + marker + halt, or the rejection.
- conn_actor: upgrade marker + status 101 -> write head, leave the HTTP
loop. Until chunk 3 that drops the fd (clients see 101 then EOF);
status check is defensive against a post-handler plug clobbering 101.
- serialise_response: 1xx no longer get content-length injected
(RFC 7230 §3.3.2). 204 left alone on purpose — separate conversation.
Accept-key path pinned to the §1.3 worked example end-to-end (unit +
wire). Suite: 34 unit + 33 integration + 2 doc.
New src/sse.rs. Conn::sse() (and sse_with_heartbeat) turns the response
into a text/event-stream: status 200 if unset, content-type +
cache-control: no-cache, and a RespBody::Stream whose heartbeat is wired
to the new StreamBody.heartbeat field. Returns (Conn, EventSender);
EventSender is Clone (stream ends when the LAST clone drops) with
send(event, data) / data(data) / comment(text), all returning
Err(SseClosed) once the conn actor has dropped the stream — the producer
exit signal. Multi-line data becomes one data: line per line (pure
format_event, unit-tested).
Heartbeat lives in the pump, as the roadmap leaned: with
StreamBody.heartbeat set, pump_stream waits recv_timeout(interval) and on
expiry writes the payload (": keep-alive" comment chunk) and keeps
waiting. Liveness inversion is deliberate: no request clock governs an
SSE response (request_timeout is read-phase only since chunk 1); a dead
client is detected when an event or heartbeat write stalls past
write_timeout, and a draining shutdown force-stops the recv park like any
stream.
Tests: 4 unit (3 framing, 1 sse() wiring) + 2 integration (event round
trip over decoded chunked stream incl. named + unnamed events and clean
0-chunk EOS on sender drop; >=2 heartbeats observed across 450ms of
producer silence at a 100ms interval).
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).