feat(channels): opt-in session persistence — v0.6 chunk 3
ChannelSession<P> (src/channels/session.rs): a channel actor that outlives its transport, buffering outbound broadcasts as the same Arc<Broadcast<P>>s the relay path carries (zero re-allocation) until rejoin, buffer cap, or TTL. Registered per pattern via PrefixRouter::channel_session::<S>(pattern, factory) / channel_session_default::<C>(pattern); one registry gen_server per registration, monomorphic over S::Key (no type erasure). Machinery: deploy() computes the key on the conn actor and casts the join handshake to the registry; the registry maps key -> (pid, control sender), forwards reattaches, spawns-and-monitors fresh actors, and prunes on Down (pid-guarded against replaced entries). The session actor selects [inbound, bus, ctl] attached and select_timeout([ctl, bus], ttl-remaining) detached; detach triggers are the closed inbound arm and ws send failure (the failed broadcast is the buffer's first entry — nothing lost). Drain re-encodes under the new join generation. As-landed decisions (each in module docs): - ChannelFactory gained a #[doc(hidden)] deploy() seam (default = ephemeral linked actor, the chunk-1 behavior verbatim); JoinHandshake and ChannelInbox are opaque pub structs. Channel construction moved from run_channel into deploy — same linked blast radius. - Every attach calls ch.join() again (rejoin acks need a payload and channels get to re-auth); session channels must treat join as re-entrant. - A rejected rejoin ENDS the session (no zombie sessions held open for unauthorized clients); next join is a cold start. - A second transport EVICTS the first (best-effort phx_close); a session is single-transport. - Explicit leave ends the session, not just the attachment. - TTL per detach episode; subscribe once, on first successful join; cap.max(1) semantics (cap 0 = first buffered message tears down, but an empty buffer still waits out the TTL). - Key: Clone beyond the ratified Eq+Hash+Send — the registry keeps a pid -> key reverse index for Down pruning. - The registry spawns eagerly at registration (channel_session is in-runtime-only, same law as ChannelHub::new): a lazy OnceLock would block std-sync under green threads on first-join races. - Shutdown composes WITHOUT links: conns die -> router Arcs drop -> registry inbox closes -> registry exits -> ctl senders drop -> detached sessions wake on the closed ctl arm, terminate, exit. shutdown_with_detached_session_terminates is the proof (ttl 30s vs 5s budget — only the chain can reap it). Tests: 6 session unit tests (buffer-and-drain ordering across reconnect, TTL expiry, cap teardown, leave, eviction incl. stale conn-entry self-prune, rejected rejoin) + the shutdown integration test. Shared toy codec + conn harness extracted to channels/testkit.rs (mod.rs tests now import it; no behavior change). Hammer: 35x lifecycle subset (+channels +session) + 3x full (phoenix) + 1x full (phoenix+smarm-trace) + featureless + channels-only, all green; dbg-grep clean. Suite: 90 unit + 46 integration + 2 doc under --features phoenix.
This commit is contained in:
+56
-1
@@ -1418,7 +1418,7 @@ mod channels_wire {
|
||||
use super::*;
|
||||
use serde_json::{json, Value};
|
||||
use urus::channels::phoenix::Json;
|
||||
use urus::channels::{Channel, ChannelHub, ChannelSocket, PrefixRouter, Status};
|
||||
use urus::channels::{Channel, ChannelHub, ChannelSession, ChannelSocket, PrefixRouter, Status};
|
||||
|
||||
type P = Json<Value>;
|
||||
|
||||
@@ -1603,4 +1603,59 @@ mod channels_wire {
|
||||
let mut rest = Vec::new();
|
||||
let _ = a.read_to_end(&mut rest);
|
||||
}
|
||||
|
||||
/// Sessions: keyed by topic, default cap/ttl. The 30s ttl is the
|
||||
/// test's lever — far past the assertion budget, so only the
|
||||
/// registry-drop chain can reap a detached session at shutdown.
|
||||
struct LobbySession;
|
||||
impl ChannelSession<P> for LobbySession {
|
||||
type Key = String;
|
||||
fn session_key(topic: &str, _p: &P) -> String {
|
||||
topic.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
fn session_pipeline() -> Pipeline {
|
||||
let hub: std::sync::Arc<std::sync::OnceLock<ChannelHub<P>>> =
|
||||
std::sync::Arc::new(std::sync::OnceLock::new());
|
||||
Pipeline::new().plug(Router::new().get("/socket", move |c: Conn, _n: Next| {
|
||||
let hub = hub.clone();
|
||||
let hub = hub.get_or_init(|| {
|
||||
ChannelHub::new(PrefixRouter::new().channel_session::<LobbySession>(
|
||||
"room:*",
|
||||
|_: &str| Box::new(Lobby) as Box<dyn Channel<P>>,
|
||||
))
|
||||
});
|
||||
hub.upgrade(c)
|
||||
}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shutdown_with_detached_session_terminates() {
|
||||
// The session variant of the exit chain: the actor is NOT
|
||||
// linked to its conn and survives the transport on purpose.
|
||||
// Conn actors die at drain -> their router Arcs drop -> the
|
||||
// registry's inbox closes -> registry exits -> control senders
|
||||
// drop -> the detached session wakes on its closed control arm,
|
||||
// terminates, and exits -> AllDone. No links anywhere in that
|
||||
// chain; this test is the proof it composes.
|
||||
let (port, handle, done_rx) =
|
||||
spawn_server_with_handle(session_pipeline(), Duration::from_millis(300));
|
||||
|
||||
let mut a = chan_connect(port);
|
||||
let mut a_buf = Vec::new();
|
||||
chan_send(&mut a, json!(["1", "1", "room:lobby", "phx_join", {}]));
|
||||
let reply = chan_read(&mut a, &mut a_buf);
|
||||
assert_eq!(reply[3], "phx_reply"); // joined: session actor live
|
||||
|
||||
// Kill the transport and give the detach a moment to land, so
|
||||
// shutdown meets a genuinely DETACHED actor (ttl 30s from now).
|
||||
drop(a);
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
|
||||
handle.shutdown();
|
||||
done_rx
|
||||
.recv_timeout(Duration::from_secs(5))
|
||||
.expect("detached session outlived the drain: the registry-drop chain is broken");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user