feat(pubsub,channels)!: handles are addresses — bus, hub and session registries are supervised children

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.
This commit is contained in:
Claude
2026-08-20 14:49:23 +00:00
parent 8a568c600c
commit 8f0da2a806
13 changed files with 466 additions and 251 deletions
+23 -44
View File
@@ -1,4 +1,4 @@
//! WebSocket chat rooms (v0.5): `urus::pubsub` wired to the v0.4 duplex.
//! 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)
@@ -6,41 +6,35 @@
//! 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,
//! because `PubSub::new()` spawns the table actor and smarm only
//! allows `spawn` in-runtime — while `serve_with_shutdown` owns the
//! runtime, there is no in-runtime moment before the first request.
//! Keep the cell non-static so the table is dropped with the drained
//! pipeline rather than pinned for the life of the process.
//! (`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.
//!
//! If your app owns its own runtime and tree (see `crud`), skip this
//! entirely: start the table as a supervised sibling of
//! `urus::endpoint(...)` and address it by name.
//! 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 — 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};
//! - **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,
};
type Bus = PubSub<String>;
const BUS: PubSub<String> = PubSub::new("chat");
struct ChatHandler {
bus: Arc<OnceLock<Bus>>,
room: String,
}
@@ -48,32 +42,23 @@ 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) {
let rx = match 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}"));
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). See the module docs for why it
// must not capture a Bus handle.
// socket is gone (WsClosed).
let out = sender.clone();
smarm::spawn(move || {
while let Ok(msg) = rx.recv() {
@@ -92,16 +77,12 @@ impl WsHandler for ChatHandler {
// 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);
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 _ = self
.bus()
.broadcast_from(smarm::self_pid(), &topic, format!("* someone left {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).
@@ -109,12 +90,9 @@ impl WsHandler for ChatHandler {
}
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 pipeline = Pipeline::new().plug(Router::new().get("/chat/:room", |c: Conn, _n: Next| {
let room = c.params.get("room").unwrap_or("lobby").to_string();
let bus = bus.clone();
c.upgrade(ChatHandler { bus, room })
c.upgrade(ChatHandler { room })
}));
let (handle, signal) = shutdown_handle();
@@ -129,6 +107,7 @@ fn main() {
Config::new("0.0.0.0:8080".parse().unwrap()),
smarm::Config::default(),
pipeline,
vec![BUS.child()],
signal,
)
.unwrap();