//! RFC 010 c2 — the owned wire envelope. //! //! Every control-plane frame is `u32` little-endian length prefix (of tag + //! body), `u8` tag, hand-encoded body. postcard appears in exactly one place: //! the payload blob inside `Send`/`SendNamed`, via [`encode_payload`] / //! [`decode_payload`] — the seam where a codec swap would land (RFC 010 §2). //! Everything else is hand-rolled and wholly owned. //! //! Integers are little-endian. Strings are `u16` length + UTF-8 bytes. //! Payload blobs are `u32` length + bytes. Enum-shaped fields //! ([`RejectReason`], [`DownReason`]) are a single tag byte. use crate::monitor::DownReason; use crate::pg::Incarnation; /// Wire protocol version, checked in the handshake (c5). pub const PROTO_VERSION: u32 = 1; /// Hard cap on the length prefix. The control plane never carries bulk data /// (RFC 010 §5 — that is the jarred rkyv plane), so anything larger is /// corruption or an attack, not a legitimate frame. pub const MAX_FRAME_LEN: usize = 16 * 1024 * 1024; /// Per-node metadata exchanged in the handshake (RFC 010 §1: not identity). #[derive(Debug, Clone, PartialEq, Eq)] pub struct NodeMeta { pub role: String, pub region: String, } /// Why a `Hello` was rejected. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RejectReason { /// Build hashes differ — not the same binary. HashMismatch, /// The offered node name is already claimed by a live peer. NameTaken, /// Wire protocol version mismatch. ProtoVersion, } /// The control-plane frame inventory (RFC 010, *Implementation details*). #[derive(Debug, Clone, PartialEq, Eq)] pub enum Frame { Hello { proto_version: u32, build_hash: u64, node_name: String, incarnation: Incarnation, meta: NodeMeta, }, HelloAck { node_name: String, incarnation: Incarnation, meta: NodeMeta, }, HelloReject { reason: RejectReason, }, Heartbeat, Send { /// Target slot index (node is implicit in the connection, incarnation /// is bound at handshake — RFC 010 §3). index: u32, generation: u32, type_hash: u64, payload: Vec, }, SendNamed { name: String, type_hash: u64, payload: Vec, }, Monitor { monitor_id: u64, index: u32, generation: u32, }, Demonitor { monitor_id: u64, }, Down { monitor_id: u64, reason: RemoteDownReason, }, } /// Why a remotely-monitored actor is reported down: either the target's own /// terminal [`DownReason`] as its node recorded it, or the *link* to that /// node was lost (or absent) — which says nothing about the actor itself. /// /// This is the cluster-side widening of `DownReason` (p5): `Disconnected` /// is a fact about a connection, never about a local actor, so it lives /// here rather than in the core enum — a local `Down` can never carry it, /// and matches on `DownReason` stay exhaustive over actor outcomes only. /// On the wire `Local(r)` uses `r`'s tag and `Disconnected` is tag 5, /// bound since c11; no peer emits it today (a lost link is synthesized /// locally), but the codec honours it both ways. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RemoteDownReason { /// The target itself terminated; the peer reported this reason. Local(DownReason), /// The link to the target's node was lost or was never up. Disconnected, } impl RemoteDownReason { /// The actor's own reason, if this was not a link loss. pub fn local(self) -> Option { match self { RemoteDownReason::Local(r) => Some(r), RemoteDownReason::Disconnected => None, } } } impl From for RemoteDownReason { fn from(r: DownReason) -> Self { RemoteDownReason::Local(r) } } // Frame tags. 0 is deliberately unassigned so an all-zero buffer never parses. const TAG_HELLO: u8 = 1; const TAG_HELLO_ACK: u8 = 2; const TAG_HELLO_REJECT: u8 = 3; const TAG_HEARTBEAT: u8 = 4; const TAG_SEND: u8 = 5; const TAG_SEND_NAMED: u8 = 6; const TAG_MONITOR: u8 = 7; const TAG_DEMONITOR: u8 = 8; const TAG_DOWN: u8 = 9; // RejectReason tags. const REJ_HASH_MISMATCH: u8 = 1; const REJ_NAME_TAKEN: u8 = 2; const REJ_PROTO_VERSION: u8 = 3; // DownReason tags. Do not reuse tags. const DR_EXIT: u8 = 1; const DR_PANIC: u8 = 2; const DR_STOPPED: u8 = 3; const DR_NOPROC: u8 = 4; const DR_DISCONNECTED: u8 = 5; // `Shutdown` never rides in a `Down` by contract (a target that honours the // request exits normally) — the tag exists so the codec stays total. const DR_SHUTDOWN: u8 = 6; /// Frame could not be encoded. The output buffer is left exactly as it was. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EncodeError { /// tag + body exceed [`MAX_FRAME_LEN`]. FrameTooLarge { len: usize }, /// A string field exceeds `u16::MAX` bytes. StringTooLong { len: usize }, } impl core::fmt::Display for EncodeError { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { Self::FrameTooLarge { len } => { write!(f, "frame body of {len} bytes exceeds MAX_FRAME_LEN") } Self::StringTooLong { len } => { write!(f, "string field of {len} bytes exceeds u16::MAX") } } } } impl std::error::Error for EncodeError {} /// Frame could not be decoded. Everything here is *corruption* — "not enough /// bytes yet" is the `Ok(None)` streaming case, never an error. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DecodeError { /// The length prefix exceeds [`MAX_FRAME_LEN`]. FrameTooLarge { declared: usize }, /// The length prefix is zero — there is no tag byte. EmptyFrame, /// Unknown frame tag. UnknownTag(u8), /// Unknown tag for an enum-shaped field. UnknownEnumTag { what: &'static str, tag: u8 }, /// A field ran past the declared frame end (the length prefix lied long, /// or a length-carrying field inside the body lied). Truncated, /// Bytes were left over after the body (the length prefix lied short). Trailing { extra: usize }, /// A string field was not valid UTF-8. Utf8, } impl core::fmt::Display for DecodeError { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { Self::FrameTooLarge { declared } => { write!(f, "declared frame length {declared} exceeds MAX_FRAME_LEN") } Self::EmptyFrame => write!(f, "zero-length frame (no tag byte)"), Self::UnknownTag(t) => write!(f, "unknown frame tag {t}"), Self::UnknownEnumTag { what, tag } => write!(f, "unknown {what} tag {tag}"), Self::Truncated => write!(f, "frame body truncated mid-field"), Self::Trailing { extra } => write!(f, "{extra} trailing bytes after frame body"), Self::Utf8 => write!(f, "string field is not valid UTF-8"), } } } impl std::error::Error for DecodeError {} impl Frame { /// Append this frame, length-prefixed, to `out`. /// /// On error `out` is left untouched. pub fn encode(&self, out: &mut Vec) -> Result<(), EncodeError> { let start = out.len(); out.extend_from_slice(&[0u8; 4]); // length placeholder, patched below let result = self.encode_body(out); match result { Ok(()) => { let frame_len = out.len() - start - 4; if frame_len > MAX_FRAME_LEN { out.truncate(start); return Err(EncodeError::FrameTooLarge { len: frame_len }); } // Cast is lossless: MAX_FRAME_LEN < u32::MAX, checked above. let len32 = frame_len as u32; out[start..start + 4].copy_from_slice(&len32.to_le_bytes()); Ok(()) } Err(e) => { out.truncate(start); Err(e) } } } fn encode_body(&self, out: &mut Vec) -> Result<(), EncodeError> { match self { Frame::Hello { proto_version, build_hash, node_name, incarnation, meta, } => { out.push(TAG_HELLO); put_u32(out, *proto_version); put_u64(out, *build_hash); put_str(out, node_name)?; put_u32(out, incarnation.get()); put_meta(out, meta)?; } Frame::HelloAck { node_name, incarnation, meta, } => { out.push(TAG_HELLO_ACK); put_str(out, node_name)?; put_u32(out, incarnation.get()); put_meta(out, meta)?; } Frame::HelloReject { reason } => { out.push(TAG_HELLO_REJECT); out.push(match reason { RejectReason::HashMismatch => REJ_HASH_MISMATCH, RejectReason::NameTaken => REJ_NAME_TAKEN, RejectReason::ProtoVersion => REJ_PROTO_VERSION, }); } Frame::Heartbeat => out.push(TAG_HEARTBEAT), Frame::Send { index, generation, type_hash, payload, } => { out.push(TAG_SEND); put_u32(out, *index); put_u32(out, *generation); put_u64(out, *type_hash); put_blob(out, payload)?; } Frame::SendNamed { name, type_hash, payload, } => { out.push(TAG_SEND_NAMED); put_str(out, name)?; put_u64(out, *type_hash); put_blob(out, payload)?; } Frame::Monitor { monitor_id, index, generation, } => { out.push(TAG_MONITOR); put_u64(out, *monitor_id); put_u32(out, *index); put_u32(out, *generation); } Frame::Demonitor { monitor_id } => { out.push(TAG_DEMONITOR); put_u64(out, *monitor_id); } Frame::Down { monitor_id, reason } => { out.push(TAG_DOWN); put_u64(out, *monitor_id); out.push(match reason { RemoteDownReason::Local(DownReason::Exit) => DR_EXIT, RemoteDownReason::Local(DownReason::Panic) => DR_PANIC, RemoteDownReason::Local(DownReason::Stopped) => DR_STOPPED, RemoteDownReason::Local(DownReason::NoProc) => DR_NOPROC, RemoteDownReason::Local(DownReason::Shutdown) => DR_SHUTDOWN, RemoteDownReason::Disconnected => DR_DISCONNECTED, }); } } Ok(()) } /// Try to decode one frame from the start of `buf`. /// /// `Ok(Some((frame, consumed)))` — a full frame; the caller advances by /// `consumed`. `Ok(None)` — not enough bytes yet (streaming); read more /// and retry. `Err(_)` — the bytes are corrupt; the connection is dead. pub fn decode(buf: &[u8]) -> Result, DecodeError> { let Some(prefix) = buf.get(0..4) else { return Ok(None); }; let mut len4 = [0u8; 4]; len4.copy_from_slice(prefix); let declared = u32::from_le_bytes(len4) as usize; if declared > MAX_FRAME_LEN { return Err(DecodeError::FrameTooLarge { declared }); } if declared == 0 { return Err(DecodeError::EmptyFrame); } let Some(body) = buf.get(4..4 + declared) else { return Ok(None); }; let mut r = Reader { buf: body, pos: 0 }; let frame = Self::decode_body(&mut r)?; if r.pos != body.len() { return Err(DecodeError::Trailing { extra: body.len() - r.pos, }); } Ok(Some((frame, 4 + declared))) } fn decode_body(r: &mut Reader<'_>) -> Result { let tag = r.u8()?; let frame = match tag { TAG_HELLO => Frame::Hello { proto_version: r.u32()?, build_hash: r.u64()?, node_name: r.string()?, incarnation: Incarnation::new(r.u32()?), meta: r.meta()?, }, TAG_HELLO_ACK => Frame::HelloAck { node_name: r.string()?, incarnation: Incarnation::new(r.u32()?), meta: r.meta()?, }, TAG_HELLO_REJECT => Frame::HelloReject { reason: match r.u8()? { REJ_HASH_MISMATCH => RejectReason::HashMismatch, REJ_NAME_TAKEN => RejectReason::NameTaken, REJ_PROTO_VERSION => RejectReason::ProtoVersion, t => { return Err(DecodeError::UnknownEnumTag { what: "RejectReason", tag: t, }) } }, }, TAG_HEARTBEAT => Frame::Heartbeat, TAG_SEND => Frame::Send { index: r.u32()?, generation: r.u32()?, type_hash: r.u64()?, payload: r.blob()?, }, TAG_SEND_NAMED => Frame::SendNamed { name: r.string()?, type_hash: r.u64()?, payload: r.blob()?, }, TAG_MONITOR => Frame::Monitor { monitor_id: r.u64()?, index: r.u32()?, generation: r.u32()?, }, TAG_DEMONITOR => Frame::Demonitor { monitor_id: r.u64()?, }, TAG_DOWN => Frame::Down { monitor_id: r.u64()?, reason: match r.u8()? { DR_EXIT => RemoteDownReason::Local(DownReason::Exit), DR_PANIC => RemoteDownReason::Local(DownReason::Panic), DR_STOPPED => RemoteDownReason::Local(DownReason::Stopped), DR_NOPROC => RemoteDownReason::Local(DownReason::NoProc), DR_SHUTDOWN => RemoteDownReason::Local(DownReason::Shutdown), DR_DISCONNECTED => RemoteDownReason::Disconnected, t => { return Err(DecodeError::UnknownEnumTag { what: "RemoteDownReason", tag: t, }) } }, }, t => return Err(DecodeError::UnknownTag(t)), }; Ok(frame) } } // --------------------------------------------------------------------------- // Body writers // --------------------------------------------------------------------------- fn put_u32(out: &mut Vec, v: u32) { out.extend_from_slice(&v.to_le_bytes()); } fn put_u64(out: &mut Vec, v: u64) { out.extend_from_slice(&v.to_le_bytes()); } fn put_str(out: &mut Vec, s: &str) -> Result<(), EncodeError> { let Ok(len) = u16::try_from(s.len()) else { return Err(EncodeError::StringTooLong { len: s.len() }); }; out.extend_from_slice(&len.to_le_bytes()); out.extend_from_slice(s.as_bytes()); Ok(()) } fn put_blob(out: &mut Vec, b: &[u8]) -> Result<(), EncodeError> { let Ok(len) = u32::try_from(b.len()) else { return Err(EncodeError::FrameTooLarge { len: b.len() }); }; out.extend_from_slice(&len.to_le_bytes()); out.extend_from_slice(b); Ok(()) } fn put_meta(out: &mut Vec, m: &NodeMeta) -> Result<(), EncodeError> { put_str(out, &m.role)?; put_str(out, &m.region) } // --------------------------------------------------------------------------- // Body reader // --------------------------------------------------------------------------- struct Reader<'a> { buf: &'a [u8], pos: usize, } impl Reader<'_> { fn take(&mut self, n: usize) -> Result<&[u8], DecodeError> { let end = self.pos.checked_add(n).ok_or(DecodeError::Truncated)?; let s = self.buf.get(self.pos..end).ok_or(DecodeError::Truncated)?; self.pos = end; Ok(s) } fn u8(&mut self) -> Result { Ok(self.take(1)?[0]) } fn u16(&mut self) -> Result { let mut b = [0u8; 2]; b.copy_from_slice(self.take(2)?); Ok(u16::from_le_bytes(b)) } fn u32(&mut self) -> Result { let mut b = [0u8; 4]; b.copy_from_slice(self.take(4)?); Ok(u32::from_le_bytes(b)) } fn u64(&mut self) -> Result { let mut b = [0u8; 8]; b.copy_from_slice(self.take(8)?); Ok(u64::from_le_bytes(b)) } fn string(&mut self) -> Result { let len = self.u16()? as usize; let bytes = self.take(len)?; match core::str::from_utf8(bytes) { Ok(s) => Ok(s.to_owned()), Err(_) => Err(DecodeError::Utf8), } } fn blob(&mut self) -> Result, DecodeError> { let len = self.u32()? as usize; Ok(self.take(len)?.to_vec()) } fn meta(&mut self) -> Result { Ok(NodeMeta { role: self.string()?, region: self.string()?, }) } } // --------------------------------------------------------------------------- // The postcard seam (RFC 010 §2) — the ONLY place payload bytes are produced // or consumed. A codec swap lands here and nowhere else. // --------------------------------------------------------------------------- /// Payload (de)serialization failed at the codec seam. #[derive(Debug)] pub struct PayloadError(String); impl core::fmt::Display for PayloadError { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { write!(f, "payload codec: {}", self.0) } } impl std::error::Error for PayloadError {} /// Serialize a payload value to the wire blob. pub fn encode_payload(value: &T) -> Result, PayloadError> { postcard::to_allocvec(value).map_err(|e| PayloadError(e.to_string())) } /// Deserialize a payload value from the wire blob. pub fn decode_payload(bytes: &[u8]) -> Result { postcard::from_bytes(bytes).map_err(|e| PayloadError(e.to_string())) }