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.
122 lines
3.8 KiB
Rust
122 lines
3.8 KiB
Rust
//! Shared test scaffolding for the channels module tree: the toy
|
|
//! pipe-delimited codec (`TP`) and the conn-side `Harness` that fakes a
|
|
//! websocket transport. `cfg(test)` only.
|
|
|
|
use super::*;
|
|
use crate::ws::duplex::Outbound;
|
|
use std::time::Duration;
|
|
|
|
/// Toy payload + codec: `jref|ref|topic|kind|event-or-status|payload`
|
|
/// with `-` for absent refs. Enough to drive the machinery.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub(crate) struct TP(pub(crate) String);
|
|
|
|
pub(crate) fn part(o: Option<&str>) -> &str {
|
|
o.unwrap_or("-")
|
|
}
|
|
|
|
impl Encode for TP {
|
|
fn encode(f: FrameRef<'_, Self>) -> Message {
|
|
let s = match f.message {
|
|
MessageRef::Event { event, payload } => format!(
|
|
"{}|{}|{}|event|{}|{}",
|
|
part(f.join_ref),
|
|
part(f.reference),
|
|
f.topic,
|
|
event,
|
|
payload.map(|p| p.0.as_str()).unwrap_or("")
|
|
),
|
|
MessageRef::Reply { status, payload } => format!(
|
|
"{}|{}|{}|reply|{}|{}",
|
|
part(f.join_ref),
|
|
part(f.reference),
|
|
f.topic,
|
|
match status {
|
|
Status::Ok => "ok",
|
|
Status::Error => "error",
|
|
},
|
|
payload.map(|p| p.0.as_str()).unwrap_or("")
|
|
),
|
|
};
|
|
Message::Text(s)
|
|
}
|
|
}
|
|
|
|
impl Decode for TP {
|
|
fn decode(msg: &Message) -> Result<ChannelFrame<Self>, DecodeError> {
|
|
let Message::Text(s) = msg else {
|
|
return Err(DecodeError("binary".into()));
|
|
};
|
|
let p: Vec<&str> = s.splitn(6, '|').collect();
|
|
if p.len() != 6 || p[3] != "event" {
|
|
return Err(DecodeError(format!("bad frame: {s}")));
|
|
}
|
|
let opt = |x: &str| (x != "-").then(|| x.to_string());
|
|
Ok(ChannelFrame {
|
|
join_ref: opt(p[0]),
|
|
reference: opt(p[1]),
|
|
topic: p[2].to_string(),
|
|
message: ChannelMessage::Event { event: p[4].to_string(), payload: TP(p[5].into()) },
|
|
})
|
|
}
|
|
}
|
|
|
|
/// A fake socket: the conn-side handler plus the raw Outbound channel
|
|
/// a real duplex loop would drain. `recv_text` pulls the next encoded
|
|
/// frame the "client" would see.
|
|
pub(crate) struct Harness {
|
|
handler: SocketHandler<TP>,
|
|
sender: WsSender,
|
|
out_rx: smarm::Receiver<Outbound>,
|
|
}
|
|
|
|
impl Harness {
|
|
pub(crate) fn new(hub: &ChannelHub<TP>) -> Self {
|
|
let (tx, out_rx) = smarm::channel();
|
|
Self {
|
|
handler: SocketHandler {
|
|
bus: hub.bus.clone(),
|
|
router: hub.router.clone(),
|
|
joined: HashMap::new(),
|
|
},
|
|
sender: WsSender { tx },
|
|
out_rx,
|
|
}
|
|
}
|
|
|
|
/// Feed one wire line in, as the duplex loop would.
|
|
pub(crate) fn send(&mut self, line: &str) {
|
|
self.handler
|
|
.on_message(Message::Text(line.to_string()), &self.sender.clone());
|
|
}
|
|
|
|
/// Assert nothing arrives for this client within a short window —
|
|
/// for "the broadcast must NOT reach the evicted transport" cases.
|
|
pub(crate) fn assert_silence(&self) {
|
|
if let Ok(Outbound::Msg(Message::Text(s))) =
|
|
self.out_rx.recv_timeout(Duration::from_millis(100))
|
|
{
|
|
panic!("expected silence, got frame: {s}");
|
|
}
|
|
}
|
|
|
|
/// Next encoded frame the client would see (2s budget — channel
|
|
/// actors answer asynchronously).
|
|
pub(crate) fn recv(&self) -> String {
|
|
match self.out_rx.recv_timeout(Duration::from_secs(2)) {
|
|
Ok(Outbound::Msg(Message::Text(s))) => s,
|
|
other => panic!("expected outbound text frame, got {:?}", other.is_ok()),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn eventually(mut f: impl FnMut() -> bool) -> bool {
|
|
for _ in 0..200 {
|
|
if f() {
|
|
return true;
|
|
}
|
|
smarm::sleep(Duration::from_millis(10));
|
|
}
|
|
false
|
|
}
|