From 640c5cd0d2742168dda93c25033716e41c09ec46 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 26 Jul 2026 16:52:54 -0400 Subject: [PATCH 01/12] =?UTF-8?q?feat(panel):=20bottom-panel=20Stage=202B?= =?UTF-8?q?=20=E2=80=94=20the=20v21=20protocol=20layer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the four wire shapes Q#BP9 names, bumps the protocol to v21, and factors the cell-grid validator so a panel frame shares the terminal's rules without inheriting its PTY caps. - `InstanceMessage::PanelFrame(PanelFramePayload)`, appended after `InitialTargetResult`; `Absent` is an explicit authoritative state, not silence, because the receiver retains its last valid frame. - `FrontendEvent::{FrontendCellGeometry, PanelResizeRows, PanelPointer}`, appended after `TerminalPointer`. Geometry is valid without a side window — gating it on panel presence would deadlock the first open, since the daemon needs columns before it can paint a first frame. - `pmacs-protocol/src/wire_grid.rs` holds the shared rules: checked area, visible-cell bound, cell count, cursor bounds, glyph legality, wide-continuation topology, the aggregate glyph budget, and the attachment rejection. The 512 per-axis caps, metadata, selection spans, and the at_bottom/scroll_offset coupling stay terminal-only. - The attachment rejection is deliberately shared despite its terminal-side wording: panels render no attachments either, so sharing it fails closed for both. Both byte pins were falsified by revert: moving `PanelFrame` ahead of `InitialTargetResult` shifts it 27 -> 28 and fails; moving the three events ahead of `TerminalPointer` shifts it 12 -> 15 and fails. The factoring changed no terminal acceptance — all 17 terminal tests pass unchanged. It did surface a pre-existing coverage gap: those tests pin the row cap but never the column cap, so widening `max_cols` to u32::MAX left them green. `a_panel_wider_than_512_columns_is_legal_while_a_terminal_is_not` now covers that direction. The daemon projection, the epoch state machine, and the GPU band are later slices of this stage. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RuhVYUPHXMHG8r2z4tsDPR --- pmacs-gpu/src/main.rs | 1 + pmacs-protocol/src/lib.rs | 7 + pmacs-protocol/src/message.rs | 100 ++++- pmacs-protocol/src/panel.rs | 213 +++++++++++ pmacs-protocol/src/terminal.rs | 263 ++++--------- pmacs-protocol/src/wire_grid.rs | 321 ++++++++++++++++ src/daemon.rs | 14 + src/frontend.rs | 5 + ...ottom_panel_stage2b_protocol_acceptance.rs | 351 ++++++++++++++++++ 9 files changed, 1081 insertions(+), 194 deletions(-) create mode 100644 pmacs-protocol/src/panel.rs create mode 100644 pmacs-protocol/src/wire_grid.rs create mode 100644 tests/bottom_panel_stage2b_protocol_acceptance.rs diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index a26f340..32a9fba 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -8904,6 +8904,7 @@ fn instance_message_label(msg: &InstanceMessage) -> &'static str { InstanceMessage::StatuslineSegments { .. } => "StatuslineSegments", InstanceMessage::TerminalFrame(_) => "TerminalFrame", InstanceMessage::InitialTargetResult(_) => "InitialTargetResult", + InstanceMessage::PanelFrame(_) => "PanelFrame", } } diff --git a/pmacs-protocol/src/lib.rs b/pmacs-protocol/src/lib.rs index ce2d2c5..82cdd5b 100644 --- a/pmacs-protocol/src/lib.rs +++ b/pmacs-protocol/src/lib.rs @@ -40,8 +40,10 @@ pub mod cell; pub mod crdt; pub mod ids; pub mod message; +pub mod panel; pub mod terminal; pub mod transport; +pub mod wire_grid; /// Logical display columns between fixed buffer-text tab stops. /// @@ -67,9 +69,14 @@ pub use message::{ StatuslineSegment, StyleSegment, StyleSpan, ThemeFace, is_builtin_pair_char, is_modeline_face_name, is_supported_protocol_version, is_ui_face_name, negotiate_capabilities, }; +pub use panel::{MAX_PANEL_VISIBLE_CELLS, PanelFrame, PanelFrameError, PanelFramePayload}; pub use terminal::{ MAX_TERMINAL_COLS, MAX_TERMINAL_FRAME_GLYPH_BYTES, MAX_TERMINAL_GRAPHEME_BYTES, MAX_TERMINAL_METADATA_BYTES, MAX_TERMINAL_ROWS, MAX_TERMINAL_VISIBLE_CELLS, TerminalFrame, TerminalFrameError, TerminalProcessState, TerminalSelectionSpan, }; pub use transport::{MAX_FRAME_BYTES, TransportError, read_message, write_message}; +pub use wire_grid::{ + MAX_WIRE_GRID_GLYPH_BYTES, MAX_WIRE_GRID_GRAPHEME_BYTES, WireGridError, WireGridLimits, + checked_area, validate_wire_grid, +}; diff --git a/pmacs-protocol/src/message.rs b/pmacs-protocol/src/message.rs index 971a78e..4103cd4 100644 --- a/pmacs-protocol/src/message.rs +++ b/pmacs-protocol/src/message.rs @@ -442,6 +442,69 @@ pub enum FrontendEvent { /// Modifiers held during the gesture. mods: Modifiers, }, + /// Bottom panel Stage 2 (protocol v21): the frontend's authoritative + /// cell-equivalent layout capacity (Q#BP15a). + /// + /// Valid **without** a side window — the daemon needs columns before + /// it can paint a first panel frame, so gating this on panel + /// presence would deadlock the first open. "Without" refers to + /// side-window presence only; the protocol and session gates still + /// apply, and the event is accepted only from an authenticated, + /// negotiated panel-capable semantic session. + /// + /// Sent immediately after attach acceptance and refreshed on window + /// resize, font change, and scale change. `geometry_epoch` is + /// frontend-owned because a font or scale change can invalidate an + /// old panel frame while `total` is **identical**, which daemon-side + /// value dedup cannot detect. + FrontendCellGeometry { + /// Which frontend declared this (untrusted; checked against the + /// transport source). + frontend_id: FrontendId, + /// Monotonic frontend-owned declaration id; `0` is reserved for + /// "never declared" and is rejected on the wire. + geometry_epoch: u64, + /// Whole-cell capacity of the frontend's frame. + total: CellSize, + }, + /// Bottom panel Stage 2 (protocol v21): requested fixed panel rows + /// from a divider drag (Q#BP15a). + /// + /// Rows are the only size component; the epochs are identities, not + /// geometry. Accepted only for the currently visible `Present` panel + /// matching both the latest geometry declaration and the current + /// presentation epoch, then clamped by Q#BP2's interactive + /// preference. + PanelResizeRows { + /// Which frontend produced the drag (untrusted, as above). + frontend_id: FrontendId, + /// Geometry declaration this request is measured against. + geometry_epoch: u64, + /// Presentation identity this request addresses. + panel_epoch: u64, + /// Requested fixed panel rows. + rows: u32, + }, + /// Bottom panel Stage 2 (protocol v21): a pointer gesture a semantic + /// frontend hit-tested to a panel CELL (Q#BP16). + /// + /// Carries both epochs so a gesture aimed at a panel that has since + /// been replaced or reopened cannot be applied to its successor. + /// Unlike [`Self::Pointer`], accepting this **activates the panel**. + PanelPointer { + /// Which frontend produced the gesture (untrusted, as above). + frontend_id: FrontendId, + /// Geometry declaration this gesture was hit-tested against. + geometry_epoch: u64, + /// Presentation identity this gesture addresses. + panel_epoch: u64, + /// Cell the pointer is over, within the declared panel grid. + coord: CellCoord, + /// Which gesture step this is. + kind: MouseKind, + /// Modifiers held during the gesture. + mods: Modifiers, + }, } /// Gesture step for [`FrontendEvent::Pointer`]. Double-click @@ -488,7 +551,10 @@ impl FrontendEvent { | Self::Pointer { frontend_id, .. } | Self::MenuPointer { frontend_id, .. } | Self::TerminalResize { frontend_id, .. } - | Self::TerminalPointer { frontend_id, .. } => *frontend_id, + | Self::TerminalPointer { frontend_id, .. } + | Self::FrontendCellGeometry { frontend_id, .. } + | Self::PanelResizeRows { frontend_id, .. } + | Self::PanelPointer { frontend_id, .. } => *frontend_id, } } } @@ -1143,6 +1209,20 @@ pub enum InstanceMessage { /// Appended after [`Self::TerminalFrame`], the final v19 variant, so no /// legacy postcard discriminant moves. InitialTargetResult(InitialTargetResult), + /// Bottom panel Stage 2 (protocol v21): the daemon's painted + /// projection of one side window, or its authoritative absence + /// (Q#BP15). + /// + /// `Absent` is sent on close **and** on hide: the receiver retains + /// its last valid frame, so silence would leave a stale band on + /// screen indefinitely. `Absent` is duplicate-suppressed like any + /// payload, and applying it clears the last declared panel size and + /// presentation epoch before any later event can validate against + /// them. + /// + /// Appended after [`Self::InitialTargetResult`], the final v20 + /// variant, so no existing postcard discriminant moves. + PanelFrame(crate::panel::PanelFramePayload), } /// One resolved UI face for [`InstanceMessage::ThemeFacts`]: a full @@ -1565,7 +1645,17 @@ pub enum ResourceBody { /// handshake extension is read only from v20 semantic sessions; the result is /// sent only when such a session requested a target. v6–v19 handshakes and /// message discriminants remain unchanged. -pub const PROTOCOL_VERSION: u32 = 20; +/// +/// Bottom panel Stage 2 (Q#BP9): bumped 20 → 21 for +/// [`InstanceMessage::PanelFrame`] and +/// [`FrontendEvent::{FrontendCellGeometry, PanelResizeRows, PanelPointer}`]. +/// All four are appended after their enum's previous final variant, so +/// no v6–v20 discriminant moves and the encoding of every existing +/// message is byte-identical. The new traffic is gated in both +/// directions: a v20 peer neither receives `PanelFrame` nor is placed in +/// a side window, because denying only the events would leave its +/// window invisible. +pub const PROTOCOL_VERSION: u32 = 21; /// T M10.5: the set of protocol versions a v1.0 binary accepts on /// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept @@ -1643,8 +1733,12 @@ pub const PROTOCOL_VERSION: u32 = 20; /// GPU initial target (Q#GT4): extended to `[6, ..., 20]`. v20 semantic /// sessions send a bounded bootstrap envelope after `AttachRequest`; legacy /// and non-semantic sessions retain their existing handshake shape. +/// +/// Bottom panel Stage 2 (Q#BP9): extended to `[6, ..., 21]`. v21 peers +/// may exchange panel traffic; v20 peers interoperate with it simply +/// absent, and are never placed in a side window. pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = - &[6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]; + &[6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21]; /// T M10.5: predicate for the handshake check. Returns `true` if /// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`]. diff --git a/pmacs-protocol/src/panel.rs b/pmacs-protocol/src/panel.rs new file mode 100644 index 0000000..d6e19d7 --- /dev/null +++ b/pmacs-protocol/src/panel.rs @@ -0,0 +1,213 @@ +//! Bottom-panel wire types (Q#BP15, Q#BP15a, Q#BP16). +//! +//! A panel frame is the daemon's painted projection of one side window. +//! It shares [`crate::wire_grid`]'s cell rules with +//! [`crate::terminal::TerminalFrame`] but not its per-axis PTY caps: a +//! 4K surface at a small font is legitimately wider than 512 columns, +//! and the area bound is what keeps the encoding inside the transport +//! budget. +//! +//! Presence is explicit. [`PanelFramePayload::Absent`] is authoritative +//! and must be sent on close *and* on hide, because the receiver +//! retains its last valid frame: silence would leave a stale band on +//! screen indefinitely. + +use crate::cell::{Cell, CellCoord, CellSize}; +use crate::ids::BufferId; +use crate::wire_grid::{ + MAX_WIRE_GRID_GLYPH_BYTES, WireGridError, WireGridLimits, validate_wire_grid, +}; + +/// Shared visible-cell ceiling for a panel grid. +/// +/// Identical to the terminal bound: it is the transport-safety limit, +/// not a PTY policy, so both messages answer to it. +pub const MAX_PANEL_VISIBLE_CELLS: usize = 262_144; + +/// Bounds a panel frame enforces on its cell grid. +/// +/// The per-axis ceilings are the area bound itself rather than 512: any +/// axis larger than the area bound is already rejected by the area +/// check, so this expresses "no independent per-axis policy" without +/// leaving the multiplication unchecked. +const PANEL_GRID_LIMITS: WireGridLimits = WireGridLimits { + max_rows: MAX_PANEL_VISIBLE_CELLS as u32, + max_cols: MAX_PANEL_VISIBLE_CELLS as u32, + max_visible_cells: MAX_PANEL_VISIBLE_CELLS, + max_glyph_bytes: MAX_WIRE_GRID_GLYPH_BYTES, +}; + +/// The daemon's painted projection of one side window. +/// +/// `panel_epoch` is opaque and monotonic per frontend: stable across +/// ordinary frames of one continuously present window/buffer, and +/// changed on buffer replacement, new side-window creation, and every +/// `Absent` → `Present` transition. That is what stops a stale +/// `PanelPointer` from addressing a reopened panel as if it were the +/// old one (Q#BP16). +/// +/// `geometry_epoch` answers a *frontend* declaration and moves whenever +/// the frontend declares new effective cell geometry — including a font +/// or scale change that leaves [`CellSize`] identical, which is exactly +/// the case daemon-side value dedup cannot see (Q#BP2S1). +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct PanelFrame { + /// Buffer this frame projects. + pub buffer_id: BufferId, + /// Presentation identity, monotonic per frontend. + pub panel_epoch: u64, + /// The frontend geometry declaration this frame answers. + pub geometry_epoch: u64, + /// Panel grid dimensions in cells. + pub size: CellSize, + /// Row-major cells; exactly `size.area()` entries. + pub cells: Vec, + /// Panel caret, or `None` when the panel shows no cursor. + /// + /// `paint_frame` returns the cursor separately from the cells, so a + /// frame carrying cells alone would lose the caret. + pub cursor: Option, + /// Whether the panel owns focus. + /// + /// Presentation and focus-chrome routing only (Q#BP14b) — the + /// *keys* decision is `DispatchIdle` (Q#BP14a). + pub focused: bool, +} + +/// Explicit panel presence. +/// +/// `Absent` is authoritative rather than implied by silence, and is +/// duplicate-suppressed like any other payload. +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum PanelFramePayload { + /// A panel is visible and this is its current frame. + Present(PanelFrame), + /// No panel is visible; clear any retained frame. + Absent, +} + +/// Why a [`PanelFrame`] is not structurally valid. +/// +/// Validation is atomic: the frame is rejected whole and the receiver +/// retains its previous valid frame. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +pub enum PanelFrameError { + /// Rows or columns are zero or above the area-derived bounds. + #[error("panel size {rows}x{cols} is outside 1..={max_rows}x1..={max_cols}")] + Size { + /// Declared rows. + rows: u32, + /// Declared columns. + cols: u32, + /// Row bound in force. + max_rows: u32, + /// Column bound in force. + max_cols: u32, + }, + /// The checked area exceeds the shared visible-cell bound. + #[error("panel 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("panel 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("panel 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, + }, + /// A cell's glyph is not a legal wire glyph. + #[error("panel 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 panels never use. + #[error("panel cell {index} carries an attachment")] + Attachment { + /// Row-major cell index. + index: usize, + }, + /// Aggregate glyph bytes exceed the shared budget. + #[error("panel frame glyph bytes exceed the aggregate bound {max}")] + GlyphBudget { + /// Shared aggregate bound. + max: usize, + }, + /// An epoch is zero, which is reserved for "never declared". + #[error("panel {field} epoch is zero, which is reserved for 'never declared'")] + ZeroEpoch { + /// Which epoch was zero. + field: &'static str, + }, +} + +impl PanelFrame { + /// Check every structural rule a panel frame must satisfy. + /// + /// Pure: a rejected frame mutates nothing, so callers get atomic + /// rejection for free. + pub fn validate(&self) -> Result<(), PanelFrameError> { + if self.panel_epoch == 0 { + return Err(PanelFrameError::ZeroEpoch { field: "panel" }); + } + if self.geometry_epoch == 0 { + return Err(PanelFrameError::ZeroEpoch { field: "geometry" }); + } + validate_wire_grid(self.size, &self.cells, self.cursor, PANEL_GRID_LIMITS) + .map_err(panel_grid_error) + } +} + +/// Map a shared wire-grid failure onto this message's error type. +fn panel_grid_error(error: WireGridError) -> PanelFrameError { + match error { + WireGridError::Size { + rows, + cols, + max_rows, + max_cols, + } => PanelFrameError::Size { + rows, + cols, + max_rows, + max_cols, + }, + WireGridError::Area { area, max } => PanelFrameError::Area { area, max }, + WireGridError::CellCount { expected, actual } => { + PanelFrameError::CellCount { expected, actual } + } + WireGridError::Cursor { + row, + col, + rows, + cols, + } => PanelFrameError::Cursor { + row, + col, + rows, + cols, + }, + WireGridError::Glyph { index, reason } => PanelFrameError::Glyph { index, reason }, + WireGridError::Attachment { index } => PanelFrameError::Attachment { index }, + WireGridError::GlyphBudget { max } => PanelFrameError::GlyphBudget { max }, + } +} diff --git a/pmacs-protocol/src/terminal.rs b/pmacs-protocol/src/terminal.rs index 8f094d7..b1b0016 100644 --- a/pmacs-protocol/src/terminal.rs +++ b/pmacs-protocol/src/terminal.rs @@ -12,13 +12,18 @@ //! that single structural policy. A second implementation of these rules //! in a frontend is a bug, not a convenience. //! -//! This module owns the crate's only `unicode-width` use: glyph column -//! width and wide-continuation topology cannot be checked without it. +//! 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, Glyph}; +use crate::cell::{Cell, CellCoord, CellSize}; use crate::ids::BufferId; -use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; +#[cfg(test)] +use unicode_width::UnicodeWidthStr; // --------------------------------------------------------------------------- // Shared limits @@ -217,6 +222,58 @@ pub enum TerminalFrameError { }, } +/// 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. /// @@ -224,23 +281,13 @@ impl TerminalFrame { /// 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> { - let area = self.checked_area()?; - if self.cells.len() != area { - return Err(TerminalFrameError::CellCount { - expected: area, - actual: self.cells.len(), - }); - } - if let Some(cursor) = self.cursor - && (cursor.row >= self.size.rows || cursor.col >= self.size.cols) - { - return Err(TerminalFrameError::Cursor { - row: cursor.row, - col: cursor.col, - rows: self.size.rows, - cols: self.size.cols, - }); - } + 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)?; } @@ -249,7 +296,6 @@ impl TerminalFrame { TerminalProcessState::Crashed(text) => validate_metadata("crash", text)?, TerminalProcessState::Running | TerminalProcessState::Exited(_) => {} } - self.validate_cells()?; self.validate_selection()?; if self.at_bottom != (self.scroll_offset == 0) { return Err(TerminalFrameError::BottomState { @@ -260,107 +306,6 @@ impl TerminalFrame { Ok(()) } - /// Declared cell area, checked against both shared bounds. - fn checked_area(&self) -> Result { - let rows = self.size.rows; - let cols = self.size.cols; - if rows == 0 - || cols == 0 - || rows > u32::from(MAX_TERMINAL_ROWS) - || cols > u32::from(MAX_TERMINAL_COLS) - { - return Err(TerminalFrameError::Size { - rows, - cols, - max_rows: u32::from(MAX_TERMINAL_ROWS), - max_cols: u32::from(MAX_TERMINAL_COLS), - }); - } - // Both factors are bounded above by 512, so the product cannot - // overflow; `checked_mul` keeps that an assertion rather than an - // assumption a later bound change could quietly break. - let area = rows - .checked_mul(cols) - .and_then(|area| usize::try_from(area).ok()) - .ok_or(TerminalFrameError::Area { - area: usize::MAX, - max: MAX_TERMINAL_VISIBLE_CELLS, - })?; - if area > MAX_TERMINAL_VISIBLE_CELLS { - return Err(TerminalFrameError::Area { - area, - max: MAX_TERMINAL_VISIBLE_CELLS, - }); - } - Ok(area) - } - - /// Glyph legality, wide-continuation topology, and the glyph budget. - fn validate_cells(&self) -> Result<(), TerminalFrameError> { - let cols = self.size.cols as usize; - let mut glyph_bytes = 0usize; - // Columns still owed to the preceding wide lead on this row. - let mut pending_continuation = false; - for (index, cell) in self.cells.iter().enumerate() { - if cell.attachment.is_some() { - return Err(TerminalFrameError::Attachment { index }); - } - let col = index % cols; - if col == 0 && pending_continuation { - // A wide lead in the final column would have to be - // completed on the next row, which is not a footprint a - // terminal grid can express. - return Err(TerminalFrameError::Glyph { - index: index - 1, - reason: "wide glyph has no continuation column on its row", - }); - } - match &cell.glyph { - Glyph::Continuation => { - if !pending_continuation { - return Err(TerminalFrameError::Glyph { - index, - reason: "continuation without a preceding wide glyph", - }); - } - pending_continuation = false; - } - Glyph::Char(ch) => { - if pending_continuation { - return Err(TerminalFrameError::Glyph { - index, - reason: "wide glyph is not followed by its continuation", - }); - } - let width = char_display_width(*ch).ok_or(TerminalFrameError::Glyph { - index, - reason: "glyph is a control or zero-width character", - })?; - glyph_bytes = add_glyph_bytes(glyph_bytes, ch.len_utf8())?; - pending_continuation = width == 2; - } - Glyph::Cluster(bytes) => { - if pending_continuation { - return Err(TerminalFrameError::Glyph { - index, - reason: "wide glyph is not followed by its continuation", - }); - } - let width = cluster_display_width(bytes, index)?; - glyph_bytes = add_glyph_bytes(glyph_bytes, bytes.len())?; - pending_continuation = width == 2; - } - } - } - if pending_continuation { - return Err(TerminalFrameError::Glyph { - index: self.cells.len() - 1, - reason: "wide glyph has no continuation column on its row", - }); - } - Ok(()) - } - /// One nonempty in-bounds span per row, strictly increasing by row. fn validate_selection(&self) -> Result<(), TerminalFrameError> { let mut previous_row: Option = None; @@ -395,73 +340,6 @@ impl TerminalFrame { } } -/// Column width of a leading `Char` glyph, or `None` when it cannot lead. -fn char_display_width(ch: char) -> Option { - if ch.is_control() { - return None; - } - match UnicodeWidthChar::width(ch) { - Some(1) => Some(1), - Some(2) => Some(2), - _ => None, - } -} - -/// Column width of a leading `Cluster` glyph. -/// -/// Width is clamped into `1..=2` exactly as the terminal screen clamps it -/// when it writes the cluster: a base plus combining marks may measure -/// wider than two columns, and the screen occupies two. Clamping in one -/// place and measuring in another is how a frame that renders correctly -/// gets rejected on the wire. -fn cluster_display_width(bytes: &[u8], index: usize) -> Result { - if bytes.is_empty() { - return Err(TerminalFrameError::Glyph { - index, - reason: "cluster is empty", - }); - } - if bytes.len() > MAX_TERMINAL_GRAPHEME_BYTES { - return Err(TerminalFrameError::Glyph { - index, - reason: "cluster exceeds the per-cluster byte limit", - }); - } - let text = std::str::from_utf8(bytes).map_err(|_| TerminalFrameError::Glyph { - index, - reason: "cluster is not valid UTF-8", - })?; - if text.chars().any(char::is_control) { - return Err(TerminalFrameError::Glyph { - index, - reason: "cluster carries a control character", - }); - } - let width = UnicodeWidthStr::width(text); - if width == 0 { - return Err(TerminalFrameError::Glyph { - index, - reason: "cluster occupies no columns", - }); - } - Ok(width.min(2)) -} - -/// Accumulate glyph bytes under the aggregate bound with checked addition. -fn add_glyph_bytes(total: usize, add: usize) -> Result { - let next = total - .checked_add(add) - .ok_or(TerminalFrameError::GlyphBudget { - max: MAX_TERMINAL_FRAME_GLYPH_BYTES, - })?; - if next > MAX_TERMINAL_FRAME_GLYPH_BYTES { - return Err(TerminalFrameError::GlyphBudget { - max: MAX_TERMINAL_FRAME_GLYPH_BYTES, - }); - } - Ok(next) -} - /// 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 { @@ -482,7 +360,10 @@ fn validate_metadata(field: &'static str, text: &str) -> Result<(), TerminalFram #[cfg(test)] mod tests { use super::*; - use crate::cell::{Color, Style, UnderlineStyle}; + // `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; diff --git a/pmacs-protocol/src/wire_grid.rs b/pmacs-protocol/src/wire_grid.rs new file mode 100644 index 0000000..9c8f8dc --- /dev/null +++ b/pmacs-protocol/src/wire_grid.rs @@ -0,0 +1,321 @@ +//! Shared cell-grid validation for every wire message that carries a +//! rectangular grid of [`Cell`]s. +//! +//! Bottom-panel Stage 2B (Q#BP15) factors this out of +//! [`crate::terminal`], which was the only such message until +//! [`crate::panel::PanelFrame`] arrived. The split follows the boundary +//! the framing names: +//! +//! - **Shared** — the checked area, the visible-cell bound, the cell +//! count, cursor bounds, glyph legality, wide-continuation topology, +//! the aggregate glyph-byte budget, and the attachment rejection. +//! - **Terminal-only** — the 512 per-axis PTY caps, title/process +//! metadata, selection spans, and the `at_bottom == (scroll_offset == +//! 0)` coupling. +//! +//! The per-axis caps are a [`WireGridLimits`] parameter rather than a +//! constant precisely because a panel does not inherit them: a 4K +//! surface at a small font is legitimately wider than 512 columns, and +//! the area bound is what keeps the encoding inside the transport +//! budget. +//! +//! The attachment rejection is deliberately **shared**, not +//! terminal-only, even though its terminal-side message reads "which +//! terminals never use". Panels render no attachments either, so +//! rejecting them here fails closed for both; classifying it as +//! terminal-only would let a panel ship a cell no frontend can paint. + +use crate::cell::{Cell, CellCoord, CellSize, Glyph}; + +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +/// Aggregate glyph-byte ceiling shared by every wire grid. +/// +/// A grid at the visible-cell bound where every cell carries a maximum +/// cluster would exceed the transport frame limit; this keeps the +/// encoded size bounded independently of the per-cell rule. +pub const MAX_WIRE_GRID_GLYPH_BYTES: usize = 8 * 1024 * 1024; + +/// Per-cell grapheme-cluster byte ceiling shared by every wire grid. +pub const MAX_WIRE_GRID_GRAPHEME_BYTES: usize = 256; + +/// Bounds a particular wire grid enforces. +/// +/// `max_rows` / `max_cols` are per-message policy. `max_visible_cells` +/// is the shared area bound and is what actually keeps the encoding +/// inside the transport budget. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct WireGridLimits { + /// Inclusive row ceiling. + pub max_rows: u32, + /// Inclusive column ceiling. + pub max_cols: u32, + /// Inclusive `rows * cols` ceiling. + pub max_visible_cells: usize, + /// Inclusive aggregate glyph-byte ceiling. + pub max_glyph_bytes: usize, +} + +/// Why a wire grid is not structurally valid. +/// +/// Callers map these onto their own message-specific error types so +/// existing wire errors keep their exact variants and text. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum WireGridError { + /// Rows or columns are zero or above this grid's bounds. + Size { + /// Declared rows. + rows: u32, + /// Declared columns. + cols: u32, + /// Row bound in force. + max_rows: u32, + /// Column bound in force. + max_cols: u32, + }, + /// The checked area exceeds the visible-cell bound. + Area { + /// Checked `rows * cols`. + area: usize, + /// Bound in force. + max: usize, + }, + /// `cells.len()` disagrees with the declared area. + CellCount { + /// Declared area. + expected: usize, + /// Supplied cell count. + actual: usize, + }, + /// The cursor lies outside the declared grid. + Cursor { + /// Cursor row. + row: u32, + /// Cursor column. + col: u32, + /// Declared rows. + rows: u32, + /// Declared columns. + cols: u32, + }, + /// A cell's glyph is not legal in a wire grid. + Glyph { + /// Row-major cell index. + index: usize, + /// Why the glyph failed. + reason: &'static str, + }, + /// A cell carries a frontend attachment, which no wire grid uses. + Attachment { + /// Row-major cell index. + index: usize, + }, + /// Aggregate glyph bytes exceed the budget. + GlyphBudget { + /// Bound in force. + max: usize, + }, +} + +/// Declared cell area, checked against this grid's bounds. +/// +/// Separate from [`validate_wire_grid`] because callers need the area +/// before they have cells to check against it. +pub fn checked_area(size: CellSize, limits: WireGridLimits) -> Result { + let rows = size.rows; + let cols = size.cols; + if rows == 0 || cols == 0 || rows > limits.max_rows || cols > limits.max_cols { + return Err(WireGridError::Size { + rows, + cols, + max_rows: limits.max_rows, + max_cols: limits.max_cols, + }); + } + // `checked_mul` rather than a bound-derived assumption: a panel's + // axis ceilings are large enough that the product genuinely can + // overflow, which the terminal's 512x512 could not. + let area = rows + .checked_mul(cols) + .and_then(|area| usize::try_from(area).ok()) + .ok_or(WireGridError::Area { + area: usize::MAX, + max: limits.max_visible_cells, + })?; + if area > limits.max_visible_cells { + return Err(WireGridError::Area { + area, + max: limits.max_visible_cells, + }); + } + Ok(area) +} + +/// Check every structural rule shared by wire grids. +/// +/// Pure: a rejected grid mutates nothing, so callers get atomic +/// rejection for free. +pub fn validate_wire_grid( + size: CellSize, + cells: &[Cell], + cursor: Option, + limits: WireGridLimits, +) -> Result<(), WireGridError> { + let area = checked_area(size, limits)?; + if cells.len() != area { + return Err(WireGridError::CellCount { + expected: area, + actual: cells.len(), + }); + } + if let Some(cursor) = cursor + && (cursor.row >= size.rows || cursor.col >= size.cols) + { + return Err(WireGridError::Cursor { + row: cursor.row, + col: cursor.col, + rows: size.rows, + cols: size.cols, + }); + } + validate_cells(size, cells, limits) +} + +/// Glyph legality, wide-continuation topology, and the glyph budget. +fn validate_cells( + size: CellSize, + cells: &[Cell], + limits: WireGridLimits, +) -> Result<(), WireGridError> { + let cols = size.cols as usize; + let mut glyph_bytes = 0usize; + // Columns still owed to the preceding wide lead on this row. + let mut pending_continuation = false; + for (index, cell) in cells.iter().enumerate() { + if cell.attachment.is_some() { + return Err(WireGridError::Attachment { index }); + } + let col = index % cols; + if col == 0 && pending_continuation { + // A wide lead in the final column would have to be completed + // on the next row, which is not a footprint a cell grid can + // express. + return Err(WireGridError::Glyph { + index: index - 1, + reason: "wide glyph has no continuation column on its row", + }); + } + match &cell.glyph { + Glyph::Continuation => { + if !pending_continuation { + return Err(WireGridError::Glyph { + index, + reason: "continuation without a preceding wide glyph", + }); + } + pending_continuation = false; + } + Glyph::Char(ch) => { + if pending_continuation { + return Err(WireGridError::Glyph { + index, + reason: "wide glyph is not followed by its continuation", + }); + } + let width = char_display_width(*ch).ok_or(WireGridError::Glyph { + index, + reason: "glyph is a control or zero-width character", + })?; + glyph_bytes = add_glyph_bytes(glyph_bytes, ch.len_utf8(), limits)?; + pending_continuation = width == 2; + } + Glyph::Cluster(bytes) => { + if pending_continuation { + return Err(WireGridError::Glyph { + index, + reason: "wide glyph is not followed by its continuation", + }); + } + let width = cluster_display_width(bytes, index)?; + glyph_bytes = add_glyph_bytes(glyph_bytes, bytes.len(), limits)?; + pending_continuation = width == 2; + } + } + } + if pending_continuation { + return Err(WireGridError::Glyph { + index: cells.len() - 1, + reason: "wide glyph has no continuation column on its row", + }); + } + Ok(()) +} + +/// Column width of a leading `Char` glyph, or `None` when it cannot lead. +pub(crate) fn char_display_width(ch: char) -> Option { + if ch.is_control() { + return None; + } + match UnicodeWidthChar::width(ch) { + Some(1) => Some(1), + Some(2) => Some(2), + _ => None, + } +} + +/// Column width of a leading `Cluster` glyph. +/// +/// Width is clamped into `1..=2` exactly as the terminal screen clamps it +/// when it writes the cluster: a base plus combining marks may measure +/// wider than two columns, and the screen occupies two. Clamping in one +/// place and measuring in another is how a frame that renders correctly +/// gets rejected on the wire. +fn cluster_display_width(bytes: &[u8], index: usize) -> Result { + if bytes.is_empty() { + return Err(WireGridError::Glyph { + index, + reason: "cluster is empty", + }); + } + if bytes.len() > MAX_WIRE_GRID_GRAPHEME_BYTES { + return Err(WireGridError::Glyph { + index, + reason: "cluster exceeds the per-cluster byte limit", + }); + } + let text = std::str::from_utf8(bytes).map_err(|_| WireGridError::Glyph { + index, + reason: "cluster is not valid UTF-8", + })?; + if text.chars().any(char::is_control) { + return Err(WireGridError::Glyph { + index, + reason: "cluster carries a control character", + }); + } + let width = UnicodeWidthStr::width(text); + if width == 0 { + return Err(WireGridError::Glyph { + index, + reason: "cluster occupies no columns", + }); + } + Ok(width.min(2)) +} + +/// Accumulate glyph bytes against the aggregate budget. +fn add_glyph_bytes( + total: usize, + add: usize, + limits: WireGridLimits, +) -> Result { + let next = total.checked_add(add).ok_or(WireGridError::GlyphBudget { + max: limits.max_glyph_bytes, + })?; + if next > limits.max_glyph_bytes { + return Err(WireGridError::GlyphBudget { + max: limits.max_glyph_bytes, + }); + } + Ok(next) +} diff --git a/src/daemon.rs b/src/daemon.rs index 84716eb..eef6095 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -3313,6 +3313,20 @@ fn apply_event( (grid terminals resize through the Stage 2 layout path)" ); } + FrontendEvent::FrontendCellGeometry { .. } + | FrontendEvent::PanelResizeRows { .. } + | FrontendEvent::PanelPointer { .. } => { + // Bottom panel Stage 2 — panel declarations belong to + // negotiated panel-capable semantic sessions and are routed + // by the authenticated source in `handle_dispatcher_event`. + // A grid session has no panel band at all, so one arriving + // here is a protocol violation; drop it rather than letting + // a payload-trusted id reach a view. + eprintln!( + "pmacs daemon: panel declaration from a grid session; dropping \ + (grid sessions negotiate no panel band)" + ); + } } } diff --git a/src/frontend.rs b/src/frontend.rs index 8fcfb47..6b8755f 100644 --- a/src/frontend.rs +++ b/src/frontend.rs @@ -439,6 +439,11 @@ impl Frontend { // Q#GT4 — this pre-window semantic bootstrap result cannot // legitimately reach the grid TUI. | InstanceMessage::InitialTargetResult(_) + // Q#BP15 — the panel band is painted by the GPU frontend; + // the grid TUI renders its side windows through the cell + // grid and negotiates no panel capability, so this cannot + // legitimately reach here. + | InstanceMessage::PanelFrame(_) | InstanceMessage::ResourceOffer { .. } // T M11.6 — DispatchIdle is consumed by `attach.rs`'s // optimistic-apply gate; if any reaches this render path diff --git a/tests/bottom_panel_stage2b_protocol_acceptance.rs b/tests/bottom_panel_stage2b_protocol_acceptance.rs new file mode 100644 index 0000000..31a95a7 --- /dev/null +++ b/tests/bottom_panel_stage2b_protocol_acceptance.rs @@ -0,0 +1,351 @@ +//! Bottom-panel Stage 2B — the v21 protocol slice. +//! +//! Covers parent acceptance 37 (round-trip plus the two byte pins) and +//! the shared/terminal-only validator split of Q#BP15. The daemon +//! projection, the epoch state machine, and the GPU band are later +//! slices of this stage and are not exercised here. + +use pmacs_protocol::cell::{Cell, CellCoord, CellSize, Glyph, Style}; +use pmacs_protocol::message::{FrontendEvent, InstanceMessage, Modifiers, MouseButton, MouseKind}; +use pmacs_protocol::panel::{PanelFrame, PanelFrameError, PanelFramePayload}; +use pmacs_protocol::terminal::{ + MAX_TERMINAL_COLS, TerminalFrame, TerminalFrameError, TerminalProcessState, +}; +use pmacs_protocol::{BufferId, FrontendId, PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS}; + +fn cell(ch: char) -> Cell { + Cell { + glyph: Glyph::Char(ch), + style: Style::default(), + attachment: None, + } +} + +fn panel_frame(rows: u32, cols: u32) -> PanelFrame { + PanelFrame { + buffer_id: BufferId::from_raw(9), + panel_epoch: 3, + geometry_epoch: 5, + size: CellSize::new(rows, cols), + cells: vec![cell(' '); (rows * cols) as usize], + cursor: Some(CellCoord::new(0, 0)), + focused: true, + } +} + +fn terminal_frame(rows: u32, cols: u32) -> TerminalFrame { + TerminalFrame { + buffer_id: BufferId::from_raw(9), + size: CellSize::new(rows, cols), + cells: vec![cell(' '); (rows * cols) as usize], + cursor: Some(CellCoord::new(0, 0)), + title: None, + screen_generation: 1, + selection: Vec::new(), + scroll_offset: 0, + at_bottom: true, + pid: 1, + process: TerminalProcessState::Running, + } +} + +// --------------------------------------------------------------------------- +// 37 — version and round-trip +// --------------------------------------------------------------------------- + +#[test] +fn the_panel_stage_takes_protocol_v21() { + assert_eq!(PROTOCOL_VERSION, 21); + assert!(SUPPORTED_PROTOCOL_VERSIONS.contains(&21)); + // v20 stays supported: a v20 peer interoperates with panel traffic + // simply absent rather than being refused the handshake. + assert!(SUPPORTED_PROTOCOL_VERSIONS.contains(&20)); +} + +#[test] +fn a_present_panel_frame_round_trips_with_both_epochs() { + let frame = panel_frame(2, 3); + let msg = InstanceMessage::PanelFrame(PanelFramePayload::Present(frame.clone())); + let bytes = postcard::to_allocvec(&msg).expect("encode"); + let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode"); + let InstanceMessage::PanelFrame(PanelFramePayload::Present(got)) = decoded else { + panic!("expected a Present panel frame, got {decoded:?}"); + }; + // Both epochs must survive: they are the identities every later + // panel event validates against, so a frame that round-trips its + // cells but drops an epoch would silently accept stale input. + assert_eq!(got.panel_epoch, frame.panel_epoch); + assert_eq!(got.geometry_epoch, frame.geometry_epoch); + assert_eq!(got.buffer_id, frame.buffer_id); + assert_eq!(got.size, frame.size); + assert_eq!(got.cells, frame.cells); + assert_eq!(got.cursor, frame.cursor); + assert_eq!(got.focused, frame.focused); +} + +#[test] +fn an_absent_panel_payload_round_trips_as_its_own_state() { + let msg = InstanceMessage::PanelFrame(PanelFramePayload::Absent); + let bytes = postcard::to_allocvec(&msg).expect("encode"); + let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode"); + assert!(matches!( + decoded, + InstanceMessage::PanelFrame(PanelFramePayload::Absent) + )); + // Absent must be distinguishable from a Present frame carrying no + // cells: it is authoritative, and conflating the two would make + // "hide the band" indistinguishable from "paint an empty band". + let empty_present = InstanceMessage::PanelFrame(PanelFramePayload::Present(panel_frame(1, 1))); + assert_ne!( + postcard::to_allocvec(&empty_present).expect("encode"), + bytes + ); +} + +#[test] +fn the_three_panel_events_round_trip() { + let fid = FrontendId(4); + let events = vec![ + FrontendEvent::FrontendCellGeometry { + frontend_id: fid, + geometry_epoch: 1, + total: CellSize::new(40, 120), + }, + FrontendEvent::PanelResizeRows { + frontend_id: fid, + geometry_epoch: 2, + panel_epoch: 7, + rows: 12, + }, + FrontendEvent::PanelPointer { + frontend_id: fid, + geometry_epoch: 2, + panel_epoch: 7, + coord: CellCoord::new(3, 9), + kind: MouseKind::Down(MouseButton::Left), + mods: Modifiers::default(), + }, + ]; + for event in events { + let bytes = postcard::to_allocvec(&event).expect("encode"); + let decoded: FrontendEvent = postcard::from_bytes(&bytes).expect("decode"); + assert_eq!(decoded, event); + assert_eq!(decoded.frontend_id(), fid); + } +} + +// --------------------------------------------------------------------------- +// 37 — byte pins on the previous final variant of each extended enum +// --------------------------------------------------------------------------- + +#[test] +fn appending_panel_frame_does_not_move_the_previous_final_instance_discriminant() { + // `InitialTargetResult` was the final v20 variant. Its encoding must + // be byte-identical after `PanelFrame` is appended; if the new + // variant were inserted anywhere earlier, this leading discriminant + // byte would shift and every v20 peer would misread the wire. + let msg = InstanceMessage::InitialTargetResult( + pmacs_protocol::message::InitialTargetResult::Opened { + buffer_id: BufferId::from_raw(1), + }, + ); + let bytes = postcard::to_allocvec(&msg).expect("encode"); + assert_eq!( + bytes[0], 27, + "InitialTargetResult must stay discriminant 27; got {bytes:?}" + ); + // And the appended variant must be the next one, not a reused slot. + let panel = InstanceMessage::PanelFrame(PanelFramePayload::Absent); + let panel_bytes = postcard::to_allocvec(&panel).expect("encode"); + assert_eq!(panel_bytes[0], 28); +} + +#[test] +fn appending_panel_events_does_not_move_the_previous_final_event_discriminant() { + // `TerminalPointer` was the final v19/v20 variant of `FrontendEvent`. + let event = FrontendEvent::TerminalPointer { + frontend_id: FrontendId(2), + buffer_id: BufferId::from_raw(3), + coord: CellCoord::new(1, 1), + kind: MouseKind::Down(MouseButton::Left), + mods: Modifiers::default(), + }; + let bytes = postcard::to_allocvec(&event).expect("encode"); + assert_eq!( + bytes[0], 12, + "TerminalPointer must stay discriminant 12; got {bytes:?}" + ); + // The three appended events take the next three slots, in order. + let fid = FrontendId(2); + for (expected, event) in [ + ( + 13u8, + FrontendEvent::FrontendCellGeometry { + frontend_id: fid, + geometry_epoch: 1, + total: CellSize::new(1, 1), + }, + ), + ( + 14, + FrontendEvent::PanelResizeRows { + frontend_id: fid, + geometry_epoch: 1, + panel_epoch: 1, + rows: 1, + }, + ), + ( + 15, + FrontendEvent::PanelPointer { + frontend_id: fid, + geometry_epoch: 1, + panel_epoch: 1, + coord: CellCoord::new(0, 0), + kind: MouseKind::Down(MouseButton::Left), + mods: Modifiers::default(), + }, + ), + ] { + let bytes = postcard::to_allocvec(&event).expect("encode"); + assert_eq!(bytes[0], expected, "wrong discriminant for {event:?}"); + } +} + +// --------------------------------------------------------------------------- +// 39 — the shared/terminal-only validator split +// --------------------------------------------------------------------------- + +#[test] +fn a_panel_wider_than_512_columns_is_legal_while_a_terminal_is_not() { + let wide = MAX_TERMINAL_COLS as u32 + 1; + + // The panel does not inherit the PTY per-axis cap: a 4K surface at a + // small font is legitimately this wide, and the area bound is what + // keeps the encoding inside the transport budget. + let panel = panel_frame(1, wide); + assert_eq!(panel.validate(), Ok(())); + + // The terminal keeps it, and reports the axis that failed. + let terminal = terminal_frame(1, wide); + assert!(matches!( + terminal.validate(), + Err(TerminalFrameError::Size { cols, max_cols, .. }) + if cols == wide && max_cols == MAX_TERMINAL_COLS as u32 + )); +} + +#[test] +fn a_panel_still_answers_to_the_shared_area_bound() { + // Removing the per-axis cap must not remove the area bound: that is + // the check that actually bounds the encoded size. + let huge = panel_frame(1, 1); + let mut huge = huge; + huge.size = CellSize::new(1024, 1024); + huge.cells = vec![cell(' '); 1]; + assert!(matches!(huge.validate(), Err(PanelFrameError::Area { .. }))); +} + +#[test] +fn a_panel_cell_carrying_an_attachment_is_rejected() { + // The attachment rejection is SHARED, not terminal-only, even though + // the terminal-side message says "which terminals never use": + // panels render no attachments either, so a shared rejection fails + // closed for both. + let mut frame = panel_frame(1, 2); + frame.cells[1].attachment = Some(pmacs_protocol::cell::Attachment::ImageCell { + image_id: 1, + sub_x: 0, + sub_y: 0, + }); + assert!(matches!( + frame.validate(), + Err(PanelFrameError::Attachment { index: 1 }) + )); +} + +#[test] +fn panel_glyph_topology_matches_the_terminal_rules() { + // A wide lead with no continuation column on its row is rejected the + // same way for both messages — the topology rule is shared. + let mut frame = panel_frame(1, 1); + frame.cells[0] = cell('\u{4e00}'); + assert!(matches!( + frame.validate(), + Err(PanelFrameError::Glyph { .. }) + )); + + let mut terminal = terminal_frame(1, 1); + terminal.cells[0] = cell('\u{4e00}'); + assert!(matches!( + terminal.validate(), + Err(TerminalFrameError::Glyph { .. }) + )); +} + +#[test] +fn a_panel_cursor_outside_its_grid_is_rejected() { + let mut frame = panel_frame(2, 2); + frame.cursor = Some(CellCoord::new(2, 0)); + assert!(matches!( + frame.validate(), + Err(PanelFrameError::Cursor { + row: 2, + rows: 2, + .. + }) + )); +} + +#[test] +fn a_zero_epoch_panel_frame_is_rejected_on_the_wire() { + // Epoch 0 is reserved for "never declared" (Q#BP2S1), so a frame + // carrying it could otherwise match a receiver that has declared + // nothing yet. + let mut frame = panel_frame(1, 1); + frame.panel_epoch = 0; + assert!(matches!( + frame.validate(), + Err(PanelFrameError::ZeroEpoch { field: "panel" }) + )); + + let mut frame = panel_frame(1, 1); + frame.geometry_epoch = 0; + assert!(matches!( + frame.validate(), + Err(PanelFrameError::ZeroEpoch { field: "geometry" }) + )); +} + +#[test] +fn terminal_frames_are_unchanged_by_the_factoring() { + // The shared validator must not have altered terminal acceptance: + // a valid frame still validates, and each terminal-only rule still + // reports its own variant. + assert_eq!(terminal_frame(3, 4).validate(), Ok(())); + + let mut bad_bottom = terminal_frame(1, 1); + bad_bottom.at_bottom = false; + bad_bottom.scroll_offset = 0; + assert!(matches!( + bad_bottom.validate(), + Err(TerminalFrameError::BottomState { .. }) + )); + + let mut bad_meta = terminal_frame(1, 1); + bad_meta.title = Some("\u{7}".into()); + assert!(matches!( + bad_meta.validate(), + Err(TerminalFrameError::Metadata { field: "title", .. }) + )); + + let mut bad_count = terminal_frame(2, 2); + bad_count.cells.pop(); + assert!(matches!( + bad_count.validate(), + Err(TerminalFrameError::CellCount { + expected: 4, + actual: 3 + }) + )); +} From 8af529b65dd32313ebd982ff521d7a232244eb7f Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 26 Jul 2026 16:59:51 -0400 Subject: [PATCH 02/12] test(protocol): move the version ladder pins to v21 Both pins failed on the bump, which is what they exist for. The ladder test now accepts 6..=21 and rejects 22, and the version assertion carries the Stage 2 entry: four variants appended after their enum's final v20 variant, gated in both directions. Also renames `protocol_version_is_twenty_for_gpu_initial_targets`, whose name pinned the old number. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RuhVYUPHXMHG8r2z4tsDPR --- pmacs-protocol/src/wire_grid.rs | 2 +- src/protocol.rs | 19 +++++++++++++------ ...ottom_panel_stage2b_protocol_acceptance.rs | 4 ++-- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/pmacs-protocol/src/wire_grid.rs b/pmacs-protocol/src/wire_grid.rs index 9c8f8dc..1ff40c9 100644 --- a/pmacs-protocol/src/wire_grid.rs +++ b/pmacs-protocol/src/wire_grid.rs @@ -60,7 +60,7 @@ pub struct WireGridLimits { /// /// Callers map these onto their own message-specific error types so /// existing wire errors keep their exact variants and text. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum WireGridError { /// Rows or columns are zero or above this grid's bounds. Size { diff --git a/src/protocol.rs b/src/protocol.rs index df65863..baf2709 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -1683,7 +1683,7 @@ mod tests { // --- M5.5a handshake & postcard round-trips --- #[test] - fn protocol_version_is_twenty_for_gpu_initial_targets() { + fn protocol_version_is_twenty_one_for_the_bottom_panel_band() { // Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp / // PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the // SemanticFrame family + FrontendEvent::Viewport). T M11.6 @@ -1722,7 +1722,13 @@ mod tests { // variant, see the placement pins). // GPU initial targets bump 19→20 with a semantic-only // SessionBootstrapRequest and appended InitialTargetResult. - assert_eq!(PROTOCOL_VERSION, 20); + // Bottom panel Stage 2 bumps 20→21 (`InstanceMessage::PanelFrame`, + // daemon-gated, plus `FrontendEvent::{FrontendCellGeometry, + // PanelResizeRows, PanelPointer}`, frontend-gated — the second + // bump that gates in BOTH directions; all four appended after + // their enum's final v20 variant, see the placement pins in + // `bottom_panel_stage2b_protocol_acceptance`). + assert_eq!(PROTOCOL_VERSION, 21); } #[test] @@ -1798,17 +1804,18 @@ mod tests { // minibuffer), v13 (`LineNumbers`), v14 (`LineNumberMode`), v15 // (`CompletionPopup`), v16 (`ThemeFacts`), v17 (`FontFacts`), // v18 (`StatuslineSegments`), v19 (the vterm terminal family), - // and v20 (semantic initial-target bootstrap) all interoperate. - for accepted in 6..=20 { + // v20 (semantic initial-target bootstrap), and v21 (the bottom + // panel band) all interoperate. + for accepted in 6..=21 { assert!( is_supported_protocol_version(accepted), "v{accepted} must be accepted" ); } - for rejected in [0, 1, 2, 3, 4, 5, 21, u32::MAX] { + for rejected in [0, 1, 2, 3, 4, 5, 22, u32::MAX] { assert!( !is_supported_protocol_version(rejected), - "v{rejected} must be rejected by a v20 binary" + "v{rejected} must be rejected by a v21 binary" ); } } diff --git a/tests/bottom_panel_stage2b_protocol_acceptance.rs b/tests/bottom_panel_stage2b_protocol_acceptance.rs index 31a95a7..91a8d48 100644 --- a/tests/bottom_panel_stage2b_protocol_acceptance.rs +++ b/tests/bottom_panel_stage2b_protocol_acceptance.rs @@ -218,7 +218,7 @@ fn appending_panel_events_does_not_move_the_previous_final_event_discriminant() #[test] fn a_panel_wider_than_512_columns_is_legal_while_a_terminal_is_not() { - let wide = MAX_TERMINAL_COLS as u32 + 1; + let wide = u32::from(MAX_TERMINAL_COLS) + 1; // The panel does not inherit the PTY per-axis cap: a 4K surface at a // small font is legitimately this wide, and the area bound is what @@ -231,7 +231,7 @@ fn a_panel_wider_than_512_columns_is_legal_while_a_terminal_is_not() { assert!(matches!( terminal.validate(), Err(TerminalFrameError::Size { cols, max_cols, .. }) - if cols == wide && max_cols == MAX_TERMINAL_COLS as u32 + if cols == wide && max_cols == u32::from(MAX_TERMINAL_COLS) )); } From 9b364adc267bff6c7df75cb4c7bc8c19318ed64d Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 26 Jul 2026 17:31:04 -0400 Subject: [PATCH 03/12] =?UTF-8?q?fix(panel):=20review=20round=201=20?= =?UTF-8?q?=E2=80=94=20buffer=5Fid,=20the=20transport=20ratchet,=20shared?= =?UTF-8?q?=20bounds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 — `PanelPointer` was missing the approved `buffer_id`. Q#BP16 gives it and `panel_epoch` different jobs and neither subsumes the other: `buffer_id` catches an A->B buffer replacement, `panel_epoch` catches close/hide/reopen of the SAME persistent buffer, which a buffer id alone cannot see. Added in the framing's field order, with a pin asserting each field independently reaches the wire. P1 — added parent criterion 39's transport-safety ratchet. It builds the maximum legal panel payload, asserts the fixture actually spends the whole aggregate glyph budget (otherwise the ratchet measures something smaller than the worst case), asserts one byte more is rejected, and pins the encoded `InstanceMessage::PanelFrame` below `MAX_FRAME_BYTES`. Shaped `1 x MAX_PANEL_VISIBLE_CELLS` deliberately: no per-axis cap makes that a legal panel geometry a terminal cannot express, so it is the worst case the terminal's own ratchet never measured. Bitten by tripling the glyph budget — 30,342,696 bytes against the 16 MiB cap. P2 — the shared bounds were duplicated literals. `MAX_TERMINAL_GRAPHEME_BYTES` now aliases `MAX_WIRE_GRID_GRAPHEME_BYTES`, so the terminal screen's truncation (`src/terminal/screen.rs:697`, `:777`) and the validator cannot drift. Two more had the same defect and are aliased too: `MAX_TERMINAL_VISIBLE_CELLS` and `MAX_TERMINAL_FRAME_GLYPH_BYTES`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RuhVYUPHXMHG8r2z4tsDPR --- pmacs-protocol/src/message.rs | 8 + pmacs-protocol/src/panel.rs | 2 +- pmacs-protocol/src/terminal.rs | 16 +- pmacs-protocol/src/wire_grid.rs | 8 + ...ottom_panel_stage2b_protocol_acceptance.rs | 180 +++++++++++++++++- 5 files changed, 208 insertions(+), 6 deletions(-) diff --git a/pmacs-protocol/src/message.rs b/pmacs-protocol/src/message.rs index 4103cd4..e3795a3 100644 --- a/pmacs-protocol/src/message.rs +++ b/pmacs-protocol/src/message.rs @@ -491,6 +491,12 @@ pub enum FrontendEvent { /// Carries both epochs so a gesture aimed at a panel that has since /// been replaced or reopened cannot be applied to its successor. /// Unlike [`Self::Pointer`], accepting this **activates the panel**. + /// + /// `buffer_id` and `panel_epoch` close different holes and neither + /// subsumes the other: `buffer_id` catches an A→B buffer + /// replacement, while `panel_epoch` catches close/hide/reopen of the + /// **same** persistent buffer — which a buffer id alone cannot + /// distinguish — without putting a `WindowId` on the wire. PanelPointer { /// Which frontend produced the gesture (untrusted, as above). frontend_id: FrontendId, @@ -498,6 +504,8 @@ pub enum FrontendEvent { geometry_epoch: u64, /// Presentation identity this gesture addresses. panel_epoch: u64, + /// Buffer the frontend believed the panel was displaying. + buffer_id: crate::BufferId, /// Cell the pointer is over, within the declared panel grid. coord: CellCoord, /// Which gesture step this is. diff --git a/pmacs-protocol/src/panel.rs b/pmacs-protocol/src/panel.rs index d6e19d7..e571ca4 100644 --- a/pmacs-protocol/src/panel.rs +++ b/pmacs-protocol/src/panel.rs @@ -22,7 +22,7 @@ use crate::wire_grid::{ /// /// Identical to the terminal bound: it is the transport-safety limit, /// not a PTY policy, so both messages answer to it. -pub const MAX_PANEL_VISIBLE_CELLS: usize = 262_144; +pub const MAX_PANEL_VISIBLE_CELLS: usize = crate::wire_grid::MAX_WIRE_GRID_VISIBLE_CELLS; /// Bounds a panel frame enforces on its cell grid. /// diff --git a/pmacs-protocol/src/terminal.rs b/pmacs-protocol/src/terminal.rs index b1b0016..8f7f507 100644 --- a/pmacs-protocol/src/terminal.rs +++ b/pmacs-protocol/src/terminal.rs @@ -37,10 +37,20 @@ pub const MAX_TERMINAL_COLS: u16 = 512; /// Maximum visible terminal cells accepted at creation, resize, or on /// the wire. -pub const MAX_TERMINAL_VISIBLE_CELLS: usize = 262_144; +/// +/// 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. -pub const MAX_TERMINAL_GRAPHEME_BYTES: usize = 256; +/// +/// 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; @@ -56,7 +66,7 @@ pub const MAX_TERMINAL_METADATA_BYTES: usize = 1_024; /// 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 = 8 * 1024 * 1024; +pub const MAX_TERMINAL_FRAME_GLYPH_BYTES: usize = crate::wire_grid::MAX_WIRE_GRID_GLYPH_BYTES; // --------------------------------------------------------------------------- // Payload types diff --git a/pmacs-protocol/src/wire_grid.rs b/pmacs-protocol/src/wire_grid.rs index 1ff40c9..a9449df 100644 --- a/pmacs-protocol/src/wire_grid.rs +++ b/pmacs-protocol/src/wire_grid.rs @@ -39,6 +39,14 @@ pub const MAX_WIRE_GRID_GLYPH_BYTES: usize = 8 * 1024 * 1024; /// Per-cell grapheme-cluster byte ceiling shared by every wire grid. pub const MAX_WIRE_GRID_GRAPHEME_BYTES: usize = 256; +/// Visible-cell ceiling shared by every wire grid. +/// +/// This is the transport-safety bound, not a per-message policy: it is +/// what keeps `rows * cols * per-cell` inside the transport frame limit, +/// so both the terminal and the panel answer to it even though they +/// carry different per-axis caps. +pub const MAX_WIRE_GRID_VISIBLE_CELLS: usize = 262_144; + /// Bounds a particular wire grid enforces. /// /// `max_rows` / `max_cols` are per-message policy. `max_visible_cells` diff --git a/tests/bottom_panel_stage2b_protocol_acceptance.rs b/tests/bottom_panel_stage2b_protocol_acceptance.rs index 91a8d48..c3ec239 100644 --- a/tests/bottom_panel_stage2b_protocol_acceptance.rs +++ b/tests/bottom_panel_stage2b_protocol_acceptance.rs @@ -5,12 +5,16 @@ //! projection, the epoch state machine, and the GPU band are later //! slices of this stage and are not exercised here. -use pmacs_protocol::cell::{Cell, CellCoord, CellSize, Glyph, Style}; +use pmacs_protocol::cell::{Cell, CellCoord, CellSize, Color, Glyph, Style, UnderlineStyle}; use pmacs_protocol::message::{FrontendEvent, InstanceMessage, Modifiers, MouseButton, MouseKind}; -use pmacs_protocol::panel::{PanelFrame, PanelFrameError, PanelFramePayload}; +use pmacs_protocol::panel::{ + MAX_PANEL_VISIBLE_CELLS, PanelFrame, PanelFrameError, PanelFramePayload, +}; use pmacs_protocol::terminal::{ MAX_TERMINAL_COLS, TerminalFrame, TerminalFrameError, TerminalProcessState, }; +use pmacs_protocol::transport::MAX_FRAME_BYTES; +use pmacs_protocol::wire_grid::{MAX_WIRE_GRID_GLYPH_BYTES, MAX_WIRE_GRID_GRAPHEME_BYTES}; use pmacs_protocol::{BufferId, FrontendId, PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS}; fn cell(ch: char) -> Cell { @@ -21,6 +25,43 @@ fn cell(ch: char) -> Cell { } } +/// The style whose postcard encoding is as long as a legal `Style` gets. +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), + } +} + +fn maximal_cell(glyph: Glyph) -> Cell { + Cell { + glyph, + style: maximal_style(), + attachment: None, + } +} + +/// A single-column cluster of exactly `len` UTF-8 bytes. +fn cluster_of_len(len: usize) -> Vec { + assert!((1..=MAX_WIRE_GRID_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); + text.into_bytes() +} + fn panel_frame(rows: u32, cols: u32) -> PanelFrame { PanelFrame { buffer_id: BufferId::from_raw(9), @@ -121,6 +162,7 @@ fn the_three_panel_events_round_trip() { frontend_id: fid, geometry_epoch: 2, panel_epoch: 7, + buffer_id: BufferId::from_raw(21), coord: CellCoord::new(3, 9), kind: MouseKind::Down(MouseButton::Left), mods: Modifiers::default(), @@ -134,6 +176,45 @@ fn the_three_panel_events_round_trip() { } } +#[test] +fn panel_pointer_carries_buffer_id_distinctly_from_panel_epoch() { + // The two fields close different holes and neither subsumes the + // other: `buffer_id` catches an A->B buffer replacement, while + // `panel_epoch` catches close/hide/reopen of the SAME buffer, which + // a buffer id alone cannot see. So each must independently reach the + // wire — a field silently dropped from the encoding would let one of + // those two stale gestures through. + let base = |buffer: u64, panel_epoch: u64| FrontendEvent::PanelPointer { + frontend_id: FrontendId(4), + geometry_epoch: 2, + panel_epoch, + buffer_id: BufferId::from_raw(buffer), + coord: CellCoord::new(1, 1), + kind: MouseKind::Down(MouseButton::Left), + mods: Modifiers::default(), + }; + let encode = |e: &FrontendEvent| postcard::to_allocvec(e).expect("encode"); + + // Same panel epoch, different buffer: must differ on the wire. + assert_ne!(encode(&base(1, 7)), encode(&base(2, 7))); + // Same buffer, different panel epoch: must also differ. + assert_ne!(encode(&base(1, 7)), encode(&base(1, 8))); + + // And both survive decode rather than being defaulted. + let event = base(31, 7); + let decoded: FrontendEvent = postcard::from_bytes(&encode(&event)).expect("decode"); + let FrontendEvent::PanelPointer { + buffer_id, + panel_epoch, + .. + } = decoded + else { + panic!("expected a PanelPointer, got {decoded:?}"); + }; + assert_eq!(buffer_id, BufferId::from_raw(31)); + assert_eq!(panel_epoch, 7); +} + // --------------------------------------------------------------------------- // 37 — byte pins on the previous final variant of each extended enum // --------------------------------------------------------------------------- @@ -201,6 +282,7 @@ fn appending_panel_events_does_not_move_the_previous_final_event_discriminant() frontend_id: fid, geometry_epoch: 1, panel_epoch: 1, + buffer_id: BufferId::from_raw(1), coord: CellCoord::new(0, 0), kind: MouseKind::Down(MouseButton::Left), mods: Modifiers::default(), @@ -349,3 +431,97 @@ fn terminal_frames_are_unchanged_by_the_factoring() { }) )); } + +// --------------------------------------------------------------------------- +// 39 — the transport-safety ratchet +// --------------------------------------------------------------------------- + +/// The largest legal panel frame, plus the same frame one glyph byte over. +/// +/// Deliberately shaped `1 x MAX_PANEL_VISIBLE_CELLS`: a panel carries no +/// per-axis cap, so this is a legal panel geometry a terminal frame +/// cannot express, and it is therefore the worst case the terminal's own +/// ratchet never measured. +fn panel_budget_boundary_frames() -> (PanelFrame, PanelFrame) { + /// Shortest cluster length postcard encodes with a two-byte length + /// prefix, which is what makes a cluster cell maximally expensive. + const WIDE_PREFIX_LEN: usize = 128; + let area = MAX_PANEL_VISIBLE_CELLS; + + // Every cell owes at least one glyph byte; the rest of the budget is + // spent on as many two-byte-prefix clusters as it affords. + let spare = MAX_WIRE_GRID_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(maximal_cell(glyph)); + } + + let cols = u32::try_from(area).expect("area fits u32"); + let exact = PanelFrame { + buffer_id: BufferId::from_raw(u64::MAX), + panel_epoch: u64::MAX, + geometry_epoch: u64::MAX, + size: CellSize::new(1, cols), + cells, + cursor: Some(CellCoord::new(0, cols - 1)), + focused: true, + }; + + let mut over = exact.clone(); + // One more byte of glyph, nothing else changed. + let last = over.cells.len() - 1; + over.cells[last] = maximal_cell(Glyph::Cluster(cluster_of_len(3).into_boxed_slice())); + + (exact, over) +} + +#[test] +fn maximum_legal_panel_frame_encodes_below_the_transport_cap() { + let (exact, over) = panel_budget_boundary_frames(); + assert_eq!(exact.validate(), Ok(())); + + // The fixture must actually sit ON the boundary, or the ratchet + // below measures something smaller than the worst case and would + // stay green while a real maximum frame overran the transport. + 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_WIRE_GRID_GLYPH_BYTES, + "the measured fixture must spend the whole aggregate budget" + ); + + // One byte over is rejected, which is what makes `exact` maximal. + assert!(matches!( + over.validate(), + Err(PanelFrameError::GlyphBudget { .. }) + )); + + let msg = InstanceMessage::PanelFrame(PanelFramePayload::Present(exact)); + let bytes = postcard::to_allocvec(&msg).expect("encode"); + assert!( + bytes.len() < MAX_FRAME_BYTES, + "largest legal panel frame encodes to {} bytes, at or above the \ + {MAX_FRAME_BYTES}-byte transport cap; the aggregate glyph bound no \ + longer keeps panel traffic inside the existing transport limit", + bytes.len() + ); +} From 56301eda02256a8b4191982a6e5e19025519bc59 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 26 Jul 2026 20:02:19 -0400 Subject: [PATCH 04/12] =?UTF-8?q?docs(panel):=20Stage=202=20framing=20rev?= =?UTF-8?q?=205=20=E2=80=94=20the=20three-way=20slice=20of=202B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rev 4 §9 scoped Stage 2B as a single PR: v21 protocol, daemon panel projection, GPU band, and the negotiated `panel_capable` flip. Implementation showed that to be roughly four thousand lines across three crates with three different failure modes. Rev 5 splits it into 2B-1 (the wire layer), 2B-2 (the daemon projection and epoch machine), and 2B-3 (the GPU band and the flip), on the rule that a slice ends where the next thing to build has a different authority. No decision changes. What changes is the allocation: - §7.2 becomes three subsections, and criteria that span a boundary are named in every slice they touch with their half stated, rather than assigned wholesale to one. Parent 39 is the clearest case: its shared-validation and transport-budget halves are wire properties provable in 2B-1, while "the previous valid frame is retained" and "a duplicate does no work" need the epoch machine and are 2B-2. A2B-1 splits the same way — grid exhaustion in 2B-2, the frontend latch in 2B-3. - §9 lists four serial PRs instead of two, each cut from `main`, and states that every slice runs the full gate set rather than the subset its own crate suggests. - §6 records which slice pays the coherence debt. The journey claim belongs to 2B-3 alone: with `panel_capable = false`, a GPU user still gets the Stage 1 non-side fallback after 2A, 2B-1 and 2B-2 have all landed. Three quarters of this stage is preparation. Two things recorded because they are easy to inherit silently: - This revision is retroactive for slice 1. `bottom-panel-stage2b` already carried the v21 protocol layer, written before the revision existed, which inverts framing -> approval -> branch -> implement. The slicing was sound; taking it in code rather than in the document is how a stage's scope drifts without anyone deciding that it should. - 2B-1 and 2B-2 ship dark. The bump advertises a capability whose only distinguishing feature is unreachable until 2B-3, so the arc must not stall between them. Safe for compatibility — appended variants, extended ladder, a v20 peer still negotiates 20 — but a stall should be visible as a decision, not inherited as a default. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011LFvC4FQtux4y32KuevZ7B --- docs/bottom-panel-stage2-framing.md | 212 +++++++++++++++++++++++++--- 1 file changed, 190 insertions(+), 22 deletions(-) diff --git a/docs/bottom-panel-stage2-framing.md b/docs/bottom-panel-stage2-framing.md index 38d94ed..2af1a6d 100644 --- a/docs/bottom-panel-stage2-framing.md +++ b/docs/bottom-panel-stage2-framing.md @@ -1,7 +1,10 @@ # Bottom panel Stage 2 — the GPU panel band (framing) -**Revision 4 — pre-implementation. Ground truth: canonical `main` @ -`ccf29e3`, protocol v20, 2026-07-25.** +**Revision 5 — 2A merged, 2B in progress. Ground truth: canonical +`main` @ `42025e4`, protocol v20 on `main` and v21 on +`bottom-panel-stage2b`, 2026-07-26.** Revisions 1–4 were +pre-implementation; rev 5 records the three-way slice of Stage 2B +(§0.0, §7.2, §9) after its first slice was already built. Stage 1 (#155, merge `e745068`) gave pmacs window placement, window parameters, TUI side windows, the divider, and the adopter `display` @@ -26,7 +29,57 @@ geometries), Q#BP16 (pointer transport), Q#BP17 (fold projection), and ## 0. Revision history -### 0.0 Round 3 (rev 3 → rev 4) — 1 blocking, 1 high, 1 medium, all closed +### 0.0 Rev 4 → rev 5 — the three-way slice of 2B (not a review round) + +This revision changes no decision. It splits one approved +implementation slice into three and reallocates the acceptance +criteria across them. + +- **R5-1 — why.** Rev 4 §9 scoped 2B as a single PR: v21 protocol, + daemon panel projection, GPU band, and the negotiated + `panel_capable` flip. Implementation showed that to be roughly four + thousand lines spanning `pmacs-protocol`, `src/daemon.rs`, and + `pmacs-gpu` — three review surfaces with different failure modes, in + one diff. The same argument that produced 2A/2B applies again one + level down, and it is the argument this arc has already accepted + twice (Lean 4 stages 3a/3b and 4a/4b). +- **R5-2 — the boundary rule.** A slice ends where the next thing to + build has a different *authority*: the wire format, the daemon that + produces frames, and the frontend that paints them. Each slice is + independently reviewable against a subset of the parent criteria, + and each is additive — no slice makes a previously-passing assertion + fail. + **Criteria that span a boundary are named in every slice they touch, + with their half stated**, rather than assigned wholesale to one. The + clearest case is parent 39: its shared-validation and + transport-budget halves are wire properties provable in 2B-1, while + "the previous valid frame is retained" and "a duplicate does no + work" are receiver-state properties that need the epoch machine and + land in 2B-2. +- **R5-3 — this revision is retroactive for slice 1, and that is a + process defect worth recording.** `bottom-panel-stage2b` already + carries the v21 protocol layer (three commits, one review round + closed) written before this revision existed. The workflow is + framing → approval → branch → implement; slice 1 inverted it. The + slicing decision was sound, but it was taken in code and discovered + in the branch rather than proposed in the document, which is exactly + how a stage's scope drifts without anyone deciding that it should. + Rev 5 exists to put the decision back where it belongs before slices + 2 and 3 are written. +- **R5-4 — two of the three slices ship dark, deliberately.** Nothing + in 2B-1 or 2B-2 is reachable by a user: `panel_capable` stays + `false` for every negotiated semantic session until 2B-3, so a v21 + daemon and a v21 GPU frontend negotiate 21 and behave exactly as + they do at v20. This is the same posture 2A took ("seam adoption + that becomes load-bearing in 2B") and it carries the same + obligation: **the version bump advertises a capability whose only + distinguishing feature is unreachable until 2B-3 lands.** That is + safe for compatibility — the variants are appended, the ladder is + extended, and a v20 peer still negotiates 20 — but it means the arc + must not stall between 2B-1 and 2B-3. Recorded here so a stall is + visible as a decision rather than inherited as a default. + +### 0.1 Round 3 (rev 3 → rev 4) — 1 blocking, 1 high, 1 medium, all closed - **R3-1 (blocker).** Rev 3's three-boundary model was right but its call-site table was wrong in five places, and each error was a real @@ -54,7 +107,7 @@ geometries), Q#BP16 (pointer transport), Q#BP17 (fold projection), and `cell.attachment.is_some()` rejection. It is now classified — and **shared**, with the reasoning pinned. -### 0.1 Round 2 (rev 2 → rev 3) — 1 blocking, 2 high, 1 medium, all closed +### 0.2 Round 2 (rev 2 → rev 3) — 1 blocking, 2 high, 1 medium, all closed - **R2-1 (blocker).** Rev 2's "one document-bottom seam" conflated two boundaries that must **diverge** once a panel exists. Several sites it @@ -81,7 +134,7 @@ geometries), Q#BP16 (pointer transport), Q#BP17 (fold projection), and - Both §8 open items are decided (§5.3): `BASE_DIVIDER_HEIGHT = 4.0` at scale 1.0, and `TEXT_TOP` stays unscaled. -### 0.2 Round 1 (rev 1 → rev 2) — 2 blocking, 3 high, 3 revision points, all closed +### 0.3 Round 1 (rev 1 → rev 2) — 2 blocking, 3 high, 3 revision points, all closed - **R1-1 (blocker).** Rev 1 said all 23 census reads route through `primary_document_window`. That contradicts Q#BP14, which routes only @@ -589,11 +642,20 @@ exactly once. - **Section this serves:** `COHERENCE.md` §14, which records the panel primitive as landed for Stage 1 and names "Stage 2 (GPU band) pending its own framing" as the open item. +- **Which slice pays the coherence debt (rev 5).** The journey claim + above is Stage 2B-3's alone. 2A, 2B-1, and 2B-2 close **no** journey + divergence: with `panel_capable = false`, a GPU user still gets the + Stage 1 non-side fallback on steps 7–10 after all three land. Stated + explicitly so no slice's PR can claim the arc's coherence benefit + before the flip earns it — three quarters of this stage is + preparation, and only the last quarter is the improvement. ## 7. Acceptance **Parent criteria 37–55 remain authoritative and are not replaced.** -This section maps them to the two slices and adds only refinements. +This section maps them to the four slices — 2A, then 2B-1/2B-2/2B-3 — +and adds only refinements. A criterion that spans a slice boundary is +named in each slice it touches, with its half stated. ### 7.1 Stage 2A — classified census routing + painter extraction @@ -638,18 +700,80 @@ Refinements 2A adds: scroll state**. Byte-identical cells alone would not catch a clamp that silently moved to the wrong window. -### 7.2 Stage 2B — v21 protocol + daemon projection + GPU band +### 7.2 Stage 2B — v21 protocol, daemon projection, GPU band -Parent criteria that apply in full: **37, 38, 39, 40, 41, 45, 46, 47, -48, 49, 50, 51, 53, 54, 55**, plus re-assertion of **42, 43, 44, and -52** **through the actual negotiated capability flip** rather than +Stage 2B as a whole owns parent criteria **37, 38, 39, 40, 41, 45, 46, +47, 48, 49, 50, 51, 53, 54, 55**, plus re-assertion of **42, 43, 44, +and 52** **through the actual negotiated capability flip** rather than through a test-only panel-capable semantic view. 52's 2B form is the production one: a real semantic frontend with `fold_projection = false` displaying a folded buffer in a panel shows every source line, and the panel path never reaches `fold_map_for_window`. -Refinements 2B adds: +Per §0.0 R5-1 those land across three slices. Each slice's own gate run +is the standing suite plus §9's named acceptance suites; **only 2B-3 +changes what a user sees.** +#### 7.2.1 Slice 2B-1 — the v21 wire layer + +**Authority: `pmacs-protocol`.** The four wire shapes Q#BP9 names, the +version bump, and the shared cell-grid validator. No producer, no +consumer, no capability change. + +- **37, in full.** `PanelFrame` round-trips including `panel_epoch` and + `geometry_epoch`, with independent byte pins on the previous final + `InstanceMessage::InitialTargetResult` and + `FrontendEvent::TerminalPointer` variants. **Both pins must be + falsified by revert**, not merely observed passing: a byte pin that + never saw the shift it exists to catch pins nothing. +- **39, the wire half only.** Shared cell/topology/glyph/area + validation; an area-bounded panel wider than 512 columns is accepted + while a terminal frame retains its 512-column PTY cap; the maximum + legal panel encoding stays below the transport limit. **The ratchet's + fixture must be shown to spend the whole aggregate glyph budget** — + otherwise it measures something smaller than the worst case and the + bound it proves is not the bound that matters. The worst case is + `1 × MAX_PANEL_VISIBLE_CELLS`, a legal panel geometry no terminal can + express, so the terminal's own ratchet has never covered it. + **39's receiver half — atomic rejection with retention of the + previous valid frame, and a duplicate doing no work — is 2B-2.** +- **The version ladder moves with the bump.** `PROTOCOL_VERSION` + becomes 21, `SUPPORTED_PROTOCOL_VERSIONS` accepts `6..=21` and + rejects 22, and any test whose *name* encodes the old number is + renamed. A ladder pin that passes across a bump was not pinning the + version. +- **Shared bounds are aliased, not duplicated.** Every constant the + terminal screen and the panel validator both enforce is one + definition with the other as an alias, so truncation and validation + cannot drift apart. +- **Not in this slice:** the daemon arm that drops panel events from a + grid session is exhaustiveness bookkeeping the bump forces, not + projection. It asserts only that a grid session's panel declaration + is dropped rather than trusted. + +#### 7.2.2 Slice 2B-2 — the daemon panel projection and epoch machine + +**Authority: `src/daemon.rs`.** Produces `PanelFrame`; derives the +grid; owns stale-event rejection. Exercised through a **test-only** +panel-capable semantic view — `panel_capable` stays `false` in +production negotiation until 2B-3. + +- **38** (open → replace buffer → hidden by a tiny frame → reappear → + close, with authoritative `Absent` and a new epoch on + replacement/reappearance), **40** (first open at a non-80×24 frame + stays absent until real `FrontendCellGeometry` arrives, never + consulting the 24×80 attach placeholder), **49**, **50**, **51**, + **53**. +- **39's receiver half**, per §7.2.1. +- **41, the daemon half:** the daemon alone derives the grid; an older + retained frame neither paints nor accepts input after a new + `geometry_epoch` until a matching `Present` arrives; row-clamping + preserves the stored request; zero, non-finite, and non-positive + metric inputs fail closed to zero usable geometry. *The pixel→cell + formula and its call sites are 2B-3.* +- **42, 43, 44, 45, 52** in their projection form, through the + test-only panel-capable view. Their production re-assertion through + the real flip is 2B-3. - **A2B-1.** The epoch state machine of §3.1 is pinned row by row, including the lower-epoch-identical-data rejection and the same-epoch-different-total rejection, and each row's @@ -660,7 +784,33 @@ Refinements 2B adds: hides (a subsequent real resize must not paint a stale-geometry panel), and a frontend that exhausts latches — a retained `Present` whose epoch still matches cannot make the band reappear, and only a - fresh session clears the latch. + fresh session clears the latch. **A2B-1's grid-exhaustion half is + 2B-2; its frontend-latch half needs a real frontend and is 2B-3.** + Both halves are named here so neither is lost at the seam. + +#### 7.2.3 Slice 2B-3 — the GPU band and the capability flip + +**Authority: `pmacs-gpu`, plus the negotiation rule.** This is the only +slice a user can observe, and the only one that closes the journey +divergence in §6. + +- **46** (band + divider shrink the document text area by exactly their + pixel height; carets, hits, and scroll geometry respect the reduced + area), **47** (divider drag, `window.min-height`, `RowResize` hover, + and the stalled-writer tail-coalescing), **48** (`PanelPointer` + driving selection, terminal mouse reporting, and click-to-focus + without disturbing the document mirror), **54** (the + `--headless-probe` run: one real daemon, real PTY, real wgpu, through + a panel-hosted terminal), **55**. +- **41, the GPU half:** the pixel→cell conversion pinned at fractional + widths and heights, and geometry refresh on window resize, font + change, and scale change. +- **42, 43, 44, 45, 52 re-asserted through the production flip**, not + the test-only view. This is the point of the re-assertion: a + test-only panel-capable view can be constructed wrongly and agree + with itself, so the production negotiation path must carry the same + assertions. +- **A2B-1's frontend-latch half**, per §7.2.2. - **A2B-2.** A font or scale change that leaves `CellSize` **identical** still produces a new `geometry_epoch`, and the older `PanelFrame` neither paints nor hit-tests until a matching `Present` arrives. This @@ -704,19 +854,37 @@ fixes. It belongs to a spacing-system change of its own. ## 9. Slices, branches, and gates -Per review round 1: **two serial implementation PRs**, each a named -slice under this framing so one-feature/one-branch/one-PR holds. **2A -lands before 2B branches** — not stacked. +Per review round 1 and §0.0 R5-1: **four serial implementation PRs**, +each a named slice under this framing so one-feature/one-branch/one-PR +holds. **Each slice lands before the next branches** — none are +stacked, and each is cut from `main`. -- **Stage 2A** — classified census routing + per-window painter - extraction. Branch `bottom-panel-stage2a`. No protocol change. The - three-boundary GPU split is **2B**, not 2A: it is only observable - once a band can be installed. -- **Stage 2B** — v21 protocol, daemon panel projection, GPU band, and - the negotiated `panel_capable` flip. Branch `bottom-panel-stage2b`, - cut from `main` after 2A merges. Repeats 2A's relevant census +- **Stage 2A — MERGED as #177** (`main` @ `0a3fcd1`). Classified census + routing + per-window painter extraction. Branch + `bottom-panel-stage2a`. No protocol change. The three-boundary GPU + split is **2B-3**, not 2A: it is only observable once a band can be + installed. +- **Stage 2B-1 — the v21 wire layer.** Branch `bottom-panel-stage2b`. + The four wire shapes, the version bump, the shared cell-grid + validator, and the version-ladder move. **No producer, no consumer, + no capability change** — `panel_capable` stays `false`. +- **Stage 2B-2 — the daemon panel projection and epoch machine.** Cut + from `main` after 2B-1 merges. Produces `PanelFrame` and owns + stale-event rejection, exercised through a **test-only** + panel-capable semantic view. Still no production flip. +- **Stage 2B-3 — the GPU band and the negotiated flip.** Cut from + `main` after 2B-2 merges. The three-boundary text-area split, the + divider, pointer routing, and `panel_capable = true` for a v21+ + negotiated authenticated semantic session. **This is the slice that + changes what a user sees**, and it repeats 2A's and 2B-2's relevant assertions through the real capability flip. +**Each slice runs the full gate set below, not a subset of it.** A +slice that touches only `pmacs-protocol` still runs the GPU and vterm +suites: the shared validator and the wire enums are exactly the kind of +change whose breakage surfaces in a consumer rather than at its own +definition. + Gates for both: the standing suite from `CLAUDE.md`, plus the **touched acceptance suites named explicitly** — the standing rule is to run the suites a change touches, and "standing suite" does not name them: From b9123c2f6da56f506c23333932bef2a22eaea14d Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 27 Jul 2026 22:39:15 -0400 Subject: [PATCH 05/12] test(protocol): advance touched-suite ratchets to v21 Make the statusline and Vterm Stage 3 acceptance suites track the bottom-panel v21 bump, including the real daemon and headless GPU probe. Record the full gate result and the unrelated stale directory-target assertion reproduced on canonical main. --- docs/active-work.md | 34 +++++++++++++++++++++++-- tests/statusline_segments_acceptance.rs | 15 ++++++----- tests/vterm_stage3_acceptance.rs | 6 ++--- 3 files changed, 43 insertions(+), 12 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 6b03343..fb6090a 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -306,12 +306,15 @@ If it does not, stop and repair the remote/fetch configuration. never been enforced. Any CI job that compiles the `crdt` targets has to fix them first or it will be red on arrival. -## Bottom-panel lane (Arc 7) — 2B-1 IN GATING +## Bottom-panel lane (Arc 7) — 2B-1 GATED; PR HELD ON MAIN RATCHET Stage 1, the Stage 2 framing, and Stage 2A are on `main`. Framing revision 5's three-way split of 2B was explicitly approved on 2026-07-27. **Stage 2B-1 is implemented, integrated with canonical -`main`, and awaiting its full gate result before a PR is opened.** +`main`, and its full matrix has run. No PR is open: one deterministic +touched-suite assertion is stale on canonical `main` after #182 and +must be corrected separately before this branch can claim a green +gate.** - **Stage 2B-1 branch:** `bottom-panel-stage2b`, based on `githubsucks/main` @ `c2d56ff` by merge because review had begun. @@ -327,6 +330,33 @@ revision 5's three-way split of 2B was explicitly approved on `9b364ad`: `PanelFrame` now identifies its buffer, the transport ratchet covers the actual attach path rather than a detached codec assertion, and shared grid bounds have one validator. +- **The full gate found and corrected two 2B-1 omissions:** the + statusline version ladder still pinned v20/rejected v21, and Vterm + Stage 3 pinned v20 both structurally and in its real headless probe. + Those ratchets now expect v21 and, where applicable, reject v22. +- **Green evidence on the corrected tree:** formatting and strict + workspace Clippy; library **1,849 passed + 3 ignored default** and + **2,034 passed + 4 ignored CRDT**; bottom-panel Stage 1 / 2A / 2B-1 + **46 / 17 / 15**; folding Stage 2 **48**; GPU font **11**; statusline + **8 CRDT**; m11_5 semantic **2 CRDT**; Vterm Stages 1 / 2 / 3 + **10 / 6 / 9 CRDT**, with Stage 3's real daemon + PTY + wgpu probe + required and green; M4 **121 passed + 3 ignored + 1 filtered**; + required GPU **202**; and the isolated-config, one-invocation full + workspace sweep green on rerun. Its first pass hit the known + completion-before-supersede race in + `m8_1_acceptance::read_dir_supersede_cancels_in_flight_predecessor`; + the exact pin, its full 10-test target, and the complete workspace + rerun all passed. +- **Sole deterministic red — reproduced unchanged on canonical + `main`:** `gpu_initial_target_acceptance` is **13/14**, failing + `malformed_or_unloadable_targets_fail_closed_without_poisoning_the_daemon`. + Its invalid-target table still includes `"."` and demands only a + failure result, while #182 deliberately made a directory target valid + and therefore sends the result plus snapshot. The identical failure + reproduces at the tree-identical #182 head `7a3a55d`; 2B-1 changes no + initial-target behavior. Correct this as a Journey/GPU-initial-target + ratchet side quest on `main`, then integrate it here and rerun that + touched gate before opening the 2B-1 PR. - **Next ordering is fixed:** 2B-2 branches from `main` only after 2B-1 lands; 2B-3 branches only after 2B-2 lands. The daemon epoch machine belongs to 2B-2; the GPU band and negotiated capability flip belong diff --git a/tests/statusline_segments_acceptance.rs b/tests/statusline_segments_acceptance.rs index 120ca23..2f5cfd9 100644 --- a/tests/statusline_segments_acceptance.rs +++ b/tests/statusline_segments_acceptance.rs @@ -786,15 +786,16 @@ fn a12_builtin_lsp_provider_tracks_real_attachment_and_unknown_label() { #[test] fn a13_17_26_protocol_semantic_init_late_join_and_version_cost() { // Vterm Stage 3 appended the terminal family as v19; GPU initial targets - // appended the semantic bootstrap family as v20. This acceptance owns the - // STATUSLINE variant's placement and gate, so it tracks the current wire - // version rather than pinning 18: the v18 floor it actually cares about is - // asserted below and in `peer_accepts_statusline_message`. - assert_eq!(PROTOCOL_VERSION, 20); - for version in 6..=20 { + // appended the semantic bootstrap family as v20; bottom-panel Stage 2B-1 + // appended the panel family as v21. This acceptance owns the STATUSLINE + // variant's placement and gate, so it tracks the current wire version + // rather than pinning 18: the v18 floor it actually cares about is asserted + // below and in `peer_accepts_statusline_message`. + assert_eq!(PROTOCOL_VERSION, 21); + for version in 6..=21 { assert!(is_supported_protocol_version(version)); } - assert!(!is_supported_protocol_version(21)); + assert!(!is_supported_protocol_version(22)); let sample = InstanceMessage::StatuslineSegments { buffer_id: BufferId::from_raw(9), left: vec![StatuslineSegment { diff --git a/tests/vterm_stage3_acceptance.rs b/tests/vterm_stage3_acceptance.rs index b7c2e9c..f729aba 100644 --- a/tests/vterm_stage3_acceptance.rs +++ b/tests/vterm_stage3_acceptance.rs @@ -716,8 +716,8 @@ fn a37_real_daemon_real_pty_and_headless_gpu_render_one_terminal_session() { assert_eq!( facts.get("server_protocol_version").copied(), - Some("20"), - "the real daemon negotiated v20 with the real client: {text}" + Some("21"), + "the real daemon negotiated v21 with the real client: {text}" ); assert_eq!( facts.get("entered_terminal_mode").copied(), @@ -846,7 +846,7 @@ fn terminal_mode_keeps_reporting_presence_so_peers_drop_the_stale_caret() { panic!("timed out waiting for {what}"); } - assert_eq!(PROTOCOL_VERSION, 20); + assert_eq!(PROTOCOL_VERSION, 21); let daemon = common::daemon::TestDaemon::spawn_with_env_and_init( &[ ("PMACS_INSTANCE_SEMANTIC_RENDER", "1"), From 17867ace0e482ce871e29a5c31fd720d3ad5b5cd Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 28 Jul 2026 12:44:27 -0400 Subject: [PATCH 06/12] docs(active-work): record integrated 2B-1 gates Record the complete post-#183 gate matrix for bottom-panel Stage 2B-1 at c8895a8 and mark the lane ready to open for review. Retain the required-GPU first-pass classification: one unrelated math render assertion failed, passed immediately in isolated single-threaded execution, and the mandatory complete 202-test rerun passed. --- docs/active-work.md | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 871bbc1..429d15e 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -305,20 +305,20 @@ If it does not, stop and repair the remote/fetch configuration. never been enforced. Any CI job that compiles the `crdt` targets has to fix them first or it will be red on arrival. -## Bottom-panel lane (Arc 7) — 2B-1 INTEGRATED; FULL GATES PENDING +## Bottom-panel lane (Arc 7) — 2B-1 GATED; PR READY Stage 1, the Stage 2 framing, and Stage 2A are on `main`. Framing revision 5's three-way split of 2B was explicitly approved on 2026-07-27. **Stage 2B-1 is implemented, integrated with canonical -`main` @ `7fd646d`, and has no remaining dependency. Its full matrix is -being rerun on the integrated tree before a PR opens.** +`main` @ `7fd646d`, and fully gated at `c8895a8`; it has no remaining +dependency. The branch is ready to push and open for review.** - **Stage 2B-1 branch:** `bottom-panel-stage2b`, based on `githubsucks/main` @ `7fd646d` by merge because review had begun. Recovery: `git fetch githubsucks && git checkout bottom-panel-stage2b`. Everything described through the integration - checkpoint is committed and pushed; nothing depends on a worktree or - `/tmp`. No PR exists yet. + and gate checkpoint is committed; nothing depends on a worktree or + `/tmp`. - **Ships only the v21 wire layer:** the four wire shapes, version bump, shared cell-grid validator, and version-ladder move. It has no producer, consumer, or capability change; `panel_capable` stays @@ -348,8 +348,23 @@ being rerun on the integrated tree before a PR opens.** corrected `gpu_initial_target_acceptance` through the public `pmacs --gpu .` path, consumed the asynchronous dired snapshot, and retained the managed daemon before the wait so failure cleanup remains - effective. The code integration auto-composed; the full matrix still - has to prove the combined tree. + effective. The code integration auto-composed. +- **The complete post-integration gate is green at `c8895a8`:** + formatting; strict workspace Clippy; library **1,849 passed + 3 + ignored default** and **2,034 passed + 4 ignored CRDT**; bottom-panel + Stage 1 / 2A / 2B-1 **46 / 17 / 15**; folding Stage 2 **48**; GPU font + **11**; statusline **8 CRDT**; m11_5 semantic **2 CRDT**; GPU initial + target and invocation **15 / 15 CRDT**; Vterm Stages 1 / 2 / 3 + **10 / 6 / 9 CRDT**, including the required real daemon + PTY + wgpu + probe; M4 **121 passed + 3 ignored + 1 filtered**; required GPU + **202/202**; the isolated-config, one-invocation full workspace sweep; + and `git diff --check`. + - The first required-GPU pass was **201/202** on + `a_fraction_draws_rule_pixels_between_its_operand_rows`, a rendering + test structurally outside this lane's protocol-only GPU diff. The + exact test passed immediately in isolation with one test thread, and + the mandatory complete rerun passed **202/202**. This is retained as + classified gate evidence, not erased as a clean first pass. - **Next ordering is fixed:** 2B-2 branches from `main` only after 2B-1 lands; 2B-3 branches only after 2B-2 lands. The daemon epoch machine belongs to 2B-2; the GPU band and negotiated capability flip belong From f82d91ed3a1c6080957960c04accdd7b8c9bb858 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 28 Jul 2026 12:51:42 -0400 Subject: [PATCH 07/12] docs(active-work): record bottom-panel 2B-1 PR Record PR #184 as open for review and keep the explicit no-merge hold in the portable lane state. --- docs/active-work.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 429d15e..b419173 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -305,20 +305,21 @@ If it does not, stop and repair the remote/fetch configuration. never been enforced. Any CI job that compiles the `crdt` targets has to fix them first or it will be red on arrival. -## Bottom-panel lane (Arc 7) — 2B-1 GATED; PR READY +## Bottom-panel lane (Arc 7) — 2B-1 GATED; PR #184 OPEN FOR REVIEW Stage 1, the Stage 2 framing, and Stage 2A are on `main`. Framing revision 5's three-way split of 2B was explicitly approved on 2026-07-27. **Stage 2B-1 is implemented, integrated with canonical `main` @ `7fd646d`, and fully gated at `c8895a8`; it has no remaining -dependency. The branch is ready to push and open for review.** +dependency. PR #184 is open and must not merge before user review.** - **Stage 2B-1 branch:** `bottom-panel-stage2b`, based on `githubsucks/main` @ `7fd646d` by merge because review had begun. Recovery: `git fetch githubsucks && git checkout bottom-panel-stage2b`. Everything described through the integration - and gate checkpoint is committed; nothing depends on a worktree or - `/tmp`. + and gate checkpoint is committed and pushed; nothing depends on a + worktree or `/tmp`. PR #184: + . - **Ships only the v21 wire layer:** the four wire shapes, version bump, shared cell-grid validator, and version-ladder move. It has no producer, consumer, or capability change; `panel_capable` stays From ab7c2079041a0f37d9ae657afa76f99b469350e3 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 28 Jul 2026 14:08:17 -0400 Subject: [PATCH 08/12] Keep the v21 panel wire dark for v20 clients Reserve the additive v21 panel schema without advertising it in the server-first production handshake. Pin a real shipped-v20 client attach, make the two aggregate-budget ratchets exactly one byte over, and update the framing, coherence audit, handoff, and volatile lane record. --- COHERENCE.md | 10 +- docs/active-work.md | 48 +++++-- docs/agent-handoff.md | 52 +++++--- docs/bottom-panel-stage2-framing.md | 126 +++++++++++++----- pmacs-protocol/src/lib.rs | 23 ++-- pmacs-protocol/src/message.rs | 24 +++- pmacs-protocol/src/terminal.rs | 18 ++- src/daemon.rs | 11 +- ...ottom_panel_stage2b_protocol_acceptance.rs | 80 ++++++++++- tests/common/daemon.rs | 4 +- tests/gpu_invocation_acceptance.rs | 12 +- tests/m5_5_acceptance.rs | 22 +-- tests/m5_7_acceptance.rs | 6 +- tests/m5_perf_acceptance.rs | 8 +- tests/mode_system_wiring_acceptance.rs | 8 +- tests/vterm_stage3_acceptance.rs | 4 +- 16 files changed, 325 insertions(+), 131 deletions(-) diff --git a/COHERENCE.md b/COHERENCE.md index 758b3e2..e997614 100644 --- a/COHERENCE.md +++ b/COHERENCE.md @@ -109,7 +109,7 @@ remain open to them. | 13 | Package lifecycle UX | **Resolution without lifecycle** | Mature resolver/lockfile; init-only install; no uninstall/disable/search | | 14 | Workbench primitives | **Partial (best trajectory)** | Listview is a real shared primitive; bottom panel landed (#155) | | 15 | Contextual affordances | **Weak** | Right-click menu only; code actions apply first-blindly; no git integration at all | -| 16 | Semantic frontend | **Strong** | v6..=v20 negotiated protocol; degradation practiced; TUI/GPU share the model | +| 16 | Semantic frontend | **Strong** | v6..=v21 schema support; production attach remains v20 during the dark panel slice; degradation practiced | | 17 | Distribution | **Missing** | CI is test-only; no binaries, channels, checksums, or update path | | 18 | Onboarding | **Missing** | No welcome, no tutorial; `C-h` deletes a word; `M-x` is the only door in | | 19 | Coherence acceptance tests | **Started** | `tests/journey_acceptance.rs` exists (steps 2, 3, 5); the other five scenarios are still unwritten | @@ -1340,9 +1340,13 @@ facto privileged implementation. **Grade: strong — the healthiest concern in this document, and most of its asks are already practiced.** -- Versioned, negotiated protocol `SUPPORTED=[6..=20]` with deliberate +- Versioned protocol schema `SUPPORTED=[6..=21]` with deliberate encoding-breaking bumps, both-frontends support required per bump, - and byte-pin discipline for appended variants (handoff §4). + and byte-pin discipline for appended variants (handoff §4). The v21 + bottom-panel family is reserved but dark in Stage 2B-1: because + `Hello` is server-first, the production daemon still advertises v20 + so shipped v20 clients remain attachable; compatible v21 activation + belongs to Stage 2B-3. - Two genuine frontends share the conceptual model; CRDT concurrent editing with presence across them; remote attach + reconnect. - **Graceful per-frontend degradation is practiced, not aspirational**: diff --git a/docs/active-work.md b/docs/active-work.md index b419173..837cd56 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -305,13 +305,15 @@ If it does not, stop and repair the remote/fetch configuration. never been enforced. Any CI job that compiles the `crdt` targets has to fix them first or it will be red on arrival. -## Bottom-panel lane (Arc 7) — 2B-1 GATED; PR #184 OPEN FOR REVIEW +## Bottom-panel lane (Arc 7) — 2B-1 REVIEW FIX IN PROGRESS; PR #184 OPEN Stage 1, the Stage 2 framing, and Stage 2A are on `main`. Framing revision 5's three-way split of 2B was explicitly approved on -2026-07-27. **Stage 2B-1 is implemented, integrated with canonical -`main` @ `7fd646d`, and fully gated at `c8895a8`; it has no remaining -dependency. PR #184 is open and must not merge before user review.** +2026-07-27; revision 6 records PR #184's review correction. **Stage +2B-1 is implemented and integrated with canonical `main` @ `7fd646d`. +The previous head was fully gated at `c8895a8`, but review round 2 found +four issues and the corrected head must run the full gate again. PR +#184 is open and must not merge before user review.** - **Stage 2B-1 branch:** `bottom-panel-stage2b`, based on `githubsucks/main` @ `7fd646d` by merge because review had begun. @@ -320,14 +322,27 @@ dependency. PR #184 is open and must not merge before user review.** and gate checkpoint is committed and pushed; nothing depends on a worktree or `/tmp`. PR #184: . -- **Ships only the v21 wire layer:** the four wire shapes, version bump, - shared cell-grid validator, and version-ladder move. It has no - producer, consumer, or capability change; `panel_capable` stays - `false`, so this slice changes no user-visible journey grade. +- **Ships only the reserved v21 wire layer:** the four wire shapes, + schema version, shared cell-grid validator, and accepted-version + ladder move. The production daemon continues advertising v20 because + its `Hello` is server-first; v21 activation belongs to 2B-3. This + slice has no producer, consumer, or capability change; + `panel_capable` stays `false`, so it changes no user-visible journey + grade and existing v20 clients remain attachable. - **Review round 1 closed:** two P1s and one P2, all corrected at `9b364ad`: `PanelFrame` now identifies its buffer, the transport ratchet covers the actual attach path rather than a detached codec assertion, and shared grid bounds have one validator. +- **Review round 2 found four issues; fixes are in progress:** the + server-first `Hello` made the advertised v20↔v21 compatibility + one-way; `COHERENCE.md` and `docs/agent-handoff.md` still named only + v20 schema support; framing §9 named a nonexistent aggregate 2B + suite instead of the three exact 2B slice suites; and the panel plus + copied terminal "one byte over" fixtures were actually two bytes + over. The correction keeps production advertisement at v20, adds a + real-daemon existing-v20-client acceptance, updates all three durable + records, names the exact slice suites, and asserts both rejecting + fixtures are exactly `limit + 1`. - **The full gate found and corrected two 2B-1 omissions:** the statusline version ladder still pinned v20/rejected v21, and Vterm Stage 3 pinned v20 both structurally and in its real headless probe. @@ -350,7 +365,7 @@ dependency. PR #184 is open and must not merge before user review.** `pmacs --gpu .` path, consumed the asynchronous dired snapshot, and retained the managed daemon before the wait so failure cleanup remains effective. The code integration auto-composed. -- **The complete post-integration gate is green at `c8895a8`:** +- **The previous complete post-integration gate was green at `c8895a8`:** formatting; strict workspace Clippy; library **1,849 passed + 3 ignored default** and **2,034 passed + 4 ignored CRDT**; bottom-panel Stage 1 / 2A / 2B-1 **46 / 17 / 15**; folding Stage 2 **48**; GPU font @@ -438,8 +453,9 @@ dependency. PR #184 is open and must not merge before user review.** `docs/agent-handoff.md` §1; the two round lessons are in §5. - Landed-docs follow-up merged as **#156** (`main` @ `d152120`, 2026-07-25). -- **Stage 2 framing: `docs/bottom-panel-stage2-framing.md` revision 5** - is commit `56301ed` on branch `githubsucks/bottom-panel-stage2b`, +- **Stage 2 framing: `docs/bottom-panel-stage2-framing.md` revision 6** + is on branch `githubsucks/bottom-panel-stage2b` (revision 5 is commit + `56301ed` there), worktree `../pmacs-bp-stage2b`. Revisions 1–4 remain on `githubsucks/bottom-panel-stage2-framing` (head `4fbd47f`, four framing commits, revision 4 at `49757e5`). Round 1 closed 2 blocking + @@ -447,7 +463,9 @@ dependency. PR #184 is open and must not merge before user review.** round 2 closed 1 blocking + 2 high + 1 medium and decided both open items; round 3 closed 1 blocking + 1 high + 1 medium. No open items remain. Revision 5 adds no decision; it records the approved - 2B-1/2B-2/2B-3 implementation split. The + 2B-1/2B-2/2B-3 implementation split. Revision 6 corrects the + server-first compatibility contract, durable protocol claims, exact + acceptance-suite names, and `limit + 1` fixture. The parent framing `docs/bottom-panel-framing.md` (rev 4) remains authoritative, **including its acceptance criteria 37–55**. - Retained, carrying nothing unmerged: branch `bottom-panel` and worktree @@ -456,12 +474,14 @@ dependency. PR #184 is open and must not merge before user review.** before the next branches: **2A** = classified §1.3 census routing + `paint_frame` per-window painter extraction (with the active-window auto-scroll preparation), no - protocol change; **2B-1** = protocol **v21** + protocol change; **2B-1** = reserved protocol schema **v21**, with + production advertisement held at v20, (`InstanceMessage::PanelFrame` plus `FrontendEvent::{FrontendCellGeometry, PanelResizeRows, PanelPointer}`, gated both directions, each extended enum byte-pinned on its own previous final variant); **2B-2** = daemon panel projection and epoch - machine; **2B-3** = the GPU band and negotiated `panel_capable` flip. + machine; **2B-3** = compatible v21 activation, the GPU band, and the + negotiated `panel_capable` flip. Stage 3 is the adopter default flip. - **Correction — this entry previously mis-stated the census contract.** It is **not** "route every consumer through `primary_document_window`". diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index d2e8ee2..bc6f440 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -1,7 +1,9 @@ # Agent handoff — cross-machine continuity -**Last updated: 2026-07-28, after the Journey/GPU directory-target -ratchet (#183), following Journey Stage 1a (#182), which made directory +**Last updated: 2026-07-28, during bottom-panel Stage 2B-1 PR #184 +review; the canonical landed base remains the Journey/GPU +directory-target ratchet (#183), following Journey Stage 1a (#182), +which made directory startup one coherent local/daemon/GPU path and incorporated the terminal configuration + copy mode landed-doc work (#180); following terminal copy mode (#178) — `C-c C-t` @@ -58,9 +60,11 @@ commands, read `docs/active-work.md` immediately after this file. #165, the GPU terminal input fix #166, Lean 4 Stage 2 #161, the dired framing #164, COHERENCE.md #163, find-file #162, Lean 4 Stage 1 #160, minimap blank-slab #159, bottom-panel Stage 1 #155). Protocol unchanged - at **v20** — bottom-panel Stage 2A deliberately carries no wire change; - v21 arrives with Stage 2B. The bullets below describe the arcs in their - own terms; this line is the head-of-`main` anchor. + at **v20** — bottom-panel Stage 2A deliberately carries no wire change. + The in-review Stage 2B-1 reserves the v21 schema but keeps the + server-first production `Hello` at v20; compatible activation belongs + to Stage 2B-3. The bullets below describe the arcs in their own terms; + this line is the head-of-`main` anchor. - **`COHERENCE.md` is now required reading and a required framing input — #163.** It carries the product-coherence thesis, an audited scorecard, per-concern gaps, and §20's priority order, and it is the @@ -395,9 +399,14 @@ commands, read `docs/active-work.md` immediately after this file. unchanged, which is the additivity gate for the `read_dir` change; M4 121; required GPU 155; isolated-`XDG_CONFIG_HOME` workspace sweep 3,205 across 93 suites. 15 claims bite-verified. -- Protocol **v20** (`SUPPORTED=[6..=20]`; v16 = `ThemeFacts`, v17 = - `FontFacts`, v18 = `StatuslineSegments`, v19 = terminal frames/events, v20 = - the GPU initial-target semantic bootstrap family). +- Canonical `main` is protocol **v20** (`SUPPORTED=[6..=20]`; v16 = + `ThemeFacts`, v17 = `FontFacts`, v18 = `StatuslineSegments`, v19 = + terminal frames/events, v20 = the GPU initial-target semantic + bootstrap family). Bottom-panel Stage 2B-1's in-review schema is v21 + (`SUPPORTED=[6..=21]`), but its production daemon deliberately + advertises v20: the handshake is server-first, so advertising 21 + would make shipped v20 GPU/TUI clients reject before + `AttachRequest`. Stage 2B-3 owns compatible production activation. - **Bottom panel Stage 1 (window placement + TUI side windows) LANDED — #155** (`docs/bottom-panel-framing.md` rev 4; merge `e745068`; two review rounds). **No protocol change (still v20).** Arc 7's substrate: pmacs now @@ -463,14 +472,18 @@ commands, read `docs/active-work.md` immediately after this file. required GPU 152; initial-target 14 CRDT; all three vterm suites; folding Stage 2 48. All 12 CI checks green at merge. - **Stage 2 (the GPU panel band) is FRAMED** — - `docs/bottom-panel-stage2-framing.md` rev 5, four review rounds, no - open items; the rev-5 implementation split was explicitly approved - 2026-07-27. It takes protocol **v21** and ships as four serial + `docs/bottom-panel-stage2-framing.md` rev 6, four framing review + rounds, no open framing items; the rev-5 implementation split was + explicitly approved 2026-07-27 and rev 6 records PR #184's + server-first compatibility and gate correction. It reserves + protocol **v21** and ships as four serial implementation slices: **2A** classified census routing + per-window painter extraction (no wire change), **2B-1** the wire, **2B-2** the daemon projection and epoch machine, then **2B-3** the - GPU band and negotiated `panel_capable` flip. Parent acceptance - 37–55 remains authoritative. Stage 3 is the adopter default flip. + GPU band, compatible v21 activation, and negotiated + `panel_capable` flip. Production attachment remains v20 through + 2B-1 and 2B-2. Parent acceptance 37–55 remains authoritative. + Stage 3 is the adopter default flip. - **The §1.3 census is CLASSIFIED, not uniformly redirected.** Only the Projection class (#1–#12, #21–#22) routes through `primary_document_window`; focus/input (#13–#15, #23), focus chrome @@ -1201,12 +1214,17 @@ buffer owns a path's recovery slot; only recover/discard release unclaimed crash data; adopt clears the old owner's skip cache. **Protocol** — encoding-breaking bumps are deliberate and versioned. Canonical -`main` is `[6..=20]`. v15 = `CompletionPopup` + `StatusFacts.message`; v16 = +`main` is `[6..=20]`. The in-review bottom-panel 2B-1 schema extends support +to `[6..=21]`, while `ADVERTISED_PROTOCOL_VERSION` stays 20 until 2B-3 +provides compatibility-preserving activation; the server-first `Hello` +cannot advertise 21 without stranding existing v20 clients before +`AttachRequest`. v15 = `CompletionPopup` + `StatusFacts.message`; v16 = `ThemeFacts`; v17 = `FontFacts`; v18 = `StatuslineSegments`; v19 = the vterm terminal family; v20 = semantic `SessionBootstrapRequest` plus appended -`InitialTargetResult`. New wire surface ⇒ bump + both-frontends support + -acceptance. An APPENDED variant must be guarded by a byte pin on the PREVIOUS -final variant — its own round-trip cannot detect a discriminant shift. +`InitialTargetResult`; v21 reserves the panel frame/event family. New wire +surface ⇒ bump + both-frontends support + acceptance. An APPENDED variant +must be guarded by a byte pin on the PREVIOUS final variant — its own +round-trip cannot detect a discriminant shift. **Fake LSP** (`src/bin/pmacs_fake_lsp.rs`) modes: `fullonly`, `rangeonly`, `rangeonly16` (UTF-16 + fail-closed bounds validation), diff --git a/docs/bottom-panel-stage2-framing.md b/docs/bottom-panel-stage2-framing.md index 28dd68b..2384118 100644 --- a/docs/bottom-panel-stage2-framing.md +++ b/docs/bottom-panel-stage2-framing.md @@ -1,10 +1,13 @@ # Bottom panel Stage 2 — the GPU panel band (framing) -**Revision 5 — APPROVED 2026-07-27; 2A merged, 2B-1 in progress. -Ground truth: canonical `main` @ `c2d56ff`, protocol v20 on `main` and -v21 on `bottom-panel-stage2b`.** Revisions 1–4 were -pre-implementation; rev 5 records the three-way slice of Stage 2B -(§0.0, §7.2, §9) after its first slice was already built. +**Revision 6 — PR #184 review correction; the underlying Stage 2 +framing remains APPROVED 2026-07-27. 2A is merged and 2B-1 is under +review. Ground truth: canonical `main` @ `7fd646d`, protocol v20 on +`main`; `bottom-panel-stage2b` reserves the v21 schema while its +server-first production handshake continues to advertise v20.** +Revisions 1–4 were pre-implementation; rev 5 recorded the three-way +slice of Stage 2B after its first slice was already built; rev 6 +corrects that slice's mixed-version and gate contracts. Stage 1 (#155, merge `e745068`) gave pmacs window placement, window parameters, TUI side windows, the divider, and the adopter `display` @@ -29,7 +32,33 @@ geometries), Q#BP16 (pointer transport), Q#BP17 (fold projection), and ## 0. Revision history -### 0.0 Rev 4 → rev 5 — the three-way slice of 2B (not a review round) +### 0.0 Rev 5 → rev 6 — PR #184 review round 2, four findings closed + +- **R6-1 (P1) — v21 is reserved, not advertised, in 2B-1.** The + protocol's handshake is server-first. An existing v20 TUI or GPU + frontend rejects a `Hello { protocol_version: 21 }` before it can + send an `AttachRequest`, so rev 5's claim that a v21 daemon and v20 + peer "still negotiate 20" was impossible. 2B-1 therefore extends the + schema and accepted-version ladder to v21 while the production daemon + continues advertising v20. A real-daemon acceptance emulates the + shipped v20 rejection point and then requires the attachment to reach + its initial grid. **2B-3 owns both a compatibility-preserving + activation mechanism and the production move to v21; it may not + simply change the unsolicited `Hello` to 21.** +- **R6-2 (P2) — durable protocol claims move with the wire.** + `COHERENCE.md` and `docs/agent-handoff.md` now distinguish v21 schema + support from the still-v20 production handshake. +- **R6-3 (P2) — the gate contract names the actual decomposition.** + §9 now names 2B-1's + `bottom_panel_stage2b_protocol_acceptance` suite and the exact planned + daemon/GPU suite names for 2B-2 and 2B-3 instead of the nonexistent + `bottom_panel_stage2b_acceptance`. +- **R6-4 (P2) — "one byte over" means exactly one.** The panel and + copied terminal boundary fixtures replace a one-byte cluster with a + two-byte cluster, and each independently asserts a total of + `limit + 1`. + +### 0.1 Rev 4 → rev 5 — the three-way slice of 2B (not a review round) This revision changes no decision. It splits one approved implementation slice into three and reallocates the acceptance @@ -68,18 +97,17 @@ criteria across them. 2 and 3 are written. - **R5-4 — two of the three slices ship dark, deliberately.** Nothing in 2B-1 or 2B-2 is reachable by a user: `panel_capable` stays - `false` for every negotiated semantic session until 2B-3, so a v21 - daemon and a v21 GPU frontend negotiate 21 and behave exactly as - they do at v20. This is the same posture 2A took ("seam adoption - that becomes load-bearing in 2B") and it carries the same - obligation: **the version bump advertises a capability whose only - distinguishing feature is unreachable until 2B-3 lands.** That is - safe for compatibility — the variants are appended, the ladder is - extended, and a v20 peer still negotiates 20 — but it means the arc - must not stall between 2B-1 and 2B-3. Recorded here so a stall is - visible as a decision rather than inherited as a default. + `false` for every negotiated semantic session until 2B-3. Rev 5 + incorrectly described that posture as a production v21 negotiation + that remained compatible with v20 clients; rev 6 R6-1 supersedes + that claim. The actual dark posture keeps the server-first + production handshake on v20 while the v21 schema is reserved. This + is the same posture 2A took ("seam adoption that becomes + load-bearing in 2B"), and it means the arc must not stall between + 2B-1 and 2B-3. Recorded here so a stall is visible as a decision + rather than inherited as a default. -### 0.1 Round 3 (rev 3 → rev 4) — 1 blocking, 1 high, 1 medium, all closed +### 0.2 Round 3 (rev 3 → rev 4) — 1 blocking, 1 high, 1 medium, all closed - **R3-1 (blocker).** Rev 3's three-boundary model was right but its call-site table was wrong in five places, and each error was a real @@ -107,7 +135,7 @@ criteria across them. `cell.attachment.is_some()` rejection. It is now classified — and **shared**, with the reasoning pinned. -### 0.2 Round 2 (rev 2 → rev 3) — 1 blocking, 2 high, 1 medium, all closed +### 0.3 Round 2 (rev 2 → rev 3) — 1 blocking, 2 high, 1 medium, all closed - **R2-1 (blocker).** Rev 2's "one document-bottom seam" conflated two boundaries that must **diverge** once a panel exists. Several sites it @@ -134,7 +162,7 @@ criteria across them. - Both §8 open items are decided (§5.3): `BASE_DIVIDER_HEIGHT = 4.0` at scale 1.0, and `TEXT_TOP` stays unscaled. -### 0.3 Round 1 (rev 1 → rev 2) — 2 blocking, 3 high, 3 revision points, all closed +### 0.4 Round 1 (rev 1 → rev 2) — 2 blocking, 3 high, 3 revision points, all closed - **R1-1 (blocker).** Rev 1 said all 23 census reads route through `primary_document_window`. That contradicts Q#BP14, which routes only @@ -181,9 +209,10 @@ criteria across them. | Byte pin `InstanceMessage::InitialTargetResult` | `pmacs-protocol/src/message.rs:1145` | Holds — still the enum's final variant | | Byte pin `FrontendEvent::TerminalPointer` | final variant of its enum | Holds | -**Protocol is still v20** (`pmacs-protocol/src/message.rs:1568`); no -intervening PR bumped it. Q#BP9's conditional resolves: **Stage 2 is -v21**, no reservation was taken and none was needed. +**Protocol was still v20 at this re-scout**; no intervening PR had +bumped it. Q#BP9's conditional resolved: **Stage 2 reserves v21**. +Rev 6 R6-1 adds the server-first compatibility constraint discovered +during 2B-1 review. Fifteen PRs merged between the parent's last re-scout (`47581f4`) and this one: #149, #150, #152–#155, #158–#166. Nothing in the parent's @@ -375,13 +404,18 @@ undedicated (Q#BP2c). "It receives no new events" is insufficient — if the daemon nevertheless places that frontend's window in a side panel it cannot render, the window becomes invisible. The gate is on placement, not only on transport. Parent acceptance 51 pins the mixed -session. +session. **The production daemon does not advertise v21 in 2B-1 or +2B-2.** Because `Hello` is server-first, 2B-3 must add or prove a +compatibility-preserving way to activate v21 before applying this rule; +merely advertising 21 would strand already-shipped v20 clients before +they can identify themselves. ## 4. Revisions to the parent framing Only these; everything else stands. -- **Q#BP9 resolves to v21.** +- **Q#BP9 resolves to the v21 schema, with production advertisement + held at v20 until 2B-3 supplies compatible activation.** - **Q#BP15a's epoch ownership is specified** by §3.1's table and API split, replacing the parent's one-line "frontend-owned" statement. - **Q#BP8's statusline criterion splits** per §3.3: one read reroutes, @@ -741,7 +775,10 @@ consumer, no capability change. becomes 21, `SUPPORTED_PROTOCOL_VERSIONS` accepts `6..=21` and rejects 22, and any test whose *name* encodes the old number is renamed. A ladder pin that passes across a bump was not pinning the - version. + version. **`ADVERTISED_PROTOCOL_VERSION` remains 20 in 2B-1 and + 2B-2** because the unsolicited `Hello` precedes any client version + signal. A real daemon must remain attachable by a client whose + supported range ends at 20. - **Shared bounds are aliased, not duplicated.** Every constant the terminal screen and the panel validator both enforce is one definition with the other as an alias, so truncation and validation @@ -790,9 +827,11 @@ production negotiation until 2B-3. #### 7.2.3 Slice 2B-3 — the GPU band and the capability flip -**Authority: `pmacs-gpu`, plus the negotiation rule.** This is the only -slice a user can observe, and the only one that closes the journey -divergence in §6. +**Authority: `pmacs-gpu`, plus the compatibility-preserving negotiation +activation.** This is the only slice a user can observe, and the only +one that closes the journey divergence in §6. It must not advertise +v21 in the server-first `Hello` until an existing v20 client can still +attach. - **46** (band + divider shrink the document text area by exactly their pixel height; carets, hits, and scroll geometry respect the reduced @@ -838,7 +877,10 @@ divergence in §6. its chrome. - **A2B-5.** `panel_capable` is true only for a v21+ negotiated authenticated semantic session; a v20 semantic session is never - **placed** in a side window, not merely denied the events. + **placed** in a side window, not merely denied the events. The same + acceptance must attach an actual v20 client to the production daemon + after v21 activation, so the new path cannot pass by breaking the old + handshake before placement is evaluated. ## 8. Open items @@ -866,17 +908,20 @@ stacked, and each is cut from `main`. installed. - **Stage 2B-1 — the v21 wire layer.** Branch `bottom-panel-stage2b`. The four wire shapes, the version bump, the shared cell-grid - validator, and the version-ladder move. **No producer, no consumer, - no capability change** — `panel_capable` stays `false`. + validator, and the version-ladder move. The v21 schema is reserved + while the production daemon continues advertising v20. **No + producer, no consumer, no capability change** — `panel_capable` + stays `false`. - **Stage 2B-2 — the daemon panel projection and epoch machine.** Cut from `main` after 2B-1 merges. Produces `PanelFrame` and owns stale-event rejection, exercised through a **test-only** panel-capable semantic view. Still no production flip. - **Stage 2B-3 — the GPU band and the negotiated flip.** Cut from `main` after 2B-2 merges. The three-boundary text-area split, the - divider, pointer routing, and `panel_capable = true` for a v21+ - negotiated authenticated semantic session. **This is the slice that - changes what a user sees**, and it repeats 2A's and 2B-2's relevant + divider, pointer routing, the compatibility-preserving v21 + activation, and `panel_capable = true` for a v21+ negotiated + authenticated semantic session. **This is the slice that changes + what a user sees**, and it repeats 2A's and 2B-2's relevant assertions through the real capability flip. **Each slice runs the full gate set below, not a subset of it.** A @@ -889,9 +934,16 @@ Gates for each slice: the standing suite from `CLAUDE.md`, plus the **touched acceptance suites named explicitly** — the standing rule is to run the suites a change touches, and "standing suite" does not name them: -- `bottom_panel_stage1_acceptance` — the substrate both slices build on. -- `bottom_panel_stage2a_acceptance` / `bottom_panel_stage2b_acceptance` - — new, one per slice. +- `bottom_panel_stage1_acceptance` — the substrate all four Stage 2 + slices build on. +- `bottom_panel_stage2a_acceptance` — Stage 2A's classified census and + painter extraction. +- `bottom_panel_stage2b_protocol_acceptance` — Stage 2B-1's v21 schema, + server-first v20 compatibility, byte pins, and shared validation. +- `bottom_panel_stage2b_daemon_acceptance` — the exact suite name + reserved for Stage 2B-2's projection and epoch machine. +- `bottom_panel_stage2b_gpu_acceptance` — the exact suite name reserved + for Stage 2B-3's band, compatible activation, and capability flip. - `statusline_segments_acceptance` — the fan-out target change (§3.3). - `m11_5_semantic_acceptance` — the semantic census (§3.2). - `gpu_initial_target_acceptance` — parent criterion 55. diff --git a/pmacs-protocol/src/lib.rs b/pmacs-protocol/src/lib.rs index 82cdd5b..e7e36e3 100644 --- a/pmacs-protocol/src/lib.rs +++ b/pmacs-protocol/src/lib.rs @@ -57,17 +57,18 @@ pub use cell::{ pub use crdt::CrdtOp; pub use ids::{BufferId, ByteRange, FrontendId, Position}; pub use message::{ - AdornmentContent, AdornmentPlacement, AttachRequest, BUILTIN_PAIR_CHARS, BlockAdornment, - CompletionPopupRow, CursorState, Decoration, DecorationKind, DecorationSegment, - FrontendCapabilities, FrontendEvent, GoodbyeReason, Hello, InitialTarget, InitialTargetResult, - InlineAdornment, InstanceCapabilities, InstanceIdentity, InstanceMessage, InstanceSignal, Key, - KeyEvent, LineNumberMode, MAX_INITIAL_TARGET_ERROR_BYTES, MAX_INITIAL_TARGET_PATH_BYTES, - MAX_STATUSLINE_FACE_BYTES, MAX_STATUSLINE_PROVIDER_NAME_BYTES, MAX_STATUSLINE_PROVIDERS, - MAX_STATUSLINE_SEGMENT_BYTES, MAX_STATUSLINE_TOTAL_TEXT_BYTES, MenuPromptRow, Modifiers, - MouseButton, MouseEvent, MouseKind, NegotiatedCapabilities, PROTOCOL_VERSION, PointerKind, - ResourceBody, SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, SessionBootstrapRequest, - StatuslineSegment, StyleSegment, StyleSpan, ThemeFace, is_builtin_pair_char, - is_modeline_face_name, is_supported_protocol_version, is_ui_face_name, negotiate_capabilities, + ADVERTISED_PROTOCOL_VERSION, AdornmentContent, AdornmentPlacement, AttachRequest, + BUILTIN_PAIR_CHARS, BlockAdornment, CompletionPopupRow, CursorState, Decoration, + DecorationKind, DecorationSegment, FrontendCapabilities, FrontendEvent, GoodbyeReason, Hello, + InitialTarget, InitialTargetResult, InlineAdornment, InstanceCapabilities, InstanceIdentity, + InstanceMessage, InstanceSignal, Key, KeyEvent, LineNumberMode, MAX_INITIAL_TARGET_ERROR_BYTES, + MAX_INITIAL_TARGET_PATH_BYTES, MAX_STATUSLINE_FACE_BYTES, MAX_STATUSLINE_PROVIDER_NAME_BYTES, + MAX_STATUSLINE_PROVIDERS, MAX_STATUSLINE_SEGMENT_BYTES, MAX_STATUSLINE_TOTAL_TEXT_BYTES, + MenuPromptRow, Modifiers, MouseButton, MouseEvent, MouseKind, NegotiatedCapabilities, + PROTOCOL_VERSION, PointerKind, ResourceBody, SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, + SessionBootstrapRequest, StatuslineSegment, StyleSegment, StyleSpan, ThemeFace, + is_builtin_pair_char, is_modeline_face_name, is_supported_protocol_version, is_ui_face_name, + negotiate_capabilities, }; pub use panel::{MAX_PANEL_VISIBLE_CELLS, PanelFrame, PanelFrameError, PanelFramePayload}; pub use terminal::{ diff --git a/pmacs-protocol/src/message.rs b/pmacs-protocol/src/message.rs index e3795a3..0e4e815 100644 --- a/pmacs-protocol/src/message.rs +++ b/pmacs-protocol/src/message.rs @@ -1665,6 +1665,16 @@ pub enum ResourceBody { /// window invisible. pub const PROTOCOL_VERSION: u32 = 21; +/// Protocol version placed in the daemon's server-first [`Hello`]. +/// +/// Bottom-panel Stage 2B-1 reserves the additive v21 wire family, but +/// production attachment remains on v20 until the Stage 2B-3 capability +/// activation can preserve compatibility with existing v20 frontends. +/// Those frontends reject an unknown server-first version before they can +/// send [`AttachRequest`], so advertising [`PROTOCOL_VERSION`] here would +/// make the otherwise-dark protocol slice user-visible. +pub const ADVERTISED_PROTOCOL_VERSION: u32 = 20; + /// T M10.5: the set of protocol versions a v1.0 binary accepts on /// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept /// `[1, 2]` so the version asymmetry the §sec:m10-backward-compat @@ -1742,9 +1752,12 @@ pub const PROTOCOL_VERSION: u32 = 21; /// sessions send a bounded bootstrap envelope after `AttachRequest`; legacy /// and non-semantic sessions retain their existing handshake shape. /// -/// Bottom panel Stage 2 (Q#BP9): extended to `[6, ..., 21]`. v21 peers -/// may exchange panel traffic; v20 peers interoperate with it simply -/// absent, and are never placed in a side window. +/// Bottom panel Stage 2 (Q#BP9): extended to `[6, ..., 21]`. Stage 2B-1 +/// reserves and validates the v21 wire while production daemons continue +/// to send [`ADVERTISED_PROTOCOL_VERSION`] in their server-first +/// [`Hello`]. The later capability-activation slice owns moving production +/// negotiation to v21 without making existing v20 frontends reject the +/// handshake. pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21]; @@ -2126,7 +2139,10 @@ pub fn negotiate_capabilities( /// frontend will use as the `FrontendId` on every event it sends. #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] pub struct Hello { - /// The instance's `PROTOCOL_VERSION`. + /// The protocol version this attachment should use. + /// + /// This can deliberately trail [`PROTOCOL_VERSION`] while an additive + /// wire family is reserved but not yet activated in production. pub protocol_version: u32, /// `FrontendId` assigned to this attachment by the instance. The /// frontend stamps this onto subsequent events. v0.1 daemons start diff --git a/pmacs-protocol/src/terminal.rs b/pmacs-protocol/src/terminal.rs index 8f7f507..0bcf7e0 100644 --- a/pmacs-protocol/src/terminal.rs +++ b/pmacs-protocol/src/terminal.rs @@ -843,14 +843,14 @@ mod tests { 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(3).into_boxed_slice())); + 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, _) = budget_boundary_frames(); + let (exact, over) = budget_boundary_frames(); assert_eq!(exact.validate(), Ok(())); let mut glyph_bytes = 0usize; @@ -865,6 +865,20 @@ mod tests { 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"); diff --git a/src/daemon.rs b/src/daemon.rs index 43c97ad..b665d07 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -67,9 +67,9 @@ use crate::lockfile::{self, LockError, LockHandle}; use crate::presence::{PresenceSnapshot, SessionRegistry}; use crate::protocol::crossterm_translate::{key_to_crossterm, mouse_to_crossterm}; use crate::protocol::{ - AttachRequest, FrontendEvent, FrontendId, GoodbyeReason, Hello, InitialTarget, - InitialTargetResult, InstanceCapabilities, InstanceIdentity, InstanceMessage, InstanceSignal, - MAX_INITIAL_TARGET_ERROR_BYTES, MAX_INITIAL_TARGET_PATH_BYTES, PROTOCOL_VERSION, PointerKind, + ADVERTISED_PROTOCOL_VERSION, AttachRequest, FrontendEvent, FrontendId, GoodbyeReason, Hello, + InitialTarget, InitialTargetResult, InstanceCapabilities, InstanceIdentity, InstanceMessage, + InstanceSignal, MAX_INITIAL_TARGET_ERROR_BYTES, MAX_INITIAL_TARGET_PATH_BYTES, PointerKind, SelectionSnapshot, SessionBootstrapRequest, }; use crate::socket_path::{SocketPathError, ensure_runtime_subdir}; @@ -712,7 +712,7 @@ fn per_attach_thread( // mismatch path without changing the default. let instance_caps_for_hello = instance_capabilities_with_env_override(); let hello = Hello { - protocol_version: PROTOCOL_VERSION, + protocol_version: ADVERTISED_PROTOCOL_VERSION, assigned_frontend_id: frontend_id, instance_identity: daemon_state.build_identity(), instance_capabilities: instance_caps_for_hello.clone(), @@ -739,7 +739,7 @@ fn per_attach_thread( let _ = write_message( &mut stream, &InstanceMessage::Goodbye(GoodbyeReason::VersionMismatch { - server: PROTOCOL_VERSION, + server: ADVERTISED_PROTOCOL_VERSION, client: req.protocol_version, }), ); @@ -3412,6 +3412,7 @@ fn apply_event( #[cfg(test)] mod tests { use super::*; + use crate::protocol::PROTOCOL_VERSION; #[test] fn daemon_state_starts_frontend_id_at_two() { diff --git a/tests/bottom_panel_stage2b_protocol_acceptance.rs b/tests/bottom_panel_stage2b_protocol_acceptance.rs index c3ec239..b066e4a 100644 --- a/tests/bottom_panel_stage2b_protocol_acceptance.rs +++ b/tests/bottom_panel_stage2b_protocol_acceptance.rs @@ -5,17 +5,28 @@ //! projection, the epoch state machine, and the GPU band are later //! slices of this stage and are not exercised here. +mod common; + +use std::time::Duration; + use pmacs_protocol::cell::{Cell, CellCoord, CellSize, Color, Glyph, Style, UnderlineStyle}; -use pmacs_protocol::message::{FrontendEvent, InstanceMessage, Modifiers, MouseButton, MouseKind}; +use pmacs_protocol::message::{ + AttachRequest, FrontendEvent, Hello, InstanceMessage, Modifiers, MouseButton, MouseKind, +}; use pmacs_protocol::panel::{ MAX_PANEL_VISIBLE_CELLS, PanelFrame, PanelFrameError, PanelFramePayload, }; use pmacs_protocol::terminal::{ MAX_TERMINAL_COLS, TerminalFrame, TerminalFrameError, TerminalProcessState, }; -use pmacs_protocol::transport::MAX_FRAME_BYTES; +use pmacs_protocol::transport::{MAX_FRAME_BYTES, read_message, write_message}; use pmacs_protocol::wire_grid::{MAX_WIRE_GRID_GLYPH_BYTES, MAX_WIRE_GRID_GRAPHEME_BYTES}; -use pmacs_protocol::{BufferId, FrontendId, PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS}; +use pmacs_protocol::{ + ADVERTISED_PROTOCOL_VERSION, BufferId, FrontendId, PROTOCOL_VERSION, + SUPPORTED_PROTOCOL_VERSIONS, +}; + +use common::daemon::{TestDaemon, build_default_caps}; fn cell(ch: char) -> Cell { Cell { @@ -98,11 +109,54 @@ fn terminal_frame(rows: u32, cols: u32) -> TerminalFrame { fn the_panel_stage_takes_protocol_v21() { assert_eq!(PROTOCOL_VERSION, 21); assert!(SUPPORTED_PROTOCOL_VERSIONS.contains(&21)); - // v20 stays supported: a v20 peer interoperates with panel traffic - // simply absent rather than being refused the handshake. + // The wire family is reserved before it is activated: the production + // server-first Hello must remain acceptable to already-shipped v20 + // clients throughout the dark protocol and daemon slices. + assert_eq!(ADVERTISED_PROTOCOL_VERSION, 20); assert!(SUPPORTED_PROTOCOL_VERSIONS.contains(&20)); } +#[test] +fn a_new_daemon_keeps_an_existing_v20_client_attachable() { + let daemon = TestDaemon::spawn(); + let mut stream = daemon.connect(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .expect("set handshake timeout"); + + // This is the rejection point in an already-shipped client: it reads the + // daemon's unsolicited Hello before it is able to identify its own + // supported range or send AttachRequest. + let hello: Hello = read_message(&mut stream).expect("read daemon Hello"); + let v20_client_supported_versions = 6..=20; + assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION); + assert!( + v20_client_supported_versions.contains(&hello.protocol_version), + "an existing v20 client would reject the server-first Hello" + ); + + write_message( + &mut stream, + &AttachRequest { + protocol_version: hello.protocol_version, + frontend_capabilities: build_default_caps(), + initial_size: CellSize::new(24, 80), + }, + ) + .expect("write v20 AttachRequest"); + + assert!( + matches!( + read_message::(&mut stream).expect("read initial grid"), + InstanceMessage::CellDelta { + full_grid: true, + .. + } + ), + "the daemon must establish the v20 session, not merely send an acceptable Hello" + ); +} + #[test] fn a_present_panel_frame_round_trips_with_both_epochs() { let frame = panel_frame(2, 3); @@ -483,7 +537,7 @@ fn panel_budget_boundary_frames() -> (PanelFrame, PanelFrame) { let mut over = exact.clone(); // One more byte of glyph, nothing else changed. let last = over.cells.len() - 1; - over.cells[last] = maximal_cell(Glyph::Cluster(cluster_of_len(3).into_boxed_slice())); + over.cells[last] = maximal_cell(Glyph::Cluster(cluster_of_len(2).into_boxed_slice())); (exact, over) } @@ -508,6 +562,20 @@ fn maximum_legal_panel_frame_encodes_below_the_transport_cap() { glyph_bytes, MAX_WIRE_GRID_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_WIRE_GRID_GLYPH_BYTES + 1, + "the rejecting twin must be exactly one byte over the aggregate budget" + ); // One byte over is rejected, which is what makes `exact` maximal. assert!(matches!( diff --git a/tests/common/daemon.rs b/tests/common/daemon.rs index 6cb087a..1c8bc0d 100644 --- a/tests/common/daemon.rs +++ b/tests/common/daemon.rs @@ -26,7 +26,7 @@ use tempfile::TempDir; #[cfg(feature = "crdt")] use pmacs::cell::CellSize; #[cfg(feature = "crdt")] -use pmacs::protocol::{AttachRequest, PROTOCOL_VERSION}; +use pmacs::protocol::AttachRequest; use pmacs::protocol::{FrontendCapabilities, Hello}; use pmacs::transport::read_message; #[cfg(feature = "crdt")] @@ -294,7 +294,7 @@ pub fn attach_multi(daemon: &TestDaemon) -> (Hello, UnixStream) { .unwrap(); let hello: Hello = read_message(&mut stream).expect("read Hello"); let req = AttachRequest { - protocol_version: PROTOCOL_VERSION, + protocol_version: hello.protocol_version, frontend_capabilities: multi_frontend_caps(), initial_size: CellSize::new(24, 80), }; diff --git a/tests/gpu_invocation_acceptance.rs b/tests/gpu_invocation_acceptance.rs index b4e3102..2f56e8f 100644 --- a/tests/gpu_invocation_acceptance.rs +++ b/tests/gpu_invocation_acceptance.rs @@ -93,9 +93,9 @@ mod crdt { use pmacs::cell::CellSize; use pmacs::crdt::CrdtState; use pmacs::protocol::{ - AttachRequest, FrontendCapabilities, FrontendEvent, FrontendId, Hello, InitialTarget, - InitialTargetResult, InstanceCapabilities, InstanceIdentity, InstanceMessage, - PROTOCOL_VERSION, SessionBootstrapRequest, + ADVERTISED_PROTOCOL_VERSION, AttachRequest, FrontendCapabilities, FrontendEvent, + FrontendId, Hello, InitialTarget, InitialTargetResult, InstanceCapabilities, + InstanceIdentity, InstanceMessage, PROTOCOL_VERSION, SessionBootstrapRequest, }; use pmacs::transport::{read_message, write_message}; @@ -244,11 +244,11 @@ mod crdt { .set_read_timeout(Some(Duration::from_secs(5))) .expect("set target frontend timeout"); let hello: Hello = read_message(&mut stream).expect("target frontend Hello"); - assert_eq!(hello.protocol_version, PROTOCOL_VERSION); + assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION); write_message( &mut stream, &AttachRequest { - protocol_version: PROTOCOL_VERSION, + protocol_version: hello.protocol_version, frontend_capabilities: FrontendCapabilities { multi_frontend: true, crdt_replica: true, @@ -583,7 +583,7 @@ mod crdt { facts .get("server_protocol_version") .and_then(|value| value.parse::().ok()), - Some(PROTOCOL_VERSION) + Some(ADVERTISED_PROTOCOL_VERSION) ); assert_eq!( facts.get("spawned_daemon").map(String::as_str), diff --git a/tests/m5_5_acceptance.rs b/tests/m5_5_acceptance.rs index ee66ea8..c8c87ed 100644 --- a/tests/m5_5_acceptance.rs +++ b/tests/m5_5_acceptance.rs @@ -37,8 +37,8 @@ use pmacs::cell::Color; #[cfg(feature = "crdt")] use pmacs::overlay_color::color_for_slot; use pmacs::protocol::{ - AttachRequest, FrontendCapabilities, FrontendEvent, GoodbyeReason, Hello, InstanceMessage, Key, - KeyEvent, Modifiers, PROTOCOL_VERSION, + ADVERTISED_PROTOCOL_VERSION, AttachRequest, FrontendCapabilities, FrontendEvent, GoodbyeReason, + Hello, InstanceMessage, Key, KeyEvent, Modifiers, PROTOCOL_VERSION, }; use pmacs::transport::{read_message, write_message}; @@ -52,9 +52,9 @@ use common::daemon::{ /// Read the daemon's `Hello`, send our `AttachRequest`, return the Hello. fn do_handshake(stream: &mut UnixStream) -> Hello { let hello: Hello = read_message(stream).expect("read Hello"); - assert_eq!(hello.protocol_version, PROTOCOL_VERSION); + assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION); let req = AttachRequest { - protocol_version: PROTOCOL_VERSION, + protocol_version: hello.protocol_version, frontend_capabilities: build_default_caps(), initial_size: CellSize::new(24, 80), }; @@ -418,7 +418,7 @@ fn version_mismatch_clean_disconnect() { // Read Hello. let hello: Hello = read_message(&mut stream).expect("Hello"); - assert_eq!(hello.protocol_version, PROTOCOL_VERSION); + assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION); // Send AttachRequest with wrong protocol version. let req = AttachRequest { @@ -431,7 +431,7 @@ fn version_mismatch_clean_disconnect() { // Expect Goodbye(VersionMismatch). match read_message::(&mut stream) { Ok(InstanceMessage::Goodbye(GoodbyeReason::VersionMismatch { server, client })) => { - assert_eq!(server, PROTOCOL_VERSION); + assert_eq!(server, ADVERTISED_PROTOCOL_VERSION); assert_eq!(client, 999); } other => panic!("expected VersionMismatch Goodbye, got {other:?}"), @@ -1145,9 +1145,9 @@ fn m10_10_production_attach_negotiates_crdt_replica() { // Production handshake — NOT the test `attach_multi()` path. let hello: Hello = read_message(&mut stream).expect("read Hello"); - assert_eq!(hello.protocol_version, PROTOCOL_VERSION); + assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION); let req = AttachRequest { - protocol_version: PROTOCOL_VERSION, + protocol_version: hello.protocol_version, frontend_capabilities: pmacs::attach::build_capabilities(), initial_size: CellSize::new(24, 80), }; @@ -1183,9 +1183,9 @@ fn m10_10_production_attach_non_crdt_build_does_not_negotiate_crdt_replica() { stream .set_read_timeout(Some(Duration::from_secs(5))) .unwrap(); - let _hello: Hello = read_message(&mut stream).expect("read Hello"); + let hello: Hello = read_message(&mut stream).expect("read Hello"); let req = AttachRequest { - protocol_version: PROTOCOL_VERSION, + protocol_version: hello.protocol_version, frontend_capabilities: pmacs::attach::build_capabilities(), initial_size: CellSize::new(24, 80), }; @@ -2171,7 +2171,7 @@ fn m10_10_f14_production_path_keystroke_flows_to_broadcast() { .unwrap(); let hello_a: Hello = read_message(&mut stream_a).expect("A Hello"); let req_a = AttachRequest { - protocol_version: PROTOCOL_VERSION, + protocol_version: hello_a.protocol_version, frontend_capabilities: pmacs::attach::build_capabilities(), initial_size: CellSize::new(24, 80), }; diff --git a/tests/m5_7_acceptance.rs b/tests/m5_7_acceptance.rs index ec0733d..ce85591 100644 --- a/tests/m5_7_acceptance.rs +++ b/tests/m5_7_acceptance.rs @@ -77,7 +77,7 @@ use nix::unistd::Pid; use tempfile::TempDir; use pmacs::attach::PMACS_TEST_SSH_BIN; -use pmacs::protocol::{Hello, PROTOCOL_VERSION}; +use pmacs::protocol::{ADVERTISED_PROTOCOL_VERSION, Hello}; use pmacs::transport::read_message; // --------------------------------------------------------------------------- @@ -385,7 +385,7 @@ fn daemon_attach_bridges_hello_from_existing_daemon() { // verbatim. (No AttachRequest sent — the daemon will hold the // attach slot until the bridge stdin closes below.) let hello: Hello = read_message(&mut bridge_stdout).expect("read Hello via bridge"); - assert_eq!(hello.protocol_version, PROTOCOL_VERSION); + assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION); // Tear down: drop bridge stdin → bridge's stdin→socket copy sees // EOF, shuts down the socket write half, the daemon notices and @@ -424,7 +424,7 @@ fn daemon_attach_auto_starts_missing_daemon() { // bound the socket and the bridge connected. let hello: Hello = read_message(&mut bridge_stdout).expect("read Hello via auto-started daemon"); - assert_eq!(hello.protocol_version, PROTOCOL_VERSION); + assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION); // The lockfile must exist now: `acquire_lock` writes it on // daemon startup. (Existence of the lockfile is what proves diff --git a/tests/m5_perf_acceptance.rs b/tests/m5_perf_acceptance.rs index 4d1a680..12139b3 100644 --- a/tests/m5_perf_acceptance.rs +++ b/tests/m5_perf_acceptance.rs @@ -72,8 +72,8 @@ use tempfile::TempDir; use pmacs::cell::CellSize; use pmacs::protocol::{ - AttachRequest, FrontendCapabilities, FrontendEvent, Hello, InstanceMessage, Key, KeyEvent, - Modifiers, PROTOCOL_VERSION, + ADVERTISED_PROTOCOL_VERSION, AttachRequest, FrontendCapabilities, FrontendEvent, Hello, + InstanceMessage, Key, KeyEvent, Modifiers, }; use pmacs::transport::{TransportError, read_message, write_message}; @@ -158,9 +158,9 @@ fn build_default_caps() -> FrontendCapabilities { fn do_handshake(stream: &mut UnixStream) -> Hello { let hello: Hello = read_message(stream).expect("read Hello"); - assert_eq!(hello.protocol_version, PROTOCOL_VERSION); + assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION); let req = AttachRequest { - protocol_version: PROTOCOL_VERSION, + protocol_version: hello.protocol_version, frontend_capabilities: build_default_caps(), initial_size: CellSize::new(24, 80), }; diff --git a/tests/mode_system_wiring_acceptance.rs b/tests/mode_system_wiring_acceptance.rs index bd79b4e..16b6fe5 100644 --- a/tests/mode_system_wiring_acceptance.rs +++ b/tests/mode_system_wiring_acceptance.rs @@ -11,8 +11,8 @@ use std::time::{Duration, Instant}; use pmacs::cell::{Cell, CellSize, Glyph}; use pmacs::protocol::{ - AttachRequest, FrontendEvent, FrontendId, Hello, InstanceMessage, Key, KeyEvent, Modifiers, - PROTOCOL_VERSION, + ADVERTISED_PROTOCOL_VERSION, AttachRequest, FrontendEvent, FrontendId, Hello, InstanceMessage, + Key, KeyEvent, Modifiers, }; use pmacs::transport::{read_message, write_message}; @@ -75,11 +75,11 @@ fn attach(daemon: &TestDaemon) -> (Client, Grid) { .set_read_timeout(Some(Duration::from_secs(5))) .expect("set daemon handshake timeout"); let hello: Hello = read_message(&mut stream).expect("read daemon Hello"); - assert_eq!(hello.protocol_version, PROTOCOL_VERSION); + assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION); write_message( &mut stream, &AttachRequest { - protocol_version: PROTOCOL_VERSION, + protocol_version: hello.protocol_version, frontend_capabilities: build_default_caps(), initial_size: CellSize::new(ROWS, COLS), }, diff --git a/tests/vterm_stage3_acceptance.rs b/tests/vterm_stage3_acceptance.rs index f729aba..9656491 100644 --- a/tests/vterm_stage3_acceptance.rs +++ b/tests/vterm_stage3_acceptance.rs @@ -716,8 +716,8 @@ fn a37_real_daemon_real_pty_and_headless_gpu_render_one_terminal_session() { assert_eq!( facts.get("server_protocol_version").copied(), - Some("21"), - "the real daemon negotiated v21 with the real client: {text}" + Some("20"), + "the dark v21 wire slice must keep the real client on v20: {text}" ); assert_eq!( facts.get("entered_terminal_mode").copied(), From 9e20175dadc07066caadf772a1ade8ef4d0cdac2 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 28 Jul 2026 14:17:43 -0400 Subject: [PATCH 09/12] Wait for required PTY output in the GPU probe Keep the real GPU/PTY acceptance probe running until the child output that its report asserts has actually reached the terminal frame. This closes the blank-last-frame race exposed by the v20-compatible handshake. --- pmacs-gpu/src/main.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index bd961e1..a35de2d 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -857,7 +857,15 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 { facts.observed_resized_frame = true; } } - if !quiet && facts.observed_resized_frame && facts.rendered_nonuniform_frames >= 2 { + // Do not exit merely because resize/composition happened + // first: that races the PTY child's initial output and + // produces a self-contradictory "successful" probe report + // whose later acceptance assertion must reject it. + if !quiet + && facts.observed_resized_frame + && facts.rendered_nonuniform_frames >= 2 + && facts.last_frame_text.contains("VTERMROW") + { break; } } From 80b761bb03bafd83d2b0cc842e562f1de5668279 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 28 Jul 2026 14:29:04 -0400 Subject: [PATCH 10/12] Record the regated PR 184 review head Capture the exact review-fix and GPU probe checkpoints, the full green gate evidence, and the classified sandbox-only socket failure in the cross-machine active-work ledger. --- docs/active-work.md | 38 +++++++++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 837cd56..1efc59a 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -305,15 +305,16 @@ If it does not, stop and repair the remote/fetch configuration. never been enforced. Any CI job that compiles the `crdt` targets has to fix them first or it will be red on arrival. -## Bottom-panel lane (Arc 7) — 2B-1 REVIEW FIX IN PROGRESS; PR #184 OPEN +## Bottom-panel lane (Arc 7) — 2B-1 REGATED; PR #184 OPEN FOR REVIEW Stage 1, the Stage 2 framing, and Stage 2A are on `main`. Framing revision 5's three-way split of 2B was explicitly approved on 2026-07-27; revision 6 records PR #184's review correction. **Stage 2B-1 is implemented and integrated with canonical `main` @ `7fd646d`. -The previous head was fully gated at `c8895a8`, but review round 2 found -four issues and the corrected head must run the full gate again. PR -#184 is open and must not merge before user review.** +Review round 2's four findings are corrected at `ab7c207`; the +gate-found GPU/PTY probe barrier is corrected and the complete suite is +green at `9e20175`. PR #184 is open and must not merge before user +review.** - **Stage 2B-1 branch:** `bottom-panel-stage2b`, based on `githubsucks/main` @ `7fd646d` by merge because review had begun. @@ -333,7 +334,7 @@ four issues and the corrected head must run the full gate again. PR `9b364ad`: `PanelFrame` now identifies its buffer, the transport ratchet covers the actual attach path rather than a detached codec assertion, and shared grid bounds have one validator. -- **Review round 2 found four issues; fixes are in progress:** the +- **Review round 2 found four issues, corrected at `ab7c207`:** the server-first `Hello` made the advertised v20↔v21 compatibility one-way; `COHERENCE.md` and `docs/agent-handoff.md` still named only v20 schema support; framing §9 named a nonexistent aggregate 2B @@ -343,6 +344,15 @@ four issues and the corrected head must run the full gate again. PR real-daemon existing-v20-client acceptance, updates all three durable records, names the exact slice suites, and asserts both rejecting fixtures are exactly `limit + 1`. +- **The full gate exposed and corrected a contradiction in Vterm Stage + 3's headless probe at `9e20175`.** Its loop exited as soon as resize + plus two nonuniform composites were observed, while its acceptance + later required the PTY child's `VTERMROW` output in the final frame. + The v20-compatible handshake made that scheduling race deterministic: + terminal mode, five frames, and resize all succeeded, but the report + sampled a blank frame. The probe now waits for the exact child-output + observation its acceptance asserts. The formerly failing exact + GPU/PTY test passes, and the full nine-test Stage 3 target passes. - **The full gate found and corrected two 2B-1 omissions:** the statusline version ladder still pinned v20/rejected v21, and Vterm Stage 3 pinned v20 both structurally and in its real headless probe. @@ -381,6 +391,24 @@ four issues and the corrected head must run the full gate again. PR exact test passed immediately in isolation with one test thread, and the mandatory complete rerun passed **202/202**. This is retained as classified gate evidence, not erased as a clean first pass. +- **The corrected review-round-2 head is fully green at `9e20175`:** + formatting; strict workspace Clippy; library **1,849 passed + 3 + ignored default** and **2,034 passed + 4 ignored CRDT**; bottom-panel + Stage 1 / 2A / 2B-1 **46 / 17 / 16**; folding Stage 2 **48**; GPU + font **11**; statusline **8 CRDT**; m11_5 semantic **2 CRDT**; GPU + initial target and invocation **15 / 15 CRDT**; the handshake + consumers m5_5 / m5_7 / mode-system wiring **36 / 7 / 1 CRDT** + (the release-only m5 perf test remains ignored by its standing + contract); Vterm Stages 1 / 2 / 3 **10 / 6 / 9 CRDT**, including the + required real daemon + PTY + wgpu probe; M4 **121 passed + 3 ignored + + 1 filtered**; required GPU **202/202**; the isolated-config, + one-invocation full workspace sweep; and `git diff --check`. + - An initial default-library attempt inside the restricted tool + sandbox produced three `Operation not permitted` failures in + socket-based attach tests. The authoritative outside-sandbox rerun + passed all **1,849 + 3 ignored**, and the matching CRDT run passed. + This is retained as environment classification, not presented as a + clean first attempt. - **Next ordering is fixed:** 2B-2 branches from `main` only after 2B-1 lands; 2B-3 branches only after 2B-2 lands. The daemon epoch machine belongs to 2B-2; the GPU band and negotiated capability flip belong From 9c79ce13b2b73f148c15e9110c41bc044dfa1068 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 28 Jul 2026 15:28:14 -0400 Subject: [PATCH 11/12] Fix fixture-specific GPU probe completion Let producer probes name the frame text they require while input probes finish on their latched echo observation. Report and assert whether the probe reached that evidence so the 20-second safety deadline cannot masquerade as successful completion. The CAT acceptance now finishes in 0.32 seconds instead of waiting out the full deadline, while the VTERMROW producer still waits for its own PTY breadcrumb. --- pmacs-gpu/src/main.rs | 19 +++++++++++++++++-- tests/vterm_stage3_acceptance.rs | 14 ++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index a35de2d..3a8bb55 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -779,11 +779,20 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 { .ok() .and_then(|value| value.parse::().ok()) .map(std::time::Duration::from_millis); + // Normal probes stop only after their fixture-specific evidence arrives. + // A producer fixture names the text it must paint; an input fixture uses + // the latched echo observation. Keeping that choice outside this generic + // runner prevents one fixture's breadcrumb from forcing another fixture + // to sit on the 20-second safety deadline. + let expected_frame_text = std::env::var("PMACS_GPU_PROBE_EXPECT_TEXT") + .ok() + .filter(|value| !value.is_empty()); let quiet = observe_window.is_some(); let deadline = std::time::Instant::now() + observe_window.unwrap_or_else(|| std::time::Duration::from_secs(20)); let mut sent_input = false; let mut sent_resize = false; + let mut completion_observed = false; while std::time::Instant::now() < deadline { let Ok(event) = rx.recv_timeout(std::time::Duration::from_millis(200)) else { continue; @@ -857,15 +866,20 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 { facts.observed_resized_frame = true; } } + let fixture_evidence_observed = expected_frame_text.as_deref().map_or_else( + || facts.input_echo_observed, + |expected| facts.last_frame_text.contains(expected), + ); // Do not exit merely because resize/composition happened - // first: that races the PTY child's initial output and + // first: that races the fixture's required PTY evidence and // produces a self-contradictory "successful" probe report // whose later acceptance assertion must reject it. if !quiet && facts.observed_resized_frame && facts.rendered_nonuniform_frames >= 2 - && facts.last_frame_text.contains("VTERMROW") + && fixture_evidence_observed { + completion_observed = true; break; } } @@ -898,6 +912,7 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 { let _ = writeln!(out, "last_title={}", facts.last_title.unwrap_or_default()); let _ = writeln!(out, "last_frame_text={}", facts.last_frame_text); let _ = writeln!(out, "input_echo_observed={}", facts.input_echo_observed); + let _ = writeln!(out, "completion_observed={completion_observed}"); let _ = writeln!(out, "disconnect={}", facts.disconnect.unwrap_or_default()); if let Err(error) = std::fs::write(report, out) { eprintln!( diff --git a/tests/vterm_stage3_acceptance.rs b/tests/vterm_stage3_acceptance.rs index 9656491..f6b1f7f 100644 --- a/tests/vterm_stage3_acceptance.rs +++ b/tests/vterm_stage3_acceptance.rs @@ -691,6 +691,9 @@ fn a37_real_daemon_real_pty_and_headless_gpu_render_one_terminal_session() { .arg(&report) // The chord the probe presses to run `vterm-probe.open`. .env("PMACS_GPU_PROBE_OPEN_KEY", "t") + // This producer fixture does not consume the probe's input; wait + // instead for its own live cursor-addressed breadcrumb. + .env("PMACS_GPU_PROBE_EXPECT_TEXT", "VTERMROW") .output() .expect("run the headless GPU probe"); @@ -746,6 +749,11 @@ fn a37_real_daemon_real_pty_and_headless_gpu_render_one_terminal_session() { .is_some_and(|t| t.contains("VTERMROW")), "the child's cursor-addressed output must reach the rendered frame: {text}" ); + assert_eq!( + facts.get("completion_observed").copied(), + Some("true"), + "the probe must finish on the fixture's PTY evidence, not its deadline: {text}" + ); let declarations: u32 = facts .get("declarations") .and_then(|v| v.parse().ok()) @@ -1311,4 +1319,10 @@ fn gpu_terminal_input_reaches_the_child_and_returns_in_a_frame() { "the typed character must reach the child and return: {}", report() ); + assert_eq!( + facts.get("completion_observed").map(String::as_str), + Some("true"), + "the probe must finish on the latched input echo, not its deadline: {}", + report() + ); } From 5539b6e8c647edb7ed320974f668912f5d4a8aa4 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 28 Jul 2026 15:29:55 -0400 Subject: [PATCH 12/12] Record the fixture-specific PR 184 probe fix Capture the follow-up review finding, the evidence-driven completion contract, the exact corrected CAT duration, and the proportional green gate matrix at 9c79ce1. --- docs/active-work.md | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 1efc59a..6a47763 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -313,8 +313,9 @@ revision 5's three-way split of 2B was explicitly approved on 2B-1 is implemented and integrated with canonical `main` @ `7fd646d`. Review round 2's four findings are corrected at `ab7c207`; the gate-found GPU/PTY probe barrier is corrected and the complete suite is -green at `9e20175`. PR #184 is open and must not merge before user -review.** +green at `9e20175`. The follow-up fixture-specific probe correction is +committed and proportionally regated at `9c79ce1`. PR #184 is open and +must not merge before user review.** - **Stage 2B-1 branch:** `bottom-panel-stage2b`, based on `githubsucks/main` @ `7fd646d` by merge because review had begun. @@ -353,6 +354,14 @@ review.** sampled a blank frame. The probe now waits for the exact child-output observation its acceptance asserts. The formerly failing exact GPU/PTY test passes, and the full nine-test Stage 3 target passes. +- **Follow-up review corrected the probe barrier's fixture leak at + `9c79ce1`.** The generic runner hard-coded the producer fixture's + `VTERMROW` breadcrumb, so the CAT input fixture could satisfy every + assertion but never satisfy the loop exit and waited out the + 20-second safety deadline. Producer probes now name their required + frame text while input probes finish on the latched echo. The report + exposes `completion_observed`, and both paths assert it, so a + deadline-driven pass cannot hide the stall again. - **The full gate found and corrected two 2B-1 omissions:** the statusline version ladder still pinned v20/rejected v21, and Vterm Stage 3 pinned v20 both structurally and in its real headless probe. @@ -409,6 +418,15 @@ review.** passed all **1,849 + 3 ignored**, and the matching CRDT run passed. This is retained as environment classification, not presented as a clean first attempt. +- **The fixture-specific follow-up is proportionally green at + `9c79ce1`:** formatting and strict workspace Clippy; protocol + **17/17**; bottom-panel Stage 2B-1 **16/16**; Vterm Stage 3 **9/9 + CRDT** with the real daemon + PTY + required wgpu probe in **5.72 s**; + the formerly stalled CAT path **1/1 in 0.32 s**; required GPU + **202/202**; and `git diff --check`. The first Stage 2B-1 and Vterm + attempts inside the restricted tool sandbox reproduced the classified + Unix-socket `Operation not permitted` denial; their authoritative + outside-sandbox reruns passed. - **Next ordering is fixed:** 2B-2 branches from `main` only after 2B-1 lands; 2B-3 branches only after 2B-2 lands. The daemon epoch machine belongs to 2B-2; the GPU band and negotiated capability flip belong