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
+223
View File
@@ -0,0 +1,223 @@
//! Phoenix V2 wire codec — feature `"phoenix"` (implies `"channels"`,
//! spends dependency slot #4 on `serde`/`serde_json`, ratified
//! 2026-06-12).
//!
//! The wire format phoenix.js speaks: one JSON array per frame,
//!
//! ```text
//! [join_ref, ref, topic, event, payload]
//! ```
//!
//! refs are strings or `null`; replies are `event: "phx_reply"` with
//! `payload: {"status": "ok"|"error", "response": <payload>}`.
//!
//! # `Json<T>`, not a blanket impl on `P`
//!
//! The ratified sketch said "JsonCodec blanket impl via serde". A
//! literal `impl<P: Serialize + DeserializeOwned> Encode for P` is
//! coherence poison: features are additive across a dependency graph,
//! so the moment ANY crate enables `"phoenix"`, every hand-written
//! `Encode` impl in every other crate stops compiling (the compiler
//! cannot prove a foreign type will never gain `Serialize`). That
//! breaks the design's own promise that `"channels"`-only users supply
//! their own codecs. The blanket therefore lives one newtype away:
//! [`Json<T>`] is V2-JSON for ANY serde-capable `T` — including
//! `Json<serde_json::Value>` for schemaless payloads — while the
//! `Encode`/`Decode` namespace stays open.
//!
//! # Strictness
//!
//! Decoding is exactly as strict as `T`'s `Deserialize`: a phoenix.js
//! `{}` join payload fails against a `T` with required fields (the
//! socket closes `1002`). Use `#[serde(default)]`, `Option` fields, or
//! `Json<serde_json::Value>` where the client is loose. Encoding a `T`
//! that fails `Serialize` (non-string map keys and friends) panics —
//! that is a bug in the payload type, and the panic rides the
//! documented channel-panic contract (linked teardown).
use super::{
ChannelFrame, ChannelMessage, Decode, DecodeError, Encode, FrameRef, MessageRef, Status,
EV_REPLY,
};
use crate::ws::Message;
use serde::de::DeserializeOwned;
use serde::Serialize;
use serde_json::{json, Map, Value};
/// V2-JSON payload wrapper: `Json<T>` speaks the phoenix wire for any
/// `T: Serialize + DeserializeOwned`. Derefs to `T`.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Json<T>(pub T);
impl<T> std::ops::Deref for Json<T> {
type Target = T;
fn deref(&self) -> &T {
&self.0
}
}
impl<T> std::ops::DerefMut for Json<T> {
fn deref_mut(&mut self) -> &mut T {
&mut self.0
}
}
impl<T> From<T> for Json<T> {
fn from(t: T) -> Self {
Json(t)
}
}
fn empty_object() -> Value {
Value::Object(Map::new())
}
fn to_value<T: Serialize>(t: &T) -> Value {
serde_json::to_value(t).expect("phoenix codec: payload failed to serialize")
}
impl<T: Serialize + DeserializeOwned + Send + Sync + 'static> Encode for Json<T> {
fn encode(f: FrameRef<'_, Self>) -> Message {
let (event, payload) = match f.message {
MessageRef::Event { event, payload } => {
(event, payload.map(|p| to_value(&p.0)).unwrap_or_else(empty_object))
}
MessageRef::Reply { status, payload } => (
EV_REPLY,
json!({
"status": match status {
Status::Ok => "ok",
Status::Error => "error",
},
"response": payload.map(|p| to_value(&p.0)).unwrap_or_else(empty_object),
}),
),
};
let arr = json!([f.join_ref, f.reference, f.topic, event, payload]);
Message::Text(arr.to_string())
}
}
impl<T: Serialize + DeserializeOwned + Send + Sync + 'static> Decode for Json<T> {
fn decode(msg: &Message) -> Result<ChannelFrame<Self>, DecodeError> {
let Message::Text(s) = msg else {
return Err(DecodeError("phoenix V2 frames are text".into()));
};
let (join_ref, reference, topic, event, payload): (
Option<String>,
Option<String>,
String,
String,
Value,
) = serde_json::from_str(s).map_err(|e| DecodeError(e.to_string()))?;
let message = if event == EV_REPLY {
// Clients don't reply in practice; decoded for symmetry
// (and ignored upstream).
let status = match payload.get("status").and_then(Value::as_str) {
Some("ok") => Status::Ok,
_ => Status::Error,
};
let response = payload.get("response").cloned().unwrap_or_else(empty_object);
let payload = serde_json::from_value::<T>(response)
.map_err(|e| DecodeError(format!("reply response: {e}")))?;
ChannelMessage::Reply { status, payload: Some(Json(payload)) }
} else {
let payload = serde_json::from_value::<T>(payload)
.map_err(|e| DecodeError(format!("payload for '{event}': {e}")))?;
ChannelMessage::Event { event, payload: Json(payload) }
};
Ok(ChannelFrame { join_ref, reference, topic, message })
}
}
#[cfg(test)]
mod tests {
use super::*;
type V = Json<Value>;
fn parse(m: &Message) -> Value {
let Message::Text(s) = m else { panic!("not text") };
serde_json::from_str(s).unwrap()
}
#[test]
fn decodes_phoenix_js_join_frame() {
let m = Message::Text(r#"["3","3","room:lobby","phx_join",{"token":"t"}]"#.into());
let f = V::decode(&m).unwrap();
assert_eq!(f.join_ref.as_deref(), Some("3"));
assert_eq!(f.reference.as_deref(), Some("3"));
assert_eq!(f.topic, "room:lobby");
let ChannelMessage::Event { event, payload } = f.message else { panic!() };
assert_eq!(event, "phx_join");
assert_eq!(payload.0, json!({"token": "t"}));
}
#[test]
fn decodes_heartbeat_null_refs_allowed() {
let m = Message::Text(r#"[null,"7","phoenix","heartbeat",{}]"#.into());
let f = V::decode(&m).unwrap();
assert_eq!(f.join_ref, None);
assert_eq!(f.topic, "phoenix");
}
#[test]
fn encodes_ok_reply_with_status_response_wrapper() {
let p = Json(json!({"user_count": 2}));
let m = V::encode(FrameRef {
join_ref: Some("3"),
reference: Some("4"),
topic: "room:lobby",
message: MessageRef::Reply { status: Status::Ok, payload: Some(&p) },
});
assert_eq!(
parse(&m),
json!(["3", "4", "room:lobby", "phx_reply",
{"status": "ok", "response": {"user_count": 2}}])
);
}
#[test]
fn encodes_payloadless_reply_and_event_as_empty_object() {
let m = V::encode(FrameRef {
join_ref: None,
reference: Some("9"),
topic: "phoenix",
message: MessageRef::Reply { status: Status::Ok, payload: None },
});
assert_eq!(
parse(&m),
json!([null, "9", "phoenix", "phx_reply", {"status": "ok", "response": {}}])
);
let m = V::encode(FrameRef {
join_ref: Some("3"),
reference: None,
topic: "room:lobby",
message: MessageRef::Event { event: "phx_close", payload: None },
});
assert_eq!(parse(&m), json!(["3", null, "room:lobby", "phx_close", {}]));
}
#[test]
fn typed_payload_round_trip_and_strictness() {
#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)]
struct Chat {
body: String,
}
let m = Message::Text(r#"["1","2","room:a","new_msg",{"body":"hi"}]"#.into());
let f = Json::<Chat>::decode(&m).unwrap();
let ChannelMessage::Event { payload, .. } = f.message else { panic!() };
assert_eq!(payload.0, Chat { body: "hi".into() });
// Missing required field: the codec is as strict as T.
let m = Message::Text(r#"["1","2","room:a","new_msg",{}]"#.into());
assert!(Json::<Chat>::decode(&m).is_err());
}
#[test]
fn garbage_and_binary_are_decode_errors() {
assert!(V::decode(&Message::Text("not json".into())).is_err());
assert!(V::decode(&Message::Text(r#"{"a":1}"#.into())).is_err());
assert!(V::decode(&Message::Binary(vec![1, 2, 3])).is_err());
}
}