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
+123 -55
View File
@@ -38,37 +38,46 @@
//! but stays alive keeps its (one-shot, inert) monitor until death;
//! that's a bounded bookkeeping entry, not a leak.
//!
//! # Construction must happen in-runtime
//! # The handle is an address, not an owner (v0.7)
//!
//! [`PubSub::new`] spawns the table actor, which smarm only permits from
//! inside `Runtime::run`. Pipelines are built *before* `serve*` boots the
//! runtime, so the working pattern (same constraint crud's store hits) is
//! lazy init from the first handler — but with a **non-static**
//! `Arc<OnceLock<PubSub<M>>>` captured by the route closure, NOT a
//! `static`: when the drained pipeline drops at graceful shutdown, the
//! cell (and thus the last handle) drops in-runtime, the table's inbox
//! closes, and the table exits — `serve_with_shutdown` returns. A
//! `static` pins the table forever and blocks smarm's all-done. The same
//! reasoning forbids long-lived consumer actors (relays, producers) from
//! holding a `PubSub` clone: relay holds table's inbox open, table holds
//! relay's receiver open, neither exits. Relays take the `Receiver` only.
//! See `examples/ws_chat.rs` and the `shutdown_with_open_chat_terminates`
//! integration test for the full chain.
//! [`PubSub::new`] takes a **name** and spawns nothing. It is `const`,
//! costs a `&'static str`, and may be built anywhere — out of the
//! runtime, in a `static`, in a route closure, held by a long-lived
//! relay. Every operation resolves the name through smarm's registry, so
//! a table restarted by its supervisor is reached transparently.
//!
//! # Why there is no `register(name)` helper (yet)
//! The table actor is started by [`PubSub::child`], a `ChildSpec` you put
//! in your supervision tree (or hand to `serve_with*`, which puts it in
//! the root ahead of the endpoint). Its lifetime is the supervisor's:
//! it stops when the supervisor shuts it down, in reverse start order,
//! after the endpoint has drained.
//!
//! smarm's registry maps `name → Pid`, but a `Pid` cannot be turned back
//! into a `GenServerRef` (the ref *is* the inbox sender). A useful named
//! lookup therefore needs either smarm support (registry-held senders)
//! or a process-global type-erased map here — both against the grain of
//! the ratified design. Deferred; pass the handle.
//! ```ignore
//! const BUS: PubSub<Event> = PubSub::new("events");
//! serve_with(cfg, rt_cfg, pipeline, vec![BUS.child()])?;
//! ```
//!
//! This replaces the v0.5–v0.6 idiom where `PubSub::new()` spawned the
//! table and the handle owned its life — a non-static
//! `Arc<OnceLock<PubSub<M>>>` lazily initialised from the first handler,
//! with matching rules that the cell must not be `static` and that
//! relays must never hold a clone. All of that existed to hand-manage a
//! refcount. smarm 0.7 made a server's lifetime its own (refs are
//! addresses; the inbox no longer closes when the last ref drops), which
//! both removed the mechanism those rules relied on and made the named
//! lookup below possible.
//!
//! Cost: one registry resolution per operation, including per broadcast.
//! Caching a `GenServerRef` in the handle would save it and go stale
//! across exactly the restart the supervisor exists to perform. Measure
//! before optimising — see the bench item in `ROADMAP.md`.
use std::collections::{HashMap, HashSet};
use std::fmt;
use std::sync::Arc;
use smarm::gen_server::{self, GenServer, GenServerCtx};
use smarm::{channel, Down, Pid, Receiver, Sender, GenServerRef, Watcher};
use smarm::gen_server::{self, GenServer, GenServerBuilder, GenServerCtx, GenServerName};
use smarm::{channel, ChildSpec, Down, GenServerRef, Pid, Receiver, Restart, Sender, Watcher};
// ---------------------------------------------------------------------------
// Public handle
@@ -87,32 +96,58 @@ impl fmt::Display for PubSubDown {
impl std::error::Error for PubSubDown {}
/// A clonable handle to one pub/sub instance (one topic table actor).
/// The address of one pub/sub instance: a name, resolved per operation.
///
/// Cheap (`Copy`, a `&'static str`), constructible anywhere including
/// `const` context, and owns nothing — the table actor it addresses is
/// started by [`child`](Self::child) under a supervisor. Every operation
/// returns [`PubSubDown`] if no live table currently holds the name.
///
/// Payloads are broadcast as `Arc<M>`: one allocation per broadcast, not
/// per subscriber.
pub struct PubSub<M: Send + Sync + 'static> {
server: GenServerRef<Table<M>>,
name: GenServerName<Table<M>>,
}
impl<M: Send + Sync + 'static> Clone for PubSub<M> {
fn clone(&self) -> Self {
PubSub { server: self.server.clone() }
*self
}
}
impl<M: Send + Sync + 'static> Default for PubSub<M> {
fn default() -> Self {
Self::new()
}
}
impl<M: Send + Sync + 'static> Copy for PubSub<M> {}
impl<M: Send + Sync + 'static> PubSub<M> {
/// Start a fresh topic table. Must run inside the smarm runtime (it
/// spawns the table's gen_server actor). The table lives until the
/// last handle is dropped.
pub fn new() -> Self {
PubSub { server: gen_server::start(Table::new()) }
/// Address the topic table registered under `name`. Spawns nothing
/// and never fails: the name is resolved at each use.
pub const fn new(name: &'static str) -> Self {
PubSub { name: GenServerName::new(name) }
}
/// The `ChildSpec` that runs this instance's table actor. Put it in
/// your supervision tree ahead of anything that broadcasts —
/// `serve_with*` takes a `Vec<ChildSpec>` for exactly this.
///
/// `Permanent`: a table that dies is a bug, and its subscribers'
/// receivers died with it, so the restart is only half a repair —
/// pair it with a `RestForOne` parent (as `serve_with*` does) so the
/// endpoint restarts behind it and connections re-subscribe.
pub fn child(&self) -> ChildSpec {
let name = self.name;
ChildSpec::new(Restart::Permanent, move || {
if let Err(e) = GenServerBuilder::new(Table::<M>::new()).named(name).run() {
panic!("urus pubsub '{}': name already taken: {e:?}", name.as_str());
}
})
}
/// The registry key this handle resolves.
pub const fn name(&self) -> &'static str {
self.name.as_str()
}
fn server(&self) -> Result<GenServerRef<Table<M>>, PubSubDown> {
gen_server::whereis_server(self.name).ok_or(PubSubDown)
}
/// Subscribe the **calling actor** to `topic`. Returns the receiving
@@ -139,7 +174,7 @@ impl<M: Send + Sync + 'static> PubSub<M> {
let (tx, rx) = channel();
// A call, not a cast: when this returns the table is updated, so
// a broadcast issued right after by the same caller is seen.
match self.server.call(Call::Subscribe { topic: topic.into(), pid, tx }) {
match self.server()?.call(Call::Subscribe { topic: topic.into(), pid, tx }) {
Ok(_) => Ok(rx),
Err(_) => Err(PubSubDown),
}
@@ -153,7 +188,7 @@ impl<M: Send + Sync + 'static> PubSub<M> {
/// [`unsubscribe`](Self::unsubscribe) for an explicit pid.
pub fn unsubscribe_as(&self, pid: Pid, topic: impl Into<String>) -> Result<(), PubSubDown> {
self.server
self.server()?
.cast(Cast::Unsubscribe { topic: topic.into(), pid })
.map_err(|_| PubSubDown)
}
@@ -181,20 +216,21 @@ impl<M: Send + Sync + 'static> PubSub<M> {
/// table — a subscriber whose receiver was dropped but hasn't been
/// pruned yet (no broadcast since, still alive) is still counted.
pub fn subscriber_count(&self, topic: impl Into<String>) -> Result<usize, PubSubDown> {
match self.server.call(Call::Count { topic: topic.into() }) {
match self.server()?.call(Call::Count { topic: topic.into() }) {
Ok(Reply::Count(n)) => Ok(n),
Ok(Reply::Subscribed) => unreachable!("Count call answered with Subscribed"),
Err(_) => Err(PubSubDown),
}
}
/// The topic table actor's pid — for introspection / registry use.
pub fn pid(&self) -> Pid {
self.server.pid()
/// The topic table actor's pid, or `None` if no live table holds the
/// name (not started yet, or between a crash and its restart).
pub fn pid(&self) -> Option<Pid> {
self.server().ok().map(|s| s.pid())
}
fn cast_broadcast(&self, topic: String, msg: M, skip: Option<Pid>) -> Result<(), PubSubDown> {
self.server
self.server()?
.cast(Cast::Broadcast { topic, msg: Arc::new(msg), skip })
.map_err(|_| PubSubDown)
}
@@ -328,15 +364,33 @@ mod tests {
false
}
/// Start `ps`'s table under a supervisor and hand back the sup's pid.
///
/// The readiness poll is smarm's "start order is not start readiness"
/// gap (see smarm ROADMAP): `start_child` spawns and moves on, so the
/// name may not be bound when this returns. Real apps don't hit it —
/// a handler only runs once a connection has been accepted, long
/// after the tree is up — but a test that broadcasts immediately does.
fn start_table<M: Send + Sync + 'static>(ps: PubSub<M>) -> Pid {
let sup = smarm::spawn(move || smarm::OneForOne::new().child(ps.child()).run());
assert!(eventually(|| ps.pid().is_some()), "table never bound its name");
sup.pid()
}
/// Ordered shutdown of the tree `start_table` built.
fn stop_table(sup: Pid) {
smarm::request_shutdown(sup);
}
#[test]
fn broadcast_reaches_all_subscribers_once() {
let got = Arc::new(Mutex::new(Vec::<(u32, String)>::new()));
let got2 = got.clone();
smarm::run(move || {
let ps = PubSub::<String>::new();
let ps = PubSub::<String>::new("test-bus");
let sup = start_table(ps);
let mut handles = Vec::new();
for i in 0..2u32 {
let ps = ps.clone();
let got = got2.clone();
handles.push(smarm::spawn(move || {
let rx = ps.subscribe("room:a").unwrap();
@@ -351,6 +405,7 @@ mod tests {
for h in handles {
h.join().unwrap();
}
stop_table(sup);
});
let mut v = got.lock().unwrap().clone();
v.sort();
@@ -362,10 +417,10 @@ mod tests {
let ptrs = Arc::new(Mutex::new(Vec::<usize>::new()));
let ptrs2 = ptrs.clone();
smarm::run(move || {
let ps = PubSub::<Vec<u8>>::new();
let ps = PubSub::<Vec<u8>>::new("test-bus");
let sup = start_table(ps);
let mut handles = Vec::new();
for _ in 0..2 {
let ps = ps.clone();
let ptrs = ptrs2.clone();
handles.push(smarm::spawn(move || {
let rx = ps.subscribe("t").unwrap();
@@ -378,6 +433,7 @@ mod tests {
for h in handles {
h.join().unwrap();
}
stop_table(sup);
});
let v = ptrs.lock().unwrap();
assert_eq!(v.len(), 2);
@@ -389,8 +445,9 @@ mod tests {
let got = Arc::new(Mutex::new(Vec::<String>::new()));
let got2 = got.clone();
smarm::run(move || {
let ps = PubSub::<String>::new();
let ps_loud = ps.clone();
let ps = PubSub::<String>::new("test-bus");
let sup = start_table(ps);
let ps_loud = ps;
let got = got2.clone();
let loud = smarm::spawn(move || {
let rx = ps_loud.subscribe("room").unwrap();
@@ -410,6 +467,7 @@ mod tests {
.unwrap()
.push(format!("root got {} then {}", *first, *second));
loud.join().unwrap();
stop_table(sup);
});
let v = got.lock().unwrap();
assert!(v.contains(&"loud got for everyone".to_string()), "{v:?}");
@@ -426,7 +484,8 @@ mod tests {
let ok = Arc::new(Mutex::new(false));
let ok2 = ok.clone();
smarm::run(move || {
let ps = PubSub::<u32>::new();
let ps = PubSub::<u32>::new("test-bus");
let sup = start_table(ps);
let rx = ps.subscribe("t").unwrap();
ps.unsubscribe("t").unwrap();
// Unsubscribe is a cast; the table holds the only sender, so
@@ -435,6 +494,7 @@ mod tests {
assert!(rx.recv().is_err());
ps.broadcast("t", 7).unwrap(); // no subscribers: no-op, no panic
*ok2.lock().unwrap() = true;
stop_table(sup);
});
assert!(*ok.lock().unwrap());
}
@@ -444,7 +504,8 @@ mod tests {
let got = Arc::new(Mutex::new((0u32, false)));
let got2 = got.clone();
smarm::run(move || {
let ps = PubSub::<u32>::new();
let ps = PubSub::<u32>::new("test-bus");
let sup = start_table(ps);
let rx_old = ps.subscribe("t").unwrap();
let rx_new = ps.subscribe("t").unwrap();
assert_eq!(ps.subscriber_count("t").unwrap(), 1, "idempotent per (pid, topic)");
@@ -452,6 +513,7 @@ mod tests {
let v = *rx_new.recv().unwrap();
let old_closed = rx_old.recv().is_err();
*got2.lock().unwrap() = (v, old_closed);
stop_table(sup);
});
assert_eq!(*got.lock().unwrap(), (42, true));
}
@@ -461,8 +523,9 @@ mod tests {
let ok = Arc::new(Mutex::new(false));
let ok2 = ok.clone();
smarm::run(move || {
let ps = PubSub::<u32>::new();
let ps2 = ps.clone();
let ps = PubSub::<u32>::new("test-bus");
let sup = start_table(ps);
let ps2 = ps;
let h = smarm::spawn(move || {
let _rx = ps2.subscribe("t").unwrap();
// Exit without ever receiving: rx drops with the stack.
@@ -471,6 +534,7 @@ mod tests {
// No broadcast issued — this MUST be the monitor path, not
// prune-on-send-failure.
*ok2.lock().unwrap() = eventually(|| ps.subscriber_count("t").unwrap() == 0);
stop_table(sup);
});
assert!(*ok.lock().unwrap(), "monitor Down never pruned the dead subscriber");
}
@@ -480,7 +544,8 @@ mod tests {
let counts = Arc::new(Mutex::new((0usize, 0usize)));
let counts2 = counts.clone();
smarm::run(move || {
let ps = PubSub::<u32>::new();
let ps = PubSub::<u32>::new("test-bus");
let sup = start_table(ps);
let rx = ps.subscribe("t").unwrap();
drop(rx);
let before = ps.subscriber_count("t").unwrap();
@@ -493,6 +558,7 @@ mod tests {
after = if eventually(|| ps.subscriber_count("t").unwrap() == 0) { 0 } else { after };
}
*counts2.lock().unwrap() = (before, after);
stop_table(sup);
});
assert_eq!(*counts.lock().unwrap(), (1, 0));
}
@@ -502,11 +568,13 @@ mod tests {
let got = Arc::new(Mutex::new(Vec::<u32>::new()));
let got2 = got.clone();
smarm::run(move || {
let ps = PubSub::<u32>::new();
let ps = PubSub::<u32>::new("test-bus");
let sup = start_table(ps);
let rx_a = ps.subscribe("a").unwrap();
ps.broadcast("b", 99).unwrap(); // nobody on b; must not reach a
ps.broadcast("a", 1).unwrap();
got2.lock().unwrap().push(*rx_a.recv().unwrap());
stop_table(sup);
});
assert_eq!(*got.lock().unwrap(), vec![1]);
}