feat(channels): phoenix V2 JSON codec — v0.6 chunk 2

Json<T> newtype codec (src/channels/phoenix.rs), feature "phoenix":
speaks the Phoenix V2 wire format [join_ref, ref, topic, event, payload]
for any T: Serialize + DeserializeOwned. Replies encode as phx_reply
with the {"status", "response"} wrapper; payload-less control frames
encode payload as {}.

Deviation from roadmap sketch (documented in module docs): the roadmap
sketched a blanket Encode/Decode impl on bare P: Serialize. That is
coherence poison — with additive features, enabling "phoenix" anywhere
in the crate graph would forbid every user-written Encode impl. The
blanket therefore lives behind the Json<T> newtype: zero-boilerplate
(Deref/DerefMut/From), but opt-in per payload type.

Encode failure panics and rides the linked-teardown contract (channel
actor is linked to the conn; a payload that cannot serialize is a
programming error, not a protocol event). Decode strictness is exactly
T's Deserialize — use serde defaults or Json<serde_json::Value> for
lenient channels.

Integration (tests/integration.rs, cfg(feature = "phoenix")):
- channels_join_heartbeat_event_broadcast_leave: join ack ordering,
  conn-side heartbeat, unrouted-topic error, join rejection, broadcast
  incl. sender, correlated echo reply, leave -> ok + phx_close, and
  post-leave isolation.
- channels_codec_garbage_closes_1002: undecodable text frame closes
  the socket with protocol-error status.
- shutdown_with_open_channel_terminates: drain force-stop tears down
  the linked channel actor and the runtime reaches AllDone in budget.

Hammer: 35x lifecycle subset (+channels filter) + 3x full (phoenix) +
1x full (phoenix+smarm-trace) + featureless + channels-only, all green.
Suite: 84 unit + 45 integration + 2 doc under --features phoenix.
This commit is contained in:
Claude
2026-06-12 14:56:26 +00:00
parent 0531390613
commit 39e4f70e9d
2 changed files with 419 additions and 0 deletions
+196
View File
@@ -1408,3 +1408,199 @@ fn shutdown_with_open_chat_terminates() {
let _ = a.read_to_end(&mut rest);
let _ = b.read_to_end(&mut rest);
}
// ===========================================================================
// Channels (v0.6) — phoenix V2 wire over a real socket
// ===========================================================================
#[cfg(feature = "phoenix")]
mod channels_wire {
use super::*;
use serde_json::{json, Value};
use urus::channels::phoenix::Json;
use urus::channels::{Channel, ChannelHub, ChannelSocket, PrefixRouter, Status};
type P = Json<Value>;
/// join rejects payloads carrying "deny"; "shout" broadcasts to the
/// room (sender included); anything else echoes as an ok reply.
struct Lobby;
impl Channel<P> for Lobby {
fn join(&mut self, topic: &str, payload: P, _s: &ChannelSocket<P>) -> Result<P, P> {
if payload.0.get("deny").is_some() {
Err(Json(json!({"reason": "denied"})))
} else {
Ok(Json(json!({"joined": topic})))
}
}
fn handle_in(&mut self, event: &str, payload: P, s: &ChannelSocket<P>) {
match event {
"shout" => s.broadcast("shouted", payload),
_ => s.reply(Status::Ok, Json(json!({"echo": event}))),
}
}
}
fn channels_pipeline() -> Pipeline {
let hub: std::sync::Arc<std::sync::OnceLock<ChannelHub<P>>> =
std::sync::Arc::new(std::sync::OnceLock::new());
Pipeline::new().plug(Router::new().get("/socket", move |c: Conn, _n: Next| {
// Hub construction is in-runtime only (it spawns the pubsub
// table); the NON-static OnceLock is what lets shutdown
// drain the table (the v0.5 pattern).
let hub = hub.clone();
let hub = hub.get_or_init(|| {
ChannelHub::new(PrefixRouter::new().channel("room:*", |_: &str| {
Box::new(Lobby) as Box<dyn Channel<P>>
}))
});
hub.upgrade(c)
}))
}
const CHAN_HANDSHAKE: &[u8] =
b"GET /socket HTTP/1.1\r\nHost: x\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n";
fn chan_connect(port: u16) -> TcpStream {
let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap();
s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
s.write_all(CHAN_HANDSHAKE).unwrap();
let head = String::from_utf8(read_response_head(&mut s)).unwrap();
assert!(head.starts_with("HTTP/1.1 101"), "handshake: {head}");
s
}
fn chan_send(s: &mut TcpStream, frame: Value) {
ws_send(s, &Frame::new(Opcode::Text, frame.to_string()));
}
fn chan_read(s: &mut TcpStream, buf: &mut Vec<u8>) -> Value {
let f = ws_read_frame(s, buf);
assert_eq!(f.opcode, Opcode::Text, "expected V2 text frame");
serde_json::from_slice(&f.payload).expect("server sent non-JSON frame")
}
#[test]
fn channels_join_heartbeat_event_broadcast_leave() {
let port = spawn_server(channels_pipeline());
let mut a = chan_connect(port);
let mut a_buf = Vec::new();
// Join — the reply IS the subscription ack.
chan_send(&mut a, json!(["1", "1", "room:lobby", "phx_join", {}]));
assert_eq!(
chan_read(&mut a, &mut a_buf),
json!(["1", "1", "room:lobby", "phx_reply",
{"status": "ok", "response": {"joined": "room:lobby"}}])
);
// Transport heartbeat.
chan_send(&mut a, json!([null, "2", "phoenix", "heartbeat", {}]));
assert_eq!(
chan_read(&mut a, &mut a_buf),
json!([null, "2", "phoenix", "phx_reply", {"status": "ok", "response": {}}])
);
// Unrouted topic: error reply.
chan_send(&mut a, json!(["9", "3", "hall:x", "phx_join", {}]));
assert_eq!(
chan_read(&mut a, &mut a_buf),
json!(["9", "3", "hall:x", "phx_reply", {"status": "error", "response": {}}])
);
// Rejected join: the channel's error payload comes back.
chan_send(&mut a, json!(["9", "4", "room:vip", "phx_join", {"deny": true}]));
assert_eq!(
chan_read(&mut a, &mut a_buf),
json!(["9", "4", "room:vip", "phx_reply",
{"status": "error", "response": {"reason": "denied"}}])
);
// Second member; a's shout reaches both (sender included).
let mut b = chan_connect(port);
let mut b_buf = Vec::new();
chan_send(&mut b, json!(["1", "1", "room:lobby", "phx_join", {}]));
chan_read(&mut b, &mut b_buf);
chan_send(&mut a, json!(["1", "5", "room:lobby", "shout", {"body": "hi"}]));
let push = json!(["1", null, "room:lobby", "shouted", {"body": "hi"}]);
assert_eq!(chan_read(&mut a, &mut a_buf), push);
assert_eq!(chan_read(&mut b, &mut b_buf), push);
// Plain event echoes as a correlated reply.
chan_send(&mut a, json!(["1", "6", "room:lobby", "wave", {}]));
assert_eq!(
chan_read(&mut a, &mut a_buf),
json!(["1", "6", "room:lobby", "phx_reply",
{"status": "ok", "response": {"echo": "wave"}}])
);
// Leave: ok reply, then phx_close; the topic is then unjoined.
chan_send(&mut a, json!(["1", "7", "room:lobby", "phx_leave", {}]));
assert_eq!(
chan_read(&mut a, &mut a_buf),
json!(["1", "7", "room:lobby", "phx_reply", {"status": "ok", "response": {}}])
);
assert_eq!(
chan_read(&mut a, &mut a_buf),
json!(["1", null, "room:lobby", "phx_close", {}])
);
chan_send(&mut a, json!(["1", "8", "room:lobby", "wave", {}]));
assert_eq!(
chan_read(&mut a, &mut a_buf),
json!(["1", "8", "room:lobby", "phx_reply", {"status": "error", "response": {}}])
);
// a is gone from the room: b's shout comes back to b only, and
// a's connection stays a working transport (heartbeat).
chan_send(&mut b, json!(["1", "9", "room:lobby", "shout", {"n": 2}]));
assert_eq!(
chan_read(&mut b, &mut b_buf),
json!(["1", null, "room:lobby", "shouted", {"n": 2}])
);
chan_send(&mut a, json!([null, "10", "phoenix", "heartbeat", {}]));
assert_eq!(
chan_read(&mut a, &mut a_buf),
json!([null, "10", "phoenix", "phx_reply", {"status": "ok", "response": {}}])
);
}
#[test]
fn channels_codec_garbage_closes_1002() {
let port = spawn_server(channels_pipeline());
let mut s = chan_connect(port);
let mut buf = Vec::new();
ws_send(&mut s, &Frame::new(Opcode::Text, "not a v2 frame"));
let f = ws_read_frame(&mut s, &mut buf);
assert_eq!(f.opcode, Opcode::Close);
assert_eq!(u16::from_be_bytes([f.payload[0], f.payload[1]]), 1002);
}
#[test]
fn shutdown_with_open_channel_terminates() {
// The whole exit chain under one roof: drain force-stops the
// conn actor parked in its select -> linked channel actor dies
// (and/or wakes on the dropped inbound arm) -> its subscription
// is monitor-pruned -> the non-static hub's last handle drops
// with the drained pipeline -> pubsub table exits -> AllDone.
let (port, handle, done_rx) =
spawn_server_with_handle(channels_pipeline(), Duration::from_millis(300));
let mut a = chan_connect(port);
let mut a_buf = Vec::new();
chan_send(&mut a, json!(["1", "1", "room:lobby", "phx_join", {}]));
let reply = chan_read(&mut a, &mut a_buf);
assert_eq!(reply[3], "phx_reply"); // joined: channel actor live
handle.shutdown();
done_rx
.recv_timeout(Duration::from_secs(5))
.expect("serve did not return: channel actor, hub, or table outlived the drain");
let mut rest = Vec::new();
let _ = a.read_to_end(&mut rest);
}
}