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.
118 lines
4.4 KiB
Rust
118 lines
4.4 KiB
Rust
//! Phoenix-style channels (v0.6): join/leave/event protocol over the
|
|
//! v0.4 WebSocket duplex, pubsub underneath, phoenix V2 JSON on the
|
|
//! wire.
|
|
//!
|
|
//! cargo run --example channels_chat --features phoenix
|
|
//! websocat ws://127.0.0.1:8080/socket (run two of these)
|
|
//! ["1","1","room:lobby","phx_join",{"name":"alice"}]
|
|
//! ["1","2","room:lobby","shout",{"text":"hi"}]
|
|
//! ["1","3","room:lobby","phx_leave",{}]
|
|
//!
|
|
//! The shapes this demonstrates:
|
|
//!
|
|
//! - **One `ChannelHub` for the whole app**, built up front. The hub is
|
|
//! a description — a bus name plus the routing table — and spawns
|
|
//! nothing; `hub.children()` is the vec of actors it needs (the bus
|
|
//! table, plus one registry per session route), handed to
|
|
//! `serve_with_shutdown` so the supervisor starts them ahead of the
|
|
//! endpoint and stops them after it has drained. Same shape as
|
|
//! `examples/ws_chat.rs`, see the docs there.
|
|
//!
|
|
//! - **A channel per joined topic, not per socket.** `Room` never sees
|
|
//! frames, refs, or the transport heartbeat — the conn-side handler
|
|
//! answers heartbeats, routes `phx_join`, and the per-join channel
|
|
//! actor (linked to the connection) runs the callbacks sequentially.
|
|
//!
|
|
//! - **`Json<T>` is the phoenix codec's opt-in newtype.** The
|
|
//! `"channels"` core is wire-neutral (`Encode`/`Decode`, zero deps);
|
|
//! `Json<Value>` here buys phoenix.js V2 interop. Typed payloads work
|
|
//! the same way: `Json<MyPayload>` for any serde type.
|
|
//!
|
|
//! - **`session::*` topics persist across reconnects** (opt-in via
|
|
//! `channel_session`): drop a websocat mid-room, reconnect and rejoin
|
|
//! within 30s, and the broadcasts you missed drain to you in order —
|
|
//! the buffer holds the relay path's own `Arc`s, nothing is copied.
|
|
//! The session is keyed by the `"name"` in the join payload.
|
|
|
|
use serde_json::{json, Value};
|
|
use urus::channels::phoenix::Json;
|
|
use urus::{
|
|
serve_with_shutdown, shutdown_handle, Channel, ChannelHub, ChannelSession, ChannelSocket,
|
|
Config, Conn, Next, Pipeline, PrefixRouter, Router, Status,
|
|
};
|
|
|
|
type P = Json<Value>;
|
|
|
|
/// One chat room. State lives across callbacks (and, for `session:*`
|
|
/// topics, across transports).
|
|
#[derive(Default)]
|
|
struct Room {
|
|
name: String,
|
|
msgs_seen: u64,
|
|
}
|
|
|
|
impl Channel<P> for Room {
|
|
fn join(&mut self, topic: &str, payload: P, _s: &ChannelSocket<P>) -> Result<P, P> {
|
|
// Re-entrant by contract for session topics: a rejoin lands
|
|
// here again on the same instance — re-auth, then ack.
|
|
self.name = payload.0["name"].as_str().unwrap_or("anon").to_string();
|
|
Ok(Json(json!({ "joined": topic, "as": self.name, "seen": self.msgs_seen })))
|
|
}
|
|
|
|
fn handle_in(&mut self, event: &str, payload: P, s: &ChannelSocket<P>) {
|
|
match event {
|
|
"shout" => {
|
|
self.msgs_seen += 1;
|
|
s.broadcast("shouted", Json(json!({ "from": self.name, "msg": payload.0 })));
|
|
}
|
|
_ => s.reply(Status::Ok, Json(json!({ "echo": event }))),
|
|
}
|
|
}
|
|
|
|
fn terminate(&mut self) {
|
|
println!("room channel for {:?} is over", self.name);
|
|
}
|
|
}
|
|
|
|
/// Session config for `session:*`: the join payload's `"name"` is the
|
|
/// session token. Defaults: 128 buffered broadcasts, 30s TTL.
|
|
struct BySessionName;
|
|
|
|
impl ChannelSession<P> for BySessionName {
|
|
type Key = String;
|
|
fn session_key(topic: &str, join_payload: &P) -> String {
|
|
format!("{topic}/{}", join_payload.0["name"].as_str().unwrap_or("anon"))
|
|
}
|
|
}
|
|
|
|
fn main() {
|
|
let (hub, children) = ChannelHub::new(
|
|
"chat-bus",
|
|
PrefixRouter::new()
|
|
.channel_default::<Room>("room:*")
|
|
.channel_session::<BySessionName>("session:*", "chat-sessions", |_: &str| {
|
|
Box::new(Room::default()) as Box<dyn Channel<P>>
|
|
}),
|
|
);
|
|
|
|
let router = Router::new().get("/socket", move |c: Conn, _n: Next| hub.upgrade(c));
|
|
|
|
let (handle, signal) = shutdown_handle();
|
|
std::thread::spawn(move || {
|
|
println!("channels_chat on ws://127.0.0.1:8080/socket — 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::new().plug(router),
|
|
children,
|
|
signal,
|
|
)
|
|
.unwrap();
|
|
println!("channels_chat: drained, bye");
|
|
}
|