Merge commit 'a957d25b02881bad31bb0d6ab6c9571545ba9bc8' into mari/tui-integration

* commit 'a957d25b02881bad31bb0d6ab6c9571545ba9bc8':
  feat(terminal): bound the lock hold by weighted work, not by bytes
  test(terminal): close the review gaps in the cluster and snapshot contracts
  feat(terminal): give an attaching subscriber the screen as it stands
  feat(terminal): give the renderer each cluster's true column

Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>

# Conflicts:
#	desktop/src-tauri/crates/buzz-terminal/src/damage.rs
#	desktop/src-tauri/crates/buzz-terminal/tests/clusters.rs
#	desktop/src-tauri/crates/buzz-terminal/tests/snapshot.rs
This commit is contained in:
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf
2026-08-02 00:53:06 -04:00
12 changed files with 1726 additions and 30 deletions

View File

@@ -381,7 +381,14 @@ fn spans(cells: &[Cell]) -> Vec<Span> {
}
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"
);

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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<u8>,
/// 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<H: Handler>(&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<H: Handler + CursorColumn>(&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<H: Handler + CursorColumn>(&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<H: Handler + CursorColumn>(&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) {

View File

@@ -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.

View File

@@ -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<L: EventListener> CursorColumn for Term<L> {
#[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<H: Handler + CursorColumn> 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<String>);
set_cursor_style(a0: Option<CursorStyle>);
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<char>);
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<usize>);
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<Hyperlink>);
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;
}
}

View File

@@ -33,7 +33,7 @@ fn render(input: &str) -> (Vec<Span>, Receiver<Action>) {
};
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<u16> = spans.iter().map(|span| span.cluster_count).collect();
let columns_at: Vec<usize> = 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);

View File

@@ -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");
}

View File

@@ -78,7 +78,7 @@ fn flood(shared: &SharedTerminal, stop: &AtomicBool) {
if stop.load(Ordering::Relaxed) {
return;
}
shared.feed(chunk);
shared.feed_fully(chunk);
}
}
}

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -49,7 +49,7 @@ fn visible_text(frame: &buzz_terminal::damage::Frame) -> Vec<String> {
#[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<usize> = 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();