feat(terminal): bound the lock hold by weighted work, not by bytes

Both existing fences meter bytes. That is right for memory and wrong for
time: `ESC[m` and `ESC#8` are four bytes each, and the second rewrites every
cell. A DECALN flood therefore 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.

So add a third quantity. `Counting` wraps the handler and charges each
callback what it touches: an O(cells) callback costs `columns * lines`, an
O(1) callback costs 1. `Feeder` accepts bytes into a pending tail and parses
only what one work budget affords, leaving the rest for the next call. That
bounds the hold. It does not bound the queue, so the tail has a cap and a
loud breach counter, and the reader is told to stop reading via a depth query
rather than a pause flag the fence owns -- a flag is a state a reader can fail
to clear, which is how a paused reader strands a child mid-teardown.

The weights are read from `alacritty_terminal-0.26.0/src/term/mod.rs` and then
checked against measurement, never fitted. Where source and measurement
disagree the source wins and the slack is recorded in the table. Three
findings from that reading are worth naming:

* `delete_chars`/`insert_blank` cost `columns` for *every* N and are worst at
  N=1 -- the swap loop runs `columns - end` times, so cost falls as N rises.
  A sweep that varies only N reports them parameter-dependent *decreasing*,
  and the honest reading of that invites charging by N, which is backwards.
* `move_backward_tabs` (CBT) assigns `col` inside the `if self.tabs[i]` test,
  so with tabstops cleared the cursor never moves, its `col == 0` exit is
  unreachable, and all N iterations rescan the row: `ESC[3g ESC[65535Z` is
  eight bytes for 82ms at 1600 columns. Its twin `move_forward_tabs` assigns
  outside the test and is fine. The wrapper stops it at the first fixed point
  -- permanent, because the scan depends only on the cursor -- which makes it
  O(columns) and, verified exhaustively over every tabstop subset of a
  12-column grid, lands on the same column as the naive loop.
* `reset_state` (RIS) resets both grids and walks the primary's scrollback, so
  two bytes can be worth more than the whole budget. Priced on *configured*
  depth, not `history_size()`, which observes only the active grid: a filled
  primary followed by `ESC[?1049h` reads as empty while the work is still paid.

Slice size is derived from the densest atom the grid admits rather than
chosen, so the bound holds on the first byte of a cold feeder. An earlier
version sized slices from observed density and was strictly worse where it
matters -- a fresh feeder has seen nothing, so its first slice is wide.

`Terminal::feed` now returns whether a tail remains and the caller pumps
`drain` between lock acquisitions. `feed_fully` is the whole-buffer form,
deliberately a separate name because it reinstates the unbounded hold.

Every fixture asserts exact counts, never `> 0` and never `<=` alone. Both
directions are load-bearing: with the CBT break deleted the callback loops
65535 times while charging `work == 1`, so an upper bound passes over it, and
the mutant lands on the same cursor column as the fix, so a semantic assertion
passes too. 20 mutants dead in debug and release.

Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
This commit is contained in:
npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6
2026-08-02 00:46:05 -04:00
parent e887bdb2c4
commit a957d25b02
11 changed files with 1544 additions and 33 deletions

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

@@ -14,6 +14,7 @@ pub mod path;
pub mod reader;
pub mod shared;
pub mod shell;
pub mod units;
#[cfg(test)]
mod context_tests;
@@ -109,7 +110,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,
},
@@ -118,8 +124,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 {
@@ -170,6 +221,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
@@ -246,7 +246,7 @@ fn a_run_longer_than_u16_max_splits_rather_than_wrapping() {
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(b"a");
shared.feed_fully(b"a");
let mut encoder = Encoder::new();
let frame = shared.render(&mut encoder);
@@ -297,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);
@@ -327,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,7 +138,7 @@ 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);
@@ -200,7 +200,7 @@ 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(b"\x1b[4;1Hbottom\x1b[4;6H");
shared.feed_fully(b"\x1b[4;1Hbottom\x1b[4;6H");
let mut attaching = Encoder::new();
let frame = shared.snapshot(&mut attaching);
@@ -226,7 +226,7 @@ fn a_snapshot_carries_every_row_and_the_true_cursor() {
// ...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(b"\x1b[?25l");
shared.feed_fully(b"\x1b[?25l");
let mut second = Encoder::new();
assert!(
!shared.snapshot(&mut second).cursor.visible,
@@ -245,7 +245,7 @@ fn a_snapshot_carries_every_row_and_the_true_cursor() {
#[test]
fn a_snapshot_is_billed_to_the_renderer_plane() {
let (shared, _actions) = terminal(20, 4);
shared.feed(b"content");
shared.feed_fully(b"content");
shared.reader_acquire().reset();
shared.renderer_acquire().reset();
@@ -271,7 +271,7 @@ fn a_snapshot_is_billed_to_the_renderer_plane() {
#[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();