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:
@@ -256,6 +256,49 @@ process registry: `urus.server` (the listener-pool supervisor) and
|
||||
`urus.listener.{i}` (each accept loop). `smarm::whereis(name)` resolves
|
||||
them from any actor inside the runtime.
|
||||
|
||||
### WebSocket (v0.4)
|
||||
|
||||
A route handler accepts the upgrade by handing `Conn::upgrade` a
|
||||
`WsHandler`; after the `101` the connection actor leaves HTTP and drives
|
||||
the handler from a duplex select loop (smarm RFC 008 fd arms: first-of
|
||||
outbound-channel / fd-readable, one actor, no fd dup):
|
||||
|
||||
```rust
|
||||
struct Echo;
|
||||
|
||||
impl urus::WsHandler for Echo {
|
||||
fn on_message(&mut self, msg: urus::Message, sender: &urus::WsSender) {
|
||||
let _ = sender.send(msg); // or .text(..) / .binary(..) / .ping() / .close(code, reason)
|
||||
}
|
||||
fn on_close(&mut self, code: Option<u16>, reason: &str) {
|
||||
// what the PEER said: Some(code) iff its close frame arrived;
|
||||
// None for EOF / write failure / handshake timeout.
|
||||
let _ = (code, reason);
|
||||
}
|
||||
}
|
||||
|
||||
Router::new().get("/echo", |c: Conn, _n: Next| c.upgrade(Echo))
|
||||
```
|
||||
|
||||
Callbacks run **inside the connection actor** — a slow `on_message`
|
||||
stops reads, which is backpressure, not a bug. A handler that wants
|
||||
concurrency spawns its own actor and hands it a `WsSender` clone (the
|
||||
SSE-producer pattern); every `WsSender` method returns `Err(WsClosed)`
|
||||
once the connection ends. While a large outbound frame is mid-write the
|
||||
actor isn't reading — accepted v1 trade-off of the one-actor topology.
|
||||
|
||||
Control frames are invisible to the handler in v1: pings are
|
||||
auto-ponged, a peer close is auto-echoed and surfaces as `on_close`.
|
||||
Protocol violations close with the mapped RFC 6455 code (1002/1009/1007;
|
||||
handler panic → 1011). Caps: `Config.max_frame_payload` (1 MiB default,
|
||||
enforced from the frame header before payload buffers) and
|
||||
`Config.max_message_bytes` (4 MiB default, spans fragments).
|
||||
|
||||
Like SSE, an open WebSocket has no request clock: idle is fine, a dead
|
||||
client is caught when a write stalls past `write_timeout`, and graceful
|
||||
shutdown force-stops the connection at the drain deadline. See
|
||||
`examples/ws_echo.rs`.
|
||||
|
||||
## Examples
|
||||
|
||||
### CRUD with Actor Ownership
|
||||
@@ -326,9 +369,10 @@ This avoids the complexity of async ecosystems while maintaining full concurrenc
|
||||
## Roadmap
|
||||
|
||||
See [`ROADMAP.md`](ROADMAP.md). Summary: v0.2 (supervised listener pool,
|
||||
graceful shutdown, enforced timeouts, registry names) and v0.3 (streaming
|
||||
bodies, chunked requests, SSE) are done; next up are WebSocket (v0.4),
|
||||
PubSub (v0.5), and Phoenix-style channels (v0.6).
|
||||
graceful shutdown, enforced timeouts, registry names), v0.3 (streaming
|
||||
bodies, chunked requests, SSE) and v0.4 (WebSocket: handshake, frame
|
||||
codec, duplex handler API) are done; next up are PubSub (v0.5) and
|
||||
Phoenix-style channels (v0.6).
|
||||
|
||||
Refer to `urus-spec.md` and `urus-v1-build-notes.md` in the artifact persistence for the original design and implementation notes.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user