9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
883 lines
34 KiB
Rust
883 lines
34 KiB
Rust
//! The work-denominated slicing seam: what bounds one lock hold, what bounds
|
|
//! the queue behind it, and what proves the work was actually done.
|
|
//!
|
|
//! **This is half a suite.** The adversarial resize and overflow cases live
|
|
//! in `slicing_adversarial.rs`, split out for the file-size ratchet; the two
|
|
//! files are one set of contracts. A mutation check scoped with
|
|
//! `--test slicing` covers 20 of 76 package tests and can report a confident
|
|
//! pass while the killing fixture sits in the sibling file. Dropping the
|
|
//! scrollback debt does exactly that, then dies under the package.
|
|
//!
|
|
//! Mutation checks run the package, never a file: `cargo test -p buzz-terminal`.
|
|
//!
|
|
//! 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_remaining, Fences, MAX_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_remaining` 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_remaining(80, 24, 0, 0, 0);
|
|
let large = slice_bytes_remaining(1600, 50, 0, 0, 0);
|
|
assert!(
|
|
small > large,
|
|
"a bigger grid makes each byte more expensive, so slices must shrink: \
|
|
80x24 -> {small}, 1600x50 -> {large}",
|
|
);
|
|
assert!(
|
|
slice_bytes_remaining(200, 50, 10_000, 0, 0) <= slice_bytes_remaining(200, 50, 0, 0, 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!((1..=MAX_SLICE).contains(&slice_bytes_remaining(columns, lines, scrollback, 0, 0)));
|
|
// 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 width = slice_bytes_remaining(columns, lines, scrollback, 0, 0);
|
|
let worst = (width as u64 / 2) * max_atom_work(columns, lines, scrollback);
|
|
assert!(
|
|
worst <= WORK_BUDGET || width == 1,
|
|
"{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),
|
|
("scroll_down N=1", "\u{1b}[1T".into(), c),
|
|
("scroll_down N=4", "\u{1b}[4T".into(), 4 * c),
|
|
(
|
|
"scroll_down N=huge",
|
|
"\u{1b}[65535T".into(),
|
|
lines as u64 * c,
|
|
),
|
|
("delete_lines N=3", "\u{1b}[3M".into(), 3 * c),
|
|
(
|
|
"delete_lines N=huge",
|
|
"\u{1b}[65535M".into(),
|
|
lines as u64 * c,
|
|
),
|
|
("insert_lines N=1", "\u{1b}[1L".into(), c),
|
|
("insert_lines N=6", "\u{1b}[6L".into(), 6 * c),
|
|
(
|
|
"insert_lines N=huge",
|
|
"\u{1b}[65535L".into(),
|
|
lines as u64 * c,
|
|
),
|
|
("put_tab N=1", "\t".into(), c),
|
|
("fwd_tabs N=1", "\u{1b}[1I".into(), c),
|
|
("fwd_tabs N=huge", "\u{1b}[65535I".into(), c),
|
|
("insert_blank N=huge", "\u{1b}[65535@".into(), c),
|
|
("clear_line ESC[0K", "\u{1b}[0K".into(), c),
|
|
("clear_line ESC[1K", "\u{1b}[1K".into(), c),
|
|
("clear_screen ESC[0J", "\u{1b}[0J".into(), cells),
|
|
("clear_screen ESC[1J", "\u{1b}[1J".into(), cells),
|
|
("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 dropping the history term.
|
|
///
|
|
/// On the alt-screen arm, honestly labelled: the active-`history_size()`
|
|
/// mispricing it was written against is **unrepresentable in this design**,
|
|
/// not merely untested. `Counting` holds `scrollback` as a scalar copied at
|
|
/// construction and has no path to a live grid, so there is no way to write
|
|
/// the mutant. The arm is kept as a regression witness -- if a `Term`
|
|
/// reference is ever wired into the wrapper it becomes load-bearing the same
|
|
/// day -- and both arms are evaluated before either can report, so the
|
|
/// primary cannot short-circuit the alt.
|
|
#[test]
|
|
fn ris_is_priced_for_both_grids_and_the_scrollback_it_walks() {
|
|
let (columns, lines) = (80usize, 24usize);
|
|
let cells = (columns * lines) as u64;
|
|
let mut observed = vec![];
|
|
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");
|
|
observed.push((label, scrollback, term.stats().completed_work));
|
|
}
|
|
}
|
|
// One comparison over the whole vector, not a loop of comparisons.
|
|
// Collecting first stops an arm from being *skipped*; asserting the
|
|
// vectors is what stops a failure from being *truncated* to the first
|
|
// mismatch. Otherwise the alt-screen receipt still never prints, which
|
|
// was the point of collecting.
|
|
let expected: Vec<_> = observed
|
|
.iter()
|
|
.map(|&(label, scrollback, _)| {
|
|
(label, scrollback, 2 * cells + (scrollback * columns) as u64)
|
|
})
|
|
.collect();
|
|
assert_eq!(
|
|
observed, expected,
|
|
"RIS must be priced on configured depth, identically 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");
|
|
|
|
assert_eq!(term.stats().completed_units, 1);
|
|
assert_eq!(
|
|
term.stats().completed_work,
|
|
1 + (columns as u64 - 1),
|
|
"with a stop in every column the walk crosses each of them once, \
|
|
so the charge is exact: one unit for the escape plus one per \
|
|
column crossed. An inequality here would not catch a 2x \
|
|
overcharge -- which lands on 159, not 160, because the escape's \
|
|
own unit is charged separately and is not doubled",
|
|
);
|
|
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);
|
|
}
|
|
|
|
/// A stream of atoms each worth more than the whole budget still drains, and
|
|
/// every drain makes progress.
|
|
///
|
|
/// The liveness half of the bound. `max_drain_work` says how much one drain
|
|
/// may cost; it says nothing about whether the loop terminates, and an
|
|
/// oversized atom is exactly where a work-denominated scheduler could refuse
|
|
/// to start one -- spending its budget checking, never advancing, and hanging
|
|
/// the terminal with a full tail. RIS on a 10k-scrollback grid is ~16x the
|
|
/// budget, so this is not hypothetical.
|
|
///
|
|
/// Kills: any yield that can decline to start work -- a `width` that reaches
|
|
/// 0, a `remaining`-scaled slice that underflows to nothing, a guard that
|
|
/// skips a slice deemed too expensive for what is left of the budget. Each of
|
|
/// those is a plausible thing to reach for when an atom costs more than the
|
|
/// whole budget, and each hangs a terminal on legitimate input.
|
|
///
|
|
/// Note on a mutant it does *not* kill: moving the budget check from after
|
|
/// the slice to before it is **equivalent**, not a defect -- `spent` is zero
|
|
/// at entry, so the first slice runs either way. Recorded because I wrote
|
|
/// this test believing it caught that, ran the mutant, and it lived.
|
|
#[test]
|
|
fn atoms_larger_than_the_budget_still_make_progress() {
|
|
for (columns, lines, scrollback) in [(80usize, 24usize, 10_000usize), (200, 50, 10_000)] {
|
|
let (mut term, _a) = Terminal::new(
|
|
Size {
|
|
columns,
|
|
screen_lines: lines,
|
|
scrollback,
|
|
},
|
|
Fences::ALL,
|
|
);
|
|
let atoms = 200usize;
|
|
let bound = max_drain_work(columns, lines, scrollback);
|
|
assert!(
|
|
bound > WORK_BUDGET * 4,
|
|
"this arm is only meaningful where one atom dwarfs the budget",
|
|
);
|
|
|
|
let mut more = term.feed(&b"\x1bc".repeat(atoms));
|
|
// `feed` already drained once; seed the baseline with its work or the
|
|
// first delta measured below silently doubles.
|
|
let mut previous = term.stats().completed_work;
|
|
let mut worst = previous;
|
|
let mut calls = 1;
|
|
while more {
|
|
let before = term.pending_bytes();
|
|
more = term.drain();
|
|
assert!(
|
|
term.pending_bytes() < before,
|
|
"no progress: the tail stuck at {before} bytes",
|
|
);
|
|
let now = term.stats().completed_work;
|
|
worst = worst.max(now - previous);
|
|
previous = now;
|
|
calls += 1;
|
|
assert!(calls < 10_000, "drain did not terminate");
|
|
}
|
|
|
|
assert_eq!(term.stats().completed_units, atoms as u64, "lost units");
|
|
assert_eq!(term.pending_bytes(), 0);
|
|
assert!(
|
|
worst <= bound,
|
|
"{columns}x{lines}: worst drain spent {worst}, over the stated \
|
|
bound {bound}",
|
|
);
|
|
}
|
|
}
|