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
+122 -32
View File
@@ -63,7 +63,7 @@
use crate::pubsub::PubSub;
use crate::ws::{Message, WsHandler, WsSender};
use smarm::{Pid, Receiver, Sender};
use smarm::{ChildSpec, Pid, Receiver, Sender};
use std::cell::RefCell;
use std::collections::HashMap;
@@ -199,6 +199,16 @@ pub trait Channel<P: Send + Sync + 'static>: Send + 'static {
pub trait ChannelFactory<P: Send + Sync + 'static>: Send + Sync {
fn create(&self, topic: &str) -> Box<dyn Channel<P>>;
/// Supervised actors this factory needs running before it can
/// deploy. Empty for the default (ephemeral) factory, which spawns
/// its channel actor per join under the connection; the session
/// factory returns its registry's spec. Internal seam, collected by
/// [`ChannelHub::children`].
#[doc(hidden)]
fn children(&self) -> Vec<ChildSpec> {
Vec::new()
}
/// How an accepted-routing join becomes a running channel actor.
/// Internal seam — the default (ephemeral actor, linked to the
/// connection, cold start per join) is the contract; only the
@@ -258,6 +268,13 @@ impl<P: Send + Sync + 'static, C: Channel<P> + Default> ChannelFactory<P> for De
/// rejects the join (status error).
pub trait TopicRouter<P: Send + Sync + 'static>: Send + Sync + 'static {
fn route(&self, topic: &str) -> Option<Arc<dyn ChannelFactory<P>>>;
/// Every supervised actor the routes need, gathered for
/// [`ChannelHub::children`]. Override only if your router holds
/// factories it does not surface through [`route`](Self::route).
fn children(&self) -> Vec<ChildSpec> {
Vec::new()
}
}
/// The shipped router: exact topics and `head:*` prefix patterns.
@@ -301,31 +318,46 @@ impl<P: Send + Sync + 'static> PrefixRouter<P> {
/// [`channel`](Self::channel), with opt-in session persistence
/// keyed and configured by `S`'s [`ChannelSession`] impl (usually
/// `S` is the channel type itself). **In-runtime only**: this
/// spawns the pattern's session-registry actor, same law as
/// [`ChannelHub::new`].
pub fn channel_session<S>(self, pattern: &str, factory: impl ChannelFactory<P> + 'static) -> Self
/// `S` is the channel type itself).
///
/// `registry` names the pattern's session-registry actor, which is
/// supervised: it appears in [`ChannelHub::children`] and must be
/// unique across the app. Spawns nothing here.
pub fn channel_session<S>(
self,
pattern: &str,
registry: &'static str,
factory: impl ChannelFactory<P> + 'static,
) -> Self
where
S: ChannelSession<P>,
S::Key: Clone,
P: Encode + Decode,
{
self.channel(pattern, session::SessionFactory::new::<S>(Arc::new(factory)))
self.channel(pattern, session::SessionFactory::new::<S>(registry, Arc::new(factory)))
}
/// [`channel_session`](Self::channel_session) with a
/// [`Default`]-built impl that is its own session config.
pub fn channel_session_default<C>(self, pattern: &str) -> Self
pub fn channel_session_default<C>(self, pattern: &str, registry: &'static str) -> Self
where
C: Channel<P> + ChannelSession<P> + Default,
C::Key: Clone,
P: Encode + Decode,
{
self.channel_session::<C>(pattern, DefaultFactory::<C>(PhantomData))
self.channel_session::<C>(pattern, registry, DefaultFactory::<C>(PhantomData))
}
}
impl<P: Send + Sync + 'static> TopicRouter<P> for PrefixRouter<P> {
fn children(&self) -> Vec<ChildSpec> {
self.exact
.values()
.chain(self.prefix.values())
.flat_map(|f| f.children())
.collect()
}
fn route(&self, topic: &str) -> Option<Arc<dyn ChannelFactory<P>>> {
if let Some(f) = self.exact.get(topic) {
return Some(f.clone());
@@ -427,13 +459,20 @@ impl<P: Encode + Decode + Send + Sync + 'static> ChannelSocket<P> {
// ChannelHub — the app-facing entry point
// ---------------------------------------------------------------------------
/// One per app (or per channel namespace): the router plus the pubsub
/// bus every channel broadcasts on.
/// One per app (or per channel namespace): the router plus the address
/// of the pubsub bus every channel broadcasts on.
///
/// **In-runtime only** (spawns the pubsub table) and **must not live in
/// a `static`** — use the non-static `Arc<OnceLock<ChannelHub<P>>>`
/// captured by the route closure, exactly the v0.5 pubsub pattern, or
/// graceful shutdown will hang waiting for the table to exit.
/// Spawns nothing — build it wherever you like, including out of the
/// runtime and alongside the pipeline. The actors it needs (the bus
/// table, plus one registry per session route) come from
/// [`children`](Self::children); hand that vec to `serve_with*` or splice
/// it into your own supervision tree ahead of the endpoint.
///
/// ```ignore
/// let (hub, children) =
/// ChannelHub::new("chat-bus", PrefixRouter::new().channel_default::<Room>("room:*"));
/// serve_with(cfg, rt_cfg, pipeline, children)?;
/// ```
pub struct ChannelHub<P: Send + Sync + 'static> {
bus: PubSub<Broadcast<P>>,
router: Arc<dyn TopicRouter<P>>,
@@ -441,13 +480,27 @@ pub struct ChannelHub<P: Send + Sync + 'static> {
impl<P: Send + Sync + 'static> Clone for ChannelHub<P> {
fn clone(&self) -> Self {
Self { bus: self.bus.clone(), router: self.router.clone() }
Self { bus: self.bus, router: self.router.clone() }
}
}
impl<P: Encode + Decode + Send + Sync + 'static> ChannelHub<P> {
pub fn new(router: impl TopicRouter<P>) -> Self {
Self { bus: PubSub::new(), router: Arc::new(router) }
/// Address a hub whose bus table is registered under `bus`, together
/// with every actor it needs running: the bus table first, then one
/// registry per session route. Spawns nothing.
///
/// The two come back together on purpose. A hub whose children were
/// never started compiles fine and fails on the first join, so the
/// constructor hands you the vec rather than leaving it behind a
/// method you can forget to call. Start them ahead of the endpoint —
/// `RestForOne` in that order means a bus crash also restarts the
/// endpoint, dropping connections whose subscriptions died with it.
#[must_use]
pub fn new(bus: &'static str, router: impl TopicRouter<P>) -> (Self, Vec<ChildSpec>) {
let hub = Self { bus: PubSub::new(bus), router: Arc::new(router) };
let mut children = vec![hub.bus.child()];
children.extend(hub.router.children());
(hub, children)
}
/// Accept the WebSocket upgrade on `conn` and speak channels over
@@ -455,7 +508,7 @@ impl<P: Encode + Decode + Send + Sync + 'static> ChannelHub<P> {
/// [`Conn::upgrade`](crate::Conn::upgrade) apply unchanged.
pub fn upgrade(&self, conn: crate::Conn) -> crate::Conn {
conn.upgrade(SocketHandler {
bus: self.bus.clone(),
bus: self.bus,
router: self.router.clone(),
joined: HashMap::new(),
})
@@ -567,7 +620,7 @@ impl<P: Encode + Decode + Send + Sync + 'static> WsHandler for SocketHandler<P>
reference: frame.reference,
payload,
ws: sender.clone(),
bus: self.bus.clone(),
bus: self.bus,
});
self.joined
.insert(frame.topic, Joined { join_ref: frame.join_ref, tx: inbox.0 });
@@ -649,7 +702,7 @@ fn run_channel<P: Encode + Decode + Send + Sync + 'static>(
let socket = ChannelSocket {
ws,
bus: bus.clone(),
bus,
topic: topic.clone(),
join_ref,
cur_ref: RefCell::new(None),
@@ -799,12 +852,39 @@ mod tests {
}
fn hub(terminated: &Arc<AtomicBool>) -> ChannelHub<TP> {
const TEST_BUS: &str = "chan-unit-bus";
fn hub(terminated: &Arc<AtomicBool>) -> (ChannelHub<TP>, Vec<ChildSpec>) {
ChannelHub::new(
TEST_BUS,
PrefixRouter::new().channel("room:*", RoomFactory { terminated: terminated.clone() }),
)
}
/// Start a hub's children under a supervisor and wait for the bus to
/// bind its name (smarm's start-order-is-not-start-readiness gap:
/// `start_child` spawns and moves on). Returns the sup's pid.
pub(crate) fn start_hub<P: Encode + Decode + Send + Sync + 'static>(
hub: &ChannelHub<P>,
children: Vec<ChildSpec>,
) -> Pid {
let sup = smarm::spawn(move || {
let mut sup = smarm::OneForOne::new();
for c in children {
sup = sup.child(c);
}
sup.run()
});
let bus = hub.bus;
for _ in 0..200 {
if bus.pid().is_some() {
return sup.pid();
}
smarm::sleep(std::time::Duration::from_millis(10));
}
panic!("hub children never came up");
}
#[test]
fn prefix_router_exact_and_prefix_and_miss() {
@@ -825,7 +905,8 @@ mod tests {
let out = Arc::new(Mutex::new(Vec::<String>::new()));
let (out2, t) = (out.clone(), Arc::new(AtomicBool::new(false)));
smarm::run(move || {
let hub = hub(&t);
let (hub, children) = hub(&t);
let _sup = start_hub(&hub, children);
let mut h = Harness::new(&hub);
h.send("j1|r1|room:a|event|phx_join|hi");
out2.lock().unwrap().push(h.recv());
@@ -843,7 +924,8 @@ mod tests {
let (out2, t) = (out.clone(), Arc::new(AtomicBool::new(false)));
let t2 = t.clone();
smarm::run(move || {
let hub = hub(&t2);
let (hub, children) = hub(&t2);
let _sup = start_hub(&hub, children);
let mut h = Harness::new(&hub);
h.send("j1|r1|room:locked|event|phx_join|hi");
*out2.lock().unwrap() = h.recv();
@@ -859,7 +941,8 @@ mod tests {
let out = Arc::new(Mutex::new(String::new()));
let (out2, t) = (out.clone(), Arc::new(AtomicBool::new(false)));
smarm::run(move || {
let hub = hub(&t);
let (hub, children) = hub(&t);
let _sup = start_hub(&hub, children);
let mut h = Harness::new(&hub);
h.send("j1|r1|hall:a|event|phx_join|hi");
*out2.lock().unwrap() = h.recv();
@@ -872,7 +955,8 @@ mod tests {
let out = Arc::new(Mutex::new(String::new()));
let (out2, t) = (out.clone(), Arc::new(AtomicBool::new(false)));
smarm::run(move || {
let hub = hub(&t);
let (hub, children) = hub(&t);
let _sup = start_hub(&hub, children);
let mut h = Harness::new(&hub);
h.send("-|hb1|phoenix|event|heartbeat|");
*out2.lock().unwrap() = h.recv();
@@ -885,7 +969,8 @@ mod tests {
let out = Arc::new(Mutex::new(String::new()));
let (out2, t) = (out.clone(), Arc::new(AtomicBool::new(false)));
smarm::run(move || {
let hub = hub(&t);
let (hub, children) = hub(&t);
let _sup = start_hub(&hub, children);
let mut h = Harness::new(&hub);
h.send("j1|r1|room:a|event|ping|x");
*out2.lock().unwrap() = h.recv();
@@ -898,7 +983,8 @@ mod tests {
let got = Arc::new(Mutex::new(Vec::<(u8, String)>::new()));
let (got2, t) = (got.clone(), Arc::new(AtomicBool::new(false)));
smarm::run(move || {
let hub = hub(&t);
let (hub, children) = hub(&t);
let _sup = start_hub(&hub, children);
let mut a = Harness::new(&hub);
let mut b = Harness::new(&hub);
a.send("j1|r1|room:a|event|phx_join|A");
@@ -930,7 +1016,8 @@ mod tests {
let (out2, t) = (out.clone(), Arc::new(AtomicBool::new(false)));
let t2 = t.clone();
smarm::run(move || {
let hub = hub(&t2);
let (hub, children) = hub(&t2);
let _sup = start_hub(&hub, children);
let mut h = Harness::new(&hub);
h.send("j1|r1|room:a|event|phx_join|hi");
out2.lock().unwrap().push(h.recv());
@@ -954,8 +1041,9 @@ mod tests {
let t = Arc::new(AtomicBool::new(false));
let t2 = t.clone();
smarm::run(move || {
let hub = hub(&t2);
let bus = hub.bus.clone();
let (hub, children) = hub(&t2);
let _sup = start_hub(&hub, children);
let bus = hub.bus;
{
let mut h = Harness::new(&hub);
h.send("j1|r1|room:a|event|phx_join|hi");
@@ -974,7 +1062,8 @@ mod tests {
let (out2, t) = (out.clone(), Arc::new(AtomicBool::new(false)));
let t2 = t.clone();
smarm::run(move || {
let hub = hub(&t2);
let (hub, children) = hub(&t2);
let _sup = start_hub(&hub, children);
let mut h = Harness::new(&hub);
h.send("j1|r1|room:a|event|phx_join|one");
out2.lock().unwrap().push(h.recv());
@@ -998,7 +1087,8 @@ mod tests {
let out = Arc::new(Mutex::new(String::new()));
let (out2, t) = (out.clone(), Arc::new(AtomicBool::new(false)));
smarm::run(move || {
let hub = hub(&t);
let (hub, children) = hub(&t);
let _sup = start_hub(&hub, children);
let mut h = Harness::new(&hub);
h.send("j1|r1|room:a|event|phx_join|hi");
h.recv();
+58 -22
View File
@@ -48,8 +48,8 @@
use super::*;
use smarm::gen_server::{self, GenServer, GenServerCtx};
use smarm::{Down, GenServerRef, Watcher};
use smarm::gen_server::{self, GenServer, GenServerBuilder, GenServerCtx, GenServerName};
use smarm::{Down, Restart, Watcher};
use std::collections::VecDeque;
use std::hash::Hash;
@@ -87,7 +87,14 @@ pub trait ChannelSession<P>: Send + 'static {
pub(super) struct SessionFactory<P: Encode + Decode + Send + Sync + 'static, K: SessionKey> {
inner: Arc<dyn ChannelFactory<P>>,
keyfn: fn(&str, &P) -> K,
registry: GenServerRef<Registry<P, K>>,
/// The registry's registered name. Resolved per deploy, so a
/// registry restarted by the supervisor is reached transparently —
/// with its session map empty, which is the honest outcome: the
/// session actors it tracked died with their control senders.
registry: GenServerName<Registry<P, K>>,
/// Everything the registry's `ChildSpec` needs to build it again.
cap: usize,
ttl: Duration,
}
/// The registry's working bounds for a session key.
@@ -95,17 +102,19 @@ pub(super) trait SessionKey: Eq + Hash + Clone + Send + 'static {}
impl<K: Eq + Hash + Clone + Send + 'static> SessionKey for K {}
impl<P: Encode + Decode + Send + Sync + 'static, K: SessionKey> SessionFactory<P, K> {
/// **In-runtime only** — spawns the registry gen_server.
pub(super) fn new<S: ChannelSession<P, Key = K>>(inner: Arc<dyn ChannelFactory<P>>) -> Self {
let registry = gen_server::start(Registry {
factory: inner.clone(),
/// Describe the session route. Spawns nothing; the registry actor is
/// started from [`children`](ChannelFactory::children).
pub(super) fn new<S: ChannelSession<P, Key = K>>(
registry: &'static str,
inner: Arc<dyn ChannelFactory<P>>,
) -> Self {
SessionFactory {
inner,
keyfn: S::session_key,
registry: GenServerName::new(registry),
cap: S::buffer_cap(),
ttl: S::ttl(),
sessions: HashMap::new(),
pid_key: HashMap::new(),
watcher: None,
});
SessionFactory { inner, keyfn: S::session_key, registry }
}
}
}
@@ -116,6 +125,23 @@ impl<P: Encode + Decode + Send + Sync + 'static, K: SessionKey> ChannelFactory<P
self.inner.create(topic)
}
fn children(&self) -> Vec<ChildSpec> {
let (name, factory, cap, ttl) = (self.registry, self.inner.clone(), self.cap, self.ttl);
vec![ChildSpec::new(Restart::Permanent, move || {
let state = Registry::<P, K> {
factory: factory.clone(),
cap,
ttl,
sessions: HashMap::new(),
pid_key: HashMap::new(),
watcher: None,
};
if let Err(e) = GenServerBuilder::new(state).named(name).run() {
panic!("urus session registry '{}': name already taken: {e:?}", name.as_str());
}
})]
}
fn deploy(&self, hs: JoinHandshake<P>) -> ChannelInbox<P>
where
P: Encode + Decode,
@@ -125,7 +151,7 @@ impl<P: Encode + Decode + Send + Sync + 'static, K: SessionKey> ChannelFactory<P
// Cast: the actor acks the join straight to the socket; the
// conn actor has nothing to wait for. A dead registry can only
// mean shutdown — the failed join is moot.
let _ = self.registry.cast(Join { key, hs, inbound: rx });
let _ = gen_server::cast(self.registry, Join { key, hs, inbound: rx });
ChannelInbox(tx)
}
}
@@ -568,10 +594,14 @@ mod tests {
fn session_hub<S: ChannelSession<TP, Key = String>>(
term: &Arc<AtomicBool>,
reject_rejoin: bool,
) -> ChannelHub<TP> {
) -> (ChannelHub<TP>, Vec<smarm::ChildSpec>) {
ChannelHub::new(
PrefixRouter::new()
.channel_session::<S>("room:*", counter_factory(term, reject_rejoin)),
"sess-unit-bus",
PrefixRouter::new().channel_session::<S>(
"room:*",
"sess-unit-registry",
counter_factory(term, reject_rejoin),
),
)
}
@@ -580,7 +610,8 @@ mod tests {
let out = Arc::new(Mutex::new(Vec::<String>::new()));
let (out2, term) = (out.clone(), Arc::new(AtomicBool::new(false)));
smarm::run(move || {
let hub = session_hub::<Cfg>(&term, false);
let (hub, children) = session_hub::<Cfg>(&term, false);
let _sup = crate::channels::tests::start_hub(&hub, children);
let mut h1 = Harness::new(&hub);
h1.send("j1|r1|room:a|event|phx_join|u");
out2.lock().unwrap().push(h1.recv());
@@ -611,7 +642,8 @@ mod tests {
let (out2, term) = (out.clone(), Arc::new(AtomicBool::new(false)));
let term2 = term.clone();
smarm::run(move || {
let hub = session_hub::<CfgTtl>(&term2, false);
let (hub, children) = session_hub::<CfgTtl>(&term2, false);
let _sup = crate::channels::tests::start_hub(&hub, children);
let mut h1 = Harness::new(&hub);
h1.send("j1|r1|room:a|event|phx_join|u");
out2.lock().unwrap().push(h1.recv());
@@ -632,7 +664,8 @@ mod tests {
let out = Arc::new(Mutex::new(Vec::<String>::new()));
let (out2, term) = (out.clone(), Arc::new(AtomicBool::new(false)));
smarm::run(move || {
let hub = session_hub::<CfgCap>(&term, false);
let (hub, children) = session_hub::<CfgCap>(&term, false);
let _sup = crate::channels::tests::start_hub(&hub, children);
let mut h1 = Harness::new(&hub);
h1.send("j1|r1|room:a|event|phx_join|u");
out2.lock().unwrap().push(h1.recv());
@@ -657,7 +690,8 @@ mod tests {
let out = Arc::new(Mutex::new(Vec::<String>::new()));
let (out2, term) = (out.clone(), Arc::new(AtomicBool::new(false)));
smarm::run(move || {
let hub = session_hub::<Cfg>(&term, false);
let (hub, children) = session_hub::<Cfg>(&term, false);
let _sup = crate::channels::tests::start_hub(&hub, children);
let mut h = Harness::new(&hub);
h.send("j1|r1|room:a|event|phx_join|u");
out2.lock().unwrap().push(h.recv());
@@ -681,7 +715,8 @@ mod tests {
let out = Arc::new(Mutex::new(Vec::<String>::new()));
let (out2, term) = (out.clone(), Arc::new(AtomicBool::new(false)));
smarm::run(move || {
let hub = session_hub::<Cfg>(&term, false);
let (hub, children) = session_hub::<Cfg>(&term, false);
let _sup = crate::channels::tests::start_hub(&hub, children);
let mut h1 = Harness::new(&hub);
h1.send("j1|r1|room:a|event|phx_join|u");
out2.lock().unwrap().push(h1.recv());
@@ -714,7 +749,8 @@ mod tests {
let out = Arc::new(Mutex::new(Vec::<String>::new()));
let (out2, term) = (out.clone(), Arc::new(AtomicBool::new(false)));
smarm::run(move || {
let hub = session_hub::<Cfg>(&term, true);
let (hub, children) = session_hub::<Cfg>(&term, true);
let _sup = crate::channels::tests::start_hub(&hub, children);
let mut h1 = Harness::new(&hub);
h1.send("j1|r1|room:a|event|phx_join|u");
out2.lock().unwrap().push(h1.recv());
+1 -1
View File
@@ -75,7 +75,7 @@ impl Harness {
let (tx, out_rx) = smarm::channel();
Self {
handler: SocketHandler {
bus: hub.bus.clone(),
bus: hub.bus,
router: hub.router.clone(),
joined: HashMap::new(),
},