smarm 0.7 (415effb, "lifetime is the actor's — refs are addresses") removed the
rule these three actors were built on: a GenServerRef no longer owns the server,
the loop holds its own inbox sender, and the inbox never closes when the last ref
drops. urus's pubsub table, channel hub bus and session registries were still
governed by that deleted rule — PubSub::new() spawned the table and the handle
owned its life — so nothing commanded them to stop. What still terminated a run
was the root-exit sweep, racing the drain: shutdown_with_open_chat_terminates and
channels_wire::shutdown_with_open_channel_terminates failed 4 times in 25
--all-features runs with "serve did not return: ... outlived the drain: Timeout".
Zero in 10 full-suite runs after this change.
The fix is not a supervisor wrapped around the old shape. Every gotcha in this
area descended from constructors that spawn: PubSub::new(), ChannelHub::new() and
PrefixRouter::channel_session() all started actors, which forced in-runtime-only
construction, which forced the Arc<OnceLock<..>> lazy-init from the first handler,
which forced the "cell must not be static" and "a relay must never hold a PubSub
clone" rules. Five documented rules propping up one inverted dependency. So:
description is separated from instantiation.
- PubSub<M> is a name, not a GenServerRef: const-constructible, Copy, spawns
nothing, valid outside the runtime and in a static. Operations resolve through
the registry per call, so a table restarted by its supervisor is reached
transparently (one lookup per broadcast — bench before caching a ref, which
would go stale across exactly the restart the supervisor exists to perform).
PubSub::new() is gone; PubSub::new(name) + PubSub::child() replace it.
- ChannelHub::new(bus, router) returns (hub, Vec<ChildSpec>) — the bus table plus
one registry per session route. Returning both is the point: a hub whose
children were never started compiles and fails on the first join, so the vec is
not left behind a method you can forget to call. #[must_use].
- channel_session gains a registry name; each session registry is separately
named and separately supervised.
- serve_with/serve_with_shutdown take a Vec<ChildSpec> of app children and build
the root as RestForOne[..app children, endpoint]. They start before the
endpoint and, shutdown being ordered in reverse, stop after it has drained, so
a request still in flight can reach the bus. RestForOne because a bus crash
leaves live sockets addressing a table that no longer knows them.
- Deleted: the Arc<OnceLock> idiom from both examples and both test pipelines,
and the module rules that existed only to hand-manage a refcount.
Known cost, not fixed here: channel_session("session:*", "chat-sessions", f) puts
two unrelated string literals side by side and nothing catches a transposition —
a RegistryName newtype is the obvious follow-up.
Tests: 111 lib + 50 integration + 2 doc green, clippy clean, 10/10 full-suite
runs. Unit tests poll for name binding before use — smarm's start-order-is-not-
start-readiness gap; real apps don't hit it, since a handler only runs once a
connection has been accepted.
116 lines
4.1 KiB
Rust
116 lines
4.1 KiB
Rust
//! WebSocket chat rooms (v0.7): `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}`). `BUS` is a `const`: the handle is an address, not
|
|
//! the table. The table actor is `BUS.child()`, handed to
|
|
//! `serve_with_shutdown` as an app child, so it starts before the
|
|
//! endpoint and — shutdown being ordered in reverse — stops after the
|
|
//! endpoint has drained.
|
|
//!
|
|
//! The v0.5 idiom this replaces was a non-static `Arc<OnceLock<PubSub>>`
|
|
//! lazily initialised from the first connection, because `PubSub::new()`
|
|
//! used to spawn the table and the handle used to own its life. Neither
|
|
//! is true any more.
|
|
//!
|
|
//! - **`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.** It may
|
|
//! also hold `BUS` — a handle pins nothing now — but it has no use for
|
|
//! one. Conn dies → monitor prunes → sender drops → relay's recv errs
|
|
//! → relay exits.
|
|
|
|
use urus::{
|
|
serve_with_shutdown, shutdown_handle, Config, Conn, Message, Next, Pipeline, PubSub, Router,
|
|
WsHandler, WsSender,
|
|
};
|
|
|
|
const BUS: PubSub<String> = PubSub::new("chat");
|
|
|
|
struct ChatHandler {
|
|
room: String,
|
|
}
|
|
|
|
impl ChatHandler {
|
|
fn topic(&self) -> String {
|
|
format!("room:{}", self.room)
|
|
}
|
|
}
|
|
|
|
impl WsHandler for ChatHandler {
|
|
fn on_open(&mut self, sender: &WsSender) {
|
|
let topic = self.topic();
|
|
let rx = match BUS.subscribe(&topic) {
|
|
Ok(rx) => rx,
|
|
Err(_) => {
|
|
let _ = sender.close(1011, "chat bus down");
|
|
return;
|
|
}
|
|
};
|
|
let _ = 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).
|
|
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 _ = BUS.broadcast_from(smarm::self_pid(), self.topic(), text);
|
|
}
|
|
|
|
fn on_close(&mut self, _code: Option<u16>, _reason: &str) {
|
|
let topic = self.topic();
|
|
let _ = 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 pipeline = Pipeline::new().plug(Router::new().get("/chat/:room", |c: Conn, _n: Next| {
|
|
let room = c.params.get("room").unwrap_or("lobby").to_string();
|
|
c.upgrade(ChatHandler { 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()),
|
|
smarm::Config::default(),
|
|
pipeline,
|
|
vec![BUS.child()],
|
|
signal,
|
|
)
|
|
.unwrap();
|
|
println!("ws_chat: drained, bye");
|
|
}
|