docs: record the supervised-bus cycle (v0.8) and retire the OnceLock idiom
ROADMAP gains a v0.8 entry: the smarm 415effb lifetime change as root cause, the description/instantiation split, the tree shape, the accepted per-call resolution cost, and what stays open (RegistryName newtype, PubSub's unprotectable const, dynamic session actors, hammer.sh's missing feature matrix). The v0.7 "unreproduced test failure" open item is closed out and pointed at it — it was this hang, hidden because hammer.sh builds with default features while the failure needs --all-features load. README and the two module headers still taught the v0.5 rules: in-runtime-only construction, the non-static Arc<OnceLock<..>> cell, and "a relay must never hold a PubSub clone". None of those are true any more; they are replaced by what actually holds now, with a note on what changed for anyone who learnt the old shape. session.rs also gains an honest note that its session actors are the last lifetime in urus implied by a drop rather than stated — they are dynamic, so a fixed ChildSpec list cannot hold them, and the trigger is at least a command now rather than a refcount.
This commit is contained in:
@@ -358,10 +358,11 @@ independent of HTTP (it imports only smarm) and built for the WebSocket
|
|||||||
relay pattern:
|
relay pattern:
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
let bus: PubSub<String> = PubSub::new(); // in-runtime only!
|
const BUS: PubSub<String> = PubSub::new("chat"); // a name; spawns nothing
|
||||||
let rx = bus.subscribe("room:lobby")?; // Receiver<Arc<String>>
|
// BUS.child() goes in your supervision tree (or serve_with*'s child vec)
|
||||||
bus.broadcast("room:lobby", "hi".to_string())?;
|
let rx = BUS.subscribe("room:lobby")?; // Receiver<Arc<String>>
|
||||||
bus.broadcast_from(smarm::self_pid(), "room:lobby", "no echo".into())?;
|
BUS.broadcast("room:lobby", "hi".to_string())?;
|
||||||
|
BUS.broadcast_from(smarm::self_pid(), "room:lobby", "no echo".into())?;
|
||||||
```
|
```
|
||||||
|
|
||||||
One generic instance per message domain; payloads broadcast as `Arc<M>`
|
One generic instance per message domain; payloads broadcast as `Arc<M>`
|
||||||
@@ -373,19 +374,25 @@ explicit pid for relay patterns. Mailboxes are unbounded: `broadcast`
|
|||||||
never blocks the table, and a slow subscriber's memory bill is bounded
|
never blocks the table, and a slow subscriber's memory bill is bounded
|
||||||
by the two cleanup paths above.
|
by the two cleanup paths above.
|
||||||
|
|
||||||
Two composition rules that matter (both enforced by
|
Composition (enforced by `shutdown_with_open_chat_terminates` in the
|
||||||
`shutdown_with_open_chat_terminates` in the integration suite):
|
integration suite):
|
||||||
|
|
||||||
1. `PubSub::new()` spawns an actor, so it must run in-runtime. If your
|
1. **The handle is an address, not the table.** `PubSub<M>` is a name:
|
||||||
app owns its tree, start the table as a supervised sibling of the
|
`const`, `Copy`, spawns nothing, fine in a `static` or outside the
|
||||||
endpoint and address it by name — the clean shape. Under `serve*`
|
runtime. The actor is `PubSub::child()`, a `ChildSpec` for your
|
||||||
there is no in-runtime moment before the first request, so build it
|
supervision tree — or for `serve_with*`'s app-children vec, which puts
|
||||||
lazily from a handler via a **non-static** `Arc<OnceLock<PubSub<M>>>`
|
it ahead of the endpoint so it stops only after the endpoint has
|
||||||
captured by the route closure; a `static` cell pins the table actor
|
drained. Operations resolve the name per call, so a restarted table is
|
||||||
forever and graceful shutdown never returns.
|
reached transparently.
|
||||||
2. Relay/producer actors hold the `Receiver` (plus e.g. a `WsSender`
|
2. Relay/producer actors hold the `Receiver` (plus e.g. a `WsSender`
|
||||||
clone) — **never a `PubSub` clone**, or relay and table keep each
|
clone). They may hold the handle too — it pins nothing — but usually
|
||||||
other alive past shutdown.
|
have no use for one.
|
||||||
|
|
||||||
|
Prior to v0.8 both of these read the other way round: `PubSub::new()`
|
||||||
|
spawned the table, the handle owned its life, and a non-static
|
||||||
|
`Arc<OnceLock<PubSub<M>>>` lazily built from the first handler was the
|
||||||
|
required idiom. smarm 0.7 made a server's lifetime its own, and that
|
||||||
|
whole apparatus went away with it.
|
||||||
|
|
||||||
See [`examples/ws_chat.rs`](examples/ws_chat.rs): rooms as topics,
|
See [`examples/ws_chat.rs`](examples/ws_chat.rs): rooms as topics,
|
||||||
`on_open` subscribes + spawns the relay, `on_message` uses
|
`on_open` subscribes + spawns the relay, `on_message` uses
|
||||||
@@ -425,8 +432,10 @@ impl Channel<P> for Room {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// in-runtime, non-static — the ws_chat OnceLock pattern applies
|
// A description: spawns nothing. `children` are the ChildSpecs it needs
|
||||||
let hub = ChannelHub::new(PrefixRouter::new().channel_default::<Room>("room:*"));
|
// (bus table + one registry per session route) — hand them to serve_with*.
|
||||||
|
let (hub, children) =
|
||||||
|
ChannelHub::new("chat-bus", PrefixRouter::new().channel_default::<Room>("room:*"));
|
||||||
// route handler: hub.upgrade(conn)
|
// route handler: hub.upgrade(conn)
|
||||||
// from anywhere with a hub handle: hub.broadcast("room:lobby", "news", payload)
|
// from anywhere with a hub handle: hub.broadcast("room:lobby", "news", payload)
|
||||||
```
|
```
|
||||||
|
|||||||
+83
-6
@@ -413,15 +413,92 @@ your root sup
|
|||||||
on `serve*`.
|
on `serve*`.
|
||||||
|
|
||||||
**Open after this cycle:**
|
**Open after this cycle:**
|
||||||
- One unreproduced test failure seen once in ~120 full-suite runs (output
|
- ~~One unreproduced test failure seen once in ~120 full-suite runs~~
|
||||||
discarded by the loop that caught it; not reproduced in 60x lib + 20x
|
**DIAGNOSED AND FIXED** in v0.8 below — it was the pubsub/channels drain
|
||||||
integration + 10x concurrent + 15x full since). Candidates: the
|
hang, not the `free_port()` race. It hid because `hammer.sh` builds with
|
||||||
`free_port()` bind race the harness already documents, or a timing margin
|
default features while the failure needs `--all-features` load.
|
||||||
in the new endpoint tests. Grab the test name next time it fires.
|
|
||||||
- `endpoint()` returns `impl Fn()`, so calling it twice = two endpoints
|
- `endpoint()` returns `impl Fn()`, so calling it twice = two endpoints
|
||||||
contending for one `Config.name` (second panics on the clash). Honest
|
contending for one `Config.name` (second panics on the clash). Honest
|
||||||
failure, but the type doesn't prevent the mistake; a consume-on-first-use
|
failure, but the type doesn't prevent the mistake; a consume-on-first-use
|
||||||
newtype would.
|
newtype would. `ChannelHub::new` returning `(hub, children)` in v0.8 is
|
||||||
|
the same lesson applied: make the type refuse the mistake.
|
||||||
|
|
||||||
|
## v0.8 — Bus, hub and session registries are supervised — DONE (2026-08-20)
|
||||||
|
|
||||||
|
Same crate version (**0.3.0**); this cycle and v0.7 ship together and are
|
||||||
|
both breaking.
|
||||||
|
|
||||||
|
v0.7 put the endpoint under a supervisor. This puts everything else there
|
||||||
|
too, because smarm 0.7 left it no choice: commit `415effb` ("lifetime is
|
||||||
|
the actor's — refs are addresses") removed the rule the pubsub table,
|
||||||
|
channel hub bus and session registries were built on. A `GenServerRef` no
|
||||||
|
longer owns its server; the loop holds its own inbox sender and the inbox
|
||||||
|
never closes when the last ref drops. Nothing commanded these three to
|
||||||
|
stop, and what still terminated a run was the root-exit sweep racing the
|
||||||
|
drain.
|
||||||
|
|
||||||
|
That is the flake above: `shutdown_with_open_chat_terminates` and
|
||||||
|
`channels_wire::shutdown_with_open_channel_terminates`, 4 failures in 25
|
||||||
|
`--all-features` integration runs, all "serve did not return: ... outlived
|
||||||
|
the drain: Timeout". 0 in 10 full-suite runs after the fix.
|
||||||
|
|
||||||
|
**Description separated from instantiation.** The gotchas here all
|
||||||
|
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.
|
||||||
|
|
||||||
|
```
|
||||||
|
your root sup (RestForOne)
|
||||||
|
├── ChildSpec(Permanent, PubSub::child) <- bus table, named
|
||||||
|
├── ChildSpec(Permanent, session registry) <- one per channel_session
|
||||||
|
└── ChildSpec(Permanent, urus::endpoint(..)) <- starts last, drains first
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`PubSub<M>` is a name, not a ref.** `const`-constructible, `Copy`,
|
||||||
|
spawns nothing, legal outside the runtime and in a `static`. Operations
|
||||||
|
resolve through the registry per call, so a supervisor-restarted table is
|
||||||
|
reached transparently. `PubSub::new()` is gone: `PubSub::new(name)` +
|
||||||
|
`PubSub::child()`.
|
||||||
|
- **`ChannelHub::new(bus, router) -> (hub, Vec<ChildSpec>)`**, `#[must_use]`.
|
||||||
|
Both halves come back together because a hub whose children were never
|
||||||
|
started compiles and fails on the first join. There is no `children()`
|
||||||
|
method to forget to call.
|
||||||
|
- **`channel_session` takes a registry name**; each registry is separately
|
||||||
|
named and supervised.
|
||||||
|
- **`serve_with` / `serve_with_shutdown` take a `Vec<ChildSpec>`** of app
|
||||||
|
children and build `RestForOne[..app children, endpoint]`. App children
|
||||||
|
start before the endpoint and — shutdown being ordered in reverse — stop
|
||||||
|
after it has drained, so a request still in flight can still 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 every module rule that existed only to hand-manage a
|
||||||
|
refcount. `ws_chat` lost 21 lines and a struct field; examples net -25.
|
||||||
|
|
||||||
|
**Cost accepted:** 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. Bench
|
||||||
|
before optimising.
|
||||||
|
|
||||||
|
**Open after this cycle:**
|
||||||
|
- `channel_session("session:*", "chat-sessions", f)` puts two unrelated
|
||||||
|
string literals side by side and nothing catches a transposition. A
|
||||||
|
`RegistryName` newtype makes it a type error. Not done.
|
||||||
|
- `PubSub` cannot be protected the way `ChannelHub` was: a `const` is
|
||||||
|
copied at each use, so it can't be consume-on-first-use. Forgetting
|
||||||
|
`BUS.child()` compiles and fails at runtime with `PubSubDown`. Judged
|
||||||
|
worth it — the `const` is what makes the handle pleasant.
|
||||||
|
- Session actors are still plain-spawned by their registry, not supervised
|
||||||
|
(they're dynamic, one per key — OTP would want `simple_one_for_one`).
|
||||||
|
Their exit chain is now commanded rather than refcounted: the registry is
|
||||||
|
shut down, its state drops, control senders drop, parked sessions wake on
|
||||||
|
the closed arm. Sound, but it is the last place a lifetime is implied by
|
||||||
|
a drop rather than stated.
|
||||||
|
- `hammer.sh` still passes no feature flags, which is why this hid for a
|
||||||
|
whole cycle. A feature matrix is the obvious fix.
|
||||||
|
|
||||||
## Known bugs
|
## Known bugs
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -9,12 +9,12 @@
|
|||||||
//!
|
//!
|
||||||
//! # Topology
|
//! # Topology
|
||||||
//!
|
//!
|
||||||
//! - The app builds one [`ChannelHub`] (a [`TopicRouter`] + a
|
//! - The app builds one [`ChannelHub`] (a [`TopicRouter`] + the name of
|
||||||
//! `PubSub<Broadcast<P>>`). **In-runtime only** — `PubSub::new`
|
//! a `PubSub<Broadcast<P>>`). It is a description and spawns nothing,
|
||||||
//! spawns the table actor — and the hub must be NON-static (the
|
//! so it may be built anywhere; [`ChannelHub::new`] hands back the
|
||||||
//! `Arc<OnceLock<..>>`-in-the-route-closure pattern from v0.5;
|
//! `ChildSpec`s for the actors it needs — the bus table, plus one
|
||||||
//! a `static` hub pins the pubsub table and hangs
|
//! registry per session route — which go in the supervision tree
|
||||||
//! `serve_with_shutdown`).
|
//! ahead of the endpoint.
|
||||||
//! - [`ChannelHub::upgrade`] turns an HTTP `Conn` into a channel
|
//! - [`ChannelHub::upgrade`] turns an HTTP `Conn` into a channel
|
||||||
//! socket: a [`WsHandler`] running in the connection actor that
|
//! socket: a [`WsHandler`] running in the connection actor that
|
||||||
//! decodes frames and routes them by topic.
|
//! decodes frames and routes them by topic.
|
||||||
|
|||||||
+11
-4
@@ -22,10 +22,17 @@
|
|||||||
//! the transport is the point. Its exits: explicit leave, rejected
|
//! the transport is the point. Its exits: explicit leave, rejected
|
||||||
//! (re)join, TTL expiry, buffer cap, pubsub relay death, or the
|
//! (re)join, TTL expiry, buffer cap, pubsub relay death, or the
|
||||||
//! registry's control sender dropping — which is exactly the shutdown
|
//! registry's control sender dropping — which is exactly the shutdown
|
||||||
//! chain (conns die -> router `Arc`s drop -> registry's inbox closes
|
//! chain (the supervisor shuts the registry down after the endpoint
|
||||||
//! -> registry exits -> control senders drop -> every parked session
|
//! has drained -> the registry's state drops -> control senders drop
|
||||||
//! wakes on the closed control arm, terminates, and exits; `AllDone`
|
//! -> every parked session wakes on the closed control arm,
|
||||||
//! composes without links).
|
//! terminates, and exits; `AllDone` composes without links).
|
||||||
|
//!
|
||||||
|
//! Note this is the last lifetime in urus implied by a drop rather
|
||||||
|
//! than stated: sessions are dynamic (one per key), so a fixed
|
||||||
|
//! `ChildSpec` list cannot hold them. The trigger is now a command
|
||||||
|
//! rather than a refcount, which is what the v0.8 cycle was about, but
|
||||||
|
//! a dynamic supervisor (OTP's `simple_one_for_one`) is the honest
|
||||||
|
//! shape if smarm grows one.
|
||||||
//!
|
//!
|
||||||
//! As-landed decisions (veto by diff):
|
//! As-landed decisions (veto by diff):
|
||||||
//! - **Every attach calls `ch.join()` again** on the same instance —
|
//! - **Every attach calls `ch.join()` again** on the same instance —
|
||||||
|
|||||||
Reference in New Issue
Block a user