feat(ws): duplex wiring + handler API + echo example (v0.4 chunk 3)

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.
This commit is contained in:
Claude
2026-06-12 09:44:35 +00:00
parent 64137cccf0
commit ffc579c500
10 changed files with 902 additions and 116 deletions
+41 -17
View File
@@ -159,7 +159,7 @@ force-stopped at the drain deadline on shutdown; full suite green.
---
## v0.4 — WebSocket upgrade path
## v0.4 — WebSocket upgrade path ✅ (landed 2026-06, chunks 1-3)
1. **Handshake** ✅ (49538cc) — `Conn::upgrade()` (handler arg comes
with chunk 3): §4.2.1 validation, 101 + accept key, marker field on
@@ -175,22 +175,46 @@ force-stopped at the drain deadline on shutdown; full suite green.
close-code wire validation, `Assembler` for fragmentation with
whole-message UTF-8 check, `FrameError`→close-code map
(1002/1009/1007). §5.7 examples pinned verbatim.
3. **Duplex topology** (the design decision from the assessment):
- **(a) Two actors, dup'd fd**: reader actor on the dup, writer actor
on the original, writer owns a `Receiver<Frame>`; the user handler
talks to both via channels. Proven trick (listener pool), works today.
- **(b) One actor, smarm first-of(fd-readable, channel)**: fd arms in
select landed with RFC 008 (`393cdd0`) — no longer blocked. Simpler
topology, no dup; use the fallible `try_select` twins (registration
errors, incl. `AlreadyExists`, surface as `Err` and the wait fully
retires).
Was "default (a), revisit if (b) lands" — (b) landed, so this is a
live choice for the v0.4 design pass. Note RFC 008 is phase 1:
single-waiter-per-fd stands, `dup` remains the documented answer for
true simultaneous read+write waits.
4. Handler API sketch (settle in design pass): trait with
`on_message/on_close` + a `WsSender` handle — gen_server-shaped, so a
ws connection *is* an actor you can monitor, link, and register.
3. **Duplex wiring** ✅ — topology decision: **(b) one actor, RFC 008
fd arms** (urus exists to put smarm through its paces; first fd-arm
consumer is the point). As landed:
- `ws::duplex::run_duplex` replaces the HTTP loop after the 101:
`try_select(&[&outbound_rx, &FdArm::readable(fd)])` per iteration —
outbound at index 0 (select is priority-ordered: owed writes drain
before reading more). Accepted gap documented: mid-write of a large
outbound frame the actor isn't reading.
- Handler API (the deferred questions, as answered): in-actor
callbacks — `WsHandler { on_message, on_close }` runs inside the
select loop; concurrency = spawn your own actor with a `WsSender`
clone (the SSE-producer pattern). `Conn::upgrade(self, handler)`
flat signature (builder deferred until per-upgrade options exist,
e.g. subprotocols). `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, handshake timeout) =
`on_close(None, "")`. `on_ping`/`on_pong` can land later as default
trait methods, non-breaking.
- Server-initiated close (WsSender::close or FrameError→1002/1009/
1007, handler panic→1011): close frame out, then a bounded drain
(one write_timeout budget) for the peer's echo, discarding data
(§1.4). Handler panics re-raise smarm's stop sentinel before being
treated as panics — the pipeline's catch_unwind dance, replicated.
- Caps: `Config.max_frame_payload` (1 MiB) / `max_message_bytes`
(4 MiB) — Config fields per the max_body_bytes precedent; header
cap checked before payload buffers.
- Buf handover: bytes pipelined past the upgrade head carry into the
duplex loop (tested).
- Registry: conn stays **Busy** for the ws lifetime (an open ws is
in-flight work); graceful shutdown force-stops it out of the select
park at the drain deadline (tested — the in-runtime request_stop
wake covers select parks, not just channel recv).
- Validated against a real client (python websocket-client): echo,
ping/pong payload, close handshake. `examples/ws_echo.rs` shows
both handler shapes (/echo in-actor, /clock producer-spawning).
4. **Handler API** ✅ — folded into chunk 3 above (was: settle in design
pass; settled: gen_server-shaped trait + WsSender handle).
## v0.5 — PubSub