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
+736
View File
@@ -0,0 +1,736 @@
//! Opt-in channel session persistence (the ratified v0.6 design).
//!
//! Default channel lifetime is transport-bound: cold start per join,
//! actor dies with the socket. A [`ChannelSession`] impl registered via
//! [`PrefixRouter::channel_session`](super::PrefixRouter::channel_session)
//! changes that: the channel actor outlives its transport, buffering
//! outbound broadcasts (`VecDeque<Arc<Broadcast<P>>>` — the same `Arc`s
//! the pubsub relay path carries, zero re-allocation) until the client
//! rejoins, the buffer fills, or the TTL expires.
//!
//! Shape (one registry gen_server per `channel_session` registration,
//! monomorphic over the session key — no type erasure):
//!
//! - `deploy` on the conn actor computes the session key and casts the
//! whole join handshake to the registry.
//! - The registry owns `key -> (pid, control sender)`. Existing entry:
//! the handshake is forwarded as an `Attach` (reattach). Absent or
//! dead (send failure raced the monitor `Down`): spawn fresh,
//! monitor, insert. Down prunes, guarded by pid match against
//! replaced entries.
//! - The session actor is **not linked** to any connection — outliving
//! the transport is the point. Its exits: explicit leave, rejected
//! (re)join, TTL expiry, buffer cap, pubsub relay death, or the
//! registry's control sender dropping — which is exactly the shutdown
//! chain (conns die -> router `Arc`s drop -> registry's inbox closes
//! -> registry exits -> control senders drop -> every parked session
//! wakes on the closed control arm, terminates, and exits; `AllDone`
//! composes without links).
//!
//! As-landed decisions (veto by diff):
//! - **Every attach calls `ch.join()` again** on the same instance —
//! the client's `phx_join` needs an ack payload and the channel gets
//! to re-auth. Session channels must treat `join` as re-entrant.
//! - **A rejected rejoin ends the session** (error reply, `terminate`,
//! exit): `Err` means "this transport may not have this channel", and
//! a zombie session held open for an unauthorized client is a
//! liability. Next join is a cold start.
//! - **A second transport evicts the first** (best-effort `phx_close`
//! on the old socket): a session is single-transport by definition;
//! two tabs wanting independent channels should not share a session
//! key.
//! - **TTL is per detach episode** (reset on every disconnect), and the
//! pubsub subscription is made once, on the first successful join, to
//! the first join's topic.
//! - **`ChannelSession::Key: Clone`** beyond the ratified
//! `Eq + Hash + Send` — the registry keeps a `pid -> key` reverse
//! index for monitor-`Down` pruning.
use super::*;
use smarm::gen_server::{self, GenServer, ServerCtx};
use smarm::{Down, ServerRef, Watcher};
use std::collections::VecDeque;
use std::hash::Hash;
use std::time::{Duration, Instant};
/// Opt-in session persistence config for a channel type. All methods
/// are static: the config is consulted before any channel instance
/// exists.
pub trait ChannelSession<P>: Send + 'static {
/// What identifies "the same session" across reconnects.
type Key: Eq + Hash + Send + 'static;
/// Derive the session key from a join. Same key, same (live)
/// channel actor; the join payload is the natural carrier for a
/// client-held session token.
fn session_key(topic: &str, join_payload: &P) -> Self::Key;
/// Detached broadcasts buffered before the session tears itself
/// down (next join is a cold start).
fn buffer_cap() -> usize {
128
}
/// How long a detached session waits for a rejoin before tearing
/// itself down. Reset on every detach.
fn ttl() -> Duration {
Duration::from_secs(30)
}
}
// ---------------------------------------------------------------------------
// The session-aware factory (what channel_session registers)
// ---------------------------------------------------------------------------
pub(super) struct SessionFactory<P: Encode + Decode + Send + Sync + 'static, K: SessionKey> {
inner: Arc<dyn ChannelFactory<P>>,
keyfn: fn(&str, &P) -> K,
registry: ServerRef<Registry<P, K>>,
}
/// The registry's working bounds for a session key.
pub(super) trait SessionKey: Eq + Hash + Clone + Send + 'static {}
impl<K: Eq + Hash + Clone + Send + 'static> SessionKey for K {}
impl<P: Encode + Decode + Send + Sync + 'static, K: SessionKey> SessionFactory<P, K> {
/// **In-runtime only** — spawns the registry gen_server.
pub(super) fn new<S: ChannelSession<P, Key = K>>(inner: Arc<dyn ChannelFactory<P>>) -> Self {
let registry = gen_server::start(Registry {
factory: inner.clone(),
cap: S::buffer_cap(),
ttl: S::ttl(),
sessions: HashMap::new(),
pid_key: HashMap::new(),
watcher: None,
});
SessionFactory { inner, keyfn: S::session_key, registry }
}
}
impl<P: Encode + Decode + Send + Sync + 'static, K: SessionKey> ChannelFactory<P>
for SessionFactory<P, K>
{
fn create(&self, topic: &str) -> Box<dyn Channel<P>> {
self.inner.create(topic)
}
fn deploy(&self, hs: JoinHandshake<P>) -> ChannelInbox<P>
where
P: Encode + Decode,
{
let key = (self.keyfn)(&hs.topic, &hs.payload);
let (tx, rx) = smarm::channel::<In<P>>();
// Cast: the actor acks the join straight to the socket; the
// conn actor has nothing to wait for. A dead registry can only
// mean shutdown — the failed join is moot.
let _ = self.registry.cast(Join { key, hs, inbound: rx });
ChannelInbox(tx)
}
}
// ---------------------------------------------------------------------------
// The registry gen_server: key -> live session actor
// ---------------------------------------------------------------------------
pub(super) struct Join<P: Send + Sync + 'static, K> {
key: K,
hs: JoinHandshake<P>,
inbound: Receiver<In<P>>,
}
struct Registry<P: Send + Sync + 'static, K> {
factory: Arc<dyn ChannelFactory<P>>,
cap: usize,
ttl: Duration,
sessions: HashMap<K, (Pid, Sender<Ctl<P>>)>,
/// Reverse index for `handle_down` — the reason `Key: Clone`.
pid_key: HashMap<Pid, K>,
watcher: Option<Watcher>,
}
impl<P: Encode + Decode + Send + Sync + 'static, K: SessionKey> GenServer for Registry<P, K> {
type Call = ();
type Reply = ();
type Cast = Join<P, K>;
type Info = ();
fn init(&mut self, ctx: &ServerCtx) {
self.watcher = Some(ctx.watcher());
}
fn handle_call(&mut self, _request: ()) {}
fn handle_cast(&mut self, Join { key, hs, inbound }: Join<P, K>) {
let attach = Ctl::Attach { hs, inbound };
let attach = match self.sessions.get(&key) {
Some((_, ctl)) => match ctl.send(attach) {
Ok(()) => return, // reattached
// The actor died and its Down hasn't landed yet:
// recover the handshake, replace below.
Err(smarm::channel::SendError(a)) => a,
},
None => attach,
};
let (ctl_tx, ctl_rx) = smarm::channel::<Ctl<P>>();
let (factory, cap, ttl) = (self.factory.clone(), self.cap, self.ttl);
let pid = smarm::spawn(move || run_session(factory, cap, ttl, ctl_rx)).pid();
// Cannot fail: the receiver is owned by the just-spawned actor.
let _ = ctl_tx.send(attach);
self.watcher
.as_ref()
.expect("watcher set in init")
.watch(smarm::monitor(pid));
self.pid_key.insert(pid, key.clone());
self.sessions.insert(key, (pid, ctl_tx));
}
fn handle_down(&mut self, down: Down) {
if let Some(key) = self.pid_key.remove(&down.pid) {
// Pid guard: a stale Down for an entry handle_cast already
// replaced must not evict the replacement.
if self.sessions.get(&key).is_some_and(|(p, _)| *p == down.pid) {
self.sessions.remove(&key);
}
}
}
}
// ---------------------------------------------------------------------------
// The session actor
// ---------------------------------------------------------------------------
enum Ctl<P: Send + Sync + 'static> {
Attach { hs: JoinHandshake<P>, inbound: Receiver<In<P>> },
}
enum Step<P: Send + Sync + 'static> {
Attach { hs: JoinHandshake<P>, inbound: Receiver<In<P>> },
Attached { inbound: Receiver<In<P>> },
Detached,
Exit,
}
fn run_session<P: Encode + Decode + Send + Sync + 'static>(
factory: Arc<dyn ChannelFactory<P>>,
cap: usize,
ttl: Duration,
ctl: Receiver<Ctl<P>>,
) {
// The initial Attach is cast by the registry in the same handler
// that spawned us. Closed instead = registry died first (shutdown
// raced the spawn): nothing exists yet, nothing to clean.
let Ok(Ctl::Attach { hs, inbound }) = ctl.recv() else { return };
// Partial moves: ws/bus/topic/join_ref go into the socket,
// reference/payload stay behind for the first attach below.
let mut socket = ChannelSocket {
ws: hs.ws,
bus: hs.bus,
topic: hs.topic,
join_ref: hs.join_ref,
cur_ref: RefCell::new(None),
};
let mut ch: Option<Box<dyn Channel<P>>> = None;
let mut joined = false;
let mut bus_rx: Option<Receiver<Arc<Broadcast<P>>>> = None;
let mut buffer: VecDeque<Arc<Broadcast<P>>> = VecDeque::new();
let mut step = attach(
&factory,
&mut ch,
&mut joined,
&mut bus_rx,
&mut buffer,
&mut socket,
hs.reference,
hs.payload,
inbound,
);
loop {
step = match step {
Step::Attach { hs, inbound } => {
socket.ws = hs.ws;
socket.join_ref = hs.join_ref;
socket.topic = hs.topic;
attach(
&factory,
&mut ch,
&mut joined,
&mut bus_rx,
&mut buffer,
&mut socket,
hs.reference,
hs.payload,
inbound,
)
}
Step::Attached { inbound } => attached_loop(
ch.as_deref_mut().expect("attached implies created"),
&socket,
&ctl,
&inbound,
bus_rx.as_ref().expect("attached implies subscribed"),
&mut buffer,
),
Step::Detached => detached_loop(
&ctl,
bus_rx.as_ref().expect("detached only after first subscribe"),
&mut buffer,
cap,
ttl,
),
Step::Exit => {
if joined {
if let Some(c) = ch.as_deref_mut() {
c.terminate();
}
}
return;
}
};
}
}
/// One attach handshake: create-once, (re)join, subscribe-once, ack,
/// drain. On a drain failure the undelivered tail stays in `buffer`
/// for the next attach.
#[allow(clippy::too_many_arguments)]
fn attach<P: Encode + Decode + Send + Sync + 'static>(
factory: &Arc<dyn ChannelFactory<P>>,
ch: &mut Option<Box<dyn Channel<P>>>,
joined: &mut bool,
bus_rx: &mut Option<Receiver<Arc<Broadcast<P>>>>,
buffer: &mut VecDeque<Arc<Broadcast<P>>>,
socket: &mut ChannelSocket<P>,
reference: Option<String>,
payload: P,
inbound: Receiver<In<P>>,
) -> Step<P> {
if ch.is_none() {
*ch = Some(factory.create(&socket.topic));
}
let c = ch.as_deref_mut().expect("just created");
socket.set_ref(reference);
let topic = socket.topic.clone();
match c.join(&topic, payload, socket) {
Ok(reply) => {
if bus_rx.is_none() {
// First successful join: subscribe BEFORE the ok reply
// (subscribe is a call — once the client sees its ack,
// membership is a fact, not a race). Once per session:
// the subscription is actor-scoped, survives detach,
// and that survival IS the buffering path.
match socket.bus.subscribe(&topic) {
Ok(rx) => *bus_rx = Some(rx),
Err(_) => {
socket.send_reply(Status::Error, None);
socket.set_ref(None);
return Step::Exit;
}
}
}
*joined = true;
socket.send_reply(Status::Ok, Some(&reply));
socket.set_ref(None);
// Drain with the NEW join generation's ref; buffered pushes
// have no request to correlate to (reference: None).
while let Some(b) = buffer.front() {
let out = P::encode(FrameRef {
join_ref: socket.join_ref.as_deref(),
reference: None,
topic: &socket.topic,
message: MessageRef::Event { event: &b.event, payload: Some(&b.payload) },
});
if socket.ws.send(out).is_ok() {
buffer.pop_front();
} else {
return Step::Detached;
}
}
Step::Attached { inbound }
}
Err(reply) => {
// Rejected (re)join = session over, by decision: error
// reply, terminate (in run_session, iff it ever joined),
// exit. Next join is a cold start.
socket.send_reply(Status::Error, Some(&reply));
socket.set_ref(None);
Step::Exit
}
}
}
fn attached_loop<P: Encode + Decode + Send + Sync + 'static>(
ch: &mut dyn Channel<P>,
socket: &ChannelSocket<P>,
ctl: &Receiver<Ctl<P>>,
inbound: &Receiver<In<P>>,
bus_rx: &Receiver<Arc<Broadcast<P>>>,
buffer: &mut VecDeque<Arc<Broadcast<P>>>,
) -> Step<P> {
// ctl sits at lowest priority: it only ever carries an eviction by
// a second transport (rare) or the closed-arm shutdown signal. A
// closed arm stays ready forever, but every closed observation
// exits the loop immediately — no spin, no starvation window.
loop {
match smarm::select(&[inbound, bus_rx, ctl]) {
0 => match inbound.try_recv() {
Ok(Some(In::Event { reference, event, payload })) => {
socket.set_ref(reference);
ch.handle_in(&event, payload, socket);
socket.set_ref(None);
}
Ok(Some(In::Leave { reference })) => {
// Explicit leave ends the SESSION, not just the
// attachment: ok reply, close event, terminate.
socket.set_ref(reference);
socket.send_reply(Status::Ok, None);
socket.set_ref(None);
close_event(socket);
return Step::Exit;
}
Ok(None) => {}
// Conn actor finished or replaced this join generation:
// the transport is gone, the session is not.
Err(_) => return Step::Detached,
},
1 => match bus_rx.try_recv() {
Ok(Some(b)) => {
let out = P::encode(FrameRef {
join_ref: socket.join_ref.as_deref(),
reference: None,
topic: &socket.topic,
message: MessageRef::Event { event: &b.event, payload: Some(&b.payload) },
});
if socket.ws.send(out).is_err() {
// Transport died under us: this broadcast is
// the buffer's first entry, nothing was lost.
buffer.push_back(b);
return Step::Detached;
}
}
Ok(None) => {}
// The topic relay died: pubsub is gone, session over.
Err(_) => return Step::Exit,
},
2 => match ctl.try_recv() {
Ok(Some(Ctl::Attach { hs, inbound })) => {
// A second transport claims the session key: evict
// this one (best effort — it may already be dead)
// and hand the session over.
close_event(socket);
return Step::Attach { hs, inbound };
}
Ok(None) => {}
// Registry gone = shutdown chain: terminate and exit.
Err(_) => return Step::Exit,
},
_ => unreachable!("three arms"),
}
}
}
fn detached_loop<P: Encode + Decode + Send + Sync + 'static>(
ctl: &Receiver<Ctl<P>>,
bus_rx: &Receiver<Arc<Broadcast<P>>>,
buffer: &mut VecDeque<Arc<Broadcast<P>>>,
cap: usize,
ttl: Duration,
) -> Step<P> {
// TTL is per detach episode: the clock starts now, every time.
let deadline = Instant::now() + ttl;
loop {
// cap.max(1): a cap of 0 means "no buffering tolerated" — the
// first buffered broadcast tears the session down; an empty
// buffer still waits out the TTL.
if buffer.len() >= cap.max(1) {
return Step::Exit;
}
let remaining = deadline.saturating_duration_since(Instant::now());
match smarm::select_timeout(&[ctl, bus_rx], remaining) {
// TTL expired with no rejoin: session over.
None => return Step::Exit,
Some(0) => match ctl.try_recv() {
Ok(Some(Ctl::Attach { hs, inbound })) => return Step::Attach { hs, inbound },
Ok(None) => {}
Err(_) => return Step::Exit,
},
Some(1) => match bus_rx.try_recv() {
Ok(Some(b)) => buffer.push_back(b),
Ok(None) => {}
Err(_) => return Step::Exit,
},
Some(_) => unreachable!("two arms"),
}
}
}
/// Best-effort `phx_close` push for the socket's current join
/// generation.
fn close_event<P: Encode + Decode + Send + Sync + 'static>(socket: &ChannelSocket<P>) {
let _ = socket.ws.send(P::encode(FrameRef {
join_ref: socket.join_ref.as_deref(),
reference: None,
topic: &socket.topic,
message: MessageRef::Event { event: EV_CLOSE, payload: None },
}));
}
// ---------------------------------------------------------------------------
// Tests — runtime-backed, on the toy pipe codec from testkit. The
// session-config types (`Cfg*`) are deliberately separate from the
// channel they configure: `channel_session::<S>` only needs `S` for
// keying/cap/ttl, the factory builds the channel.
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::super::testkit::{eventually, Harness, TP};
use super::super::*;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use std::time::Duration;
/// Counts its joins (cold start replies `joined:1`); flags
/// terminate; broadcasts on "shout".
struct Counter {
joins: u32,
term: Arc<AtomicBool>,
reject_rejoin: bool,
}
impl Channel<TP> for Counter {
fn join(&mut self, _topic: &str, _payload: TP, _s: &ChannelSocket<TP>) -> Result<TP, TP> {
self.joins += 1;
if self.reject_rejoin && self.joins > 1 {
return Err(TP("not again".into()));
}
Ok(TP(format!("joined:{}", self.joins)))
}
fn handle_in(&mut self, event: &str, payload: TP, s: &ChannelSocket<TP>) {
match event {
"shout" => s.broadcast("news", payload),
_ => s.reply(Status::Ok, TP(format!("echo:{}", payload.0))),
}
}
fn terminate(&mut self) {
self.term.store(true, Ordering::SeqCst);
}
}
fn counter_factory(term: &Arc<AtomicBool>, reject_rejoin: bool) -> impl ChannelFactory<TP> {
let term = term.clone();
move |_t: &str| -> Box<dyn Channel<TP>> {
Box::new(Counter { joins: 0, term: term.clone(), reject_rejoin })
}
}
/// Session config: keyed by topic alone, defaults otherwise.
struct Cfg;
impl ChannelSession<TP> for Cfg {
type Key = String;
fn session_key(topic: &str, _p: &TP) -> String {
topic.to_owned()
}
}
/// Tiny TTL (50ms) for the expiry test.
struct CfgTtl;
impl ChannelSession<TP> for CfgTtl {
type Key = String;
fn session_key(topic: &str, _p: &TP) -> String {
topic.to_owned()
}
fn ttl() -> Duration {
Duration::from_millis(50)
}
}
/// Cap of 2 (and a long TTL so only the cap can fire).
struct CfgCap;
impl ChannelSession<TP> for CfgCap {
type Key = String;
fn session_key(topic: &str, _p: &TP) -> String {
topic.to_owned()
}
fn buffer_cap() -> usize {
2
}
fn ttl() -> Duration {
Duration::from_secs(30)
}
}
fn session_hub<S: ChannelSession<TP, Key = String>>(
term: &Arc<AtomicBool>,
reject_rejoin: bool,
) -> ChannelHub<TP> {
ChannelHub::new(
PrefixRouter::new()
.channel_session::<S>("room:*", counter_factory(term, reject_rejoin)),
)
}
#[test]
fn session_buffers_across_reconnect_and_drains_in_order() {
let out = Arc::new(Mutex::new(Vec::<String>::new()));
let (out2, term) = (out.clone(), Arc::new(AtomicBool::new(false)));
smarm::run(move || {
let hub = session_hub::<Cfg>(&term, false);
let mut h1 = Harness::new(&hub);
h1.send("j1|r1|room:a|event|phx_join|u");
out2.lock().unwrap().push(h1.recv());
// Transport dies. Whether the actor has observed the detach
// yet or not, both paths (closed inbound arm, failed ws
// send) land the broadcasts in the buffer.
drop(h1);
hub.broadcast("room:a", "news", TP("one".into()));
hub.broadcast("room:a", "news", TP("two".into()));
let mut h2 = Harness::new(&hub);
h2.send("j2|r2|room:a|event|phx_join|u");
out2.lock().unwrap().push(h2.recv()); // warm rejoin ack
out2.lock().unwrap().push(h2.recv()); // drained, in order
out2.lock().unwrap().push(h2.recv());
});
let out = out.lock().unwrap();
assert_eq!(out[0], "j1|r1|room:a|reply|ok|joined:1");
assert_eq!(out[1], "j2|r2|room:a|reply|ok|joined:2");
assert_eq!(out[2], "j2|-|room:a|event|news|one");
assert_eq!(out[3], "j2|-|room:a|event|news|two");
}
#[test]
fn session_ttl_expiry_tears_down_then_cold_start() {
let out = Arc::new(Mutex::new(Vec::<String>::new()));
let (out2, term) = (out.clone(), Arc::new(AtomicBool::new(false)));
let term2 = term.clone();
smarm::run(move || {
let hub = session_hub::<CfgTtl>(&term2, false);
let mut h1 = Harness::new(&hub);
h1.send("j1|r1|room:a|event|phx_join|u");
out2.lock().unwrap().push(h1.recv());
drop(h1);
assert!(eventually(|| term2.load(Ordering::SeqCst)), "ttl never fired");
let mut h2 = Harness::new(&hub);
h2.send("j2|r2|room:a|event|phx_join|u");
out2.lock().unwrap().push(h2.recv());
});
let out = out.lock().unwrap();
assert_eq!(out[0], "j1|r1|room:a|reply|ok|joined:1");
assert_eq!(out[1], "j2|r2|room:a|reply|ok|joined:1"); // cold
}
#[test]
fn session_buffer_cap_tears_down_then_cold_start() {
let out = Arc::new(Mutex::new(Vec::<String>::new()));
let (out2, term) = (out.clone(), Arc::new(AtomicBool::new(false)));
smarm::run(move || {
let hub = session_hub::<CfgCap>(&term, false);
let mut h1 = Harness::new(&hub);
h1.send("j1|r1|room:a|event|phx_join|u");
out2.lock().unwrap().push(h1.recv());
drop(h1);
// Cap is 2: the second buffered broadcast tears it down
// (ttl is 30s — only the cap can fire here).
hub.broadcast("room:a", "news", TP("one".into()));
hub.broadcast("room:a", "news", TP("two".into()));
assert!(eventually(|| term.load(Ordering::SeqCst)), "cap never fired");
let mut h2 = Harness::new(&hub);
h2.send("j2|r2|room:a|event|phx_join|u");
out2.lock().unwrap().push(h2.recv());
});
let out = out.lock().unwrap();
assert_eq!(out[0], "j1|r1|room:a|reply|ok|joined:1");
assert_eq!(out[1], "j2|r2|room:a|reply|ok|joined:1"); // cold
}
#[test]
fn leave_ends_the_session_not_just_the_attachment() {
let out = Arc::new(Mutex::new(Vec::<String>::new()));
let (out2, term) = (out.clone(), Arc::new(AtomicBool::new(false)));
smarm::run(move || {
let hub = session_hub::<Cfg>(&term, false);
let mut h = Harness::new(&hub);
h.send("j1|r1|room:a|event|phx_join|u");
out2.lock().unwrap().push(h.recv());
h.send("j1|r2|room:a|event|phx_leave|");
out2.lock().unwrap().push(h.recv()); // ok reply
out2.lock().unwrap().push(h.recv()); // phx_close
assert!(eventually(|| term.load(Ordering::SeqCst)), "leave never terminated");
h.send("j2|r3|room:a|event|phx_join|u");
out2.lock().unwrap().push(h.recv());
});
let out = out.lock().unwrap();
assert_eq!(out[0], "j1|r1|room:a|reply|ok|joined:1");
assert_eq!(out[1], "j1|r2|room:a|reply|ok|");
assert_eq!(out[2], "j1|-|room:a|event|phx_close|");
assert_eq!(out[3], "j2|r3|room:a|reply|ok|joined:1"); // cold
}
#[test]
fn second_transport_evicts_first() {
let out = Arc::new(Mutex::new(Vec::<String>::new()));
let (out2, term) = (out.clone(), Arc::new(AtomicBool::new(false)));
smarm::run(move || {
let hub = session_hub::<Cfg>(&term, false);
let mut h1 = Harness::new(&hub);
h1.send("j1|r1|room:a|event|phx_join|u");
out2.lock().unwrap().push(h1.recv());
let mut h2 = Harness::new(&hub);
h2.send("j2|r2|room:a|event|phx_join|u");
out2.lock().unwrap().push(h2.recv()); // warm handover ack
out2.lock().unwrap().push(h1.recv()); // eviction phx_close
hub.broadcast("room:a", "news", TP("x".into()));
out2.lock().unwrap().push(h2.recv()); // only h2 is attached
h1.assert_silence();
// h1's conn-side entry is stale: its inbound receiver was
// dropped on eviction, so the next event self-prunes with
// an error reply.
h1.send("j1|r3|room:a|event|ping|x");
out2.lock().unwrap().push(h1.recv());
});
let out = out.lock().unwrap();
assert_eq!(out[0], "j1|r1|room:a|reply|ok|joined:1");
assert_eq!(out[1], "j2|r2|room:a|reply|ok|joined:2");
assert_eq!(out[2], "j1|-|room:a|event|phx_close|");
assert_eq!(out[3], "j2|-|room:a|event|news|x");
assert_eq!(out[4], "j1|r3|room:a|reply|error|");
}
#[test]
fn rejected_rejoin_ends_the_session() {
let out = Arc::new(Mutex::new(Vec::<String>::new()));
let (out2, term) = (out.clone(), Arc::new(AtomicBool::new(false)));
smarm::run(move || {
let hub = session_hub::<Cfg>(&term, true);
let mut h1 = Harness::new(&hub);
h1.send("j1|r1|room:a|event|phx_join|u");
out2.lock().unwrap().push(h1.recv());
drop(h1);
let mut h2 = Harness::new(&hub);
h2.send("j2|r2|room:a|event|phx_join|u");
out2.lock().unwrap().push(h2.recv()); // rejoin rejected
assert!(eventually(|| term.load(Ordering::SeqCst)), "reject never terminated");
let mut h3 = Harness::new(&hub);
h3.send("j3|r3|room:a|event|phx_join|u");
out2.lock().unwrap().push(h3.recv()); // cold start
});
let out = out.lock().unwrap();
assert_eq!(out[0], "j1|r1|room:a|reply|ok|joined:1");
assert_eq!(out[1], "j2|r2|room:a|reply|error|not again");
assert_eq!(out[2], "j3|r3|room:a|reply|ok|joined:1");
}
}