diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 326587b87f..bfe4fcc857 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -9,6 +9,14 @@ const MAX_LINES = 1000; const rules = [ { root: "src-tauri/src", extensions: new Set([".rs"]), maxLines: MAX_LINES }, + // Workspace member crates. Without this the ratchet's only Rust root is + // `src-tauri/src`, and a crate under `src-tauri/crates/` is born outside the + // repo's one size discipline -- silently, since the check still exits 0. + { + root: "src-tauri/crates", + extensions: new Set([".rs"]), + maxLines: MAX_LINES, + }, { root: "src/app", extensions: new Set([".ts", ".tsx"]), diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 00d3fba3b5..a927db36c9 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -98,6 +98,30 @@ dependencies = [ "memchr", ] +[[package]] +name = "alacritty_terminal" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda177466b9524d59f1b12f0dd30b68696788e9992a7e959021c4a0ed96fcf59" +dependencies = [ + "base64 0.22.1", + "bitflags 2.13.0", + "home", + "libc", + "log", + "miow", + "parking_lot", + "piper", + "polling", + "regex-automata", + "rustix 1.1.4", + "rustix-openpty", + "signal-hook 0.4.4", + "unicode-width 0.2.2", + "vte 0.15.0", + "windows-sys 0.59.0", +] + [[package]] name = "alloc-no-stdlib" version = "2.0.4" @@ -1174,6 +1198,14 @@ dependencies = [ "uuid", ] +[[package]] +name = "buzz-terminal" +version = "0.1.0" +dependencies = [ + "alacritty_terminal", + "parking_lot", +] + [[package]] name = "buzz-voice" version = "0.1.0" @@ -1882,7 +1914,7 @@ dependencies = [ "mio", "parking_lot", "rustix 0.38.44", - "signal-hook", + "signal-hook 0.3.18", "signal-hook-mio", "winapi", ] @@ -1900,7 +1932,7 @@ dependencies = [ "mio", "parking_lot", "rustix 1.1.4", - "signal-hook", + "signal-hook 0.3.18", "signal-hook-mio", "winapi", ] @@ -2067,6 +2099,12 @@ dependencies = [ "cmov", ] +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -5585,6 +5623,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "miow" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "model-artifact" version = "0.74.0" @@ -8661,6 +8708,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustix-openpty" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de16c7c59892b870a6336f185dc10943517f1327447096bbb7bb32cd85e2393" +dependencies = [ + "errno", + "libc", + "rustix 1.1.4", +] + [[package]] name = "rustls" version = "0.23.42" @@ -9355,6 +9413,16 @@ dependencies = [ "signal-hook-registry", ] +[[package]] +name = "signal-hook" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d" +dependencies = [ + "libc", + "signal-hook-registry", +] + [[package]] name = "signal-hook-mio" version = "0.2.5" @@ -9363,7 +9431,7 @@ checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" dependencies = [ "libc", "mio", - "signal-hook", + "signal-hook 0.3.18", ] [[package]] @@ -9719,7 +9787,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" dependencies = [ - "vte", + "vte 0.14.1", ] [[package]] @@ -10581,7 +10649,7 @@ dependencies = [ "bitflags 2.13.0", "parking_lot", "rustix 1.1.4", - "signal-hook", + "signal-hook 0.3.18", "windows-sys 0.61.2", ] @@ -10632,7 +10700,7 @@ dependencies = [ "pest_derive", "phf 0.11.3", "sha2 0.10.9", - "signal-hook", + "signal-hook 0.3.18", "siphasher", "terminfo", "termios", @@ -11761,6 +11829,19 @@ dependencies = [ "memchr", ] +[[package]] +name = "vte" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" +dependencies = [ + "arrayvec", + "bitflags 2.13.0", + "cursor-icon", + "log", + "memchr", +] + [[package]] name = "vtparse" version = "0.6.2" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index b80684f955..f89542f465 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,4 +1,9 @@ [workspace] +# Explicit: membership must NOT be inferred from the path-dependency edge below. +# With a bare `[workspace]` and no `members`, `cargo test/check --workspace` +# expands to a set that excludes this crate, and its gates pass green-and-empty +# over a real defect. Verified: Sami Arm A/D, Dawn `.scratch/armA`. +members = ["crates/buzz-terminal"] [package] name = "buzz-desktop" diff --git a/desktop/src-tauri/crates/buzz-terminal/Cargo.toml b/desktop/src-tauri/crates/buzz-terminal/Cargo.toml new file mode 100644 index 0000000000..eb37397012 --- /dev/null +++ b/desktop/src-tauri/crates/buzz-terminal/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "buzz-terminal" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" + +[dependencies] +alacritty_terminal = { version = "0.26.0", default-features = false } +parking_lot = "0.12" + +[dev-dependencies] +# Tests reach into the grid to prove content survived a frame. +alacritty_terminal = { version = "0.26.0", default-features = false } +parking_lot = "0.12" diff --git a/desktop/src-tauri/crates/buzz-terminal/src/damage.rs b/desktop/src-tauri/crates/buzz-terminal/src/damage.rs new file mode 100644 index 0000000000..f0e235b1ae --- /dev/null +++ b/desktop/src-tauri/crates/buzz-terminal/src/damage.rs @@ -0,0 +1,228 @@ +//! Turning grid changes into frames for the renderer. +//! +//! Two rules shape this module, both measured: +//! +//! 1. **Nothing but reading and copying happens under the `Term` lock.** The +//! caller copies rows out; encoding, hashing and serializing run after the +//! lock is released. Encoding inline costs ~75x in lock hold. +//! 2. **Damage over-reports.** `Term::damage()` marks the cursor line every +//! call, so an idle terminal reports damage nearly every frame. Per-line +//! content hashing suppresses those, so the transport never sees a no-op. + +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; + +use alacritty_terminal::grid::Dimensions; +use alacritty_terminal::index::{Column, Line}; +use alacritty_terminal::term::cell::{Cell, Flags}; +use alacritty_terminal::term::{Term, TermDamage}; + +use crate::listener::Listener; + +/// A run of cells sharing one visual style. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Span { + /// First column of the run. + pub column: usize, + /// The run's text. Grapheme clusters are kept whole: a cell's zerowidth + /// combining marks follow its base character, so the renderer never sees + /// a base and its accent as separate glyphs. + pub text: String, + /// Packed style: fg, bg, and attribute flags. + pub style: Style, +} + +/// Visual style of a span, as the renderer needs it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Style { + pub fg: u32, + pub bg: u32, + pub flags: u16, +} + +/// One changed row. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RowFrame { + pub line: usize, + pub spans: Vec, +} + +/// The cursor, carried separately from row content. +/// +/// Upstream damages the cursor's line on every `damage()` call. If the cursor +/// travelled inside the row payload, every frame would carry a row rewrite for +/// a caret that moved one column. As its own plane it costs a few bytes and +/// leaves row dedup free to suppress the row. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CursorFrame { + pub line: usize, + pub column: usize, + pub visible: bool, +} + +/// One update for the renderer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Frame { + pub rows: Vec, + pub cursor: CursorFrame, + /// Whether the renderer should discard what it has and repaint. + pub full: bool, +} + +impl Frame { + /// True when there is nothing for the renderer to do. + pub fn is_empty(&self) -> bool { + self.rows.is_empty() && !self.full + } +} + +/// Raw rows copied out from under the lock, awaiting encode. +pub struct RawFrame { + rows: Vec<(usize, Vec)>, + cursor: CursorFrame, + full: bool, +} + +/// Copy the damaged rows out of the terminal. **Runs under the lock; does no +/// encoding.** Keep this function boring — everything added here is lock hold. +pub fn capture(term: &mut Term) -> RawFrame { + let columns = term.columns(); + let screen_lines = term.screen_lines(); + let cursor_point = term.grid().cursor.point; + let visible = term + .mode() + .contains(alacritty_terminal::term::TermMode::SHOW_CURSOR); + + let (lines, full) = match term.damage() { + TermDamage::Full => ((0..screen_lines).collect::>(), true), + TermDamage::Partial(iter) => ( + iter.map(|bounds| bounds.line) + .filter(|l| *l < screen_lines) + .collect(), + false, + ), + }; + + let grid = term.grid(); + let mut rows = Vec::with_capacity(lines.len()); + for line in lines { + let row = &grid[Line(line as i32)]; + rows.push((line, row[..Column(columns)].to_vec())); + } + let cursor = CursorFrame { + line: cursor_point.line.0.max(0) as usize, + column: cursor_point.column.0, + visible, + }; + + term.reset_damage(); + RawFrame { rows, cursor, full } +} + +/// Suppresses rows whose content did not actually change. +#[derive(Default)] +pub struct Encoder { + hashes: Vec, +} + +impl Encoder { + pub fn new() -> Self { + Self::default() + } + + /// Encode a captured frame. **Runs with the lock released.** + pub fn encode(&mut self, raw: RawFrame) -> Frame { + if raw.full { + self.hashes.clear(); + } + let mut rows = Vec::with_capacity(raw.rows.len()); + for (line, cells) in raw.rows { + let hash = hash_cells(&cells); + if self.hashes.len() <= line { + self.hashes.resize(line + 1, 0); + } + if self.hashes[line] == hash { + continue; + } + self.hashes[line] = hash; + rows.push(RowFrame { + line, + spans: spans(&cells), + }); + } + Frame { + rows, + cursor: raw.cursor, + full: raw.full, + } + } +} + +fn hash_cells(cells: &[Cell]) -> u64 { + let mut hasher = DefaultHasher::new(); + for cell in cells { + cell.c.hash(&mut hasher); + // Hash the *packed* colors, not the enum: this is the representation + // the renderer receives, so the dedup key cannot disagree with the + // wire encoding and suppress a row that actually changed on screen. + pack_color(cell.fg).hash(&mut hasher); + pack_color(cell.bg).hash(&mut hasher); + cell.flags.bits().hash(&mut hasher); + if let Some(zerowidth) = cell.zerowidth() { + zerowidth.hash(&mut hasher); + } + } + hasher.finish() +} + +/// Group a row's cells into styled runs. +fn spans(cells: &[Cell]) -> Vec { + let mut spans: Vec = Vec::new(); + for (column, cell) in cells.iter().enumerate() { + // A wide glyph occupies two cells: the character, then a spacer. The + // spacer carries no text of its own -- emitting its placeholder space + // would insert a phantom column after every CJK character or emoji. + if cell.flags.contains(Flags::WIDE_CHAR_SPACER) { + continue; + } + let style = style_of(cell); + let mut text = String::new(); + text.push(cell.c); + if let Some(zerowidth) = cell.zerowidth() { + text.extend(zerowidth); + } + match spans.last_mut() { + Some(last) if last.style == style => last.text.push_str(&text), + _ => spans.push(Span { + column, + text, + style, + }), + } + } + spans +} + +fn style_of(cell: &Cell) -> Style { + Style { + fg: pack_color(cell.fg), + bg: pack_color(cell.bg), + flags: cell.flags.bits(), + } +} + +/// Pack a color into a tagged u32 the renderer resolves against the theme. +/// +/// Named and indexed colors stay symbolic rather than being resolved here: +/// the substrate must follow the user's chosen theme, so the palette belongs +/// to the renderer, not to a snapshot taken at damage time. +fn pack_color(color: alacritty_terminal::vte::ansi::Color) -> u32 { + use alacritty_terminal::vte::ansi::Color; + match color { + Color::Named(named) => 0x0100_0000 | named as u32, + Color::Indexed(index) => 0x0200_0000 | index as u32, + Color::Spec(rgb) => { + 0x0300_0000 | ((rgb.r as u32) << 16) | ((rgb.g as u32) << 8) | rgb.b as u32 + } + } +} diff --git a/desktop/src-tauri/crates/buzz-terminal/src/fences.rs b/desktop/src-tauri/crates/buzz-terminal/src/fences.rs new file mode 100644 index 0000000000..92e651c28c --- /dev/null +++ b/desktop/src-tauri/crates/buzz-terminal/src/fences.rs @@ -0,0 +1,98 @@ +//! The two hardening fences, and the counters that prove they ran. +//! +//! A hostile program can hold the parser's synchronized-update buffer open +//! (BSU without ESU) or an OSC string open, and upstream will buffer without +//! bound. Two independent fences, both enforced on **byte counts** — never on +//! a clock, because a clock makes the bound depend on how fast the machine is: +//! +//! * **F1** aborts a synchronized update once its buffer reaches [`SYNC_CAP`]. +//! * **F2** rebuilds the parser once [`OSC_BUDGET`] parser-visible bytes have +//! been charged without the parser returning to a clean state. +//! +//! F1 is also the interactive-latency fence. Without it a 2 MiB synchronized +//! frame releases into the parser in one call, holding the `Term` lock for +//! ~13 ms; with it the same frame arrives in 64 KiB pieces and renderer lock +//! acquisition drops from ~4.2 ms to ~29 us (146x). Deleting F1 regresses both +//! memory and latency. + +/// Max bytes a synchronized update may buffer before it is aborted. +pub const SYNC_CAP: usize = 64 << 10; + +/// Max parser-visible bytes chargeable before the parser is rebuilt. +pub const OSC_BUDGET: usize = 256 << 10; + +/// Which fences are active. Both on in production. +/// +/// The mutation law requires exercising each fence with the other **disabled**, +/// because F1's abort releases the sync buffer in small pieces and thereby +/// masks a miscounting F2. This is deliberately a runtime value and not a cargo +/// feature: a fence that can be compiled out is one more way for a gate to pass +/// green over code that never ran. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Fences { + /// F1: abort a synchronized update at [`SYNC_CAP`]. + pub sync_abort: bool, + /// F2: rebuild the parser at [`OSC_BUDGET`]. + pub osc_budget: bool, +} + +impl Default for Fences { + fn default() -> Self { + Self { + sync_abort: true, + osc_budget: true, + } + } +} + +impl Fences { + /// Production configuration: both fences enforced. + pub const ALL: Self = Self { + sync_abort: true, + osc_budget: true, + }; + /// F2 alone — the arm that can observe F2's counting, unmasked by F1. + pub const OSC_ONLY: Self = Self { + sync_abort: false, + osc_budget: true, + }; + /// F1 alone. + pub const SYNC_ONLY: Self = Self { + sync_abort: true, + osc_budget: false, + }; + /// Neither — the unfenced control that shows what upstream does alone. + pub const NONE: Self = Self { + sync_abort: false, + osc_budget: false, + }; +} + +/// Per-run fence observations. Every field is what some gate asserts on. +/// +/// `charged_bytes` is deliberately separate from `osc_resets`: a deleted F2 +/// shows up as `osc_resets == 0`, but an F2 that counts the *wrong* bytes +/// (omitting flush routes, or charging raw input) still resets — only the +/// charge total distinguishes those. One counter cannot see both mutations. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct FenceStats { + /// F1 aborts performed. + pub sync_aborts: u64, + /// Largest number of bytes released to the parser by a single flush, + /// whether via an F1 abort or a legitimate end-of-update. This is the + /// quantity that bounds one lock hold. + pub max_release: usize, + /// F2 parser rebuilds performed. + pub osc_resets: u64, + /// Parser-visible bytes charged against the F2 budget, cumulative across + /// resets. Includes every flush route, not just directly-advanced input. + pub charged_bytes: u64, +} + +impl FenceStats { + /// Clear all counters. Diagnostics are per-run; a gate that reads a + /// counter accumulated across runs is asserting on the wrong thing. + pub fn reset(&mut self) { + *self = Self::default(); + } +} diff --git a/desktop/src-tauri/crates/buzz-terminal/src/lib.rs b/desktop/src-tauri/crates/buzz-terminal/src/lib.rs new file mode 100644 index 0000000000..c20c136a58 --- /dev/null +++ b/desktop/src-tauri/crates/buzz-terminal/src/lib.rs @@ -0,0 +1,117 @@ +//! Terminal engine for the Buzz substrate. +//! +//! Owns the emulator: grid state, the parser, the two hardening fences, and +//! the damage encoding the renderer consumes. It does **not** own the PTY, the +//! child process, or the transport — those are the embedder's, so this crate +//! stays testable against byte fixtures with no process and no window. + +pub mod damage; +pub mod fences; +pub mod listener; +pub mod reader; +pub mod shared; + +use alacritty_terminal::grid::Dimensions; +use alacritty_terminal::term::{Config, Osc52, Term}; +use alacritty_terminal::vte::ansi::CursorStyle; + +pub use fences::{FenceStats, Fences}; +pub use listener::{Action, Listener}; +pub use shared::{AcquireMeter, AcquireStats, SharedTerminal}; + +/// Terminal dimensions in cells, plus how much scrollback to retain. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Size { + pub columns: usize, + pub screen_lines: usize, + pub scrollback: usize, +} + +impl Default for Size { + fn default() -> Self { + Self { + columns: 80, + screen_lines: 24, + scrollback: 10_000, + } + } +} + +impl Dimensions for Size { + fn total_lines(&self) -> usize { + self.screen_lines + self.scrollback + } + + fn screen_lines(&self) -> usize { + self.screen_lines + } + + fn columns(&self) -> usize { + self.columns + } +} + +/// Build the emulator config. +/// +/// Written as an explicit literal rather than `..Default::default()` so that +/// every security-relevant field is stated here and an upstream default change +/// cannot alter our posture silently. In particular `osc52` defaults to +/// `OnlyCopy` upstream, which would let terminal output write the user's +/// clipboard; we disable it outright. +pub fn config(size: Size) -> Config { + Config { + scrolling_history: size.scrollback, + default_cursor_style: CursorStyle::default(), + vi_mode_cursor_style: None, + semantic_escape_chars: String::from(",│`|:\"' ()[]{}<>\t"), + kitty_keyboard: false, + osc52: Osc52::Disabled, + } +} + +/// A terminal: emulator state plus the fenced parser that drives it. +pub struct Terminal { + term: Term, + feeder: reader::Feeder, + size: Size, +} + +impl Terminal { + pub fn new(size: Size, fences: Fences) -> (Self, std::sync::mpsc::Receiver) { + let (listener, actions) = Listener::new(); + let term = Term::new(config(size), &size, listener); + ( + Self { + term, + feeder: reader::Feeder::new(fences), + size, + }, + actions, + ) + } + + /// Feed PTY output through the fences into the emulator. + pub fn feed(&mut self, bytes: &[u8]) { + self.feeder.feed(&mut self.term, bytes); + } + + pub fn stats(&self) -> FenceStats { + self.feeder.stats() + } + + pub fn reset_stats(&mut self) { + self.feeder.reset_stats(); + } + + pub fn size(&self) -> Size { + self.size + } + + pub fn term(&self) -> &Term { + &self.term + } + + pub fn term_mut(&mut self) -> &mut Term { + &mut self.term + } +} diff --git a/desktop/src-tauri/crates/buzz-terminal/src/listener.rs b/desktop/src-tauri/crates/buzz-terminal/src/listener.rs new file mode 100644 index 0000000000..202cc063e9 --- /dev/null +++ b/desktop/src-tauri/crates/buzz-terminal/src/listener.rs @@ -0,0 +1,115 @@ +//! The closed set of terminal events we act on. +//! +//! `EventListener` is how the emulator asks the embedder to do something. Most +//! of those requests write back to the PTY, and one of them — `ClipboardLoad` — +//! would let terminal output read the user's clipboard into the shell. We +//! answer a fixed set and **drop everything else by default**, so a new upstream +//! variant is inert until someone deliberately handles it. + +use std::fmt; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::sync::Arc; + +use alacritty_terminal::event::{Event, EventListener, WindowSize}; +use alacritty_terminal::vte::ansi::Rgb; + +/// Something the embedder must do on the terminal's behalf. +/// +/// Two variants carry upstream's reply formatters rather than a finished +/// string: the answers depend on state this listener does not own (the color +/// palette, the cell metrics). Resolving them here would mean inventing +/// values, and a program that asked for its terminal's real background color +/// would silently be told black. +#[derive(Clone)] +pub enum Action { + /// Write bytes back to the PTY. + PtyWrite(String), + /// Reply with palette entry `index`, formatted by `format`. + ColorReply { + index: usize, + format: Arc String + Send + Sync>, + }, + /// Reply with the text area size, formatted by `format`. + SizeReply { + format: Arc String + Send + Sync>, + }, + /// The program set the window title (already clamped). + Title(String), + /// The program reset the window title. + ResetTitle, + /// New content is available; the renderer should sample damage. + Wakeup, + /// The program rang the bell. + Bell, +} + +impl fmt::Debug for Action { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::PtyWrite(text) => write!(f, "PtyWrite({text:?})"), + Self::ColorReply { index, .. } => write!(f, "ColorReply({index})"), + Self::SizeReply { .. } => write!(f, "SizeReply"), + Self::Title(title) => write!(f, "Title({title:?})"), + Self::ResetTitle => write!(f, "ResetTitle"), + Self::Wakeup => write!(f, "Wakeup"), + Self::Bell => write!(f, "Bell"), + } + } +} + +/// Longest title we will carry. A title is program-controlled text that ends up +/// in UI chrome; an unbounded one is a memory and layout problem. +pub const TITLE_LIMIT: usize = 512; + +/// Clamp on a character boundary, never mid-UTF-8. +fn clamp_title(title: String) -> String { + match title.char_indices().nth(TITLE_LIMIT) { + None => title, + Some((byte_idx, _)) => title[..byte_idx].to_string(), + } +} + +/// Translates upstream events into the closed [`Action`] set. +#[derive(Clone)] +pub struct Listener(Sender); + +impl Listener { + pub fn new() -> (Self, Receiver) { + let (tx, rx) = mpsc::channel(); + (Self(tx), rx) + } +} + +impl EventListener for Listener { + fn send_event(&self, event: Event) { + let action = match event { + // Replies the program is waiting on. These are the only routes by + // which emulator state travels back into the shell. + Event::PtyWrite(text) => Action::PtyWrite(text), + // A program blocked on a color reply must get one, or it hangs. + // The palette lives in `Term`, so the caller resolves the index; + // the formatter is carried through untouched. + Event::ColorRequest(index, format) => Action::ColorReply { index, format }, + Event::TextAreaSizeRequest(format) => Action::SizeReply { format }, + Event::Title(title) => Action::Title(clamp_title(title)), + Event::ResetTitle => Action::ResetTitle, + Event::Wakeup => Action::Wakeup, + Event::Bell => Action::Bell, + + // Dropped on purpose, and enumerated so the reason survives: + // + // ClipboardLoad would let terminal output paste the user's + // clipboard into the shell. Never handled. + Event::ClipboardLoad(..) => return, + // ClipboardStore is an OSC 52 write; OSC 52 is disabled in the + // Term config, so this should be unreachable rather than merely + // unhandled. + Event::ClipboardStore(..) => return, + // Presentation concerns the renderer polls for; no action here. + Event::MouseCursorDirty | Event::CursorBlinkingChange => return, + // Lifecycle is owned by the PTY layer, not the emulator. + Event::Exit | Event::ChildExit(_) => return, + }; + let _ = self.0.send(action); + } +} diff --git a/desktop/src-tauri/crates/buzz-terminal/src/reader.rs b/desktop/src-tauri/crates/buzz-terminal/src/reader.rs new file mode 100644 index 0000000000..a55f4d1edd --- /dev/null +++ b/desktop/src-tauri/crates/buzz-terminal/src/reader.rs @@ -0,0 +1,103 @@ +//! Fence enforcement around `vte`'s `Processor`. +//! +//! Everything that reaches the terminal's parser goes through [`Feeder::feed`]. +//! It is the single place both fences are applied, so there is no route by +//! which bytes become parser-visible without being charged. + +use alacritty_terminal::vte::ansi::{Handler, Processor, StdSyncHandler}; + +use crate::fences::{FenceStats, Fences, OSC_BUDGET, SYNC_CAP}; + +/// Owns the parser and enforces F1/F2 on every byte fed to it. +pub struct Feeder { + parser: Processor, + fences: Fences, + stats: FenceStats, + /// Bytes charged since the last F2 reset. + since_reset: usize, +} + +impl Feeder { + pub fn new(fences: Fences) -> Self { + Self { + parser: Processor::new(), + fences, + stats: FenceStats::default(), + since_reset: 0, + } + } + + pub fn stats(&self) -> FenceStats { + self.stats + } + + pub fn reset_stats(&mut self) { + self.stats.reset(); + } + + /// Bytes currently buffered inside a synchronized update. + pub fn pending_sync_bytes(&self) -> usize { + self.parser.sync_bytes_count() + } + + /// Feed one chunk of PTY output to the parser, applying both fences. + pub fn feed(&mut self, handler: &mut H, bytes: &[u8]) { + let sync_before = self.parser.sync_bytes_count(); + self.parser.advance(handler, bytes); + let sync_after = self.parser.sync_bytes_count(); + + // Charge exactly the bytes the parser could see, by route: + // + // * the buffer shrank -> a synchronized update ended and released + // `sync_before` buffered bytes plus whatever of `bytes` followed it. + // Charging only `bytes` here is the "omitted flush accounting" + // mutation: it under-charges by the whole buffered frame. + // * the buffer grew -> these bytes were swallowed into the buffer + // and are not yet parser-visible. Charging them now is the "raw + // counting" mutation: it over-charges, and resets the parser in the + // middle of a legitimate frame, destroying content. + // * neither -> ordinary unsynchronized input. + let charged = if sync_after < sync_before { + let released = sync_before + bytes.len() - sync_after; + self.note_release(released); + released + } else if sync_after > sync_before { + // Buffered, not yet visible. Charged when it is released. + 0 + } else { + bytes.len() + }; + self.charge(charged); + + // F1: a synchronized update may not buffer without bound. One abort + // per breach; the released bytes are parser-visible and are charged. + if self.fences.sync_abort && self.parser.sync_bytes_count() >= SYNC_CAP { + let released = self.parser.sync_bytes_count(); + self.parser.stop_sync(handler); + self.stats.sync_aborts += 1; + self.note_release(released); + self.charge(released); + } + + // F2: rebuild the parser once the budget is spent. Unconditional -- + // a fresh `Processor` is the only way to discard parser state that a + // hostile stream is holding open, and it must not depend on the + // parser agreeing that it is in a bad state. + if self.fences.osc_budget && self.since_reset >= OSC_BUDGET { + self.parser = Processor::new(); + self.stats.osc_resets += 1; + self.since_reset = 0; + } + } + + fn charge(&mut self, bytes: usize) { + self.since_reset += bytes; + self.stats.charged_bytes += bytes as u64; + } + + fn note_release(&mut self, bytes: usize) { + if bytes > self.stats.max_release { + self.stats.max_release = bytes; + } + } +} diff --git a/desktop/src-tauri/crates/buzz-terminal/src/shared.rs b/desktop/src-tauri/crates/buzz-terminal/src/shared.rs new file mode 100644 index 0000000000..dba7de80da --- /dev/null +++ b/desktop/src-tauri/crates/buzz-terminal/src/shared.rs @@ -0,0 +1,184 @@ +//! The lock the reader and the renderer contend for, and the meter on it. +//! +//! This lives in the engine crate rather than in the embedder because the +//! property it exists to prove is a property of the emulator *and* the lock +//! together: F1 bounds how many bytes one `feed` releases into the parser, +//! which bounds how long the reader can hold this mutex, which bounds how long +//! the renderer waits for it. Split the lock out to the Tauri layer and the +//! gate can only be written where no fixture runs. +//! +//! Measured, not assumed. Under a 180 MB/s flood the reader's own hold is +//! p50 1 us while the renderer's *acquire* is p50 4245 us -- four orders apart, +//! because 0.389% of calls carry 96.4% of the lock time. Holding time is the +//! wrong quantity; waiting time is the one a human feels. So the two planes are +//! metered separately: pooling them would let the reader's millions of fast +//! acquires dilute the renderer's tail into a false pass. + +use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; +use std::time::Instant; + +use alacritty_terminal::sync::FairMutex; +use parking_lot::MutexGuard; + +use crate::damage::{self, Encoder, Frame}; +use crate::Terminal; + +/// Number of latency buckets. Bucket `i` covers `[2^(i-1), 2^i)` microseconds, +/// so bucket 31 tops out around 35 minutes -- unreachable in practice, which is +/// the point: nothing is silently clamped into the last bucket. +const BUCKETS: usize = 32; + +fn bucket_of(micros: u64) -> usize { + (u64::BITS - micros.leading_zeros()) as usize +} + +/// Upper bound of a bucket, in microseconds. Percentiles report this, so a +/// reported latency is never better than what was actually observed. +fn bucket_ceiling(bucket: usize) -> u64 { + if bucket == 0 { + 0 + } else { + (1u64 << bucket) - 1 + } +} + +/// Lock-acquisition latencies for one plane, recorded without taking a second +/// lock -- an instrument that contends is measuring itself. +#[derive(Debug)] +pub struct AcquireMeter { + acquisitions: AtomicU64, + max_micros: AtomicU64, + buckets: [AtomicU32; BUCKETS], +} + +impl Default for AcquireMeter { + fn default() -> Self { + Self { + acquisitions: AtomicU64::new(0), + max_micros: AtomicU64::new(0), + buckets: std::array::from_fn(|_| AtomicU32::new(0)), + } + } +} + +impl AcquireMeter { + fn record(&self, micros: u64) { + self.acquisitions.fetch_add(1, Ordering::Relaxed); + self.max_micros.fetch_max(micros, Ordering::Relaxed); + self.buckets[bucket_of(micros)].fetch_add(1, Ordering::Relaxed); + } + + /// Read the counters. Cheap and non-blocking; safe to call from a gate + /// while the flood is still running. + pub fn snapshot(&self) -> AcquireStats { + AcquireStats { + acquisitions: self.acquisitions.load(Ordering::Relaxed), + max_micros: self.max_micros.load(Ordering::Relaxed), + buckets: std::array::from_fn(|i| self.buckets[i].load(Ordering::Relaxed)), + } + } + + /// Clear the counters. Diagnostics are per-run. + pub fn reset(&self) { + self.acquisitions.store(0, Ordering::Relaxed); + self.max_micros.store(0, Ordering::Relaxed); + for bucket in &self.buckets { + bucket.store(0, Ordering::Relaxed); + } + } +} + +/// A read of one plane's acquisition latencies. +/// +/// `max_micros` is exact because the budget it answers to -- no acquire above +/// one frame at 60 Hz -- is a statement about a single worst event. The +/// distribution is bucketed by powers of two because the budget *it* answers to +/// has 80x of headroom (p95 measured at 49 us against 4 ms), and a factor-of-two +/// resolution against 80x of margin buys nothing for the memory it costs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AcquireStats { + pub acquisitions: u64, + pub max_micros: u64, + buckets: [u32; BUCKETS], +} + +impl AcquireStats { + /// Latency at percentile `p` (0.0..=1.0), in microseconds, rounded up to + /// the enclosing bucket's ceiling. + pub fn percentile_micros(&self, p: f64) -> u64 { + if self.acquisitions == 0 { + return 0; + } + let target = (self.acquisitions as f64 * p).ceil() as u64; + let mut seen = 0u64; + for (bucket, count) in self.buckets.iter().enumerate() { + seen += *count as u64; + if seen >= target { + return bucket_ceiling(bucket); + } + } + self.max_micros + } +} + +/// A [`Terminal`] shared between the PTY reader and the renderer. +pub struct SharedTerminal { + term: FairMutex, + reader: AcquireMeter, + renderer: AcquireMeter, +} + +impl SharedTerminal { + pub fn new(term: Terminal) -> Self { + Self { + term: FairMutex::new(term), + reader: AcquireMeter::default(), + renderer: AcquireMeter::default(), + } + } + + /// Acquisition latencies for the PTY-reader plane. + pub fn reader_acquire(&self) -> &AcquireMeter { + &self.reader + } + + /// Acquisition latencies for the renderer plane. This is the one with a + /// budget attached. + pub fn renderer_acquire(&self) -> &AcquireMeter { + &self.renderer + } + + /// Feed PTY output into the emulator. Reader plane. + pub fn feed(&self, bytes: &[u8]) { + self.acquire(&self.reader).feed(bytes); + } + + /// Sample damage and encode a frame. Renderer plane. + /// + /// The lock covers the copy only; `encode` -- hashing, span grouping, + /// allocation -- runs after the guard drops, which is worth ~75x in hold + /// time. The `Encoder` is the caller's because its dedup state is per + /// consumer, and passing it in keeps the encode off this lock by + /// construction rather than by remembering to. + pub fn render(&self, encoder: &mut Encoder) -> Frame { + let raw = { + let mut term = self.acquire(&self.renderer); + damage::capture(term.term_mut()) + }; + encoder.encode(raw) + } + + /// Take the lock for something the two methods above don't cover (resize, + /// input, reading stats). Metered on the renderer plane, since anything + /// that isn't the read loop competes with the renderer for the same lock. + pub fn lock(&self) -> MutexGuard<'_, Terminal> { + self.acquire(&self.renderer) + } + + fn acquire(&self, meter: &AcquireMeter) -> MutexGuard<'_, Terminal> { + let started = Instant::now(); + let guard = self.term.lock(); + meter.record(started.elapsed().as_micros() as u64); + guard + } +} diff --git a/desktop/src-tauri/crates/buzz-terminal/tests/fences.rs b/desktop/src-tauri/crates/buzz-terminal/tests/fences.rs new file mode 100644 index 0000000000..ad65a95709 --- /dev/null +++ b/desktop/src-tauri/crates/buzz-terminal/tests/fences.rs @@ -0,0 +1,180 @@ +//! Fence behaviour against byte fixtures. No PTY, no process, no window -- +//! these are pure functions of input bytes and run identically on every +//! platform. +//! +//! Per the mutation law, arms that can be masked by the *other* fence run with +//! that fence disabled. F1's abort releases a synchronized buffer in 64 KiB +//! pieces, which hides an F2 that charges the wrong bytes. + +use buzz_terminal::fences::{Fences, OSC_BUDGET, SYNC_CAP}; +use buzz_terminal::{Size, Terminal}; + +fn size() -> Size { + Size { + columns: 120, + screen_lines: 40, + scrollback: 2000, + } +} + +/// A hostile stream: open a synchronized update, then never close it. +fn unterminated_sync(total: usize, chunk: usize) -> Vec> { + let mut chunks = vec![b"\x1b[?2026h".to_vec()]; + let mut sent = 0; + while sent < total { + chunks.push(vec![b'A'; chunk]); + sent += chunk; + } + chunks +} + +/// A legitimate frame: one synchronized update carrying `markers` findable +/// markers, closed properly. +fn legit_frame(markers: usize, per_marker: usize) -> Vec { + let mut payload = Vec::new(); + payload.extend_from_slice(b"\x1b[?2026h\x1b[2J\x1b[H"); + for marker in 0..markers { + payload.extend_from_slice(format!("MK{marker:03}\r\n").as_bytes()); + let target = payload.len() + per_marker; + while payload.len() < target { + payload.extend_from_slice(b"\x1b[1;32mx\x1b[0m"); + } + payload.extend_from_slice(b"\r\n"); + } + payload.extend_from_slice(b"\x1b[?2026l"); + payload +} + +fn count_markers(term: &Terminal, markers: usize) -> usize { + use alacritty_terminal::grid::Dimensions; + use alacritty_terminal::index::{Column, Line, Point}; + let grid = term.term().grid(); + let mut text = String::new(); + let top = -(grid.history_size() as i32); + for line in top..term.size().screen_lines as i32 { + for column in 0..term.size().columns { + text.push(grid[Point::new(Line(line), Column(column))].c); + } + text.push('\n'); + } + (0..markers) + .filter(|m| text.contains(&format!("MK{m:03}"))) + .count() +} + +/// G1: a synchronized update may not buffer without bound. +#[test] +fn g1_sync_abort_bounds_one_release() { + let (mut term, _actions) = Terminal::new(size(), Fences::ALL); + for chunk in unterminated_sync(8 << 20, 8192) { + term.feed(&chunk); + } + let stats = term.stats(); + assert!( + stats.sync_aborts > 0, + "F1 never fired on an unterminated update" + ); + // One chunk may arrive after the cap is reached but before the abort, so + // the bound is the cap plus one read. + assert!( + stats.max_release <= SYNC_CAP + 8192, + "single release of {} B exceeds the F1 bound", + stats.max_release + ); +} + +/// The unfenced control: without F1 the same stream releases in ~2 MiB gulps. +/// If this stops holding, `g1_sync_abort_bounds_one_release` proves nothing. +/// +/// Note what is asserted: **peak release**, not the trailing buffer. vte caps +/// its own sync buffer at `SYNC_BUFFER_SIZE` (2 MiB, `vte-0.15.0` ansi.rs:39) +/// and self-flushes there, so the buffer is near-empty when the stream stops. +/// That upstream cap is the ~2 MiB residual the plan scopes to "F1 absent or +/// ineffective" -- it is a backstop 32x above our bound, not a substitute. +#[test] +fn g1_control_unfenced_releases_in_megabyte_gulps() { + let (mut term, _actions) = Terminal::new(size(), Fences::NONE); + for chunk in unterminated_sync(8 << 20, 8192) { + term.feed(&chunk); + } + let stats = term.stats(); + assert_eq!(stats.sync_aborts, 0); + assert!( + stats.max_release > SYNC_CAP, + "unfenced release peaked at {} B, inside the F1 bound; the fixture is not hostile \ + and the G1 assertion is vacuous", + stats.max_release + ); +} + +/// G2 arm 1: F2 fires on a hostile unsynchronized stream. +/// F1 is retained here on purpose -- this arm proves F2 *deletion* under the +/// combined production shape. +#[test] +fn g2_hostile_osc_triggers_reset() { + let (mut term, _actions) = Terminal::new(size(), Fences::ALL); + let mut chunks = vec![b"\x1b]0;".to_vec()]; + for _ in 0..256 { + chunks.push(vec![b'A'; 8192]); + } + for chunk in &chunks { + term.feed(chunk); + } + assert!(term.stats().osc_resets > 0, "F2 never rebuilt the parser"); +} + +/// G2 arm 2: charge accounting includes the flush route. +/// +/// **F1 disabled** -- with it on, the abort releases the buffer in pieces that +/// are charged as ordinary input, so an F2 that ignores flushes still resets +/// and the arm cannot see the mutation. +#[test] +fn g2_flush_route_is_charged() { + let (mut term, _actions) = Terminal::new(size(), Fences::OSC_ONLY); + // Each frame buffers just under the budget, then releases it all at once. + // The released bytes are parser-visible only at the ESU. + let frame_bytes = OSC_BUDGET - (32 << 10); + let frames = 8; + for _ in 0..frames { + term.feed(b"\x1b[?2026h"); + let mut sent = 0; + while sent < frame_bytes { + term.feed(&vec![b'A'; 8192]); + sent += 8192; + } + term.feed(b"\x1b[?2026l"); + } + let stats = term.stats(); + assert_eq!( + stats.sync_aborts, 0, + "F1 must be off for this arm to discriminate" + ); + assert!( + stats.charged_bytes >= (frames * frame_bytes) as u64, + "charged {} B of {} B released through the flush route", + stats.charged_bytes, + frames * frame_bytes + ); + assert!( + stats.osc_resets > 0, + "budget never spent: flush accounting omitted" + ); +} + +/// G2 arm 3: a legitimate frame survives intact. +/// +/// **F1 disabled** -- this is the arm that catches raw counting, and F1's +/// abort masks it by releasing the buffer before the raw count can overrun. +#[test] +fn g2_legitimate_frame_survives() { + let markers = 200; + let (mut term, _actions) = Terminal::new(size(), Fences::OSC_ONLY); + for chunk in legit_frame(markers, 7853).chunks(8192) { + term.feed(chunk); + } + assert_eq!( + count_markers(&term, markers), + markers, + "content lost from a legitimate frame" + ); +} diff --git a/desktop/src-tauri/crates/buzz-terminal/tests/latency.rs b/desktop/src-tauri/crates/buzz-terminal/tests/latency.rs new file mode 100644 index 0000000000..004e9c3e55 --- /dev/null +++ b/desktop/src-tauri/crates/buzz-terminal/tests/latency.rs @@ -0,0 +1,136 @@ +//! G3: the renderer's wait for the terminal lock, under flood. +//! +//! The plan originally required "reader hold < 16.7 ms". That requirement was +//! struck: measured under a 180 MB/s flood, reader hold is p50 1 us while +//! renderer *acquire* is p50 4245 us. Hold time passes trivially while the +//! window is visibly stuck, because 0.389% of feeds carry 96.4% of the lock +//! time and the p50 hold never sees them. What a human feels is the wait, so +//! that is what is gated here. +//! +//! F1 is the fence being tested. It is a memory bound *and* a latency fence: +//! it turns one ~2 MiB parser release into ~64 KiB pieces, and the renderer's +//! wait falls with it. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::thread; +use std::time::Duration; + +use buzz_terminal::damage::Encoder; +use buzz_terminal::fences::Fences; +use buzz_terminal::{SharedTerminal, Size, Terminal}; + +/// One frame at 60 Hz. No acquire may exceed this: a single wait this long is +/// a dropped frame regardless of how good the distribution looks. +const FRAME_MICROS: u64 = 16_667; + +/// p95 budget. Measured at 127 us with F1 on -- 31x of headroom, which is the +/// margin that lets this run on a loaded CI runner without becoming a coin flip. +const P95_MICROS: u64 = 4_000; + +/// Frames sampled per arm. Counted rather than timed: sample count under a +/// wall-clock budget is a function of how slow the arm is, so a duration-based +/// loop gives the *unfenced* arm the fewest samples -- fewest exactly where the +/// tail being measured lives. Counting frames makes both arms the same +/// experiment. +const FRAMES: u32 = 200; + +/// A ~2 MiB synchronized update, closed, replayed in PTY-sized reads. +/// +/// The payload's *shape* is the load-bearing part, and it cost me a wrong +/// result to learn it. An earlier version poured 8 KiB blocks of `A` into an +/// update that was never closed. It floods just as many bytes per second, and +/// it does not discriminate F1 at all: measured p95 63 us fenced vs 63 us +/// unfenced. Plain `A` overwrites one line at a few ns per byte, so even a +/// 2 MiB release is a short lock hold. +/// +/// What makes a release expensive is work per byte -- SGR state changes and +/// `\r\n` line feeds that push rows into scrollback. With that payload the same +/// experiment separates by 129x. So this gate is sensitive to input shape and +/// not merely to input rate, which is why the control below is not optional. +fn flood(shared: &SharedTerminal, stop: &AtomicBool) { + let mut payload: Vec = b"\x1b[?2026h".to_vec(); + while payload.len() < (2 << 20) { + payload.extend_from_slice(b"\x1b[1;32mbuzz\x1b[0m substrate line of output 0123456789\r\n"); + } + payload.extend_from_slice(b"\x1b[?2026l"); + while !stop.load(Ordering::Relaxed) { + for chunk in payload.chunks(8192) { + if stop.load(Ordering::Relaxed) { + return; + } + shared.feed(chunk); + } + } +} + +/// Render at 60 Hz for the duration of the flood, and report the renderer +/// plane's acquisition latencies. +fn measure(fences: Fences) -> buzz_terminal::AcquireStats { + let size = Size { + columns: 200, + screen_lines: 50, + scrollback: 10_000, + }; + let (term, _actions) = Terminal::new(size, fences); + let shared = Arc::new(SharedTerminal::new(term)); + let stop = Arc::new(AtomicBool::new(false)); + + let writer = { + let (shared, stop) = (Arc::clone(&shared), Arc::clone(&stop)); + thread::spawn(move || flood(&shared, &stop)) + }; + + // Don't measure the ramp: let the flood reach steady state, then clear. + thread::sleep(Duration::from_millis(200)); + shared.renderer_acquire().reset(); + + let mut encoder = Encoder::new(); + for _ in 0..FRAMES { + shared.render(&mut encoder); + thread::sleep(Duration::from_micros(FRAME_MICROS)); + } + let stats = shared.renderer_acquire().snapshot(); + + stop.store(true, Ordering::Relaxed); + writer.join().expect("flood thread panicked"); + + assert_eq!(stats.acquisitions, FRAMES as u64, "meter lost samples"); + stats +} + +/// G3: with F1 on, the renderer's wait stays inside a frame -- and the +/// unfenced control shows the fence is what puts it there. +/// +/// Both arms live in one `#[test]` on purpose. As separate tests they run +/// concurrently by default, each with its own flood thread, so each arm's +/// measurement includes the other arm's CPU load and the control's ratio +/// becomes a race between two floods rather than a statement about F1. +#[test] +fn g3_renderer_acquire_stays_within_frame_budget() { + let fenced = measure(Fences::ALL); + let p95 = fenced.percentile_micros(0.95); + assert!( + p95 <= P95_MICROS, + "renderer acquire p95 {p95} us over the {P95_MICROS} us budget (max {} us, n={})", + fenced.max_micros, + fenced.acquisitions + ); + assert!( + fenced.max_micros <= FRAME_MICROS, + "renderer waited {} us for the terminal lock -- a dropped frame (p95 {p95} us, n={})", + fenced.max_micros, + fenced.acquisitions + ); + + // The control. Without it this gate could pass because the fixture never + // contended -- green over an experiment that did not run. + let unfenced = measure(Fences::OSC_ONLY); + assert!( + unfenced.max_micros > fenced.max_micros.max(1) * 4, + "unfenced renderer max {} us vs fenced {} us -- F1 is not what holds \ + renderer latency down, and this gate is measuring something else", + unfenced.max_micros, + fenced.max_micros + ); +}