Files
urus/examples/ws_echo.rs
T
Claude ffc579c500 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.
2026-06-12 09:44:35 +00:00

104 lines
3.5 KiB
Rust

//! WebSocket echo server (v0.4).
//!
//! cargo run --example ws_echo
//! websocat ws://127.0.0.1:8080/echo
//!
//! Demonstrates the chunk-3 handler model:
//! - `EchoHandler` runs INSIDE the connection actor's select loop — echo
//! is exactly the workload that wants zero extra moving parts.
//! - `/clock` shows the other shape: the handler spawns a producer actor
//! at `on_message("start")` and hands it a `WsSender` clone — the
//! SSE-producer pattern, verbatim. The producer exits when its send
//! fails (`WsClosed`: client gone or server shutting down).
//!
//! Routing happens before the upgrade, so one server can host both
//! endpoints — `Conn::upgrade(handler)` is just another thing a route
//! handler returns.
use std::time::Duration;
use urus::{
serve_with_shutdown, shutdown_handle, Config, Conn, Message, Next, Pipeline, Router,
WsHandler, WsSender,
};
// ---------------------------------------------------------------------------
// /echo — everything comes straight back
// ---------------------------------------------------------------------------
struct EchoHandler;
impl WsHandler for EchoHandler {
fn on_message(&mut self, msg: Message, sender: &WsSender) {
if matches!(&msg, Message::Text(t) if t == "bye") {
let _ = sender.close(1000, "you said bye");
return;
}
let _ = sender.send(msg);
}
fn on_close(&mut self, code: Option<u16>, reason: &str) {
println!("ws_echo: /echo closed (code {code:?}, reason {reason:?})");
}
}
// ---------------------------------------------------------------------------
// /clock — "start" spawns a producer actor ticking once a second
// ---------------------------------------------------------------------------
struct ClockHandler {
started: bool,
}
impl WsHandler for ClockHandler {
fn on_message(&mut self, msg: Message, sender: &WsSender) {
match msg {
Message::Text(t) if t == "start" && !self.started => {
self.started = true;
let out = sender.clone();
smarm::spawn(move || {
let mut n = 0u64;
loop {
if out.text(format!("tick {n}")).is_err() {
return; // WsClosed: connection over, we follow
}
n += 1;
smarm::sleep(Duration::from_secs(1));
}
});
}
_ => {
let _ = sender.text("send \"start\" to begin");
}
}
}
}
// ---------------------------------------------------------------------------
// main
// ---------------------------------------------------------------------------
fn main() {
let pipeline = Pipeline::new().plug(
Router::new()
.get("/echo", |c: Conn, _n: Next| c.upgrade(EchoHandler))
.get("/clock", |c: Conn, _n: Next| {
c.upgrade(ClockHandler { started: false })
}),
);
let cfg = Config::new("127.0.0.1:8080".parse().unwrap());
println!("ws_echo: ws://127.0.0.1:8080/echo and /clock — press Enter to shut down");
let (handle, signal) = shutdown_handle();
std::thread::spawn(move || {
let mut line = String::new();
let _ = std::io::stdin().read_line(&mut line);
println!("ws_echo: shutting down…");
handle.shutdown();
});
serve_with_shutdown(cfg, pipeline, signal).unwrap();
println!("ws_echo: bye");
}