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
+76 -114
View File
@@ -73,6 +73,12 @@ use std::sync::Arc;
#[cfg(feature = "phoenix")]
pub mod phoenix;
mod session;
pub use session::ChannelSession;
#[cfg(test)]
pub(crate) mod testkit;
// ---------------------------------------------------------------------------
// Wire-neutral frames
// ---------------------------------------------------------------------------
@@ -192,8 +198,43 @@ pub trait Channel<P: Send + Sync + 'static>: Send + 'static {
/// possible (`"room:lobby"` arrives whole; pull `"lobby"` out yourself).
pub trait ChannelFactory<P: Send + Sync + 'static>: Send + Sync {
fn create(&self, topic: &str) -> Box<dyn Channel<P>>;
/// How an accepted-routing join becomes a running channel actor.
/// Internal seam — the default (ephemeral actor, linked to the
/// connection, cold start per join) is the contract; only the
/// session machinery overrides it. Not part of the public API.
#[doc(hidden)]
fn deploy(&self, hs: JoinHandshake<P>) -> ChannelInbox<P>
where
P: Encode + Decode,
{
let ch = self.create(&hs.topic);
let conn_pid = smarm::self_pid();
let (tx, rx) = smarm::channel::<In<P>>();
smarm::spawn(move || {
run_channel(conn_pid, ch, hs.topic, hs.join_ref, hs.reference, hs.payload, hs.ws, hs.bus, rx)
});
ChannelInbox(tx)
}
}
/// Everything the conn-side handler knows about one `phx_join`, handed
/// to [`ChannelFactory::deploy`]. Opaque: fields are crate-internal.
#[doc(hidden)]
pub struct JoinHandshake<P: Send + Sync + 'static> {
topic: String,
join_ref: Option<String>,
reference: Option<String>,
payload: P,
ws: WsSender,
bus: PubSub<Broadcast<P>>,
}
/// The conn-side handler's sender into a deployed channel actor.
/// Opaque: the field is crate-internal.
#[doc(hidden)]
pub struct ChannelInbox<P: Send + Sync + 'static>(Sender<In<P>>);
impl<P: Send + Sync + 'static, F> ChannelFactory<P> for F
where
F: Fn(&str) -> Box<dyn Channel<P>> + Send + Sync,
@@ -257,6 +298,31 @@ impl<P: Send + Sync + 'static> PrefixRouter<P> {
{
self.channel(pattern, DefaultFactory::<C>(PhantomData))
}
/// [`channel`](Self::channel), with opt-in session persistence
/// keyed and configured by `S`'s [`ChannelSession`] impl (usually
/// `S` is the channel type itself). **In-runtime only**: this
/// spawns the pattern's session-registry actor, same law as
/// [`ChannelHub::new`].
pub fn channel_session<S>(self, pattern: &str, factory: impl ChannelFactory<P> + 'static) -> Self
where
S: ChannelSession<P>,
S::Key: Clone,
P: Encode + Decode,
{
self.channel(pattern, session::SessionFactory::new::<S>(Arc::new(factory)))
}
/// [`channel_session`](Self::channel_session) with a
/// [`Default`]-built impl that is its own session config.
pub fn channel_session_default<C>(self, pattern: &str) -> Self
where
C: Channel<P> + ChannelSession<P> + Default,
C::Key: Clone,
P: Encode + Decode,
{
self.channel_session::<C>(pattern, DefaultFactory::<C>(PhantomData))
}
}
impl<P: Send + Sync + 'static> TopicRouter<P> for PrefixRouter<P> {
@@ -495,18 +561,16 @@ impl<P: Encode + Decode + Send + Sync + 'static> WsHandler for SocketHandler<P>
);
return;
};
let (tx, rx) = smarm::channel::<In<P>>();
let conn_pid = smarm::self_pid();
let topic = frame.topic.clone();
let join_ref = frame.join_ref.clone();
let reference = frame.reference;
let ws = sender.clone();
let bus = self.bus.clone();
smarm::spawn(move || {
run_channel(conn_pid, factory, topic, join_ref, reference, payload, ws, bus, rx)
let inbox = factory.deploy(JoinHandshake {
topic: frame.topic.clone(),
join_ref: frame.join_ref.clone(),
reference: frame.reference,
payload,
ws: sender.clone(),
bus: self.bus.clone(),
});
self.joined
.insert(frame.topic, Joined { join_ref: frame.join_ref, tx });
.insert(frame.topic, Joined { join_ref: frame.join_ref, tx: inbox.0 });
}
EV_LEAVE => match self.joined.remove(&frame.topic) {
Some(j) => {
@@ -567,7 +631,7 @@ impl<P: Encode + Decode + Send + Sync + 'static> WsHandler for SocketHandler<P>
#[allow(clippy::too_many_arguments)]
fn run_channel<P: Encode + Decode + Send + Sync + 'static>(
conn_pid: Pid,
factory: Arc<dyn ChannelFactory<P>>,
mut ch: Box<dyn Channel<P>>,
topic: String,
join_ref: Option<String>,
reference: Option<String>,
@@ -590,7 +654,6 @@ fn run_channel<P: Encode + Decode + Send + Sync + 'static>(
join_ref,
cur_ref: RefCell::new(None),
};
let mut ch = factory.create(&topic);
socket.set_ref(reference);
match ch.join(&topic, payload, &socket) {
@@ -694,65 +757,11 @@ fn channel_loop<P: Encode + Decode + Send + Sync + 'static>(
#[cfg(test)]
mod tests {
use super::*;
use crate::ws::duplex::Outbound;
use super::testkit::{eventually, Harness, TP};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
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)]
struct TP(String);
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 room channel: rejects topic "room:locked"; echoes events back as
/// replies; broadcasts on "shout"; flags terminate.
@@ -789,44 +798,6 @@ mod tests {
}
}
/// 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.
struct Harness {
handler: SocketHandler<TP>,
sender: WsSender,
out_rx: smarm::Receiver<Outbound>,
}
impl Harness {
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.
fn send(&mut self, line: &str) {
self.handler
.on_message(Message::Text(line.to_string()), &self.sender.clone());
}
/// Next encoded frame the client would see (2s budget — channel
/// actors answer asynchronously).
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()),
}
}
}
fn hub(terminated: &Arc<AtomicBool>) -> ChannelHub<TP> {
ChannelHub::new(
@@ -834,15 +805,6 @@ mod tests {
)
}
fn eventually(mut f: impl FnMut() -> bool) -> bool {
for _ in 0..200 {
if f() {
return true;
}
smarm::sleep(Duration::from_millis(10));
}
false
}
#[test]
fn prefix_router_exact_and_prefix_and_miss() {