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:
+41
-17
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user