Merge Dawn's terminal scheduler repair

Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
This commit is contained in:
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf
2026-08-02 01:32:40 -04:00
5 changed files with 630 additions and 55 deletions
@@ -75,6 +75,45 @@ pub fn slice_bytes(columns: usize, lines: usize, scrollback: usize) -> usize {
((WORK_BUDGET / densest) as usize).clamp(MIN_SLICE, MAX_SLICE)
}
/// Bytes to hand the parser when `spent` of the budget is already gone.
///
/// The scheduling rule in one place so the fixtures can assert on it rather
/// than on a copy of the arithmetic: a slice of `N` bytes holds at most
/// `N / atom_bytes` atoms, so `remaining / densest` bytes cannot carry a
/// drain past the budget.
///
/// `next_escape` is how far the next `ESC` is from the front of the tail.
/// This is the difference between a correct bound and an unusable one. Only
/// an escape can buy grid-sized work in two bytes; a run of ordinary
/// characters costs at most `columns` per byte (a wrapping line feed that
/// scrolls), which is four orders of magnitude cheaper than RIS. Pricing
/// plain text as though every byte might be RIS drops throughput from
/// 181 MB/s to 69 MB/s at the default scrollback -- measured -- while
/// bounding something that cannot happen. So a plain run is sliced against
/// the plain-byte cost and only the escape itself is metered against the
/// worst atom.
pub fn slice_bytes_remaining(
columns: usize,
lines: usize,
scrollback: usize,
spent: u64,
next_escape: usize,
) -> usize {
let remaining = WORK_BUDGET.saturating_sub(spent);
if next_escape > 0 {
// A plain run, and it stops at the escape: an escape sharing a slice
// with the text in front of it is how a callback runs *after* the one
// that crossed the budget, which is the overrun this bound exists to
// prevent. Worst case per plain byte is a line feed that scrolls,
// which resets one row: `columns`.
let per_byte = (columns as u64).max(1);
return ((remaining / per_byte) as usize).clamp(1, next_escape.min(MAX_SLICE));
}
// An escape starts here. `ESC c` is the densest at two bytes.
let densest = (max_atom_work(columns, lines, scrollback) / 2).max(1);
((remaining / densest) as usize).clamp(1, MAX_SLICE)
}
/// Work the single worst uninterruptible callback can cost on this grid.
///
/// This is the irreducible overrun past [`WORK_BUDGET`]: no scheduler outside
@@ -95,8 +134,17 @@ pub fn max_atom_work(columns: usize, lines: usize, scrollback: usize) -> u64 {
// 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)
//
// Saturating, and widened to u64 *before* multiplying. `Size` fields are
// unclamped `usize` with no caller bounding them, so the products here
// are reachable overflows: in debug that is a panic in the accounting
// path, and in release it wraps to a small number, which understates the
// bound -- an overflow that reports the parser as cheap is the worst of
// the three outcomes.
let (columns, lines, scrollback) = (columns as u64, lines as u64, scrollback as u64);
let both_grids = columns.saturating_mul(lines).saturating_mul(2);
let history = scrollback.saturating_mul(columns);
both_grids.saturating_add(history).max(columns)
}
/// Upper bound on the work a single [`crate::reader::Feeder::drain`] can do.
@@ -115,12 +163,11 @@ pub fn max_atom_work(columns: usize, lines: usize, scrollback: usize) -> u64 {
/// 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
// One atom, not one slice: [`crate::reader::Feeder::drain`] sizes every
// slice against the *remaining* budget, so it cannot start a slice able
// to hold more work than is left. What it cannot do is preempt a callback
// that has begun, which is where this term comes from.
WORK_BUDGET.saturating_add(max_atom_work(columns, lines, scrollback))
}
/// Max bytes that may sit unparsed before the reader must stop reading the
@@ -224,7 +224,7 @@ impl Terminal {
return self.viewport();
}
self.term.resize(size);
self.feeder.resize(size.columns, size.screen_lines);
self.feeder.resize(size);
self.size = size;
self.generation += 1;
self.viewport()
@@ -7,8 +7,8 @@
use alacritty_terminal::vte::ansi::{Handler, Processor, StdSyncHandler};
use crate::fences::{
slice_bytes, FenceStats, Fences, MAX_SLICE, OSC_BUDGET, SYNC_CAP, TAIL_CAP, TAIL_RESUME,
WORK_BUDGET,
slice_bytes_remaining, FenceStats, Fences, MAX_SLICE, OSC_BUDGET, SYNC_CAP, TAIL_CAP,
TAIL_RESUME, WORK_BUDGET,
};
use crate::units::{Counting, CursorColumn};
@@ -33,8 +33,23 @@ pub struct Feeder {
/// 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.
/// Whether the parser is part-way through an escape sequence that has not
/// yet dispatched. Governs how the next slice is metered -- see
/// [`crate::fences::slice_bytes_remaining`].
mid_escape: bool,
/// Deepest scrollback this feeder has ever been configured for.
///
/// A high-water mark rather than the current depth, and the difference is
/// not conservatism for its own sake -- the rows are still there. Upstream
/// frees history lazily: `Storage::shrink_lines` truncates only once the
/// buffer exceeds the new length by `MAX_CACHE_SIZE`, so immediately after
/// a decrease the grid still owns rows that a reset must walk. Pricing at
/// the new depth would charge for a grid that does not exist yet.
///
/// Never lowered, so it needs no clearing transition and cannot go stale
/// in the unsafe direction. The cost is that a session which shrinks its
/// scrollback keeps paying the deep price for the rest of its life; the
/// alternative is a bound that is wrong immediately after every shrink.
scrollback: usize,
}
@@ -47,16 +62,28 @@ impl Feeder {
since_reset: 0,
pending: Vec::new(),
pending_at: 0,
mid_escape: false,
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;
/// Track a geometry change, so the cost weights describe the current grid.
///
/// Takes the whole [`crate::Size`] rather than a column/line pair on
/// purpose. Scrollback is as load-bearing as the other two -- it is most
/// of RIS's price and therefore most of the slice derivation -- and a
/// signature that accepted only the dimensions let a caller change the
/// depth on the `Term` while the feeder kept charging the construction
/// value. One argument, one ownership boundary, no way to update two of
/// three.
pub fn resize(&mut self, size: crate::Size) {
self.columns = size.columns;
self.lines = size.screen_lines;
// Grows only. See the field: a decrease does not immediately free the
// rows a reset has to walk.
self.scrollback = self.scrollback.max(size.scrollback);
}
pub fn stats(&self) -> FenceStats {
@@ -158,14 +185,60 @@ impl Feeder {
// 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;
let mut spent: u64 = 0;
while self.pending_at < self.pending.len() {
// Size each slice against what is *left* of the budget, and
// against what is actually in front of the parser. A slice can
// only be as expensive as the callbacks it contains, and only an
// escape can buy grid-sized work in two bytes -- so a plain run
// is sliced against the plain-byte cost and stops at the next
// `ESC`, which then gets a slice metered against the worst atom.
// The drain therefore returns on the atom that crosses the
// budget, not at the end of a slice that ran several more.
//
// Where one atom is worth more than the entire budget -- RIS at
// any real scrollback depth -- that escape gets a one-byte slice.
// That is the honest consequence of the law: nothing wider can
// promise to stop after the crossing atom when a single atom
// always crosses.
// A slice is never wider than MAX_SLICE, so the scan for the next
// escape stops there too: searching the whole tail would be
// O(tail) per slice and O(tail^2) per drain, which measured as a
// 7x throughput *regression* on plain text -- a bound that costs
// more than the thing it bounds.
let horizon = (self.pending_at + MAX_SLICE).min(self.pending.len());
let next_escape = if self.mid_escape {
// Already inside a sequence whose callback has not fired. Its
// remaining bytes are *not* plain text -- `ESC` then `c` is a
// grid reset -- so they keep the escape's metering. Without
// this the byte after a lone `ESC` is priced as a character
// and the atom rides into a wide slice with whatever follows
// it, which is the post-atom overrun by another door.
0
} else {
self.pending[self.pending_at..horizon]
.iter()
.position(|&b| b == 0x1b)
.unwrap_or(horizon - self.pending_at)
};
let width = slice_bytes_remaining(
self.columns,
self.lines,
self.scrollback,
spent,
next_escape,
);
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]);
let cost = self.advance_slice(handler, &buf[..len]);
// A slice that contained an escape but dispatched nothing left the
// parser mid-sequence. Work is the signal because it is the thing
// being budgeted: a sequence that has not yet cost anything has
// not yet run.
self.mid_escape = (self.mid_escape || buf[..len].contains(&0x1b)) && cost == 0;
spent = spent.saturating_add(cost);
if spent >= WORK_BUDGET {
break;
}
@@ -184,14 +257,15 @@ impl Feeder {
/// 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 mut spent: u64 = 0;
let sync_before = self.parser.sync_bytes_count();
{
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();
self.stats.completed_units =
self.stats.completed_units.saturating_add(counting.units());
self.stats.completed_work = self.stats.completed_work.saturating_add(counting.work());
spent = spent.saturating_add(counting.work());
}
let sync_after = self.parser.sync_bytes_count();
@@ -229,9 +303,11 @@ impl Feeder {
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.completed_units =
self.stats.completed_units.saturating_add(counting.units());
self.stats.completed_work =
self.stats.completed_work.saturating_add(counting.work());
spent = spent.saturating_add(counting.work());
}
self.stats.sync_aborts += 1;
self.note_release(released);
@@ -113,9 +113,12 @@ impl<'a, H: Handler + CursorColumn> Counting<'a, H> {
self.work
}
/// Cells in the grid. Saturating: `Size` is unclamped `usize`, so this
/// product is reachable, and a wrapped weight prices the most expensive
/// callbacks as the cheapest.
#[inline]
fn cells(&self) -> u64 {
self.columns * self.lines
self.columns.saturating_mul(self.lines)
}
/// A parameter charged at its clamped value.
@@ -132,8 +135,8 @@ impl<'a, H: Handler + CursorColumn> Counting<'a, H> {
#[inline]
fn charge(&mut self, weight: u64) {
self.units += 1;
self.work += weight;
self.units = self.units.saturating_add(1);
self.work = self.work.saturating_add(weight);
}
}
@@ -216,7 +219,8 @@ macro_rules! counting_handler {
// 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;
let scanned = if after == before { before } else { before - after };
self.work = self.work.saturating_add(scanned as u64);
if after == before {
// A fixed point is permanent: the scan depends only
// on the cursor, and the cursor did not move.
@@ -340,10 +344,10 @@ counting_handler! {
// 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;
scroll_up(n: usize) => |this| this.clamp(n, this.lines).saturating_mul(this.columns);
delete_lines(n: usize) => |this| this.clamp(n, this.lines).saturating_mul(this.columns);
scroll_down(n: usize) => |this| this.clamp(n, this.lines).saturating_mul(this.columns);
insert_blank_lines(n: usize) => |this| this.clamp(n, this.lines).saturating_mul(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.
@@ -372,6 +376,7 @@ counting_handler! {
// 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;
reset_state() => |this| this.cells().saturating_mul(2)
.saturating_add(this.scrollback.saturating_mul(this.columns));
}
}
@@ -10,8 +10,8 @@
//! `> 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,
max_atom_work, max_drain_work, slice_bytes, slice_bytes_remaining, Fences, MAX_SLICE,
MIN_SLICE, SYNC_CAP, TAIL_CAP, WORK_BUDGET,
};
use buzz_terminal::{Size, Terminal};
@@ -505,7 +505,34 @@ fn every_amplifiable_escape_is_priced_exactly() {
("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),
];
@@ -535,16 +562,21 @@ fn every_amplifiable_escape_is_priced_exactly() {
/// 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.
/// 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(
@@ -558,15 +590,17 @@ fn ris_is_priced_for_both_grids_and_the_scrollback_it_walks() {
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",
);
observed.push((label, scrollback, term.stats().completed_work));
}
}
for (label, scrollback, work) in observed {
assert_eq!(
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
@@ -708,11 +742,15 @@ fn the_worst_atom_is_bounded_under_the_layout_that_maximises_steps() {
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.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,
@@ -758,3 +796,412 @@ fn stopping_the_worst_atom_early_preserves_its_semantics() {
// 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}",
);
}
}
/// A scrollback change reprices RIS *and* the slicing derived from it.
///
/// Kills: updating the feeder's columns and lines on resize but not its
/// scrollback -- and, separately, a repair that reprices the charge while
/// leaving slice width stale. Those are different failures and neither
/// observable sees the other: fix only the charge and the drain count stays
/// wrong; fix only the derivation and the charge stays wrong.
///
/// Two properties, because one is not enough:
///
/// * The exact RIS charge at the new depth. Direct, and it is what a
/// pricing-only repair passes.
/// * Equality with a terminal *constructed* at the new depth, across work
/// and drain count. A resized feeder that is genuinely repaired is
/// indistinguishable from one that was born there. This is stronger than a
/// hand-picked threshold and immune to `WORK_BUDGET`/`MIN_SLICE` moving,
/// since both arms move together -- and the sanity arm proves the
/// comparison is deterministic before it is used to judge anything.
///
/// `completed_units` is deliberately *not* the discriminator here: it reads
/// 200 in both arms, because the same callbacks run either way and only their
/// cost and slicing differ. It is asserted anyway as the invariant that must
/// hold -- no unit lost or duplicated across a resize -- while carrying none
/// of the discrimination.
#[test]
fn a_scrollback_change_reprices_the_densest_atom_and_the_slicing() {
let shallow = Size {
columns: 200,
screen_lines: 50,
scrollback: 100,
};
let deep = Size {
scrollback: 10_000,
..shallow
};
let cells = (deep.columns * deep.screen_lines) as u64;
// Preconditions, asserted rather than assumed, because both are easy to
// break by "generalising" this fixture later:
//
// * The geometry must let the *scheduling* fields separate. They only do
// when the two depths land on different slice widths, and the deep side
// is always floored -- so the shallow side must not be. At 1600x50 the
// visible grid alone floors every depth from 0 upward, and three of the
// four observables below go silently inert.
// * The payload must be RIS. It is the only escape reaching the only
// weight carrying a scrollback term (`units::reset_state`); DECALN and
// every other atom are priced on cells or columns and are blind to
// depth, so a conforming repair would show work identical to the
// control and the assertions here would invert into false failures.
assert!(
slice_bytes(shallow.columns, shallow.screen_lines, shallow.scrollback) > MIN_SLICE,
"geometry cannot discriminate: the shallow arm is already floored",
);
assert_eq!(
slice_bytes(deep.columns, deep.screen_lines, deep.scrollback),
MIN_SLICE,
);
// How a terminal at `size` retires 200 RIS: work, and how many
// acquisitions it took. Both are feeder behaviour, not helper output.
let run = |size: Size, resize_from: Option<Size>| {
let (mut term, _a) = Terminal::new(resize_from.unwrap_or(size), Fences::ALL);
if resize_from.is_some() {
term.resize(size);
}
term.reset_stats();
let mut drains = 1;
let mut more = term.feed(&b"c".repeat(200));
while more {
more = term.drain();
drains += 1;
}
(
term.stats().completed_units,
term.stats().completed_work,
drains,
)
};
let control = run(deep, None);
let sanity = run(deep, None);
assert_eq!(
control, sanity,
"two terminals built the same way must agree before this comparison can judge anything",
);
let resized = run(deep, Some(shallow));
assert_eq!(resized.0, 200, "no unit may be lost or duplicated");
assert_eq!(
resized, control,
"a feeder resized to a depth must be indistinguishable from one constructed at it -- in charge and in how many acquisitions it took",
);
// The exact charge, stated rather than inferred from the equality: a
// repair that made both arms equally *wrong* would pass the comparison.
let (mut term, _a) = Terminal::new(shallow, Fences::ALL);
term.resize(deep);
term.reset_stats();
term.feed_fully(b"c");
assert_eq!(
term.stats().completed_work,
2 * cells + (deep.scrollback * deep.columns) as u64,
);
// Shrinking retains the debt, and the fixture proves retention rather
// than merely permitting it.
//
// `>= fresh` alone is the predicate three of us proposed and all three
// withdrew: a feeder that dropped the debt reads *exactly* equal to a
// fresh shallow one, so `>=` passes on the unrepaired state. Strictness
// on the pricing field is what rejects it. The scheduling fields are
// asserted directionally with per-field signs -- `first_units` inverts,
// because a narrower slice retires fewer atoms per un-preemptable drain,
// which is the fence working -- but none of them is the discriminator:
// they separate only when the two depths straddle the slice floor, and
// `completed_work` separates at every positive depth gap.
let debt = |from: Size, to: Size| {
let (mut term, _a) = Terminal::new(from, Fences::ALL);
term.resize(deep);
term.resize(to);
term.reset_stats();
let mut drains = 1;
let mut more = term.feed(&b"\x1bc".repeat(200));
let first_units = term.stats().completed_units;
let first_pending = term.pending_bytes();
while more {
more = term.drain();
drains += 1;
}
(
first_units,
first_pending,
drains,
term.stats().completed_units,
term.stats().completed_work,
)
};
let shrunk = debt(shallow, shallow);
let fresh = run(shallow, None);
assert_eq!(shrunk.3, 200, "no unit may be lost on the way down either");
assert!(
shrunk.4 > fresh.1,
"a feeder that has been deep must still price deep after shrinking: \
{} against a fresh shallow {}. Equality here is the signature of a \
feeder that dropped the debt, which is indistinguishable from one \
that never had it",
shrunk.4,
fresh.1,
);
assert!(
shrunk.0 <= 12,
"narrower slices retire fewer atoms per drain"
);
assert!(shrunk.2 >= fresh.2, "and take more drains to do it");
// A later resize on a different axis must not disturb the third one -- // A later resize on a different axis must not disturb the third one --
// the split was permanent, with columns and lines tracking correctly
// while a stale depth persisted forever.
term.resize(Size {
columns: deep.columns * 2,
..deep
});
term.reset_stats();
term.feed_fully(b"c");
assert_eq!(
term.stats().completed_work,
2 * (deep.columns * 2 * deep.screen_lines) as u64
+ (deep.scrollback * deep.columns * 2) as u64,
"a columns resize must keep the scrollback it was already given",
);
}
/// One oversized atom per drain -- no callback runs after the one that
/// crosses the budget.
///
/// Kills: sizing slices from the *whole* budget rather than what remains of
/// it. RIS at any real scrollback depth is worth more than an entire budget,
/// so a slice wide enough for several callbacks runs several: measured
/// `completed_units == 3` for `ESC c` followed by `Xmore`, where the law
/// permits exactly one. The fix makes slice width a function of `remaining`,
/// which is a single byte once an atom this size is in play.
///
/// Also asserts the tail survives it: yielding after the crossing atom is
/// only correct if what follows is still parsed, exactly once.
#[test]
fn an_oversized_atom_yields_before_the_next_callback() {
let size = Size {
columns: 400,
screen_lines: 100,
scrollback: 10_000,
};
let (mut term, _a) = Terminal::new(size, Fences::ALL);
let ris_work =
2 * (size.columns * size.screen_lines) as u64 + (size.scrollback * size.columns) as u64;
assert!(
ris_work > WORK_BUDGET,
"this arm needs an atom bigger than the whole budget",
);
let more = term.feed(b"\x1bcXmore");
assert!(more, "the drain must yield with a tail");
assert_eq!(
term.stats().completed_units,
1,
"exactly the crossing atom ran: a callback after it is post-atom \
overrun, which is the thing the budget cannot preempt and therefore \
must not start",
);
assert_eq!(term.stats().completed_work, ris_work);
while term.drain() {}
assert_eq!(
term.stats().completed_units,
1 + 5,
"the five characters after it must still be parsed, exactly once",
);
assert_eq!(term.pending_bytes(), 0);
}
/// Extreme dimensions saturate rather than wrapping or panicking.
///
/// Kills: `columns * lines` in `usize` before the cast. `Size` is unclamped
/// and reaches the weight path from a caller, so this product is a reachable
/// overflow -- a debug panic inside the accounting path, or a release wrap
/// that reports the most expensive callback in the emulator as one of the
/// cheapest. Saturating is the only one of the three that fails safe.
#[test]
fn extreme_dimensions_saturate_instead_of_wrapping() {
let huge = usize::MAX / 2;
assert_eq!(max_atom_work(huge, huge, huge), u64::MAX);
assert_eq!(max_drain_work(huge, huge, huge), u64::MAX);
// The *direction* is the assertion, not merely the absence of a panic.
// A wrapping build does not produce a slightly-wrong bound, it produces a
// tiny one -- and `slice_bytes` divides the budget by it, so an
// undercharged atom yields an *oversized* slice exactly when the atom is
// most expensive. Wrapping inverts the fence. So: the widest possible
// atom must give the narrowest possible slice.
assert_eq!(
slice_bytes(huge, huge, huge),
MIN_SLICE,
"an overflowing grid must clamp to the smallest slice; a wrapped \
`max_atom_work` would hand back a generous one",
);
assert_eq!(
slice_bytes_remaining(huge, huge, huge, 0, 0),
1,
"and the escape at the front of such a grid gets a single byte",
);
// The property behind those endpoints, and the stronger statement: a
// grid that costs more may never buy a wider slice. Endpoints pin the
// ends; only a sweep catches a non-monotone middle, and a wrap *is* a
// non-monotone middle -- it makes the worst grid look cheap and hands it
// the widest slice of all.
// Every axis independently: a wrap on any one of the three products is a
// non-monotone middle on that axis alone, and sweeping only scrollback
// would miss a truncating `columns * lines`.
for (axis, at) in [
(
"scrollback",
(|n| slice_bytes(200, 50, n)) as fn(usize) -> usize,
),
("columns", |n| slice_bytes(n.max(1), 50, 0)),
("lines", |n| slice_bytes(200, n.max(1), 0)),
] {
let mut previous = usize::MAX;
for exponent in 0..60 {
let width = at(1usize << exponent);
assert!(
width <= previous,
"slice widened from {previous} to {width} at {axis} \
2^{exponent}: more expensive grid, more generous slice",
);
assert!(width >= MIN_SLICE);
previous = width;
}
}
// Just past 32 bits on one axis: large enough that a narrowing cast
// shows (`1 << 32` truncates to 0 in `u32`, pricing an enormous grid at
// nothing), small enough that the honest answer is exact rather than
// saturated. Neither the extreme endpoints above nor the ordinary grids
// below can see this -- the endpoints saturate either way and the
// ordinary ones fit in 32 bits.
assert_eq!(max_atom_work(1 << 32, 1, 0), 2 * (1u64 << 32));
assert_eq!(max_atom_work(1, 1 << 32, 0), 2 * (1u64 << 32));
assert_eq!(max_atom_work(1, 1, 1 << 32), 2 + (1u64 << 32));
// Ordinary grids are untouched by the saturation: exact, not clamped.
assert_eq!(max_atom_work(80, 24, 0), 2 * 80 * 24);
assert_eq!(max_atom_work(80, 24, 100), 2 * 80 * 24 + 100 * 80);
}
/// An escape split across slices keeps its escape metering.
///
/// Kills: deciding "plain run or escape?" by looking only at the bytes ahead.
/// After a slice ending on a lone `ESC`, the next byte is `c` -- which looks
/// like ordinary text and is in fact a full grid reset. Meter it as text and
/// the oversized atom rides into a wide slice with whatever follows, which is
/// the post-atom overrun arriving through a different door. Found by the
/// oversized-atom fixture failing after I "optimised" the plain path, which
/// is the argument for keeping both.
#[test]
fn an_escape_split_across_slices_keeps_its_metering() {
let size = Size {
columns: 400,
screen_lines: 100,
scrollback: 10_000,
};
let ris_work =
2 * (size.columns * size.screen_lines) as u64 + (size.scrollback * size.columns) as u64;
// Deliver the escape one byte at a time, so the parser is left mid-
// sequence with a tail that begins on the continuation byte.
let (mut term, _a) = Terminal::new(size, Fences::ALL);
term.feed(b"\x1b");
assert_eq!(
term.stats().completed_units,
0,
"ESC alone dispatches nothing"
);
let more = term.feed(b"cXmore");
assert!(more, "the completed RIS must still yield with a tail");
assert_eq!(
term.stats().completed_units,
1,
"the continuation byte completed a grid reset; nothing may run after it",
);
assert_eq!(term.stats().completed_work, ris_work);
while term.drain() {}
assert_eq!(term.stats().completed_units, 1 + 5);
assert_eq!(term.pending_bytes(), 0);
}