Files
urus/examples/ws_chat.rs
T
Claude ad19848db3 feat(ws+pubsub): on_open hook + ws_chat — v0.5 chunk 2
WsHandler::on_open(&mut self, sender) — defaulted, non-breaking,
added mid-chunk for veto-by-diff (the v0.3 "push" precedent for
defaults-level decisions): without it a listen-only ws client can
never be subscribed, since on_message never fires for a client that
doesn't send. Runs exactly once, first, in the conn actor, before any
buffered pipelined frame is decoded. Panic contract identical to
on_message: check_cancelled re-raise dance, else 1011 and no
on_close.

DISCOVERY 1 (documented, not fixed — inherent): the 101 reaches the
client BEFORE on_open runs, so "is my subscription live yet" is a
race client-side. App-level ack is the answer (any reply proves
on_open completed; callbacks are sequential). The integration tests
hit this immediately (first read of a join notice flaked into a 5s
read-timeout) and use a sync/synced ack; same reason Phoenix joins
reply.

DISCOVERY 2 (the structural one): PubSub::new() is in-runtime only,
but pipelines are built pre-runtime. crud's static-OnceLock bootstrap
DOES NOT COMPOSE with serve_with_shutdown here: a static pins the
table actor's last ServerRef forever, its inbox never closes, the
gen_server never exits, AllDone is unreachable — serve hangs (the
cross-thread-unpark limitation closes the workaround routes). The
pattern that works: NON-static Arc<OnceLock<PubSub<M>>> captured by
the route closure. Drain stops conns+listeners -> last Arc<Pipeline>
drops IN-runtime -> cell+handle drop -> inbox closes -> table exits.
Corollary: relays hold the Receiver only, NEVER a PubSub clone
(relay holds table's inbox open, table holds relay's receiver open:
mutual keepalive, shutdown hangs). Both rules in module docs, README,
and enforced by the new shutdown_with_open_chat_terminates test:
two open chat sockets + live relays + live table, shutdown must
return within 5s.

examples/ws_chat.rs: rooms as topics (room:{name}), on_open
subscribes (conn-actor pid: the monitor scopes cleanup to the
session) + spawns the relay (rx -> WsSender clone; exits on prune or
WsClosed), on_message broadcast_from(self_pid()) so the speaker is
never echoed, on_close broadcasts the leave notice and relies on the
monitor for unsubscribe.

Integration: ws_chat_broadcast_reaches_other_client_not_sender
(no-echo proven orderingly, no timeout reads: B speaks after A, A's
next frame must be B's) + shutdown_with_open_chat_terminates.

hammer.sh default subset now includes ws_ (v0.4 ran it ad hoc; ws IS
conn-lifecycle). Hammered: 35x subset (incl. both new tests) + 3x
full + 1x full under smarm-trace, all green. dbg!-grep clean. smarm
PRISTINE. README pub/sub + on_open sections; ROADMAP v0.5 as-landed.

Suite: 67 unit + 42 integration + 2 doc.
2026-06-12 10:30:08 +00:00

129 lines
4.7 KiB
Rust

//! WebSocket chat rooms (v0.5): `urus::pubsub` wired to the v0.4 duplex.
//!
//! cargo run --example ws_chat
//! websocat ws://127.0.0.1:8080/chat/lobby (run two of these)
//!
//! The shapes this demonstrates:
//!
//! - **One `PubSub<String>` for the whole app**, topics are rooms
//! (`room:{name}`). Built lazily on the first connection via a
//! NON-static `Arc<OnceLock<PubSub>>` captured by the route closure:
//! `PubSub::new()` spawns the table actor, which smarm only allows
//! in-runtime — and keeping the cell non-static means the table's
//! last handle drops in-runtime when the drained pipeline drops, so
//! `serve_with_shutdown` actually returns. A `static` cell would pin
//! the table forever and block smarm's all-done. (Same constraint
//! crud's store solves with its OnceLock; the non-static refinement
//! is what makes graceful shutdown compose.)
//!
//! - **`on_open` subscribes and spawns the relay** — a listen-only
//! client receives the room without ever sending. The subscription is
//! pinned to the CONNECTION actor (`on_open` runs inside it), so the
//! monitor cleans up exactly when the connection dies.
//!
//! - **The relay holds the `Receiver` and a `WsSender` clone — and
//! deliberately NOT a `PubSub` handle.** A relay holding the handle
//! keeps the table's inbox open while the table keeps the relay's
//! receiver open: neither ever exits, and shutdown hangs. Receiver
//! only: conn dies → monitor prunes → sender drops → relay's recv
//! errs → relay exits. Every link in that chain is in-runtime.
use std::sync::{Arc, OnceLock};
use urus::{
serve_with_shutdown, shutdown_handle, Config, Conn, Message, Next, Pipeline, PubSub, Router,
WsHandler, WsSender,
};
type Bus = PubSub<String>;
struct ChatHandler {
bus: Arc<OnceLock<Bus>>,
room: String,
}
impl ChatHandler {
fn topic(&self) -> String {
format!("room:{}", self.room)
}
fn bus(&self) -> &Bus {
// First connection anywhere spawns the table; we are inside the
// connection actor here, so the spawn is legal.
self.bus.get_or_init(PubSub::new)
}
}
impl WsHandler for ChatHandler {
fn on_open(&mut self, sender: &WsSender) {
let topic = self.topic();
let rx = match self.bus().subscribe(&topic) {
Ok(rx) => rx,
Err(_) => {
let _ = sender.close(1011, "chat bus down");
return;
}
};
let _ = self
.bus()
.broadcast_from(smarm::self_pid(), &topic, format!("* someone joined {topic}"));
// The relay: room messages -> this socket. Exits when the
// subscription is pruned (conn death / unsubscribe) or the
// socket is gone (WsClosed). See the module docs for why it
// must not capture a Bus handle.
let out = sender.clone();
smarm::spawn(move || {
while let Ok(msg) = rx.recv() {
if out.send(Message::Text((*msg).clone())).is_err() {
break; // client gone or server draining
}
}
});
}
fn on_message(&mut self, msg: Message, sender: &WsSender) {
let Message::Text(text) = msg else {
let _ = sender.close(1003, "text only");
return;
};
// broadcast_from: the sender's own relay is skipped — no echo.
// self_pid() here is the connection actor, the pid on_open
// subscribed as.
let _ = self
.bus()
.broadcast_from(smarm::self_pid(), &self.topic(), text);
}
fn on_close(&mut self, _code: Option<u16>, _reason: &str) {
let topic = self.topic();
let _ = self
.bus()
.broadcast_from(smarm::self_pid(), &topic, format!("* someone left {topic}"));
// No explicit unsubscribe: the connection actor is about to
// exit and the monitor prunes the subscription (which is also
// what stops the relay).
}
}
fn main() {
let bus: Arc<OnceLock<Bus>> = Arc::new(OnceLock::new());
let pipeline = Pipeline::new().plug(Router::new().get("/chat/:room", move |c: Conn, _n: Next| {
let room = c.params.get("room").unwrap_or("lobby").to_string();
let bus = bus.clone();
c.upgrade(ChatHandler { bus, room })
}));
let (handle, signal) = shutdown_handle();
std::thread::spawn(move || {
println!("ws_chat on ws://127.0.0.1:8080/chat/:room — press Enter to shut down");
let mut line = String::new();
let _ = std::io::stdin().read_line(&mut line);
handle.shutdown();
});
serve_with_shutdown(Config::new("0.0.0.0:8080".parse().unwrap()), pipeline, signal).unwrap();
println!("ws_chat: drained, bye");
}