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:
Claude
2026-06-12 15:22:54 +00:00
parent 39e4f70e9d
commit 0de2baa72d
5 changed files with 990 additions and 116 deletions
+56 -1
View File
@@ -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");
}
}