// transport.rs --- Length-prefix postcard codec. //! Length-prefix framed postcard codec for the M5.5 frontend ↔ instance //! protocol (T M5.5b). //! //! # Wire format //! //! Each message is encoded as: //! //! ```text //! [u32 big-endian length][postcard bytes] //! ``` //! //! - The length is the byte count of the postcard payload that follows. //! Zero-length payloads are valid (postcard encodes some types as //! empty byte strings). //! - Length values exceeding [`MAX_FRAME_BYTES`] are rejected without //! allocation on the read side, and refused before any bytes hit the //! wire on the write side. This caps both worst-case allocation and //! the maximum legitimate message size — large payloads should be //! chunked at a higher layer. //! //! # Encoding choice //! //! Postcard is a Serde-driven, no-std-friendly format chosen for its //! compactness on the cell-stream traffic (60 Hz cell-delta frames //! dominate the wire) and for the future option of a thin attach //! client without `tokio`. Schema evolution is handled by an explicit //! version handshake rather than the encoding itself; see //! [`crate::PROTOCOL_VERSION`]. //! //! The worker-protocol encoding (spec §5.5) remains `MessagePack` via //! `rmp-serde`; that subsystem values schema flexibility over wire //! compactness. use serde::{Serialize, de::DeserializeOwned}; use std::io::{Read, Write}; /// Maximum legitimate frame payload size, in bytes. /// /// 16 MiB. Comfortably above any single cell-delta frame in v0.1: a /// full 4K terminal at 60 Hz with truecolor styling fits well under a /// megabyte per frame. Frames larger than this are presumed bugs or /// hostile peers and are rejected. pub const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024; /// Errors produced by [`read_message`] and [`write_message`]. #[derive(Debug)] pub enum TransportError { /// Underlying I/O failed (broken pipe, connection reset, etc.). Io(std::io::Error), /// Postcard refused to encode the message (typically a `Serialize` /// implementation returning an error). Encode(postcard::Error), /// Postcard refused to decode the bytes — malformed payload from /// peer, or peer running an incompatible message shape that /// slipped past the version handshake. Decode(postcard::Error), /// Advertised or computed frame length exceeded [`MAX_FRAME_BYTES`]. FrameTooLarge { /// The length the peer advertised (read side) or the size of /// the encoded payload (write side). len: usize, }, /// Peer disconnected before a full frame could be read. The same /// error is returned for "EOF before any bytes," "EOF mid /// length-prefix," and "EOF mid payload"; the caller treats these /// identically. Eof, } impl std::fmt::Display for TransportError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Io(e) => write!(f, "transport I/O error: {e}"), Self::Encode(e) => write!(f, "transport encode error: {e}"), Self::Decode(e) => write!(f, "transport decode error: {e}"), Self::FrameTooLarge { len } => { write!(f, "frame length {len} exceeds maximum {MAX_FRAME_BYTES}") } Self::Eof => write!(f, "peer disconnected before frame complete"), } } } impl std::error::Error for TransportError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::Io(e) => Some(e), Self::Encode(e) | Self::Decode(e) => Some(e), _ => None, } } } impl From for TransportError { fn from(e: std::io::Error) -> Self { Self::Io(e) } } /// Read a single framed message from `reader`. /// /// Returns [`TransportError::Eof`] if the peer disconnected before a /// full frame arrived (whether at the start, mid length-prefix, or /// mid payload — all three look identical to the caller). pub fn read_message(reader: &mut impl Read) -> Result { let mut len_buf = [0u8; 4]; read_exact_or_eof(reader, &mut len_buf)?; let len = u32::from_be_bytes(len_buf) as usize; if len > MAX_FRAME_BYTES { return Err(TransportError::FrameTooLarge { len }); } let mut buf = vec![0u8; len]; read_exact_or_eof(reader, &mut buf)?; postcard::from_bytes(&buf).map_err(TransportError::Decode) } /// Write a single framed message to `writer`. /// /// Returns [`TransportError::FrameTooLarge`] if the encoded form /// exceeds [`MAX_FRAME_BYTES`]; in that case no bytes are written. pub fn write_message(writer: &mut impl Write, msg: &M) -> Result<(), TransportError> { let payload = postcard::to_allocvec(msg).map_err(TransportError::Encode)?; if payload.len() > MAX_FRAME_BYTES { return Err(TransportError::FrameTooLarge { len: payload.len() }); } let len = u32::try_from(payload.len()).expect("payload length bounded by MAX_FRAME_BYTES"); writer.write_all(&len.to_be_bytes())?; writer.write_all(&payload)?; Ok(()) } /// Fill `buf` from `reader`, returning [`TransportError::Eof`] if the /// peer disconnects before the buffer is full. Retries on /// [`std::io::ErrorKind::Interrupted`]. /// /// `std::io::Read::read_exact` collapses both "read 0 bytes" and "read /// some-but-not-all" into `ErrorKind::UnexpectedEof`, but it is not /// guaranteed to retry on `Interrupted`. This helper makes both /// behaviors explicit. fn read_exact_or_eof(reader: &mut impl Read, buf: &mut [u8]) -> Result<(), TransportError> { let mut filled = 0; while filled < buf.len() { match reader.read(&mut buf[filled..]) { Ok(0) => return Err(TransportError::Eof), Ok(n) => filled += n, Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {} Err(e) => return Err(TransportError::Io(e)), } } Ok(()) } #[cfg(test)] mod tests { use super::*; use crate::{ AttachRequest, FrontendCapabilities, FrontendEvent, FrontendId, Hello, InstanceCapabilities, InstanceIdentity, Key, KeyEvent, Modifiers, PROTOCOL_VERSION, }; use std::io::Cursor; fn round_trip(msg: &M) { let mut buf = Vec::new(); write_message(&mut buf, msg).expect("write"); let mut cursor = Cursor::new(buf); let decoded: M = read_message(&mut cursor).expect("read"); assert_eq!(&decoded, msg); } #[test] fn hello_round_trips_through_transport() { let h = Hello { protocol_version: PROTOCOL_VERSION, assigned_frontend_id: FrontendId(2), instance_identity: InstanceIdentity { pmacs_version: "0.1.0".into(), build_hash: None, instance_name: None, uptime_secs: 12, working_directory: "/tmp".into(), }, instance_capabilities: InstanceCapabilities::default(), }; round_trip(&h); } #[test] fn pointer_event_round_trips_through_transport() { // Q#M1 (protocol v5): the byte-position pointer gesture. let ev = FrontendEvent::Pointer { frontend_id: FrontendId(7), buffer_id: crate::BufferId::next(), byte: 4096, kind: crate::PointerKind::DoubleDown, mods: Modifiers::SHIFT, }; round_trip(&ev); } #[test] fn attach_request_round_trips_through_transport() { let req = AttachRequest { protocol_version: PROTOCOL_VERSION, frontend_capabilities: FrontendCapabilities { synchronized_output: true, unicode_smp: true, true_color: true, mouse: true, bracketed_paste: true, terminal_kind: Some("xterm-256color".into()), multi_frontend: false, crdt_replica: false, semantic_render: false, }, initial_size: crate::cell::CellSize::new(24, 80), }; round_trip(&req); } #[test] fn key_event_round_trips_through_transport() { let ev = FrontendEvent::Key(KeyEvent { frontend_id: FrontendId(2), key: Key::Char('a'), mods: Modifiers::CTRL, timestamp_ns: 0, }); round_trip(&ev); } #[test] fn empty_input_returns_eof() { let mut cursor = Cursor::new(Vec::::new()); match read_message::(&mut cursor) { Err(TransportError::Eof) => {} other => panic!("expected Eof, got {other:?}"), } } #[test] fn truncated_length_prefix_returns_eof() { // Two bytes of a four-byte length prefix. let mut cursor = Cursor::new(vec![0x00, 0x10]); match read_message::(&mut cursor) { Err(TransportError::Eof) => {} other => panic!("expected Eof, got {other:?}"), } } #[test] fn truncated_payload_returns_eof() { // Length advertises 100 bytes; only 5 follow. let mut bytes = 100u32.to_be_bytes().to_vec(); bytes.extend_from_slice(&[0x01, 0x02, 0x03, 0x04, 0x05]); let mut cursor = Cursor::new(bytes); match read_message::(&mut cursor) { Err(TransportError::Eof) => {} other => panic!("expected Eof, got {other:?}"), } } #[test] fn frame_larger_than_max_rejected_without_allocating() { // Advertise MAX_FRAME_BYTES + 1; we expect rejection before any // body bytes are read. let len = u32::try_from(MAX_FRAME_BYTES + 1).expect("fits in u32"); let bytes = len.to_be_bytes().to_vec(); let mut cursor = Cursor::new(bytes); match read_message::(&mut cursor) { Err(TransportError::FrameTooLarge { len: l }) => { assert_eq!(l, MAX_FRAME_BYTES + 1); } other => panic!("expected FrameTooLarge, got {other:?}"), } } #[test] fn frame_at_exact_max_size_passes_length_check() { // Advertise exactly MAX_FRAME_BYTES — the boundary case must // not be rejected by the length check. We don't actually have // a payload this large; we expect Eof from the body fetch, // which proves the length check passed. let len = u32::try_from(MAX_FRAME_BYTES).expect("fits in u32"); let bytes = len.to_be_bytes().to_vec(); let mut cursor = Cursor::new(bytes); match read_message::(&mut cursor) { Err(TransportError::Eof) => {} other => panic!("expected Eof at MAX_FRAME_BYTES boundary, got {other:?}"), } } #[test] fn bad_postcard_bytes_return_decode_error() { // Length prefix says 8, payload is garbage bytes that do not // decode as a Hello. let payload = vec![0xFFu8; 8]; let mut bytes = u32::try_from(payload.len()).unwrap().to_be_bytes().to_vec(); bytes.extend_from_slice(&payload); let mut cursor = Cursor::new(bytes); match read_message::(&mut cursor) { Err(TransportError::Decode(_)) => {} other => panic!("expected Decode, got {other:?}"), } } #[test] fn multiple_messages_back_to_back() { // Two messages share one buffer; framing must not leak state // between them. let h1 = FrontendEvent::Detach(FrontendId(1)); let h2 = FrontendEvent::Detach(FrontendId(2)); let mut buf = Vec::new(); write_message(&mut buf, &h1).expect("write 1"); write_message(&mut buf, &h2).expect("write 2"); let mut cursor = Cursor::new(buf); let d1: FrontendEvent = read_message(&mut cursor).expect("read 1"); let d2: FrontendEvent = read_message(&mut cursor).expect("read 2"); match d1 { FrontendEvent::Detach(id) => assert_eq!(id, FrontendId(1)), other => panic!("expected Detach(1), got {other:?}"), } match d2 { FrontendEvent::Detach(id) => assert_eq!(id, FrontendId(2)), other => panic!("expected Detach(2), got {other:?}"), } } #[test] fn read_after_consuming_only_message_returns_eof() { let h = FrontendEvent::Detach(FrontendId(7)); let mut buf = Vec::new(); write_message(&mut buf, &h).expect("write"); let mut cursor = Cursor::new(buf); let _: FrontendEvent = read_message(&mut cursor).expect("read"); match read_message::(&mut cursor) { Err(TransportError::Eof) => {} other => panic!("expected Eof after consuming the only message, got {other:?}"), } } }