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.
104 lines
3.5 KiB
Rust
104 lines
3.5 KiB
Rust
//! WebSocket echo server (v0.4).
|
|
//!
|
|
//! cargo run --example ws_echo
|
|
//! websocat ws://127.0.0.1:8080/echo
|
|
//!
|
|
//! Demonstrates the chunk-3 handler model:
|
|
//! - `EchoHandler` runs INSIDE the connection actor's select loop — echo
|
|
//! is exactly the workload that wants zero extra moving parts.
|
|
//! - `/clock` shows the other shape: the handler spawns a producer actor
|
|
//! at `on_message("start")` and hands it a `WsSender` clone — the
|
|
//! SSE-producer pattern, verbatim. The producer exits when its send
|
|
//! fails (`WsClosed`: client gone or server shutting down).
|
|
//!
|
|
//! Routing happens before the upgrade, so one server can host both
|
|
//! endpoints — `Conn::upgrade(handler)` is just another thing a route
|
|
//! handler returns.
|
|
|
|
use std::time::Duration;
|
|
|
|
use urus::{
|
|
serve_with_shutdown, shutdown_handle, Config, Conn, Message, Next, Pipeline, Router,
|
|
WsHandler, WsSender,
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// /echo — everything comes straight back
|
|
// ---------------------------------------------------------------------------
|
|
|
|
struct EchoHandler;
|
|
|
|
impl WsHandler for EchoHandler {
|
|
fn on_message(&mut self, msg: Message, sender: &WsSender) {
|
|
if matches!(&msg, Message::Text(t) if t == "bye") {
|
|
let _ = sender.close(1000, "you said bye");
|
|
return;
|
|
}
|
|
let _ = sender.send(msg);
|
|
}
|
|
|
|
fn on_close(&mut self, code: Option<u16>, reason: &str) {
|
|
println!("ws_echo: /echo closed (code {code:?}, reason {reason:?})");
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// /clock — "start" spawns a producer actor ticking once a second
|
|
// ---------------------------------------------------------------------------
|
|
|
|
struct ClockHandler {
|
|
started: bool,
|
|
}
|
|
|
|
impl WsHandler for ClockHandler {
|
|
fn on_message(&mut self, msg: Message, sender: &WsSender) {
|
|
match msg {
|
|
Message::Text(t) if t == "start" && !self.started => {
|
|
self.started = true;
|
|
let out = sender.clone();
|
|
smarm::spawn(move || {
|
|
let mut n = 0u64;
|
|
loop {
|
|
if out.text(format!("tick {n}")).is_err() {
|
|
return; // WsClosed: connection over, we follow
|
|
}
|
|
n += 1;
|
|
smarm::sleep(Duration::from_secs(1));
|
|
}
|
|
});
|
|
}
|
|
_ => {
|
|
let _ = sender.text("send \"start\" to begin");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// main
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn main() {
|
|
let pipeline = Pipeline::new().plug(
|
|
Router::new()
|
|
.get("/echo", |c: Conn, _n: Next| c.upgrade(EchoHandler))
|
|
.get("/clock", |c: Conn, _n: Next| {
|
|
c.upgrade(ClockHandler { started: false })
|
|
}),
|
|
);
|
|
|
|
let cfg = Config::new("127.0.0.1:8080".parse().unwrap());
|
|
println!("ws_echo: ws://127.0.0.1:8080/echo and /clock — press Enter to shut down");
|
|
|
|
let (handle, signal) = shutdown_handle();
|
|
std::thread::spawn(move || {
|
|
let mut line = String::new();
|
|
let _ = std::io::stdin().read_line(&mut line);
|
|
println!("ws_echo: shutting down…");
|
|
handle.shutdown();
|
|
});
|
|
|
|
serve_with_shutdown(cfg, smarm::Config::default(), pipeline, Vec::new(), signal).unwrap();
|
|
println!("ws_echo: bye");
|
|
}
|