mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
commit
03499c956e
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user