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:
+76
-114
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user