//! Terminal wire types (Vterm Stage 3, protocol v19). //! //! A terminal is inherently a cell protocol: its identity buffer is an //! empty CRDT anchor, so a semantic frontend that receives only //! `BufferSnapshot` has nothing to draw. [`TerminalFrame`] carries the //! complete visible grid instead — cells, cursor, per-view selection and //! scroll state, and sanitized process metadata. //! //! Every limit needed to validate a frame lives here rather than in the //! `pmacs` crate, so the daemon's pre-emission check and a frontend's //! post-decode check run the SAME policy. [`TerminalFrame::validate`] is //! that single structural policy. A second implementation of these rules //! in a frontend is a bug, not a convenience. //! //! Glyph column width and wide-continuation topology moved to //! [`crate::wire_grid`] in bottom-panel Stage 2B, which is now the //! crate's only non-test `unicode-width` use: those rules are shared //! with [`crate::panel::PanelFrame`]. The 512 per-axis PTY caps, //! metadata, selection spans, and the `at_bottom`/`scroll_offset` //! coupling stay here, because a panel does not inherit them. use crate::cell::{Cell, CellCoord, CellSize}; use crate::ids::BufferId; #[cfg(test)] use unicode_width::UnicodeWidthStr; // --------------------------------------------------------------------------- // Shared limits // --------------------------------------------------------------------------- /// Maximum terminal rows accepted at creation, resize, or on the wire. pub const MAX_TERMINAL_ROWS: u16 = 512; /// Maximum terminal columns accepted at creation, resize, or on the wire. pub const MAX_TERMINAL_COLS: u16 = 512; /// Maximum visible terminal cells accepted at creation, resize, or on /// the wire. /// /// An alias of the shared wire-grid bound: this is transport safety, not /// a PTY policy, so it must not drift from the panel's. pub const MAX_TERMINAL_VISIBLE_CELLS: usize = crate::wire_grid::MAX_WIRE_GRID_VISIBLE_CELLS; /// Maximum UTF-8 bytes retained in one terminal grapheme cluster. /// /// An **alias** of the shared wire-grid bound, not an independent value. /// The terminal screen truncates clusters to this constant while /// [`crate::wire_grid`] validates against its own; if the two were /// separate literals, raising one would make the producer emit clusters /// its own validator rejects — or, worse, accept clusters no frontend /// budgeted for. Keeping this a re-export means they cannot drift. pub const MAX_TERMINAL_GRAPHEME_BYTES: usize = crate::wire_grid::MAX_WIRE_GRID_GRAPHEME_BYTES; /// Shared cap for terminal title and process-outcome metadata. pub const MAX_TERMINAL_METADATA_BYTES: usize = 1_024; /// Aggregate cap on glyph bytes carried by one [`TerminalFrame`]. /// /// The visible-cell bound alone does not bound a frame's encoded size: /// every cell may legally carry a [`MAX_TERMINAL_GRAPHEME_BYTES`] /// cluster, and `262_144 * 256` is 64 MiB — four times the transport's /// [`crate::MAX_FRAME_BYTES`]. Raising the transport cap would widen the /// allocation ceiling for every pre-handshake and established message on /// every connection, so v19 bounds the terminal payload instead. The /// protocol test `maximum_legal_terminal_frame_encodes_below_the_transport_cap` /// measures the largest legal frame this bound admits and pins it below /// the unchanged 16 MiB cap. pub const MAX_TERMINAL_FRAME_GLYPH_BYTES: usize = crate::wire_grid::MAX_WIRE_GRID_GLYPH_BYTES; // --------------------------------------------------------------------------- // Payload types // --------------------------------------------------------------------------- /// Process outcome published with a terminal frame. /// /// `Signaled` and `Crashed` text is sanitized metadata subject to /// [`MAX_TERMINAL_METADATA_BYTES`] and the control-character rule; it is /// display text, never a host control effect. #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] pub enum TerminalProcessState { /// Child is running, or termination has only been requested. Running, /// Child exited with a status code. Exited(i32), /// Child was terminated by a sanitized symbolic signal. Signaled(String), /// Supervision failed after the session was published. Crashed(String), } /// One selected span on one visible terminal row. /// /// `start_col` is inclusive, `end_col` exclusive. A frame carries at most /// one span per row, in strictly increasing row order. #[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] pub struct TerminalSelectionSpan { /// Visible row. pub row: u32, /// Inclusive starting column. pub start_col: u32, /// Exclusive ending column. pub end_col: u32, } /// A complete visible terminal grid for one frontend/window view. /// /// This is a whole-grid replacement, never a delta. Empty is NOT a clear /// sentinel: valid dimensions are nonzero and `cells.len()` equals the /// checked area exactly, so a receiver that decodes a valid frame always /// has a complete picture. /// /// `screen_generation` describes the published screen/title generation /// only. Selection, scroll, viewport, and process state change WITHOUT /// advancing it, so a producer must compare the complete ordered payload /// — not that counter — when deciding whether to send. #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] pub struct TerminalFrame { /// Identity buffer whose session this frame projects. pub buffer_id: BufferId, /// Visible grid dimensions in cells. pub size: CellSize, /// Row-major visible cells; exactly `size.area()` entries. pub cells: Vec, /// Visible child cursor, or `None` when hidden. pub cursor: Option, /// Sanitized child title. pub title: Option, /// Published terminal screen/title generation. pub screen_generation: u64, /// Per-row selection spans, strictly increasing by row. pub selection: Vec, /// Physical retained rows between this viewport and the live tail. pub scroll_offset: u32, /// Whether this view reaches the live tail; always `scroll_offset == 0`. pub at_bottom: bool, /// Operating-system process id for this session generation. pub pid: u32, /// Latest observed process state. pub process: TerminalProcessState, } /// Why a [`TerminalFrame`] is not structurally valid. /// /// Validation is atomic: the frame is rejected whole. A receiver retains /// its previous valid frame rather than applying any part of this one. #[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] pub enum TerminalFrameError { /// Rows or columns are zero or above the shared bounds. #[error("terminal size {rows}x{cols} is outside 1..={max_rows}x1..={max_cols}")] Size { /// Declared rows. rows: u32, /// Declared columns. cols: u32, /// Shared row bound. max_rows: u32, /// Shared column bound. max_cols: u32, }, /// The checked area exceeds the shared visible-cell bound. #[error("terminal area {area} exceeds the visible-cell bound {max}")] Area { /// Checked `rows * cols`. area: usize, /// Shared visible-cell bound. max: usize, }, /// `cells.len()` disagrees with the declared area. #[error("terminal frame carries {actual} cells for a {expected}-cell area")] CellCount { /// Declared area. expected: usize, /// Supplied cell count. actual: usize, }, /// The cursor lies outside the declared grid. #[error("terminal cursor ({row},{col}) is outside the {rows}x{cols} grid")] Cursor { /// Cursor row. row: u32, /// Cursor column. col: u32, /// Declared rows. rows: u32, /// Declared columns. cols: u32, }, /// Title or process metadata is too long or carries control characters. #[error("terminal {field} metadata is invalid: {reason}")] Metadata { /// Which metadata field failed. field: &'static str, /// Why it failed. reason: &'static str, }, /// A cell's glyph is not a legal terminal glyph. #[error("terminal cell {index} has an invalid glyph: {reason}")] Glyph { /// Row-major cell index. index: usize, /// Why the glyph failed. reason: &'static str, }, /// A cell carries a frontend attachment, which terminals never use. #[error("terminal cell {index} carries an attachment")] Attachment { /// Row-major cell index. index: usize, }, /// Aggregate glyph bytes exceed [`MAX_TERMINAL_FRAME_GLYPH_BYTES`]. #[error("terminal frame glyph bytes exceed the aggregate bound {max}")] GlyphBudget { /// Shared aggregate bound. max: usize, }, /// A selection span is empty, out of order, duplicated, or out of bounds. #[error("terminal selection span {index} is invalid: {reason}")] Selection { /// Index into `selection`. index: usize, /// Why the span failed. reason: &'static str, }, /// `at_bottom` disagrees with `scroll_offset == 0`. #[error("terminal at_bottom {at_bottom} disagrees with scroll_offset {scroll_offset}")] BottomState { /// Declared bottom state. at_bottom: bool, /// Declared scroll offset. scroll_offset: u32, }, } /// Bounds a terminal frame enforces on its cell grid. /// /// The per-axis caps are the PTY-specific half of the split: a panel /// frame shares every other rule but not these, because a panel is /// sized by the frontend's surface rather than by a pty window size. const TERMINAL_GRID_LIMITS: crate::wire_grid::WireGridLimits = crate::wire_grid::WireGridLimits { max_rows: MAX_TERMINAL_ROWS as u32, max_cols: MAX_TERMINAL_COLS as u32, max_visible_cells: MAX_TERMINAL_VISIBLE_CELLS, max_glyph_bytes: MAX_TERMINAL_FRAME_GLYPH_BYTES, }; /// Map a shared wire-grid failure onto this message's error type. /// /// The variants and their text are unchanged by the Stage 2B factoring: /// every existing terminal-frame assertion still observes exactly what /// it observed before. fn terminal_grid_error(error: crate::wire_grid::WireGridError) -> TerminalFrameError { use crate::wire_grid::WireGridError; match error { WireGridError::Size { rows, cols, max_rows, max_cols, } => TerminalFrameError::Size { rows, cols, max_rows, max_cols, }, WireGridError::Area { area, max } => TerminalFrameError::Area { area, max }, WireGridError::CellCount { expected, actual } => { TerminalFrameError::CellCount { expected, actual } } WireGridError::Cursor { row, col, rows, cols, } => TerminalFrameError::Cursor { row, col, rows, cols, }, WireGridError::Glyph { index, reason } => TerminalFrameError::Glyph { index, reason }, WireGridError::Attachment { index } => TerminalFrameError::Attachment { index }, WireGridError::GlyphBudget { max } => TerminalFrameError::GlyphBudget { max }, } } impl TerminalFrame { /// Check every structural rule a terminal frame must satisfy. /// /// This is the single policy used by the daemon before emission and /// by a frontend after decode. It is pure: a rejected frame mutates /// nothing, so callers get atomic rejection for free. pub fn validate(&self) -> Result<(), TerminalFrameError> { crate::wire_grid::validate_wire_grid( self.size, &self.cells, self.cursor, TERMINAL_GRID_LIMITS, ) .map_err(terminal_grid_error)?; if let Some(title) = &self.title { validate_metadata("title", title)?; } match &self.process { TerminalProcessState::Signaled(text) => validate_metadata("signal", text)?, TerminalProcessState::Crashed(text) => validate_metadata("crash", text)?, TerminalProcessState::Running | TerminalProcessState::Exited(_) => {} } self.validate_selection()?; if self.at_bottom != (self.scroll_offset == 0) { return Err(TerminalFrameError::BottomState { at_bottom: self.at_bottom, scroll_offset: self.scroll_offset, }); } Ok(()) } /// One nonempty in-bounds span per row, strictly increasing by row. fn validate_selection(&self) -> Result<(), TerminalFrameError> { let mut previous_row: Option = None; for (index, span) in self.selection.iter().enumerate() { if span.row >= self.size.rows { return Err(TerminalFrameError::Selection { index, reason: "row is outside the frame", }); } if span.start_col >= span.end_col { return Err(TerminalFrameError::Selection { index, reason: "span is empty or reversed", }); } if span.end_col > self.size.cols { return Err(TerminalFrameError::Selection { index, reason: "span extends past the final column", }); } if previous_row.is_some_and(|previous| previous >= span.row) { return Err(TerminalFrameError::Selection { index, reason: "rows are not strictly increasing", }); } previous_row = Some(span.row); } Ok(()) } } /// Length and control-character rules shared by title and process text. fn validate_metadata(field: &'static str, text: &str) -> Result<(), TerminalFrameError> { if text.len() > MAX_TERMINAL_METADATA_BYTES { return Err(TerminalFrameError::Metadata { field, reason: "exceeds the metadata byte limit", }); } if text.chars().any(char::is_control) { return Err(TerminalFrameError::Metadata { field, reason: "carries a control character", }); } Ok(()) } #[cfg(test)] mod tests { use super::*; // `Glyph` is no longer used by this module's production code — the // glyph rules moved to `crate::wire_grid` — but these tests still // construct frames cell by cell. use crate::cell::{Color, Glyph, Style, UnderlineStyle}; use crate::message::InstanceMessage; use crate::transport::MAX_FRAME_BYTES; /// The style whose postcard encoding is as long as a legal `Style` /// gets: three truecolor components, a non-`None` underline, and /// every boolean set. fn maximal_style() -> Style { Style { fg: Color::Rgb(0xff, 0xee, 0xdd), bg: Color::Rgb(0x11, 0x22, 0x33), bold: true, italic: true, underline: UnderlineStyle::Dashed, reverse: true, underline_color: Color::Rgb(0x44, 0x55, 0x66), } } /// A legal one-column cluster of exactly `len` UTF-8 bytes. /// /// A combining mark is two bytes, so parity comes from the base: /// a one-byte space for odd lengths, a two-byte `é` for even ones. /// Width stays 1 no matter how many marks are appended. fn cluster_of_len(len: usize) -> Vec { assert!((1..=MAX_TERMINAL_GRAPHEME_BYTES).contains(&len)); let mut text = String::with_capacity(len); if len % 2 == 1 { text.push(' '); } else { text.push('\u{e9}'); } while text.len() < len { text.push('\u{301}'); } assert_eq!(text.len(), len); assert_eq!(UnicodeWidthStr::width(text.as_str()), 1); text.into_bytes() } fn cell_with(glyph: Glyph) -> Cell { Cell { glyph, style: maximal_style(), attachment: None, } } /// A minimal valid frame: `rows * cols` single-space cells. fn frame(rows: u32, cols: u32) -> TerminalFrame { let area = (rows * cols) as usize; TerminalFrame { buffer_id: BufferId::from_raw(7), size: CellSize::new(rows, cols), cells: vec![cell_with(Glyph::Char(' ')); area], cursor: Some(CellCoord::new(0, 0)), title: Some("sh".into()), screen_generation: 3, selection: Vec::new(), scroll_offset: 0, at_bottom: true, pid: 4321, process: TerminalProcessState::Running, } } #[test] fn exact_shared_boundaries_are_accepted() { let mut f = frame(2, 4); // A wide lead plus its continuation, a maximal-length cluster, // and a maximal metadata string are all exactly legal. f.cells[0] = cell_with(Glyph::Char('\u{4e00}')); f.cells[1] = cell_with(Glyph::Continuation); f.cells[2] = cell_with(Glyph::Cluster( cluster_of_len(MAX_TERMINAL_GRAPHEME_BYTES).into_boxed_slice(), )); f.title = Some("t".repeat(MAX_TERMINAL_METADATA_BYTES)); f.process = TerminalProcessState::Signaled("s".repeat(MAX_TERMINAL_METADATA_BYTES)); f.cursor = Some(CellCoord::new(1, 3)); f.selection = vec![ TerminalSelectionSpan { row: 0, start_col: 0, end_col: 4, }, TerminalSelectionSpan { row: 1, start_col: 3, end_col: 4, }, ]; assert_eq!(f.validate(), Ok(())); } #[test] #[allow(clippy::too_many_lines, reason = "one case per structural rule")] fn every_structural_violation_is_rejected() { // Each case mutates one axis of an otherwise valid frame, so a // failure names exactly the rule that stopped enforcing. let zero_rows = { let mut f = frame(1, 1); f.size = CellSize::new(0, 1); f }; assert!(matches!( zero_rows.validate(), Err(TerminalFrameError::Size { .. }) )); let over_rows = { let mut f = frame(1, 1); f.size = CellSize::new(u32::from(MAX_TERMINAL_ROWS) + 1, 1); f }; assert!(matches!( over_rows.validate(), Err(TerminalFrameError::Size { .. }) )); let over_area = { let mut f = frame(1, 1); f.size = CellSize::new(u32::from(MAX_TERMINAL_ROWS), u32::from(MAX_TERMINAL_COLS)); f }; // 512 x 512 is exactly the visible-cell bound, so this frame // fails on its cell count, not its area — the area bound is // reached only if a later limit change makes them disagree. assert!(matches!( over_area.validate(), Err(TerminalFrameError::CellCount { .. }) )); let bad_count = { let mut f = frame(2, 2); f.cells.pop(); f }; assert!(matches!( bad_count.validate(), Err(TerminalFrameError::CellCount { expected: 4, actual: 3 }) )); let bad_cursor = { let mut f = frame(2, 2); f.cursor = Some(CellCoord::new(2, 0)); f }; assert!(matches!( bad_cursor.validate(), Err(TerminalFrameError::Cursor { .. }) )); let long_title = { let mut f = frame(1, 1); f.title = Some("t".repeat(MAX_TERMINAL_METADATA_BYTES + 1)); f }; assert!(matches!( long_title.validate(), Err(TerminalFrameError::Metadata { field: "title", .. }) )); let control_title = { let mut f = frame(1, 1); f.title = Some("ok\u{7}".into()); f }; assert!(matches!( control_title.validate(), Err(TerminalFrameError::Metadata { field: "title", .. }) )); let long_process = { let mut f = frame(1, 1); f.process = TerminalProcessState::Crashed("c".repeat(MAX_TERMINAL_METADATA_BYTES + 1)); f }; assert!(matches!( long_process.validate(), Err(TerminalFrameError::Metadata { field: "crash", .. }) )); let control_glyph = { let mut f = frame(1, 1); f.cells[0] = cell_with(Glyph::Char('\u{7}')); f }; assert!(matches!( control_glyph.validate(), Err(TerminalFrameError::Glyph { index: 0, .. }) )); let malformed_cluster = { let mut f = frame(1, 1); f.cells[0] = cell_with(Glyph::Cluster(vec![0xff, 0xfe].into_boxed_slice())); f }; assert!(matches!( malformed_cluster.validate(), Err(TerminalFrameError::Glyph { index: 0, .. }) )); let empty_cluster = { let mut f = frame(1, 1); f.cells[0] = cell_with(Glyph::Cluster(Vec::new().into_boxed_slice())); f }; assert!(matches!( empty_cluster.validate(), Err(TerminalFrameError::Glyph { index: 0, .. }) )); let overlong_cluster = { let mut f = frame(1, 1); let mut bytes = cluster_of_len(MAX_TERMINAL_GRAPHEME_BYTES); bytes.extend_from_slice("\u{301}".as_bytes()); f.cells[0] = cell_with(Glyph::Cluster(bytes.into_boxed_slice())); f }; assert!(matches!( overlong_cluster.validate(), Err(TerminalFrameError::Glyph { index: 0, .. }) )); let orphan_continuation = { let mut f = frame(1, 2); f.cells[1] = cell_with(Glyph::Continuation); f.cells[0] = cell_with(Glyph::Char('a')); f }; assert!(matches!( orphan_continuation.validate(), Err(TerminalFrameError::Glyph { index: 1, .. }) )); let missing_continuation = { let mut f = frame(1, 2); f.cells[0] = cell_with(Glyph::Char('\u{4e00}')); f }; assert!(matches!( missing_continuation.validate(), Err(TerminalFrameError::Glyph { index: 1, .. }) )); // A wide lead in the final column has nowhere to put its // continuation: the next cell belongs to the next row. let wide_at_row_end = { let mut f = frame(2, 2); f.cells[1] = cell_with(Glyph::Char('\u{4e00}')); f }; assert!(matches!( wide_at_row_end.validate(), Err(TerminalFrameError::Glyph { index: 1, .. }) )); let attachment = { let mut f = frame(1, 1); f.cells[0].attachment = Some(crate::cell::Attachment::ImageCell { image_id: 1, sub_x: 0, sub_y: 0, }); f }; assert!(matches!( attachment.validate(), Err(TerminalFrameError::Attachment { index: 0 }) )); let reversed_span = { let mut f = frame(1, 4); f.selection = vec![TerminalSelectionSpan { row: 0, start_col: 3, end_col: 1, }]; f }; assert!(matches!( reversed_span.validate(), Err(TerminalFrameError::Selection { index: 0, .. }) )); let empty_span = { let mut f = frame(1, 4); f.selection = vec![TerminalSelectionSpan { row: 0, start_col: 2, end_col: 2, }]; f }; assert!(matches!( empty_span.validate(), Err(TerminalFrameError::Selection { index: 0, .. }) )); let duplicate_row = { let mut f = frame(2, 4); f.selection = vec![ TerminalSelectionSpan { row: 1, start_col: 0, end_col: 1, }, TerminalSelectionSpan { row: 1, start_col: 1, end_col: 2, }, ]; f }; assert!(matches!( duplicate_row.validate(), Err(TerminalFrameError::Selection { index: 1, .. }) )); let out_of_order = { let mut f = frame(2, 4); f.selection = vec![ TerminalSelectionSpan { row: 1, start_col: 0, end_col: 1, }, TerminalSelectionSpan { row: 0, start_col: 0, end_col: 1, }, ]; f }; assert!(matches!( out_of_order.validate(), Err(TerminalFrameError::Selection { index: 1, .. }) )); let span_past_end = { let mut f = frame(1, 4); f.selection = vec![TerminalSelectionSpan { row: 0, start_col: 0, end_col: 5, }]; f }; assert!(matches!( span_past_end.validate(), Err(TerminalFrameError::Selection { index: 0, .. }) )); let span_off_frame = { let mut f = frame(1, 4); f.selection = vec![TerminalSelectionSpan { row: 1, start_col: 0, end_col: 1, }]; f }; assert!(matches!( span_off_frame.validate(), Err(TerminalFrameError::Selection { index: 0, .. }) )); let inconsistent_bottom = { let mut f = frame(1, 1); f.scroll_offset = 4; f.at_bottom = true; f }; assert!(matches!( inconsistent_bottom.validate(), Err(TerminalFrameError::BottomState { .. }) )); let inconsistent_top = { let mut f = frame(1, 1); f.scroll_offset = 0; f.at_bottom = false; f }; assert!(matches!( inconsistent_top.validate(), Err(TerminalFrameError::BottomState { .. }) )); } #[test] fn aggregate_glyph_budget_accepts_the_exact_bound_and_rejects_one_byte_over() { // Reaching the aggregate bound exactly requires the whole legal // grid — no smaller frame can spend 8 MiB of glyph — so the // boundary pair is built once and asserted in both directions. let (exact, over) = budget_boundary_frames(); assert_eq!(exact.validate(), Ok(())); assert_eq!( over.validate(), Err(TerminalFrameError::GlyphBudget { max: MAX_TERMINAL_FRAME_GLYPH_BYTES }) ); } /// The exact-budget frame and its one-byte-over twin. /// /// Cluster lengths are chosen to maximize serialized length-prefix /// overhead: postcard spends two bytes on any length `>= 128`, so as /// many cells as the budget allows carry a 128-byte cluster and the /// rest carry the one-byte minimum, rather than packing the budget /// into a few maximal clusters that would each cost one prefix. fn budget_boundary_frames() -> (TerminalFrame, TerminalFrame) { /// Shortest cluster length postcard encodes with a two-byte /// length prefix. const WIDE_PREFIX_LEN: usize = 128; let rows = u32::from(MAX_TERMINAL_ROWS); let cols = u32::from(MAX_TERMINAL_COLS); let area = (rows * cols) as usize; assert_eq!(area, MAX_TERMINAL_VISIBLE_CELLS); let spare = MAX_TERMINAL_FRAME_GLYPH_BYTES - area; let wide_cells = spare / (WIDE_PREFIX_LEN - 1); let remainder = spare % (WIDE_PREFIX_LEN - 1); assert!(wide_cells + usize::from(remainder > 0) <= area); let wide = cluster_of_len(WIDE_PREFIX_LEN).into_boxed_slice(); let single = cluster_of_len(1).into_boxed_slice(); let mut cells = Vec::with_capacity(area); for index in 0..area { let glyph = if index < wide_cells { Glyph::Cluster(wide.clone()) } else if index == wide_cells && remainder > 0 { Glyph::Cluster(cluster_of_len(remainder + 1).into_boxed_slice()) } else { Glyph::Cluster(single.clone()) }; cells.push(cell_with(glyph)); } let selection = (0..rows) .map(|row| TerminalSelectionSpan { row, start_col: 0, end_col: cols, }) .collect(); let exact = TerminalFrame { buffer_id: BufferId::from_raw(u64::MAX), size: CellSize::new(rows, cols), cells, cursor: Some(CellCoord::new(rows - 1, cols - 1)), title: Some("t".repeat(MAX_TERMINAL_METADATA_BYTES)), screen_generation: u64::MAX, selection, scroll_offset: u32::MAX, at_bottom: false, pid: u32::MAX, process: TerminalProcessState::Crashed("c".repeat(MAX_TERMINAL_METADATA_BYTES)), }; let mut over = exact.clone(); // One more byte of glyph, nothing else changed. let last = over.cells.len() - 1; over.cells[last] = cell_with(Glyph::Cluster(cluster_of_len(2).into_boxed_slice())); (exact, over) } #[test] fn maximum_legal_terminal_frame_encodes_below_the_transport_cap() { let (exact, over) = budget_boundary_frames(); assert_eq!(exact.validate(), Ok(())); let mut glyph_bytes = 0usize; for cell in &exact.cells { glyph_bytes += match &cell.glyph { Glyph::Char(ch) => ch.len_utf8(), Glyph::Cluster(bytes) => bytes.len(), Glyph::Continuation => 0, }; } assert_eq!( glyph_bytes, MAX_TERMINAL_FRAME_GLYPH_BYTES, "the measured fixture must spend the whole aggregate budget" ); let over_glyph_bytes = over .cells .iter() .map(|cell| match &cell.glyph { Glyph::Char(ch) => ch.len_utf8(), Glyph::Cluster(bytes) => bytes.len(), Glyph::Continuation => 0, }) .sum::(); assert_eq!( over_glyph_bytes, MAX_TERMINAL_FRAME_GLYPH_BYTES + 1, "the rejecting twin must be exactly one byte over the aggregate budget" ); let msg = InstanceMessage::TerminalFrame(exact); let bytes = postcard::to_allocvec(&msg).expect("encode"); assert!( bytes.len() < MAX_FRAME_BYTES, "largest legal terminal frame encodes to {} bytes, at or above the \ {MAX_FRAME_BYTES}-byte transport cap; the aggregate glyph bound no \ longer keeps terminal traffic inside the existing transport limit", bytes.len() ); } #[test] fn frames_round_trip_through_postcard() { let mut f = frame(3, 5); f.cells[0] = cell_with(Glyph::Char('\u{4e00}')); f.cells[1] = cell_with(Glyph::Continuation); f.cells[2] = cell_with(Glyph::Cluster(cluster_of_len(9).into_boxed_slice())); f.selection = vec![TerminalSelectionSpan { row: 2, start_col: 1, end_col: 4, }]; f.scroll_offset = 6; f.at_bottom = false; f.process = TerminalProcessState::Exited(-1); assert_eq!(f.validate(), Ok(())); let msg = InstanceMessage::TerminalFrame(f); let bytes = postcard::to_allocvec(&msg).expect("encode"); let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode"); assert_eq!(decoded, msg); } }