Commit Graph
2 Commits
Author SHA1 Message Date
Claude 0de2baa72d 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.
2026-06-12 15:22:54 +00:00
Claude 0531390613 feat(channels): core protocol machinery — v0.6 chunk 1
Feature 'channels' (zero new deps): wire-neutral ChannelFrame/FrameRef +
Encode/Decode codec traits (one method each, whole-envelope — the codec
owns the wire format end to end); Channel trait; ChannelFactory +
TopicRouter + shipped PrefixRouter (exact map + head:* prefix map);
ChannelSocket (reply/push/broadcast/broadcast_from/broadcast_to);
ChannelHub (router + PubSub<Broadcast<P>>, in-runtime + non-static per
the v0.5 pattern, hub.upgrade(conn) entry, server-side hub.broadcast).

Topology as ratified: one channel actor per (socket, topic), linked to
the conn actor, select(&[&inbound, &bus_rx]) with inbound at index 0;
subscription pinned to the channel actor pid. Subscribe-before-ok-reply
makes the join ack first-class (the v0.5 on_open race, answered
structurally). Cleanup is the sender-drop chain (conn handler drops ->
inbound closes -> closed arm wakes -> terminate); bus arm dropped from
the select set once observed closed (closed arms stay ready forever).
Heartbeat answered conn-side on topic 'phoenix'; rejoin replaces the
old generation with a phx_close to the old join_ref; lazy prune of dead
channel entries on first failed forward (the pubsub discipline).

As-landed deviations from the ratified sketch (module docs, veto by
diff): join takes &mut self (trait-object callable; construction moved
to ChannelFactory which still gets the full raw topic); reply(status,
payload) with the ref threaded invisibly (the sketch listed both);
broadcast gained the event parameter (unencodable without it); route()
returns Arc not Box. Outbound payloads encode from borrows (FrameRef)
so bus fan-out never clones P; payload-less control frames
(phx_close, error/heartbeat replies) encode as the codec's empty
payload rather than conjuring a P.

11 runtime-backed unit tests with a toy pipe codec (core provably
JSON-free). Suites: 78 w/ feature, 67 featureless, both green.
2026-06-12 13:53:52 +00:00