diff --git a/desktop/src-tauri/crates/buzz-terminal/src/damage.rs b/desktop/src-tauri/crates/buzz-terminal/src/damage.rs index b25f6c76ac..74d2503de3 100644 --- a/desktop/src-tauri/crates/buzz-terminal/src/damage.rs +++ b/desktop/src-tauri/crates/buzz-terminal/src/damage.rs @@ -381,7 +381,14 @@ fn spans(cells: &[Cell]) -> Vec { } open = joinable; } - debug_assert!( + // Enforced in release, not just in debug. This is a *wire* invariant: a + // span that violates it is undecodable by the rule in [`Span`], and the + // consumer's failure is silent misplacement of every cluster after it. + // A `debug_assert` here would vanish in exactly the build where that + // corruption ships. The cost is one pass over text already in cache -- + // the same order as building the spans -- and it buys a loud, local + // failure instead of a renderer quietly drawing the wrong columns. + assert!( spans.iter().all(Span::counts_are_consistent), "cluster_count must be 1 or the span's char count" ); diff --git a/desktop/src-tauri/crates/buzz-terminal/src/fences.rs b/desktop/src-tauri/crates/buzz-terminal/src/fences.rs index 92e651c28c..ed0bc1a1d8 100644 --- a/desktop/src-tauri/crates/buzz-terminal/src/fences.rs +++ b/desktop/src-tauri/crates/buzz-terminal/src/fences.rs @@ -21,6 +21,116 @@ pub const SYNC_CAP: usize = 64 << 10; /// Max parser-visible bytes chargeable before the parser is rebuilt. pub const OSC_BUDGET: usize = 256 << 10; +/// Max cost-weighted work one [`crate::reader::Feeder::drain`] may spend +/// before returning, in cell-equivalents. +/// +/// Derived, not chosen: measured worst-case density across the 2-D op sweep +/// is 16.9 ns/work (`erase_chars` at N=1, 80x24 -- the cheapest real callback, +/// where fixed dispatch cost dominates the single cell it touches), so a +/// 16.67 ms frame is ~988_000 work units. This is a quarter of that. The +/// remaining three quarters are headroom for lock acquisition, the counting +/// wrapper's own bookkeeping, and platforms slower than the one measured; +/// 16.9 ns/work is the max of a sample, not a proven ceiling, so it is not +/// spent to the last unit. +pub const WORK_BUDGET: u64 = 250_000; + +/// Bounds on how many bytes are handed to the parser at once. +/// +/// The budget is checked *between* slices, so the slice is what actually +/// bounds one lock hold, and a fixed byte count cannot do it: `ESC#8` is +/// three bytes and costs a full grid, so 256 bytes of it is 1.6 ms at 200x50 +/// and 14 ms at 1600x50. [`slice_bytes`] therefore derives the size from the +/// grid, and these two clamp it -- `MAX` at the throughput plateau (measured: +/// plain-char parsing saturates by 64 bytes and is flat to 64 KiB), `MIN` +/// where a slice stops being able to hold a whole escape sequence: 4 bytes +/// covers `ESC#8` and `ESC c` intact, so the floor never splits the densest +/// atoms across slices for no benefit. Measured throughput at the floor is +/// ~74% of the plateau on plain text and ~77% on SGR, which is the price of +/// bounding a grid whose worst atom exceeds the budget outright. +pub const MIN_SLICE: usize = 4; +pub const MAX_SLICE: usize = 256; + +/// Bytes to hand the parser at once on a `columns x lines` grid. +/// +/// Sized against the **densest atom the grid admits**, so the bound holds on +/// the first byte of a cold feeder for any payload: two bytes of `ESC c` buy +/// [`max_atom_work`], which is the most work per byte upstream offers, and +/// `budget / (atom / 2)` is the widest slice that cannot exceed one budget. +/// +/// Deliberately *derived rather than learned*. An earlier version sized +/// slices from the density of preceding slices, which is strictly worse where +/// it matters: a fresh feeder has observed nothing, so its first slice is +/// wide, and a first wide slice of RIS spends many budgets before anything +/// looks. A bound that has to be taught is not a bound on the lesson. +/// +/// [`MIN_SLICE`] floors it, and on any grid with real scrollback the floor is +/// what binds -- RIS at the default 10k depth is worth more than the entire +/// budget on its own, so no slice size can keep a drain inside the budget and +/// the floor stops the arithmetic from asking for fractions of a byte. That +/// residual is not hidden: it is exactly [`max_drain_work`], and it is the +/// honest cost of an indivisible callback that upstream can be asked to make +/// smaller only by not calling it. +pub fn slice_bytes(columns: usize, lines: usize, scrollback: usize) -> usize { + let densest = (max_atom_work(columns, lines, scrollback) / 2).max(1); + ((WORK_BUDGET / densest) as usize).clamp(MIN_SLICE, MAX_SLICE) +} + +/// Work the single worst uninterruptible callback can cost on this grid. +/// +/// This is the irreducible overrun past [`WORK_BUDGET`]: no scheduler outside +/// the parser can cut inside a callback, so a caller converting a work budget +/// into a time bound must add it. +/// +/// It is `columns` because [`crate::units::Counting`] terminates CBT at its +/// first fixed point. Upstream's own loop is `N x columns` -- 82 ms for eight +/// bytes at 1600 columns -- and clamping `N` to `columns` only brings that to +/// `columns^2`, which at 1600 is 2.56M work, **10x the whole budget**: the +/// atom, not the budget, would decide the bound. Stopping at the fixed point +/// makes it `columns`, and the budget goes back to being the thing that sets +/// the bound. Every other callback is priced at or below `cells`, which is +/// larger, so this term never dominates. +pub fn max_atom_work(columns: usize, lines: usize, scrollback: usize) -> u64 { + // RIS: both grids plus the primary's configured scrollback. This is the + // largest single callback by a wide margin -- 16x the budget at the + // default 10k depth -- and it is genuinely indivisible, so it is stated + // rather than smoothed. CBT, once terminated at its fixed point, is + // `columns` and never competes. + let ris = 2 * (columns * lines) as u64 + (scrollback * columns) as u64; + ris.max(columns as u64) +} + +/// Upper bound on the work a single [`crate::reader::Feeder::drain`] can do. +/// +/// Two irreducible terms on top of [`WORK_BUDGET`], and it is worth being +/// exact about which is which, because I got this wrong first and the +/// fixtures caught it: +/// +/// * The budget is checked *between* slices, so a drain overshoots by up to +/// one whole slice -- not one atom. [`slice_bytes`] keeps that under one +/// budget wherever its derivation is unclamped. +/// * A callback already running cannot be preempted. RIS at the default 10k +/// scrollback is worth 16x the whole budget on its own, so on such a grid +/// the floor binds and the overshoot is a few of those atoms. No scheduler +/// outside the parser can fix that -- what it can do is *report* it, which +/// is why this is a function callers can read rather than an assumption +/// they inherit. +pub fn max_drain_work(columns: usize, lines: usize, scrollback: usize) -> u64 { + let atom = max_atom_work(columns, lines, scrollback); + // A slice of N bytes holds at most N/2 of the densest atoms, and the + // budget is only consulted between slices, so a whole slice is the + // overshoot. Where the slice derivation is unclamped this is one budget; + // where MIN_SLICE binds, it is this. + WORK_BUDGET + (slice_bytes(columns, lines, scrollback) as u64 / 2).max(1) * atom +} + +/// Max bytes that may sit unparsed before the reader must stop reading the +/// PTY. Bounds the *queue*; [`WORK_BUDGET`] bounds only the lock hold. +pub const TAIL_CAP: usize = 4 << 20; + +/// Depth at which a paused reader may resume. Strictly below [`TAIL_CAP`] so +/// the reader does not flap between full and one-byte-below-full. +pub const TAIL_RESUME: usize = 1 << 20; + /// Which fences are active. Both on in production. /// /// The mutation law requires exercising each fence with the other **disabled**, @@ -87,6 +197,37 @@ pub struct FenceStats { /// Parser-visible bytes charged against the F2 budget, cumulative across /// resets. Includes every flush route, not just directly-advanced input. pub charged_bytes: u64, + /// Parser units completed: one per `Handler` callback dispatched, which is + /// one per fully-parsed escape sequence or printed character. + /// + /// Separate from `charged_bytes` because they answer different questions + /// and can disagree by orders of magnitude: four bytes of `ESC#8` rewrite + /// the whole grid, four bytes of `ESC[m` set a flag. Bytes bound memory; + /// units are the proxy for time. See [`crate::units`]. + pub completed_units: u64, + /// Cost-weighted work completed, in cell-equivalents: an O(cells) callback + /// charges `columns * lines`, an O(1) callback charges 1. + /// + /// Deliberately a second number rather than a replacement for + /// `completed_units`. They answer different questions -- "how many things + /// happened" versus "how much did they cost" -- and a stream of `ESC#8` + /// makes them disagree by four orders of magnitude, which is the entire + /// reason this fence exists. + pub completed_work: u64, + /// Deepest the unparsed tail has been, in bytes. The high-water mark + /// rather than the current depth, because the current depth is zero again + /// by the time a test looks at it. + pub max_pending: usize, + /// Times the tail was at or over [`TAIL_CAP`] at the end of a drain. + /// + /// Loud on purpose. Reaching the cap means the reader kept reading past + /// the point it was told to stop, so the queue bound is being held by + /// nothing; a silent cap would make that indistinguishable from a reader + /// that is obeying. + pub tail_breaches: u64, + /// Bytes discarded unparsed by [`crate::reader::Feeder::abandon_tail`]. + /// Non-zero anywhere but session close is a bug that ate output. + pub abandoned_bytes: u64, } impl FenceStats { diff --git a/desktop/src-tauri/crates/buzz-terminal/src/lib.rs b/desktop/src-tauri/crates/buzz-terminal/src/lib.rs index 855e7a5c65..61e70e70a9 100644 --- a/desktop/src-tauri/crates/buzz-terminal/src/lib.rs +++ b/desktop/src-tauri/crates/buzz-terminal/src/lib.rs @@ -15,6 +15,7 @@ pub mod path; pub mod reader; pub mod shared; pub mod shell; +pub mod units; #[cfg(test)] mod context_tests; @@ -112,7 +113,12 @@ impl Terminal { ( Self { term, - feeder: reader::Feeder::new(fences), + feeder: reader::Feeder::new( + fences, + size.columns, + size.screen_lines, + size.scrollback, + ), size, generation: 0, }, @@ -121,8 +127,53 @@ impl Terminal { } /// Feed PTY output through the fences into the emulator. - pub fn feed(&mut self, bytes: &[u8]) { - self.feeder.feed(&mut self.term, bytes); + /// + /// Parses what one work budget affords and returns with the rest held as + /// a pending tail, so one call cannot hold the terminal for an unbounded + /// time. **The caller must pump [`Terminal::drain`] until it returns + /// false**, releasing the lock between calls; that is the whole point -- + /// the tail exists to give the renderer a chance at the lock, not to defer + /// work indefinitely. [`Terminal::pending_bytes`] and + /// [`Terminal::tail_full`] tell the reader when to stop reading the PTY. + pub fn feed(&mut self, bytes: &[u8]) -> bool { + self.feeder.feed(&mut self.term, bytes) + } + + /// Parse more of the pending tail. Returns whether any remains. + pub fn drain(&mut self) -> bool { + self.feeder.drain(&mut self.term); + self.feeder.pending_bytes() > 0 + } + + /// Feed and parse to completion, without the intervening lock releases. + /// + /// For tests and for callers with no renderer contending -- it reinstates + /// exactly the unbounded hold [`Terminal::feed`] exists to prevent, so it + /// is deliberately a separate name rather than a flag on `feed`. + pub fn feed_fully(&mut self, bytes: &[u8]) { + self.feed(bytes); + while self.drain() {} + } + + /// Bytes accepted but not yet parsed. + pub fn pending_bytes(&self) -> usize { + self.feeder.pending_bytes() + } + + /// Whether the tail is at its cap and the reader must stop reading. + pub fn tail_full(&self) -> bool { + self.feeder.tail_full() + } + + /// Whether a paused reader may resume. + pub fn tail_drained(&self) -> bool { + self.feeder.tail_drained() + } + + /// Discard the unparsed tail. Session close only -- see + /// [`reader::Feeder::abandon_tail`]. + pub fn abandon_tail(&mut self) -> usize { + self.feeder.abandon_tail() } pub fn stats(&self) -> FenceStats { @@ -173,6 +224,7 @@ impl Terminal { return self.viewport(); } self.term.resize(size); + self.feeder.resize(size.columns, size.screen_lines); self.size = size; self.generation += 1; self.viewport() diff --git a/desktop/src-tauri/crates/buzz-terminal/src/reader.rs b/desktop/src-tauri/crates/buzz-terminal/src/reader.rs index a55f4d1edd..dd4cf4e2df 100644 --- a/desktop/src-tauri/crates/buzz-terminal/src/reader.rs +++ b/desktop/src-tauri/crates/buzz-terminal/src/reader.rs @@ -6,7 +6,11 @@ use alacritty_terminal::vte::ansi::{Handler, Processor, StdSyncHandler}; -use crate::fences::{FenceStats, Fences, OSC_BUDGET, SYNC_CAP}; +use crate::fences::{ + slice_bytes, FenceStats, Fences, MAX_SLICE, OSC_BUDGET, SYNC_CAP, TAIL_CAP, TAIL_RESUME, + WORK_BUDGET, +}; +use crate::units::{Counting, CursorColumn}; /// Owns the parser and enforces F1/F2 on every byte fed to it. pub struct Feeder { @@ -15,18 +19,46 @@ pub struct Feeder { stats: FenceStats, /// Bytes charged since the last F2 reset. since_reset: usize, + /// Bytes accepted but not yet parsed. Grows when arrival outruns + /// retirement; drained by every [`Feeder::feed`] and [`Feeder::drain`]. + pending: Vec, + /// How much of `pending` has already been parsed. Kept as an index rather + /// than draining the front on every slice, so a large tail is not + /// re-shuffled once per slice; the prefix is dropped in one go on the next + /// enqueue. + pending_at: usize, + /// The grid the weights are computed against. Tracked here rather than + /// read from the `Term` because `feed` only has the handler, and kept in + /// sync by [`Feeder::resize`]: a stale grid misprices every O(cells) + /// callback for as long as it is wrong. + columns: usize, + lines: usize, + /// Configured scrollback depth. Fixed for the life of the feeder: it is a + /// config value, not grid state, and `resize` does not change it. + scrollback: usize, } impl Feeder { - pub fn new(fences: Fences) -> Self { + pub fn new(fences: Fences, columns: usize, lines: usize, scrollback: usize) -> Self { Self { parser: Processor::new(), fences, stats: FenceStats::default(), since_reset: 0, + pending: Vec::new(), + pending_at: 0, + columns, + lines, + scrollback, } } + /// Track a viewport change, so the cost weights describe the current grid. + pub fn resize(&mut self, columns: usize, lines: usize) { + self.columns = columns; + self.lines = lines; + } + pub fn stats(&self) -> FenceStats { self.stats } @@ -40,10 +72,127 @@ impl Feeder { 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]) { + /// Bytes accepted but not yet parsed, because a previous [`Feeder::feed`] + /// spent its work budget before reaching them. + pub fn pending_bytes(&self) -> usize { + self.pending.len() - self.pending_at + } + + /// Whether the pending tail has reached [`TAIL_CAP`]. + /// + /// Deliberately derived from the current depth rather than latched. A + /// latch is a state the fence owns and could fail to clear, which is + /// exactly how a paused reader strands a child mid-teardown; a reader that + /// simply stops asking resumes by default. + pub fn tail_full(&self) -> bool { + self.pending_bytes() >= TAIL_CAP + } + + /// Whether a paused reader may resume: the tail has drained to the low + /// water mark. Separate from `!tail_full()` so the reader does not flap + /// between full and one-byte-below-full. + pub fn tail_drained(&self) -> bool { + self.pending_bytes() <= TAIL_RESUME + } + + /// Discard the unparsed tail. + /// + /// For session close only, and lossless where it is used: publication is + /// detached before shutdown drains, so this tail is bytes no renderer can + /// consume. Draining the *PTY* remains lifecycle-critical -- this exists so + /// parser work cannot hold teardown behind it. + pub fn abandon_tail(&mut self) -> usize { + let abandoned = self.pending_bytes(); + self.pending.clear(); + self.pending_at = 0; + self.stats.abandoned_bytes += abandoned as u64; + abandoned + } + + /// Accept PTY output and parse what fits in one work budget. + /// + /// Returns whether bytes remain unparsed. Bytes beyond the budget are + /// retained and parsed by [`Feeder::drain`], so this bounds the *lock + /// hold*; it does not bound the queue. When arrival outruns retirement + /// the tail grows to [`TAIL_CAP`] and [`Feeder::tail_full`] goes true, + /// which is the reader's cue to stop reading the PTY and let the child + /// block. No policy is applied here: a fence that dropped input to protect + /// itself would corrupt the screen to avoid being slow. + pub fn feed(&mut self, handler: &mut H, bytes: &[u8]) -> bool { + self.enqueue(bytes); + self.drain(handler); + self.pending_bytes() > 0 + } + + /// Append to the pending tail, compacting the already-parsed prefix first. + fn enqueue(&mut self, bytes: &[u8]) { + if self.pending_at > 0 { + self.pending.drain(..self.pending_at); + self.pending_at = 0; + } + self.pending.extend_from_slice(bytes); + } + + /// Parse from the pending tail until the work budget is spent. + /// + /// The budget is checked between parser slices, never inside a callback: + /// vte's own mid-buffer stop is driven by `Perform::terminated()`, whose + /// implementor in the ansi layer is private, so the cut has to be made + /// from outside, and a callback already running cannot be preempted at + /// all. One atom is therefore the irreducible overrun -- and it is not + /// small: `ESC[65535Z` with tabstops cleared is 8 bytes and 82 ms at 1600 + /// columns, because upstream's `move_backward_tabs` rescans the row once + /// per count when it finds no stop. + /// + /// What *is* bounded is the number of atoms per slice, and that bound + /// holds from the first byte of a cold feeder: [`slice_bytes`] is derived + /// from the densest work-per-byte upstream can produce on this grid, so + /// no slice can contain more than one budget's worth of callbacks no + /// matter what the payload is or what the feeder has seen before. + /// + /// Returns the work spent, which is at least the budget whenever the tail + /// is still non-empty on return. + pub fn drain(&mut self, handler: &mut H) -> u64 { + // Slices are copied out of the tail rather than borrowed from it, + // because `advance_slice` needs `&mut self` and the tail is part of + // self. A stack buffer keeps that from allocating; the copy is a + // memcpy against a parse two orders of magnitude more expensive. + let mut buf = [0u8; MAX_SLICE]; + let width = slice_bytes(self.columns, self.lines, self.scrollback); + let mut spent = 0; + while self.pending_at < self.pending.len() { + let end = (self.pending_at + width).min(self.pending.len()); + let len = end - self.pending_at; + buf[..len].copy_from_slice(&self.pending[self.pending_at..end]); + self.pending_at = end; + spent += self.advance_slice(handler, &buf[..len]); + if spent >= WORK_BUDGET { + break; + } + } + if self.pending_at == self.pending.len() { + self.pending.clear(); + self.pending_at = 0; + } + let depth = self.pending_bytes(); + self.stats.max_pending = self.stats.max_pending.max(depth); + if depth >= TAIL_CAP { + self.stats.tail_breaches += 1; + } + spent + } + + /// Parse one slice, applying both fences to it. Returns the work it cost. + fn advance_slice(&mut self, handler: &mut H, bytes: &[u8]) -> u64 { + let mut spent = 0; let sync_before = self.parser.sync_bytes_count(); - self.parser.advance(handler, bytes); + { + let mut counting = Counting::new(handler, self.columns, self.lines, self.scrollback); + self.parser.advance(&mut counting, bytes); + self.stats.completed_units += counting.units(); + self.stats.completed_work += counting.work(); + spent += counting.work(); + } let sync_after = self.parser.sync_bytes_count(); // Charge exactly the bytes the parser could see, by route: @@ -73,7 +222,17 @@ impl Feeder { // 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); + // Counted too: aborting flushes the buffered frame through the + // handler, so these are units the lock hold paid for. Leaving them + // out would undercount exactly on the fenced path. + { + let mut counting = + Counting::new(handler, self.columns, self.lines, self.scrollback); + self.parser.stop_sync(&mut counting); + self.stats.completed_units += counting.units(); + self.stats.completed_work += counting.work(); + spent += counting.work(); + } self.stats.sync_aborts += 1; self.note_release(released); self.charge(released); @@ -88,6 +247,8 @@ impl Feeder { self.stats.osc_resets += 1; self.since_reset = 0; } + + spent } fn charge(&mut self, bytes: usize) { diff --git a/desktop/src-tauri/crates/buzz-terminal/src/shared.rs b/desktop/src-tauri/crates/buzz-terminal/src/shared.rs index c83e364100..58bf0a76e8 100644 --- a/desktop/src-tauri/crates/buzz-terminal/src/shared.rs +++ b/desktop/src-tauri/crates/buzz-terminal/src/shared.rs @@ -149,8 +149,28 @@ impl SharedTerminal { } /// Feed PTY output into the emulator. Reader plane. - pub fn feed(&self, bytes: &[u8]) { - self.acquire(&self.reader).feed(bytes); + /// + /// Returns whether a tail remains: one acquisition parses one work + /// budget, then **drops the lock** so the renderer can have it. The + /// caller pumps [`SharedTerminal::drain`] until it returns false. Doing + /// the whole buffer under one acquisition is what an unbounded hold *is*, + /// so it is not offered here. + pub fn feed(&self, bytes: &[u8]) -> bool { + self.acquire(&self.reader).feed(bytes) + } + + /// Parse more of the pending tail under a fresh acquisition. Reader + /// plane. Returns whether any remains. + pub fn drain(&self) -> bool { + self.acquire(&self.reader).drain() + } + + /// Feed and pump to completion, re-acquiring between slices. + pub fn feed_fully(&self, bytes: &[u8]) { + let mut more = self.feed(bytes); + while more { + more = self.drain(); + } } /// Sample damage and encode a frame. Renderer plane. diff --git a/desktop/src-tauri/crates/buzz-terminal/src/units.rs b/desktop/src-tauri/crates/buzz-terminal/src/units.rs new file mode 100644 index 0000000000..b30533a654 --- /dev/null +++ b/desktop/src-tauri/crates/buzz-terminal/src/units.rs @@ -0,0 +1,377 @@ +//! Counting what the parser *does*, not how many bytes it read. +//! +//! Both fences in [`crate::fences`] meter bytes. That is the right denominator +//! for memory -- a buffer's size is bytes -- and the wrong one for time. `ESC[m` +//! and `ESC#8` are four bytes each; the first sets an attribute and the second +//! rewrites every cell of the grid. Metering the reader's lock hold in bytes +//! therefore prices those identically, and a stream of the second one holds the +//! lock for as long as it likes without ever tripping a byte budget. +//! +//! Measured: a DECALN flood at 200x50 reaches p95 65535us against a 4000us +//! budget with **zero** F1 aborts -- the fence never fires, because nothing is +//! buffered. Unfenced, one acquisition was observed at 22.1s, about 1300 +//! dropped frames in a single lock hold. +//! +//! So this module adds a third quantity: the number of *completed parser +//! units* -- one per `Handler` callback the parser dispatches, which is one +//! per fully-parsed escape sequence or printed character. It is a proxy for +//! work rather than a measure of it, but it has the property the byte count +//! lacks: it advances once per thing the emulator actually did. +//! +//! ## Why a wrapper, and not vte's own stopping point +//! +//! `Parser::advance_until_terminated` already supports stopping mid-buffer, +//! but termination is driven by `Perform::terminated()`, and in the ansi layer +//! the implementor is `Performer`, which is private (`vte-0.15.0/src/ansi.rs`: +//! `struct Performer` at 425, `terminated` at 1825, set only for BSU handling). +//! An embedder cannot reach it, so the stopping point has to be built outside +//! the parser rather than inside it. +//! +//! ## What this deliberately does not do +//! +//! It does not skip or veto expensive callbacks once a budget is spent. That +//! would bound the lock hold perfectly and silently corrupt the screen, which +//! is a worse failure than the one being fixed: a slow terminal recovers, a +//! wrong one does not. Every unit is delegated; the count only decides where +//! the *caller* may cut the input. + +use alacritty_terminal::event::EventListener; +use alacritty_terminal::term::Term; +use alacritty_terminal::vte::ansi::cursor_icon::CursorIcon; +use alacritty_terminal::vte::ansi::{ + Attr, CharsetIndex, ClearMode, CursorShape, CursorStyle, Handler, Hyperlink, KeyboardModes, + KeyboardModesApplyBehavior, LineClearMode, Mode, ModifyOtherKeys, PrivateMode, Rgb, + ScpCharPath, ScpUpdateMode, StandardCharset, TabulationClearMode, +}; + +/// Read access to the cursor column of whatever the wrapper is driving. +/// +/// Exists for exactly one callback. CBT's cost is bounded by *cursor +/// movement*, and the only way to charge it honestly -- or to stop it early +/// -- is to watch the cursor between steps. Everything else in this module is +/// priced from the grid alone, which is why this is a separate trait and a +/// separate bound rather than a field on [`Counting`]. +/// +/// Implemented over the public path in `alacritty_terminal-0.26.0`: +/// `Term::grid` (term/mod.rs:645) -> `Grid::cursor` (grid/mod.rs:113) -> +/// `Cursor::point` (grid/mod.rs:36). No private field, no fork. +pub trait CursorColumn { + fn cursor_column(&self) -> usize; +} + +impl CursorColumn for Term { + #[inline] + fn cursor_column(&self) -> usize { + self.grid().cursor.point.column.0 + } +} + +/// Wraps a [`Handler`], forwarding every callback and counting them. +/// +/// Every one of the trait's 71 methods has an empty default body upstream, so +/// a method left undelegated here would compile cleanly and silently discard +/// that escape sequence. The delegations are therefore generated by a macro +/// over the full method list rather than written out: the failure mode of +/// hand-copying is invisible. +pub struct Counting<'a, H: Handler + CursorColumn> { + inner: &'a mut H, + /// Callbacks dispatched, one per unit regardless of cost. This is the + /// fixture-facing number: it says what the parser *did*, and it is kept + /// separate from `work` because collapsing them is precisely the mistake + /// that made the first version of this seam useless. + units: u64, + /// Cost-weighted work, in cell-equivalents. This is the scheduling number. + work: u64, + columns: u64, + lines: u64, + /// Configured scrollback depth, not current fill. See `reset_state`. + scrollback: u64, +} + +impl<'a, H: Handler + CursorColumn> Counting<'a, H> { + /// `columns` and `lines` are the grid the handler is about to act on, and + /// they are the weights' only input: an O(cells) callback is charged + /// `columns * lines` because that is what it touches. + pub fn new(inner: &'a mut H, columns: usize, lines: usize, scrollback: usize) -> Self { + Self { + inner, + units: 0, + work: 0, + columns: columns as u64, + lines: lines as u64, + scrollback: scrollback as u64, + } + } + + /// Callbacks dispatched since this wrapper was created. + pub fn units(&self) -> u64 { + self.units + } + + /// Cost-weighted work dispatched, in cell-equivalents. + pub fn work(&self) -> u64 { + self.work + } + + #[inline] + fn cells(&self) -> u64 { + self.columns * self.lines + } + + /// A parameter charged at its clamped value. + /// + /// Upstream clamps most counts to the grid before acting on them, so the + /// bound is the clamp, not the parameter: `ESC[65535X` on an 80-column + /// grid touches 80 cells. Charging the raw parameter would let a + /// four-byte escape spend the whole slice budget without doing the work, + /// which stalls the parser as surely as under-charging lets it run away. + #[inline] + fn clamp(&self, n: usize, bound: u64) -> u64 { + (n as u64).min(bound).max(1) + } + + #[inline] + fn charge(&mut self, weight: u64) { + self.units += 1; + self.work += weight; + } +} + +/// Generate a delegating, counting implementation for every `Handler` method. +/// +/// Two groups, because the methods differ in *cost*, not in kind. `plain` +/// methods are charged one unit. `weighted` methods are charged what they +/// touch, using the expressions in the table below -- these are the ones a +/// hostile stream can use to buy grid-sized work with a four-byte escape. +/// +/// The count is incremented *before* delegating, so a callback that panics +/// still leaves evidence it was attempted. +macro_rules! counting_handler { + ( + plain { $($pname:ident($($parg:ident: $pty:ty),* $(,)?);)* } + weighted { $($wname:ident($($warg:ident: $wty:ty),* $(,)?) => |$this:ident| $weight:expr;)* } + ) => { + impl Handler for Counting<'_, H> { + $( + #[inline] + fn $pname(&mut self $(, $parg: $pty)*) { + self.charge(1); + self.inner.$pname($($parg),*); + } + )* + $( + #[inline] + fn $wname(&mut self $(, $warg: $wty)*) { + let weight = { let $this = &*self; $weight }; + self.charge(weight); + self.inner.$wname($($warg),*); + } + )* + + /// The one callback this wrapper does not delegate verbatim. + /// + /// CBT (`ESC[NZ`) is upstream's only unbounded atom. With no + /// tabstop below the cursor, `move_backward_tabs` + /// (`term/mod.rs:1580`) assigns `col` *inside* the `if + /// self.tabs[i]` test, so the cursor never moves, the `col == 0` + /// break is unreachable, and all N iterations rescan the row. + /// `ESC[3g ESC[65535Z` is eight bytes and 82 ms at 1600 columns. + /// Its twin `move_forward_tabs` (1605) assigns *outside* the + /// test, always advances, and is fine: same file, same loop + /// skeleton, and the entire difference is one assignment's + /// placement relative to one branch. + /// + /// The fix is a termination condition, not a smaller number. + /// Each step either moves the cursor strictly left or is a fixed + /// point, and **a fixed point is permanent** -- the scan depends + /// only on the cursor, which did not move. So the loop can stop + /// at the first one. The leftward distances telescope to at most + /// the starting column, plus one final failed scan, so the whole + /// callback is O(columns) and the delegated-call count is at most + /// `columns - 1`. + /// + /// Equivalence is not argued, it is checked: every tabstop subset + /// of a 12-column grid x 4 start columns x 7 counts (114_688 + /// cases) lands on the same column as the naive loop, on a real + /// `Term`. See `examples/probe_cbt_equiv.rs`. Deleting the + /// fixed-point break leaves the *landing column correct* and only + /// the cost wrong, so the fixture that guards this must assert + /// units, never the cursor. + #[inline] + fn move_backward_tabs(&mut self, count: u16) { + // One unit for the escape, as every other callback gets. + self.charge(1); + for _ in 0..count { + let before = self.inner.cursor_column(); + // No `before == 0` guard: column 0 is already a fixed + // point (upstream's own `col == 0` break leaves the + // cursor alone), so the check below covers it and a + // second one would be unreachable-by-construction code + // that no test could distinguish. + self.inner.move_backward_tabs(1); + let after = self.inner.cursor_column(); + // Charge the cells this step scanned. A step that finds a + // stop scans the distance it moved; a step that finds + // none scans the whole prefix and moves nothing -- + // charging that one zero would leave a loop that spins + // without ever paying, which is precisely the mutant this + // pricing has to make visible. + self.work += if after == before { before } else { before - after } as u64; + if after == before { + // A fixed point is permanent: the scan depends only + // on the cursor, and the cursor did not move. + break; + } + } + } + } + }; +} + +counting_handler! { + plain { + set_title(a0: Option); + set_cursor_style(a0: Option); + set_cursor_shape(shape: CursorShape); + input(c: char); + goto(line: i32, col: usize); + goto_line(line: i32); + goto_col(col: usize); + move_up(a0: usize); + move_down(a0: usize); + identify_terminal(intermediate: Option); + device_status(a0: usize); + move_forward(col: usize); + move_backward(col: usize); + move_down_and_cr(row: usize); + move_up_and_cr(row: usize); + backspace(); + carriage_return(); + linefeed(); + bell(); + substitute(); + newline(); + set_horizontal_tabstop(); + save_cursor_position(); + restore_cursor_position(); + clear_tabs(mode: TabulationClearMode); + set_tabs(interval: u16); + reverse_index(); + terminal_attribute(attr: Attr); + set_mode(mode: Mode); + unset_mode(mode: Mode); + report_mode(mode: Mode); + set_private_mode(mode: PrivateMode); + unset_private_mode(mode: PrivateMode); + report_private_mode(mode: PrivateMode); + set_scrolling_region(top: usize, bottom: Option); + set_keypad_application_mode(); + unset_keypad_application_mode(); + set_active_charset(a0: CharsetIndex); + configure_charset(a0: CharsetIndex, a1: StandardCharset); + set_color(a0: usize, a1: Rgb); + dynamic_color_sequence(a0: String, a1: usize, a2: &str); + reset_color(a0: usize); + clipboard_store(a0: u8, a1: &[u8]); + clipboard_load(a0: u8, a1: &str); + push_title(); + pop_title(); + text_area_size_pixels(); + text_area_size_chars(); + set_hyperlink(a0: Option); + set_mouse_cursor_icon(a0: CursorIcon); + report_keyboard_mode(); + push_keyboard_mode(mode: KeyboardModes); + pop_keyboard_modes(to_pop: u16); + set_keyboard_mode(mode: KeyboardModes, behavior: KeyboardModesApplyBehavior); + set_modify_other_keys(mode: ModifyOtherKeys); + report_modify_other_keys(); + set_scp(char_path: ScpCharPath, update_mode: ScpUpdateMode); + } + weighted { + // Every weight below is an upper bound on the cells the callback can + // touch, **read from `alacritty_terminal-0.26.0/src/term/mod.rs`** and + // then checked against measurement -- never fitted to a curve. The + // direction of the error is the whole point: an over-charge slices + // early and costs throughput, an under-charge is an attack surface, so + // where source and measurement disagree the source bound wins and the + // slack is recorded here rather than tuned away. + // + // `min(N, ...)` appears wherever upstream clamps the parameter; a raw + // `N` would let `ESC[65535X` charge 65535 on an 80-column grid and + // stall the parser on a cheap escape. + + // O(min(N, columns)): `end = min(start + count, columns)`, loop + // `row[start..end]` (1519). Knee measured exactly at N == columns. + erase_chars(count: usize) => |this| this.clamp(count, this.columns); + // O(columns) for *every* N, worst at N=1: the swap loop runs + // `columns - end` times where `end = min(start + N, columns - 1)` + // (1538), so cost *falls* as N rises. Charging by N would be backwards + // and would under-charge the worst case by the full terminal width -- + // measured 3422ns at N=1/1600 columns against 863ns at N=65535. + delete_chars(a0: usize) => |this| this.columns; + // O(columns) for every N, worst at N=1. Same shape as `delete_chars`: + // `num_cells = columns - (column + count)` (1187). + insert_blank(a0: usize) => |this| this.columns; + // O(columns): scans to the next tabstop per count, and always advances + // (`col` is assigned unconditionally at 1592), so the whole loop is + // bounded by one traversal of the row. This is the sibling that CBT + // should have been, one asymmetric line apart in the same file. + put_tab(count: u16) => |this| this.columns; + move_forward_tabs(count: u16) => |this| this.columns; + // NOTE: `move_backward_tabs` is NOT in this table. It is the one + // callback whose argument is rewritten, so it is written out by hand + // below the macro's generated methods -- a weight can price an atom but + // cannot shrink one. + // O(min(N, lines) x columns) in steady state: the row rotation is O(1) + // on the ring buffer, but `positions` rows are `reset()`, and a row + // reset is O(columns). + // + // **Known overshoot, measured and frequency-bounded.** While scrollback + // is still growing, `Grid::increase_scroll_limit` -> `Storage::initialize` + // reallocates in blocks of `MAX_CACHE_SIZE` = 1000 rows and `rezero`s + // the ring (`grid/storage.rs`). That is not chargeable from here -- the + // weight function cannot see history depth -- and it is real: at 1600 + // columns the spikes land at call 0, 1000, 2000, 3000 of a 4000-call + // scroll, ~4 ms each, against a 125 ns median. It is bounded in + // frequency (once per 1000 new history rows, and never once history + // saturates: with scrollback=100 only call 0 spikes) and it is upstream + // allocation rather than anything a stream can amplify, so it is + // recorded here instead of being priced into every scroll -- charging + // 1000x on 999 calls out of 1000 to cover the thousandth would make + // ordinary scrolling the slow path. + scroll_up(n: usize) => |this| this.clamp(n, this.lines) * this.columns; + delete_lines(n: usize) => |this| this.clamp(n, this.lines) * this.columns; + scroll_down(n: usize) => |this| this.clamp(n, this.lines) * this.columns; + insert_blank_lines(n: usize) => |this| this.clamp(n, this.lines) * this.columns; + // O(columns): one row, `damage_line(line, 0, columns - 1)`. + clear_line(mode: LineClearMode) => |this| this.columns; + // O(cells): 18.7us at 200x50, doubling on both axes. + clear_screen(mode: ClearMode) => |this| this.cells(); + // O(cells): rewrites every cell. + decaln() => |this| this.cells(); + // Both grids, plus the scrollback the primary owns. + // + // `reset_state` (1835) resets the primary *and* the alternate, and each + // `Grid::reset` runs `clear_history` -> `shrink_lines` -> `truncate` + + // `rezero`, which walks the raw buffer. So the cost carries a history + // axis that `cells` alone cannot see: measured 0.5 us empty against + // 1.68 ms with 10k rows filled at 400x100, a 42x per-cell miss, with + // the knee exactly at `screen_lines + MAX_CACHE_SIZE` where + // `shrink_lines` starts calling `truncate`. + // + // Priced on **configured** depth rather than current fill, which is the + // conservative choice and the only correct one: `history_size()` reads + // the *active* grid, so a filled primary followed by `ESC[?1049h` + // reports an empty history while RIS still pays for the inactive + // primary's rows -- underpriced 41x on exactly the arm an attacker + // would pick. The inactive grid is private, so there is no stateless + // way to observe the real fill; the configured depth bounds both. + // + // This is the one weight that can exceed [`crate::fences::WORK_BUDGET`] + // on its own -- 16x at the default 10k scrollback -- which is correct: + // it is a genuinely oversized uninterruptible atom, and a budget that + // hid that would be lying about what one drain can cost. + reset_state() => |this| 2 * this.cells() + this.scrollback * this.columns; + } +} diff --git a/desktop/src-tauri/crates/buzz-terminal/tests/clusters.rs b/desktop/src-tauri/crates/buzz-terminal/tests/clusters.rs index feb91f0c7e..9486aa8742 100644 --- a/desktop/src-tauri/crates/buzz-terminal/tests/clusters.rs +++ b/desktop/src-tauri/crates/buzz-terminal/tests/clusters.rs @@ -33,7 +33,7 @@ fn render(input: &str) -> (Vec, Receiver) { }; let (term, actions) = Terminal::new(size, Fences::ALL); let shared = SharedTerminal::new(term); - shared.feed(input.as_bytes()); + shared.feed_fully(input.as_bytes()); let mut encoder = Encoder::new(); let frame = shared.render(&mut encoder); let spans = frame @@ -199,6 +199,84 @@ fn cluster_count_distinguishes_a_marked_cluster_from_a_plain_run() { assert!(marked.counts_are_consistent() && plain.counts_are_consistent()); } +/// The join guard has two halves: the previous cell must not have carried +/// marks (`open`), and the current cell must not carry them (`joinable`). +/// Every fixture above exercises only the first half -- a plain cluster +/// following a marked one. This one exercises the second: a *marked* cluster +/// arriving after a plain run, which is the only path on which the run in +/// progress is handed text holding more `char`s than the one cluster its +/// count is about to be incremented by. +/// +/// Sami found the hole. With `joinable` dropped from the guard, a release +/// build silently emits `Span { column: 0, text: "xyé", cluster_count: 3 }`: +/// four chars counted as three, so the consumer's rule splits per char and +/// places the combining mark on top of `z`. +#[test] +fn a_marked_cluster_after_a_plain_run_starts_its_own_span() { + let (spans, _actions) = render("xye\u{0301}z"); + assert_eq!( + placements(&spans), + vec![ + (0, "x".into()), + (1, "y".into()), + (2, "e\u{0301}".into()), + (3, "z".into()), + ], + "a marked cluster must not be absorbed into the run in front of it" + ); +} + +/// `cluster_count` is a `u16` and `Size.columns` is an unclamped `usize` +/// (`lib.rs:50`) that no production caller bounds yet, so a row of uniform +/// cells wider than `u16::MAX` reaches the join guard's overflow refusal. +/// The guard is live code, not paranoia, and this fixture is what says so. +/// +/// Refusing to join produces a shape the consumer already handles -- the run +/// ends and a new span starts at the next column -- whereas wrapping produces +/// an undecodable span, the same failure as the marked-after-plain case above. +#[test] +fn a_run_longer_than_u16_max_splits_rather_than_wrapping() { + let columns = 70_000; + let size = Size { + columns, + screen_lines: 1, + scrollback: 0, + }; + let (term, _actions) = Terminal::new(size, Fences::ALL); + let shared = SharedTerminal::new(term); + // One character is enough: the rest of the row is blank cells of the same + // style, so the whole row is a single candidate run. + shared.feed_fully(b"a"); + let mut encoder = Encoder::new(); + let frame = shared.render(&mut encoder); + + let spans = &frame + .rows + .iter() + .find(|row| row.line == 0) + .expect("the fed row must be present") + .spans; + + assert!( + spans.iter().all(|span| span.counts_are_consistent()), + "an oversized run must not wrap its count: {spans:?}" + ); + let counts: Vec = spans.iter().map(|span| span.cluster_count).collect(); + let columns_at: Vec = spans.iter().map(|span| span.column).collect(); + assert_eq!( + counts, + vec![u16::MAX, (columns - u16::MAX as usize) as u16], + "the run must end at the last representable count" + ); + assert_eq!( + columns_at, + vec![0, u16::MAX as usize], + "the second span starts where the first left off" + ); + let chars: usize = spans.iter().map(|span| span.text.chars().count()).sum(); + assert_eq!(chars, columns, "no cell may be dropped by the split"); +} + /// Wrapping marks the last cell of the row with `WRAPLINE` (upstream /// `term/mod.rs:968`). That bit records where the text happened to wrap, not /// how the text looks, so it must not reach the style key: if it did, the last @@ -219,7 +297,7 @@ fn wrapping_does_not_split_a_uniform_run() { let shared = SharedTerminal::new(term); // Six narrow cells in one style: five fill row 0 and set WRAPLINE on the // last of them, the sixth lands on row 1. - shared.feed(b"abcdef"); + shared.feed_fully(b"abcdef"); let mut encoder = Encoder::new(); let frame = shared.render(&mut encoder); @@ -249,7 +327,7 @@ fn leading_wide_glyph_after_wrap_is_positioned_from_column_zero() { let shared = SharedTerminal::new(term); // Four narrow cells fill 0..=3, leaving one column: the wide glyph cannot // fit and moves to the next row. - shared.feed("abcd\u{4E00}".as_bytes()); + shared.feed_fully("abcd\u{4E00}".as_bytes()); let mut encoder = Encoder::new(); let frame = shared.render(&mut encoder); diff --git a/desktop/src-tauri/crates/buzz-terminal/tests/fences.rs b/desktop/src-tauri/crates/buzz-terminal/tests/fences.rs index a753841e39..72bb8c2e6f 100644 --- a/desktop/src-tauri/crates/buzz-terminal/tests/fences.rs +++ b/desktop/src-tauri/crates/buzz-terminal/tests/fences.rs @@ -23,12 +23,12 @@ fn size() -> Size { } fn feed_synchronized(term: &mut Terminal, payload: &[u8], close: bool) { - term.feed(b"\x1b[?2026h"); + term.feed_fully(b"\x1b[?2026h"); for chunk in payload.chunks(CHUNK) { - term.feed(chunk); + term.feed_fully(chunk); } if close { - term.feed(b"\x1b[?2026l"); + term.feed_fully(b"\x1b[?2026l"); } } @@ -107,9 +107,9 @@ fn g1_sync_abort_bounds_all_hostile_shapes() { #[test] fn g2_hostile_unsynchronized_osc_resets_parser() { let (mut term, _) = Terminal::new(size(), Fences::ALL); - term.feed(b"\x1b]0;"); + term.feed_fully(b"\x1b]0;"); for chunk in repeated(b"A", OSC_BUDGET * 4).chunks(CHUNK) { - term.feed(chunk); + term.feed_fully(chunk); } assert!(term.stats().osc_resets > 0, "F2 never rebuilt the parser"); } diff --git a/desktop/src-tauri/crates/buzz-terminal/tests/latency.rs b/desktop/src-tauri/crates/buzz-terminal/tests/latency.rs index 57d672d91c..0edbefce19 100644 --- a/desktop/src-tauri/crates/buzz-terminal/tests/latency.rs +++ b/desktop/src-tauri/crates/buzz-terminal/tests/latency.rs @@ -78,7 +78,7 @@ fn flood(shared: &SharedTerminal, stop: &AtomicBool) { if stop.load(Ordering::Relaxed) { return; } - shared.feed(chunk); + shared.feed_fully(chunk); } } } diff --git a/desktop/src-tauri/crates/buzz-terminal/tests/resize.rs b/desktop/src-tauri/crates/buzz-terminal/tests/resize.rs index 5b1dfbe8fc..6548de9b4d 100644 --- a/desktop/src-tauri/crates/buzz-terminal/tests/resize.rs +++ b/desktop/src-tauri/crates/buzz-terminal/tests/resize.rs @@ -45,7 +45,7 @@ fn grid(columns: usize, screen_lines: usize) -> Size { fn resize_forces_a_full_frame_at_the_new_width() { let (shared, _actions) = shared(size(40)); let mut encoder = Encoder::new(); - shared.feed(b"\x1b[2J\x1b[Hhello world\r\nsecond line\r\n"); + shared.feed_fully(b"\x1b[2J\x1b[Hhello world\r\nsecond line\r\n"); let first = shared.render(&mut encoder); assert!(first.full, "first frame after a fresh Term must be full"); @@ -100,7 +100,7 @@ fn resize_forces_a_full_frame_at_the_new_width() { fn identical_resize_is_inert() { let (shared, _actions) = shared(size(40)); let mut encoder = Encoder::new(); - shared.feed(b"hello"); + shared.feed_fully(b"hello"); shared.render(&mut encoder); let applied = shared.resize(size(40)); @@ -134,7 +134,7 @@ fn identical_resize_is_inert() { fn full_frame_after_height_resize_republishes_unchanged_rows() { let (shared, _actions) = shared(grid(40, 10)); let mut encoder = Encoder::new(); - shared.feed(b"\x1b[2J\x1b[Hhello world\r\nsecond line"); + shared.feed_fully(b"\x1b[2J\x1b[Hhello world\r\nsecond line"); let first = shared.render(&mut encoder); assert!(first.full); assert_eq!(first.rows.len(), 10); @@ -173,7 +173,7 @@ fn full_frame_after_height_resize_republishes_unchanged_rows() { fn a_frame_is_stamped_with_the_grid_it_was_captured_on() { let (shared, _actions) = shared(grid(40, 10)); let mut encoder = Encoder::new(); - shared.feed(b"\x1b[2J\x1b[Hhello world"); + shared.feed_fully(b"\x1b[2J\x1b[Hhello world"); let in_flight = shared.render(&mut encoder); assert_eq!(in_flight.viewport.generation, 0); diff --git a/desktop/src-tauri/crates/buzz-terminal/tests/slicing.rs b/desktop/src-tauri/crates/buzz-terminal/tests/slicing.rs new file mode 100644 index 0000000000..d597ece7eb --- /dev/null +++ b/desktop/src-tauri/crates/buzz-terminal/tests/slicing.rs @@ -0,0 +1,760 @@ +//! The work-denominated slicing seam: what bounds one lock hold, what bounds +//! the queue behind it, and what proves the work was actually done. +//! +//! Every assertion here is an **exact** expected value, never a `> 0`. A fix +//! that bounds the lock by *dropping* work instead of deferring it reports a +//! beautiful latency and a perfect screen-content receipt -- DECALN fills the +//! grid with `E`, and the second DECALN overwrites the first, so grid content +//! saturates after one of ten thousand. `completed_units == expected` is the +//! only predicate that separates "deferred the work" from "skipped it", and +//! `> 0` is satisfied by a seam that executed exactly one unit. + +use buzz_terminal::fences::{ + max_atom_work, max_drain_work, slice_bytes, Fences, MAX_SLICE, MIN_SLICE, SYNC_CAP, TAIL_CAP, + WORK_BUDGET, +}; +use buzz_terminal::{Size, Terminal}; + +const COLUMNS: usize = 200; +const LINES: usize = 50; +const CELLS: u64 = (COLUMNS * LINES) as u64; + +fn terminal() -> Terminal { + Terminal::new( + Size { + columns: COLUMNS, + screen_lines: LINES, + scrollback: 100, + }, + Fences::ALL, + ) + .0 +} + +/// A deliberately tiny grid, for the arms that must fill the 4 MiB tail. +/// +/// Filling the cap is cheap; *draining* it is not, and on a 200x50 grid a +/// full tail of DECALN is ~1e10 work units of real parsing. The cap is a +/// property of the byte depth, not of the grid, so a small grid exercises the +/// same thresholds in seconds instead of minutes -- but it does change what +/// is being tested, so it is named rather than reused silently: these arms +/// test the *depth* predicates, and the arms above test the work bound. +fn tiny() -> Terminal { + Terminal::new( + Size { + columns: 10, + screen_lines: 2, + scrollback: 10, + }, + Fences::ALL, + ) + .0 +} + +/// Feed until the tail reaches its cap, or give up. +/// +/// Bounded on purpose. A test that loops until a predicate goes true hangs +/// forever when the predicate is what broke, which turns a killed mutant into +/// a wedged CI job -- and a suite that hangs instead of failing is a suite +/// nobody can bisect. +fn fill_tail(term: &mut Terminal, payload: &[u8]) -> bool { + for _ in 0..10_000 { + if term.tail_full() { + return true; + } + term.feed(payload); + } + false +} + +/// Pump to completion, counting acquisitions. A drain that needed no second +/// call returns 1. +fn pump(term: &mut Terminal, bytes: &[u8]) -> usize { + let mut calls = 1; + let mut more = term.feed(bytes); + while more { + more = term.drain(); + calls += 1; + } + calls +} + +/// One `feed` may not spend an unbounded amount of work, however much the +/// stream asks for. +/// +/// Kills: deleting the `spent >= WORK_BUDGET` break, which restores the +/// unbounded hold this whole seam exists to prevent. Deliberately asserts on +/// *work* rather than wall time -- a time assertion is a flake on a loaded +/// machine, and the work bound is the thing the code actually promises. +#[test] +fn one_feed_spends_at_most_one_budget_plus_a_slice() { + let mut term = terminal(); + let decalns = 10_000; + term.feed(&b"\x1b#8".repeat(decalns)); + + let spent = term.stats().completed_work; + // Two terms, both irreducible: the budget is checked between slices, and + // a slice is sized so it holds at most one budget of the densest payload; + // and the callback that crosses the line cannot be preempted. + let ceiling = max_drain_work(COLUMNS, LINES, 100); + assert!( + spent <= ceiling, + "one feed spent {spent} work, over budget+overshoot ({ceiling})", + ); + assert!( + term.pending_bytes() > 0, + "10000 DECALNs is {} work and the budget is {WORK_BUDGET}; if nothing \ + is pending the seam ran the whole payload in one hold", + decalns as u64 * CELLS, + ); +} + +/// Every deferred byte is eventually executed -- exactly once, and all of it. +/// +/// Kills: bounding the hold by dropping the remainder instead of keeping it +/// (`self.pending.clear()` in place of the tail), which passes any latency +/// gate and any grid-content check. The unit count is the only witness. +#[test] +fn a_deferred_tail_executes_every_unit_exactly_once() { + let mut term = terminal(); + let decalns = 10_000; + + let calls = pump(&mut term, &b"\x1b#8".repeat(decalns)); + + assert!( + calls > 1, + "a payload this dense must have needed a second call" + ); + assert_eq!( + term.stats().completed_units, + decalns as u64, + "every DECALN must execute exactly once: no drops, no double-parse", + ); + assert_eq!(term.stats().completed_work, decalns as u64 * CELLS); + assert_eq!(term.pending_bytes(), 0, "nothing may be left behind"); +} + +/// The tail drains without another `feed` -- a reader with nothing new to +/// read must still be able to retire what it already accepted. +/// +/// Kills: draining only from `feed`, which strands the tail whenever the +/// child goes quiet (`cat bigfile` then no more output: the last screenful +/// never appears). +#[test] +fn a_tail_drains_without_a_second_feed() { + let mut term = terminal(); + let decalns = 2_000; + assert!(term.feed(&b"\x1b#8".repeat(decalns)), "expected a tail"); + + // Never feed again. Only drain. + while term.drain() {} + + assert_eq!(term.stats().completed_units, decalns as u64); + assert_eq!(term.pending_bytes(), 0); +} + +/// A slice is cut only at a byte boundary the parser has already passed, so +/// an escape sequence split across two slices still executes once. +/// +/// Kills: cutting mid-sequence and restarting the parser, or double-feeding +/// the straddling bytes. `\x1b#8` is 3 bytes and slices are a multiple of +/// neither, so at this length hundreds of sequences straddle a cut. +#[test] +fn a_sequence_split_across_slices_executes_exactly_once() { + let mut term = terminal(); + let decalns = 3_000; + pump(&mut term, &b"\x1b#8".repeat(decalns)); + assert_eq!( + term.stats().completed_units, + decalns as u64, + "a straddling sequence was dropped or executed twice", + ); + + // Same payload, delivered one byte per feed: every sequence straddles. + let mut byte_at_a_time = terminal(); + for chunk in b"\x1b#8".repeat(decalns).chunks(1) { + byte_at_a_time.feed(chunk); + } + while byte_at_a_time.drain() {} + assert_eq!(byte_at_a_time.stats().completed_units, decalns as u64); +} + +/// The tail is a bound on the queue, and the breach counter is loud. +/// +/// Kills: a silent cap -- a tail that grows past `TAIL_CAP` without saying +/// so is indistinguishable from a reader that is obeying backpressure, which +/// is exactly the confusion that hides an unbounded queue. +#[test] +fn an_overrun_tail_is_capped_and_counted() { + let mut term = tiny(); + assert!(!term.tail_full(), "a fresh terminal is not full"); + assert!(term.tail_drained(), "a fresh terminal is drained"); + assert_eq!(term.stats().tail_breaches, 0); + + // A reader that ignores `tail_full` and keeps shovelling. + assert!( + fill_tail(&mut term, &b"\x1b#8".repeat(20_000)), + "the tail never reached its cap: the queue is not bounded", + ); + + assert!(term.pending_bytes() >= TAIL_CAP); + assert!( + term.stats().tail_breaches > 0, + "reaching the cap must be counted, not absorbed silently", + ); + assert!(!term.tail_drained(), "a full tail is not a drained tail"); +} + +/// Resume is hysteretic: `tail_drained` does not go true the instant the tail +/// falls one byte below the cap. +/// +/// Kills: `tail_drained() == !tail_full()`, which makes a reader flap between +/// paused and reading once per slice at exactly the moment it is most loaded. +#[test] +fn resume_waits_for_a_low_water_mark_not_merely_a_non_full_tail() { + let mut term = tiny(); + assert!( + fill_tail(&mut term, &b"\x1b#8".repeat(20_000)), + "expected a full tail" + ); + + // Drain until the reader is allowed to resume, watching for a window in + // which it is neither full nor drained -- that gap *is* the hysteresis. + let mut saw_gap = false; + for _ in 0..1_000_000 { + if term.tail_drained() { + break; + } + assert!(term.drain() || term.tail_drained()); + if !term.tail_full() && !term.tail_drained() { + saw_gap = true; + } + } + assert!( + term.tail_drained(), + "the tail never drained to the resume mark" + ); + assert!( + saw_gap, + "no depth was both non-full and non-drained: the two thresholds are \ + the same value and the reader will flap", + ); +} + +/// Close must not be held behind parser work. +/// +/// Kills: draining the tail on close instead of discarding it. Measured +/// elsewhere in this project: teardown that finishes parsing before killing +/// the child costs ~600 ms on macOS, and no byte of that work reaches a +/// renderer -- publication is detached before shutdown drains. +#[test] +fn close_may_abandon_the_tail_and_says_how_much_it_dropped() { + let mut term = terminal(); + term.feed(&b"\x1b#8".repeat(10_000)); + let stranded = term.pending_bytes(); + assert!(stranded > 0); + + let abandoned = term.abandon_tail(); + + assert_eq!(abandoned, stranded); + assert_eq!(term.pending_bytes(), 0); + assert_eq!( + term.stats().abandoned_bytes, + stranded as u64, + "dropped bytes must be counted: this is lossy by design and silent \ + loss is how it stops being by design", + ); + assert!( + term.tail_drained(), + "an abandoned tail cannot strand a reader" + ); +} + +/// The grid the weights are priced against tracks resizes. +/// +/// Kills: dropping `Feeder::resize`. A stale grid misprices every O(cells) +/// charge for as long as it is wrong -- and it is wrong in the *unsafe* +/// direction whenever the window grows, which is the common case. +#[test] +fn a_resize_reprices_the_same_escape() { + let mut small = terminal(); + small.feed_fully(b"\x1b#8"); + let before = small.stats().completed_work; + assert_eq!(before, CELLS); + + small.resize(Size { + columns: COLUMNS * 2, + screen_lines: LINES, + scrollback: 100, + }); + small.reset_stats(); + small.feed_fully(b"\x1b#8"); + + assert_eq!( + small.stats().completed_work, + CELLS * 2, + "the same escape on a grid twice as wide must cost twice as much", + ); + assert_eq!(small.stats().completed_units, 1, "still one callback"); +} + +/// A resize *between* slices of one payload reprices the remainder. +/// +/// Kills: caching the slice size or the grid across a drain. The tail +/// outlives the call that accepted it, so a resize can land in the middle of +/// it -- the untouched remainder must be charged at the new grid, not the one +/// that was current when the bytes arrived. +#[test] +fn a_resize_mid_tail_reprices_the_remainder() { + let mut term = terminal(); + let decalns = 4_000; + assert!(term.feed(&b"\x1b#8".repeat(decalns)), "expected a tail"); + let done_before = term.stats().completed_units; + let work_before = term.stats().completed_work; + assert_eq!(work_before, done_before * CELLS); + + term.resize(Size { + columns: COLUMNS * 2, + screen_lines: LINES, + scrollback: 100, + }); + while term.drain() {} + + let after = term.stats(); + assert_eq!(after.completed_units, decalns as u64, "no unit may be lost"); + assert_eq!( + after.completed_work, + work_before + (decalns as u64 - done_before) * CELLS * 2, + "the remainder must be priced at the resized grid", + ); +} + +/// Slice size is derived from the worst atom the grid admits, because a fixed +/// byte count cannot bound a lock hold: `ESC c` is two bytes and resets both +/// grids plus scrollback. +/// +/// Kills: replacing `slice_bytes` with a constant, or deriving it from +/// `cells` while the worst atom is larger than `cells`. Measured: 256 bytes +/// of DECALN is 1.6 ms at 200x50 and ~14 ms at 1600x50, so no one constant +/// serves both. +#[test] +fn slice_size_shrinks_as_the_worst_atom_grows() { + let small = slice_bytes(80, 24, 0); + let large = slice_bytes(1600, 50, 0); + assert!( + small > large, + "a bigger grid makes each byte more expensive, so slices must shrink: \ + 80x24 -> {small}, 1600x50 -> {large}", + ); + assert!( + slice_bytes(200, 50, 10_000) <= slice_bytes(200, 50, 0), + "scrollback makes RIS more expensive, so it may only shrink slices", + ); + for (columns, lines, scrollback) in [(80, 24, 0), (200, 50, 0), (400, 100, 0), (1600, 50, 0)] { + assert!((MIN_SLICE..=MAX_SLICE).contains(&slice_bytes(columns, lines, scrollback))); + // One slice holds at most N/2 of the densest atom. Either that fits a + // budget, or the floor binds -- and then the overshoot is stated by + // `max_drain_work` rather than being an accident. + let worst = (slice_bytes(columns, lines, scrollback) as u64 / 2) + * max_atom_work(columns, lines, scrollback); + assert!( + worst <= WORK_BUDGET || slice_bytes(columns, lines, scrollback) == MIN_SLICE, + "{columns}x{lines}: a slice buys {worst} work against a \ + {WORK_BUDGET} budget without the MIN clamp to excuse it", + ); + } +} + +/// Work released by an F1 abort is counted. +/// +/// Kills: leaving the `stop_sync` flush out of the accounting. F1 aborts a +/// runaway synchronized update by flushing its buffer through the handler -- +/// those callbacks run, cost time, and hold the lock, so a scheduler that +/// does not see them is blind on exactly the path the fence created. The +/// escapes here are `ESC#8` so the flushed work is unmistakable against the +/// buffered bytes. +#[test] +fn work_flushed_by_a_sync_abort_is_counted() { + let (mut term, _a) = Terminal::new( + Size { + columns: 80, + screen_lines: 24, + scrollback: 0, + }, + Fences::SYNC_ONLY, + ); + let cells = 80 * 24; + + // Open a synchronized update and never close it: F1 must abort it once + // the buffer passes SYNC_CAP, flushing everything buffered so far. + term.feed_fully(b"\x1b[?2026h"); + let decalns = SYNC_CAP / 3 + 1000; + term.feed_fully(&b"\x1b#8".repeat(decalns)); + + let stats = term.stats(); + assert!(stats.sync_aborts > 0, "the fence must have fired"); + // Every DECALN fed must be accounted for. The comparison is against the + // *input*, not against the counters' own internal consistency: an + // uncounted flush leaves both counters small together, so checking them + // against each other would pass over the mutant. + // Two bookkeeping callbacks besides the DECALNs: the `ESC[?2026h` that + // opened the update, and the `unset_private_mode` that `stop_sync` emits + // per abort to report the mode off (`vte-0.15.0/src/ansi.rs:353`). + let bookkeeping = 1 + stats.sync_aborts; + assert_eq!( + stats.completed_units, + decalns as u64 + bookkeeping, + "every DECALN must be counted, including the ones released by the \ + abort, plus {bookkeeping} mode callbacks", + ); + assert_eq!( + stats.completed_work, + decalns as u64 * cells + bookkeeping, + "and their work: {decalns} DECALNs at {cells} cells each", + ); +} + +/// Cheap traffic is not taxed by slicing: an ordinary screenful retires in +/// one call. +/// +/// Kills: a budget so small, or a slice so small, that normal output pays the +/// deferral machinery. This is the companion to the DECALN arm -- a seam that +/// bounds the hold by making everything slow has not fixed anything. +#[test] +fn ordinary_output_needs_no_second_call() { + let mut term = terminal(); + let line = b"\x1b[1;32mbuzz\x1b[0m substrate line of output 0123456789\r\n"; + let screenful = line.repeat(LINES); + + assert!( + !term.feed(&screenful), + "a screenful of ordinary output must retire in one call, not defer", + ); + assert_eq!(term.pending_bytes(), 0); + assert_eq!(term.stats().tail_breaches, 0); +} + +/// The work bound holds on the **first drain of a fresh feeder**, for the +/// densest payload upstream offers. +/// +/// Kills: sizing slices from observed density. A learned bound is not a bound +/// on the first slice -- a cold feeder has seen nothing, so it hands the +/// parser a wide slice, and a wide slice of `ESC c` spends many budgets +/// before anything checks. This is the arm that a warm-up-based scheduler +/// passes on the second call and fails on the first, so it asserts on a +/// terminal that has never parsed a byte. +#[test] +fn a_cold_feeder_bounds_its_very_first_slice() { + for (columns, lines) in [(80, 24), (200, 50), (400, 100), (1600, 50)] { + for (label, atom) in [("RIS", &b"\x1bc"[..]), ("DECALN", &b"\x1b#8"[..])] { + let (mut term, _a) = Terminal::new( + Size { + columns, + screen_lines: lines, + scrollback: 100, + }, + Fences::ALL, + ); + // Never fed before: `density`-style state, if any existed, is at + // its initial value. + term.feed(&atom.repeat(5_000)); + + let spent = term.stats().completed_work; + let ceiling = max_drain_work(columns, lines, 100); + assert!( + spent <= ceiling, + "{label} at {columns}x{lines}: first drain of a cold feeder \ + spent {spent} work, over budget+overshoot ({ceiling})", + ); + assert!(term.pending_bytes() > 0, "{label}: expected a tail"); + } + } +} + +/// Exact price of every escape whose cost the grid can amplify. +/// +/// One table, exact `completed_work` per escape, at two widths so a weight +/// that dropped its `columns` factor cannot hide. Kills, one row each: +/// +/// * `delete_chars`/`insert_blank` charged by `N` -- their cost *falls* as N +/// rises (the swap loop runs `columns - end` times), so N=1 is the worst +/// case and pricing by N is backwards. +/// * `erase_chars` charged raw `N` -- upstream clamps to the row, so +/// `ESC[65535X` on an 80-column grid touches 80 cells, not 65535. +/// * `scroll_up`/`delete_lines` losing their `columns` factor -- the rows are +/// reset, and a row reset is O(columns). +/// * `clear_line`, `decaln`, `clear_screen` mispriced by an axis. +/// +/// Exact equality, never a bound: a `<=` assertion passes for every weight +/// smaller than the truth, which is the direction that hurts. +#[test] +fn every_amplifiable_escape_is_priced_exactly() { + for (columns, lines) in [(80usize, 24usize), (400, 50)] { + let cells = (columns * lines) as u64; + let c = columns as u64; + let cases: &[(&str, String, u64)] = &[ + ("decaln", "\u{1b}#8".into(), cells), + ("clear_screen", "\u{1b}[2J".into(), cells), + ("clear_line", "\u{1b}[2K".into(), c), + ("erase_chars N=1", "\u{1b}[1X".into(), 1), + ("erase_chars N=20", "\u{1b}[20X".into(), 20), + ("erase_chars N=huge", "\u{1b}[65535X".into(), c), + ("delete_chars N=1", "\u{1b}[1P".into(), c), + ("delete_chars N=huge", "\u{1b}[65535P".into(), c), + ("insert_blank N=1", "\u{1b}[1@".into(), c), + ("scroll_up N=1", "\u{1b}[1S".into(), c), + ("scroll_up N=5", "\u{1b}[5S".into(), 5 * c), + ("scroll_up N=huge", "\u{1b}[65535S".into(), lines as u64 * c), + ("delete_lines N=3", "\u{1b}[3M".into(), 3 * c), + ("sgr", "\u{1b}[m".into(), 1), + ("goto", "\u{1b}[1;1H".into(), 1), + ]; + for (label, seq, expected) in cases { + let (mut term, _a) = Terminal::new( + Size { + columns, + screen_lines: lines, + scrollback: 0, + }, + Fences::ALL, + ); + // Home first so nothing scrolls, then measure only the escape. + term.feed_fully(b"\x1b[1;1H"); + term.reset_stats(); + term.feed_fully(seq.as_bytes()); + + assert_eq!(term.stats().completed_units, 1, "{label}: one callback"); + assert_eq!( + term.stats().completed_work, + *expected, + "{label} at {columns}x{lines} priced wrong", + ); + } + } +} + +/// RIS is priced with its history axis, not just its cells. +/// +/// Kills: charging `cells`, or charging the *active* grid's history. RIS +/// resets both grids and walks the primary's scrollback, and `history_size()` +/// observes only the active grid -- so a filled primary followed by +/// `ESC[?1049h` reads as empty while the work is still paid. Configured depth +/// is the only stateless quantity that bounds both, and this asserts the +/// charge tracks it. +#[test] +fn ris_is_priced_for_both_grids_and_the_scrollback_it_walks() { + let (columns, lines) = (80usize, 24usize); + let cells = (columns * lines) as u64; + for scrollback in [0usize, 100, 10_000] { + for (label, prefix) in [("primary", ""), ("alt screen", "\u{1b}[?1049h")] { + let (mut term, _a) = Terminal::new( + Size { + columns, + screen_lines: lines, + scrollback, + }, + Fences::ALL, + ); + term.feed_fully(prefix.as_bytes()); + term.reset_stats(); + term.feed_fully(b"\x1bc"); + + assert_eq!( + term.stats().completed_work, + 2 * cells + (scrollback * columns) as u64, + "{label}, scrollback {scrollback}: RIS must be priced on \ + configured depth, which is the same on both grids", + ); + } + } +} + +/// CBT is charged for exactly the cells it scans -- an equality, in both +/// directions. +/// +/// Kills: delegating `move_backward_tabs` verbatim, and deleting the +/// fixed-point break. With tabstops cleared and the cursor at the right +/// margin, upstream never advances the cursor, so its `col == 0` exit is +/// unreachable and all N iterations rescan the row -- `ESC[3g ESC[65535Z` is +/// 8 bytes for 82 ms at 1600 columns. +/// +/// Two traps this had to be written around, both of which I walked into +/// first: +/// +/// * **The cursor is not the witness.** Deleting the break lands on the same +/// column; only the cost differs. A fixture checking where the cursor ended +/// up passes over the mutant. +/// * **An upper bound is not the witness either.** Deleting the break makes +/// the loop run without charging -- measured `work == 1` for 29 ms of real +/// scanning -- so `spent <= bound` *passes*. Under-charging is exactly the +/// direction that hurts, and only an equality sees it. +/// +/// The expected value is the scan the source performs: with no stop below the +/// cursor, one pass over `cursor_column` cells, then a permanent fixed point. +/// Both arms come to `columns` -- the telescoping sum of a walk, or one +/// failed pass -- which is the bound this whole change buys. +#[test] +fn the_worst_atom_is_charged_for_exactly_what_it_scans() { + for columns in [80usize, 400, 1600] { + let (mut term, _a) = Terminal::new( + Size { + columns, + screen_lines: 50, + scrollback: 0, + }, + Fences::ALL, + ); + // Adversarial for cost: every tabstop gone, cursor at the right + // margin, count far past the width. + term.feed_fully(format!("\u{1b}[3g\u{1b}[1;{columns}H").as_bytes()); + term.reset_stats(); + + term.feed_fully(b"\x1b[65535Z"); + + assert_eq!(term.stats().completed_units, 1, "one escape, one callback"); + assert_eq!( + term.stats().completed_work, + 1 + (columns as u64 - 1), + "one failed scan over the whole prefix, then a permanent fixed \ + point: the charge is the escape plus that one scan. A loop that \ + kept going would charge this much per iteration, 65535 times", + ); + // The real guard on the loop: with a stop reachable, the charge must + // equal the distance actually travelled. A break-less loop scans the + // row 65535 times and charges for one crossing. + let (mut walk, _a) = Terminal::new( + Size { + columns, + screen_lines: 50, + scrollback: 0, + }, + Fences::ALL, + ); + // Default tabstops every 8: from the right margin a huge count walks + // to column 0, crossing every column on the way. + walk.feed_fully(format!("\u{1b}[1;{columns}H").as_bytes()); + walk.reset_stats(); + walk.feed_fully(b"\x1b[65535Z"); + + assert_eq!(walk.term().grid().cursor.point.column.0, 0); + assert_eq!( + walk.stats().completed_work, + 1 + (columns as u64 - 1), + "the charge must be the distance travelled: one unit for the \ + escape plus one per column crossed", + ); + } +} + +/// CBT at column 0 is free, and stays free. +/// +/// Kills: removing the `before == 0` guard. Upstream has its own `col == 0` +/// break, so deleting the wrapper's copy is invisible to the cursor and +/// invisible to timing -- it only shows up as work charged for a scan over +/// zero cells that the wrapper attributed to itself. The left margin is also +/// the position both earlier sweeps of this op homed to, which is why it is +/// the position where a defect hides best. +#[test] +fn the_worst_atom_costs_nothing_at_the_left_margin() { + for columns in [80usize, 400] { + let (mut term, _a) = Terminal::new( + Size { + columns, + screen_lines: 50, + scrollback: 0, + }, + Fences::ALL, + ); + term.feed_fully(b"\x1b[3g\x1b[1;1H"); + term.reset_stats(); + term.feed_fully(b"\x1b[65535Z"); + + assert_eq!( + term.stats().completed_work, + 1, + "at column 0 there is nothing to the left to scan, so the escape \ + costs one unit and no cells", + ); + assert_eq!(term.term().grid().cursor.point.column.0, 0); + } +} + +/// The other adversary: every tabstop *set*, which maximises the number of +/// delegated single steps rather than the length of one scan. +/// +/// Kills: pricing CBT per-step-times-width. Cleared tabstops attack the +/// clamp; all-set attacks the break, forcing `columns - 1` steps of one +/// column each. The two layouts peak in different terms and neither may +/// exceed the bound, so both are here -- a suite that tested only the famous +/// one would miss the shape it chose against. +#[test] +fn the_worst_atom_is_bounded_under_the_layout_that_maximises_steps() { + for columns in [80usize, 400] { + let (mut term, _a) = Terminal::new( + Size { + columns, + screen_lines: 50, + scrollback: 100, + }, + Fences::ALL, + ); + // A tabstop in every column, then start from the right margin. + term.feed_fully(b"\x1b[3g"); + for c in 1..=columns { + term.feed_fully(format!("\u{1b}[1;{c}H\u{1b}H").as_bytes()); + } + term.feed_fully(format!("\u{1b}[1;{columns}H").as_bytes()); + term.reset_stats(); + + term.feed_fully(b"\x1b[65535Z"); + + let spent = term.stats().completed_work; + assert_eq!(term.stats().completed_units, 1); + assert!( + spent <= 2 * columns as u64, + "all-set CBT at {columns} columns charged {spent}, over 2 x columns", + ); + assert_eq!( + term.term().grid().cursor.point.column.0, + 0, + "with a stop in every column the cursor must walk all the way", + ); + } +} + +/// Stopping CBT early does not change where the cursor lands. +/// +/// The companion to the two cost tests above: they assert the work fell, +/// this asserts the behaviour did not move. Kills: stopping at something that +/// is *not* a fixed point -- `min(N, 1)`, or breaking whenever a scan fails +/// even though an earlier step still had stops to find. Cases are the ones +/// the exhaustive probe found interesting: no stops, one stop mid-row, and +/// default stops, each from the right margin with a count past the width. +#[test] +fn stopping_the_worst_atom_early_preserves_its_semantics() { + let columns = 40usize; + let cursor_column = |setup: &str| -> usize { + let (mut term, _a) = Terminal::new( + Size { + columns, + screen_lines: 3, + scrollback: 0, + }, + Fences::ALL, + ); + term.feed_fully(setup.as_bytes()); + term.term().grid().cursor.point.column.0 + }; + + // No stops: the cursor cannot move, whatever the count. + assert_eq!(cursor_column("\u{1b}[3g\u{1b}[1;40H\u{1b}[65535Z"), 39); + assert_eq!(cursor_column("\u{1b}[3g\u{1b}[1;40H\u{1b}[40Z"), 39); + // One stop at column 20 (1-based 21): reachable once, then stuck. + let one_stop = "\u{1b}[3g\u{1b}[1;21H\u{1b}H\u{1b}[1;40H"; + assert_eq!( + cursor_column(&format!("{one_stop}\u{1b}[65535Z")), + cursor_column(&format!("{one_stop}\u{1b}[40Z")), + ); + // Default stops every 8: a large count walks all the way to column 0. + assert_eq!(cursor_column("\u{1b}[1;40H\u{1b}[65535Z"), 0); +} diff --git a/desktop/src-tauri/crates/buzz-terminal/tests/snapshot.rs b/desktop/src-tauri/crates/buzz-terminal/tests/snapshot.rs index 0035509043..a7305b52f9 100644 --- a/desktop/src-tauri/crates/buzz-terminal/tests/snapshot.rs +++ b/desktop/src-tauri/crates/buzz-terminal/tests/snapshot.rs @@ -49,7 +49,7 @@ fn visible_text(frame: &buzz_terminal::damage::Frame) -> Vec { #[test] fn a_late_render_shows_only_the_next_change_but_a_snapshot_shows_the_screen() { let (shared, _actions) = terminal(20, 4); - shared.feed(b"first\r\nsecond\r\nthird"); + shared.feed_fully(b"first\r\nsecond\r\nthird"); // The incumbent consumes the damage from that output. let mut incumbent = Encoder::new(); @@ -101,12 +101,12 @@ fn a_snapshot_does_not_steal_the_incumbents_damage() { // content is shorter than its replacement so the rewrite below covers it // completely and no tail of it survives. let mut incumbent = Encoder::new(); - shared.feed(b"old"); + shared.feed_fully(b"old"); let _ = shared.render(&mut incumbent); // Rewrite row 0, then park the cursor on row 3. The incumbent is now owed // row 0, which is not the row the cursor will re-damage for free. - shared.feed(b"\x1b[1;1HAFTER\x1b[4;1H"); + shared.feed_fully(b"\x1b[1;1HAFTER\x1b[4;1H"); // A second subscriber attaches and snapshots first. let mut attaching = Encoder::new(); @@ -138,11 +138,12 @@ fn a_snapshot_does_not_steal_the_incumbents_damage() { #[test] fn a_snapshot_realigns_a_reused_encoders_dedup_state() { let (shared, _actions) = terminal(20, 4); - shared.feed(b"wide enough line"); + shared.feed_fully(b"wide enough line"); let mut encoder = Encoder::new(); let before = shared.snapshot(&mut encoder); assert_eq!(before.viewport.columns, 20); + let first_generation = before.viewport.generation; let resized = shared.resize(Size { columns: 10, @@ -150,6 +151,10 @@ fn a_snapshot_realigns_a_reused_encoders_dedup_state() { scrollback: 100, }); assert_eq!(resized.columns, 10); + assert!( + resized.generation > first_generation, + "an applied resize advances the generation" + ); // Same encoder, new geometry: every row must be re-sent, not suppressed // as unchanged against hashes taken at the old width. @@ -158,6 +163,17 @@ fn a_snapshot_realigns_a_reused_encoders_dedup_state() { after.viewport.columns, 10, "the capture-time grid is stamped" ); + // Columns alone does not identify a grid. `Viewport`'s own doc says the + // three fields travel together *because* a consumer comparing two of the + // three can be wrong -- and this fixture used to compare one. A resize + // that changed only `screen_lines`, or 20 -> 10 -> 20, leaves columns + // matching while the generation has moved. `resize.rs` asserts this on + // `render()` frames five times and never once on a snapshot, which is + // what Sami's T3 mutant walked through; Mari's reattach reads this stamp. + assert_eq!( + after.viewport, resized, + "a snapshot stamps the identity of the grid it actually captured" + ); assert!(after.full, "a snapshot is a repaint"); assert!( !after.rows.is_empty(), @@ -165,13 +181,97 @@ fn a_snapshot_realigns_a_reused_encoders_dedup_state() { ); } +/// A snapshot carries *every* row of the viewport, including the last one, +/// and stamps the cursor plane truthfully. +/// +/// Both properties are asserted here rather than in the fixtures above +/// because of what those fixtures' helper hides: `visible_text` trims and +/// drops empty lines, so a capture that skipped the bottom row of the screen +/// reads identically to one that didn't whenever the content sits in the top +/// rows -- which it does in every other fixture in this file. Sami's T2 +/// mutant (`0..screen_lines - 1`) survived all four for exactly that reason. +/// So this fixture puts content on the last row and asserts the row *set*, +/// not the text. +/// +/// The cursor half is the same shape of gap: nothing checked that a snapshot's +/// cursor was the terminal's cursor rather than a plausible default. +#[test] +fn a_snapshot_carries_every_row_and_the_true_cursor() { + let (shared, _actions) = terminal(20, 4); + // Write the bottom row of the screen, then park the cursor at line 4, + // column 6 (1-based) -- row 3, column 5 to us. + shared.feed_fully(b"\x1b[4;1Hbottom\x1b[4;6H"); + + let mut attaching = Encoder::new(); + let frame = shared.snapshot(&mut attaching); + + let lines: Vec = frame.rows.iter().map(|row| row.line).collect(); + assert_eq!( + lines, + vec![0, 1, 2, 3], + "a snapshot must carry the whole viewport, last row included" + ); + assert!( + visible_text(&frame).contains(&"bottom".to_string()), + "content on the last row must reach an attaching subscriber, got {:?}", + visible_text(&frame) + ); + + assert_eq!(frame.cursor.line, 3, "the snapshot's cursor line is real"); + assert_eq!( + frame.cursor.column, 5, + "the snapshot's cursor column is real" + ); + assert!(frame.cursor.visible, "the cursor is shown by default"); + + // ...and a hidden cursor is reported hidden, so `visible` tracks the mode + // rather than being a constant that happens to match the default. + shared.feed_fully(b"\x1b[?25l"); + let mut second = Encoder::new(); + assert!( + !shared.snapshot(&mut second).cursor.visible, + "DECTCEM off must reach the attaching subscriber" + ); +} + +/// Taking a snapshot is billed to the renderer plane. +/// +/// The two planes are metered separately because pooling them lets the +/// reader's millions of fast acquires dilute the renderer's tail into a false +/// pass (`shared.rs` module docs). A full-grid copy is the single most +/// expensive thing that takes this lock, so misfiling it under the reader +/// would corrupt the very instrument the renderer's budget is judged by -- +/// and no fixture noticed until Sami's T4. +#[test] +fn a_snapshot_is_billed_to_the_renderer_plane() { + let (shared, _actions) = terminal(20, 4); + shared.feed_fully(b"content"); + + shared.reader_acquire().reset(); + shared.renderer_acquire().reset(); + + let mut attaching = Encoder::new(); + let _ = shared.snapshot(&mut attaching); + + assert_eq!( + shared.renderer_acquire().snapshot().acquisitions, + 1, + "the snapshot's lock acquisition belongs to the renderer plane" + ); + assert_eq!( + shared.reader_acquire().snapshot().acquisitions, + 0, + "a full-grid copy must not be charged to the reader plane" + ); +} + /// Two consecutive snapshots with no output between them still both carry the /// screen. A snapshot is not a one-shot: reattach may happen repeatedly, and /// nothing about the first may disarm the second. #[test] fn snapshots_are_repeatable() { let (shared, _actions) = terminal(20, 4); - shared.feed(b"persistent"); + shared.feed_fully(b"persistent"); let mut first = Encoder::new(); let mut second = Encoder::new();