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.
This commit is contained in:
Claude
2026-06-12 13:53:52 +00:00
parent a676c01adc
commit 0531390613
3 changed files with 1059 additions and 0 deletions
+7
View File
@@ -11,8 +11,15 @@ httparse = "1.9"
libc = "0.2" libc = "0.2"
sha1_smol = "1" sha1_smol = "1"
# dep #4, ratified 2026-06-12: serde/serde_json behind the opt-in
# "phoenix" feature only — the "channels" core stays dependency-free.
serde = { version = "1", optional = true }
serde_json = { version = "1", optional = true }
[features] [features]
smarm-trace = ["smarm/smarm-trace"] smarm-trace = ["smarm/smarm-trace"]
channels = []
phoenix = ["channels", "dep:serde", "dep:serde_json"]
[dev-dependencies] [dev-dependencies]
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
+1048
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -29,6 +29,8 @@ pub mod serve;
pub mod sse; pub mod sse;
pub mod ws; pub mod ws;
pub mod pubsub; pub mod pubsub;
#[cfg(feature = "channels")]
pub mod channels;
// Re-exports — what most users want at the crate root. // Re-exports — what most users want at the crate root.
pub use conn::{Assigns, Body, Conn, HeaderMap, HttpVersion, Method, Params, RespBody, StreamBody}; pub use conn::{Assigns, Body, Conn, HeaderMap, HttpVersion, Method, Params, RespBody, StreamBody};
@@ -36,6 +38,8 @@ pub use plug::{Next, Pipeline, Plug};
pub use router::Router; pub use router::Router;
pub use sse::{EventSender, SseClosed}; pub use sse::{EventSender, SseClosed};
pub use pubsub::{PubSub, PubSubDown}; pub use pubsub::{PubSub, PubSubDown};
#[cfg(feature = "channels")]
pub use channels::{Channel, ChannelHub, ChannelSocket, PrefixRouter, Status, TopicRouter};
pub use ws::{Message, WsClosed, WsHandler, WsSender}; pub use ws::{Message, WsClosed, WsHandler, WsSender};
pub use serve::{ pub use serve::{
serve, serve_with, serve_with_shutdown, shutdown_handle, Config, Handle, ShutdownSignal, serve, serve_with, serve_with_shutdown, shutdown_handle, Config, Handle, ShutdownSignal,