mirror of
https://github.com/block/buzz.git
synced 2026-10-31 22:41:12 +00:00
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:
commit
1a06fffddc
@@ -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"
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
377
desktop/src-tauri/crates/buzz-terminal/src/units.rs
Normal file
377
desktop/src-tauri/crates/buzz-terminal/src/units.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ fn flood(shared: &SharedTerminal, stop: &AtomicBool) {
|
||||
if stop.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
shared.feed(chunk);
|
||||
shared.feed_fully(chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
760
desktop/src-tauri/crates/buzz-terminal/tests/slicing.rs
Normal file
760
desktop/src-tauri/crates/buzz-terminal/tests/slicing.rs
Normal 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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user