feat: import Chinese-localized Buzz source snapshot
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
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>
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
//! The cluster-positioning contract: what the renderer may rely on to place
|
||||
//! text at the right column without consulting Unicode tables.
|
||||
//!
|
||||
//! The consumer's rule reads two numbers off each span and does arithmetic:
|
||||
//! `cluster_count == 1` means the whole text is one cluster at `column`,
|
||||
//! otherwise cluster `i` is the i-th `char` at `column + i * width`.
|
||||
//!
|
||||
//! These fixtures exist because that rule is not self-evidently satisfiable --
|
||||
//! the two cases below require *opposite* text-splitting rules, so no encoding
|
||||
//! that ships a concatenated string and a start column can be correct:
|
||||
//!
|
||||
//! * a regional-indicator flag is two ordinary one-column cells, so its two
|
||||
//! codepoints occupy two columns and must split per codepoint;
|
||||
//! * a keycap is one cell holding three codepoints, so it occupies one column
|
||||
//! and must split per grapheme.
|
||||
//!
|
||||
//! Both are handled here by construction rather than by rule: uniform `width`
|
||||
//! within a span, and a span of its own for any cluster carrying zerowidth
|
||||
//! marks.
|
||||
|
||||
use buzz_terminal::damage::{Encoder, Span};
|
||||
use buzz_terminal::fences::Fences;
|
||||
use buzz_terminal::{Action, SharedTerminal, Size, Terminal};
|
||||
use std::sync::mpsc::Receiver;
|
||||
|
||||
/// The receiver is returned rather than dropped: dropping it disconnects the
|
||||
/// channel and every subsequent listener send silently fails.
|
||||
fn render(input: &str) -> (Vec<Span>, Receiver<Action>) {
|
||||
let size = Size {
|
||||
columns: 20,
|
||||
screen_lines: 2,
|
||||
scrollback: 100,
|
||||
};
|
||||
let (term, actions) = Terminal::new(size, Fences::ALL);
|
||||
let shared = SharedTerminal::new(term);
|
||||
shared.feed_fully(input.as_bytes());
|
||||
let mut encoder = Encoder::new();
|
||||
let frame = shared.render(&mut encoder);
|
||||
let spans = frame
|
||||
.rows
|
||||
.into_iter()
|
||||
.find(|row| row.line == 0)
|
||||
.map(|row| row.spans)
|
||||
.unwrap_or_default();
|
||||
(spans, actions)
|
||||
}
|
||||
|
||||
/// Apply the documented consumer rule and return `(column, cluster)` pairs,
|
||||
/// dropping trailing blank padding.
|
||||
///
|
||||
/// This is the renderer's arithmetic, written out. Note what is *not* here: no
|
||||
/// Unicode table, no zerowidth classifier, no grapheme segmentation. The
|
||||
/// earlier draft of this helper carried a hand-rolled `is_zerowidth` matcher,
|
||||
/// which is how we learned the encoding was under-specified -- if the fixture
|
||||
/// needs a Unicode table to decode the wire, so does every real consumer.
|
||||
fn placements(spans: &[Span]) -> Vec<(usize, String)> {
|
||||
let mut placed = Vec::new();
|
||||
for span in spans {
|
||||
assert!(
|
||||
span.counts_are_consistent(),
|
||||
"encoder emitted an undecodable span: {span:?}"
|
||||
);
|
||||
let clusters: Vec<String> = if span.cluster_count == 1 {
|
||||
vec![span.text.clone()]
|
||||
} else {
|
||||
span.text.chars().map(|c| c.to_string()).collect()
|
||||
};
|
||||
for (i, cluster) in clusters.into_iter().enumerate() {
|
||||
if cluster != " " {
|
||||
placed.push((span.column + i * span.width as usize, cluster));
|
||||
}
|
||||
}
|
||||
}
|
||||
placed
|
||||
}
|
||||
|
||||
/// Max's case: mixed narrow and wide glyphs in one style. Every cluster must
|
||||
/// land on the column the grid actually put it in.
|
||||
#[test]
|
||||
fn mixed_width_clusters_keep_their_columns() {
|
||||
let (spans, _actions) = render("a\u{1F600}b\u{4E00}c");
|
||||
assert_eq!(
|
||||
placements(&spans),
|
||||
vec![
|
||||
(0, "a".into()),
|
||||
(1, "\u{1F600}".into()),
|
||||
(3, "b".into()),
|
||||
(4, "\u{4E00}".into()),
|
||||
(6, "c".into()),
|
||||
],
|
||||
"wide glyphs must advance two columns and narrow ones must not"
|
||||
);
|
||||
}
|
||||
|
||||
/// A combining mark rides with its base character and consumes no column of
|
||||
/// its own, so the text that follows must not be displaced by it.
|
||||
///
|
||||
/// Against the previous encoding this row was a single span `"éxy"` at column
|
||||
/// 0, and a consumer stepping one column per `char` placed `x` at 1 and `y`
|
||||
/// at 2 -- both one column left of the truth.
|
||||
#[test]
|
||||
fn combining_marks_do_not_displace_following_text() {
|
||||
let (spans, _actions) = render("e\u{0301}xy");
|
||||
assert_eq!(
|
||||
placements(&spans),
|
||||
vec![(0, "e\u{0301}".into()), (1, "x".into()), (2, "y".into()),],
|
||||
"a zerowidth mark must not consume a column"
|
||||
);
|
||||
}
|
||||
|
||||
/// A regional-indicator pair: two separate one-column cells. This is the case
|
||||
/// that must split *per codepoint*.
|
||||
#[test]
|
||||
fn regional_indicator_flag_occupies_two_columns() {
|
||||
let (spans, _actions) = render("\u{1F1FA}\u{1F1F8}X");
|
||||
assert_eq!(
|
||||
placements(&spans),
|
||||
vec![
|
||||
(0, "\u{1F1FA}".into()),
|
||||
(1, "\u{1F1F8}".into()),
|
||||
(2, "X".into()),
|
||||
],
|
||||
"regional indicators are one column each; X must sit at 2"
|
||||
);
|
||||
}
|
||||
|
||||
/// A keycap: one cell holding three codepoints. This is the case that must
|
||||
/// split *per grapheme* -- the opposite rule from the flag above, which is why
|
||||
/// the width and the cluster break both have to come from the grid.
|
||||
#[test]
|
||||
fn keycap_occupies_one_column() {
|
||||
let (spans, _actions) = render("1\u{FE0F}\u{20E3}X");
|
||||
assert_eq!(
|
||||
placements(&spans),
|
||||
vec![(0, "1\u{FE0F}\u{20E3}".into()), (1, "X".into()),],
|
||||
"a keycap is one column; X must sit at 1"
|
||||
);
|
||||
}
|
||||
|
||||
/// Width is uniform within a span by construction. Without this a consumer
|
||||
/// cannot multiply -- it would have to know each cluster's width individually,
|
||||
/// which is the Unicode table this design exists to avoid.
|
||||
#[test]
|
||||
fn a_span_never_mixes_widths() {
|
||||
let (spans, _actions) = render("ab\u{4E00}\u{4E00}cd");
|
||||
for span in &spans {
|
||||
let expected = span.width;
|
||||
assert!(
|
||||
span.width == 1 || span.width == 2,
|
||||
"width must be 1 or 2, got {expected}"
|
||||
);
|
||||
}
|
||||
let widths: Vec<u8> = spans.iter().map(|s| s.width).collect();
|
||||
assert!(
|
||||
widths.contains(&2),
|
||||
"fixture must actually produce a wide span, got {widths:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
placements(&spans),
|
||||
vec![
|
||||
(0, "a".into()),
|
||||
(1, "b".into()),
|
||||
(2, "\u{4E00}".into()),
|
||||
(4, "\u{4E00}".into()),
|
||||
(6, "c".into()),
|
||||
(7, "d".into()),
|
||||
],
|
||||
"two adjacent wide glyphs must advance two columns each"
|
||||
);
|
||||
}
|
||||
|
||||
/// `cluster_count` is what makes the wire decodable without a Unicode table,
|
||||
/// so it is asserted directly here rather than only implied by placements.
|
||||
///
|
||||
/// The decisive pair: both spans below are width 1 with more than one `char`
|
||||
/// of text, and they differ *only* in whether the count tracks the char count.
|
||||
/// A consumer without that number cannot tell them apart -- which is the
|
||||
/// defect Mari caught in the previous encoding.
|
||||
#[test]
|
||||
fn cluster_count_distinguishes_a_marked_cluster_from_a_plain_run() {
|
||||
let (marked, _a) = render("e\u{0301}");
|
||||
let marked = marked.first().expect("a span must be emitted");
|
||||
assert_eq!(marked.text.chars().count(), 2, "base plus combining mark");
|
||||
assert_eq!(marked.cluster_count, 1, "one cluster occupying one column");
|
||||
|
||||
// The plain run absorbs the row's blank padding, so its length is the
|
||||
// viewport width rather than 2 -- what matters is that the count tracks
|
||||
// the char count instead of collapsing to 1.
|
||||
let (plain, _b) = render("ab");
|
||||
let plain = plain.first().expect("a span must be emitted");
|
||||
assert!(plain.cluster_count > 1, "a plain run is not one cluster");
|
||||
assert_eq!(
|
||||
usize::from(plain.cluster_count),
|
||||
plain.text.chars().count(),
|
||||
"one cluster per char"
|
||||
);
|
||||
|
||||
assert_eq!(marked.width, plain.width, "both are width 1");
|
||||
assert!(marked.counts_are_consistent() && plain.counts_are_consistent());
|
||||
}
|
||||
|
||||
/// The join guard has two halves: the previous cell must not have carried
|
||||
/// marks (`open`), and the current cell must not carry them (`joinable`).
|
||||
/// Every fixture above exercises only the first half -- a plain cluster
|
||||
/// following a marked one. This one exercises the second: a *marked* cluster
|
||||
/// arriving after a plain run, which is the only path on which the run in
|
||||
/// progress is handed text holding more `char`s than the one cluster its
|
||||
/// count is about to be incremented by.
|
||||
///
|
||||
/// Sami found the hole. With `joinable` dropped from the guard, a release
|
||||
/// build silently emits `Span { column: 0, text: "xyé", cluster_count: 3 }`:
|
||||
/// four chars counted as three, so the consumer's rule splits per char and
|
||||
/// places the combining mark on top of `z`.
|
||||
#[test]
|
||||
fn a_marked_cluster_after_a_plain_run_starts_its_own_span() {
|
||||
let (spans, _actions) = render("xye\u{0301}z");
|
||||
assert_eq!(
|
||||
placements(&spans),
|
||||
vec![
|
||||
(0, "x".into()),
|
||||
(1, "y".into()),
|
||||
(2, "e\u{0301}".into()),
|
||||
(3, "z".into()),
|
||||
],
|
||||
"a marked cluster must not be absorbed into the run in front of it"
|
||||
);
|
||||
}
|
||||
|
||||
/// `cluster_count` is a `u16` and `Size.columns` is an unclamped `usize`
|
||||
/// (`lib.rs:50`) that no production caller bounds yet, so a row of uniform
|
||||
/// cells wider than `u16::MAX` reaches the join guard's overflow refusal.
|
||||
/// The guard is live code, not paranoia, and this fixture is what says so.
|
||||
///
|
||||
/// Refusing to join produces a shape the consumer already handles -- the run
|
||||
/// ends and a new span starts at the next column -- whereas wrapping produces
|
||||
/// an undecodable span, the same failure as the marked-after-plain case above.
|
||||
#[test]
|
||||
fn a_run_longer_than_u16_max_splits_rather_than_wrapping() {
|
||||
let columns = 70_000;
|
||||
let size = Size {
|
||||
columns,
|
||||
screen_lines: 1,
|
||||
scrollback: 0,
|
||||
};
|
||||
let (term, _actions) = Terminal::new(size, Fences::ALL);
|
||||
let shared = SharedTerminal::new(term);
|
||||
// One character is enough: the rest of the row is blank cells of the same
|
||||
// style, so the whole row is a single candidate run.
|
||||
shared.feed_fully(b"a");
|
||||
let mut encoder = Encoder::new();
|
||||
let frame = shared.render(&mut encoder);
|
||||
|
||||
let spans = &frame
|
||||
.rows
|
||||
.iter()
|
||||
.find(|row| row.line == 0)
|
||||
.expect("the fed row must be present")
|
||||
.spans;
|
||||
|
||||
assert!(
|
||||
spans.iter().all(|span| span.counts_are_consistent()),
|
||||
"an oversized run must not wrap its count: {spans:?}"
|
||||
);
|
||||
let counts: Vec<u16> = spans.iter().map(|span| span.cluster_count).collect();
|
||||
let columns_at: Vec<usize> = spans.iter().map(|span| span.column).collect();
|
||||
assert_eq!(
|
||||
counts,
|
||||
vec![u16::MAX, (columns - u16::MAX as usize) as u16],
|
||||
"the run must end at the last representable count"
|
||||
);
|
||||
assert_eq!(
|
||||
columns_at,
|
||||
vec![0, u16::MAX as usize],
|
||||
"the second span starts where the first left off"
|
||||
);
|
||||
let chars: usize = spans.iter().map(|span| span.text.chars().count()).sum();
|
||||
assert_eq!(chars, columns, "no cell may be dropped by the split");
|
||||
}
|
||||
|
||||
/// Wrapping marks the last cell of the row with `WRAPLINE` (upstream
|
||||
/// `term/mod.rs:968`). That bit records where the text happened to wrap, not
|
||||
/// how the text looks, so it must not reach the style key: if it did, the last
|
||||
/// column of every wrapped row would split off into a span of its own -- an
|
||||
/// extra wire record per wrapped line, and span boundaries that move when the
|
||||
/// window is resized.
|
||||
///
|
||||
/// Quinn found this by reading `cell.rs:21` while checking the `WIDE_CHAR`
|
||||
/// mask; this fixture is the proof that was missing from the source read.
|
||||
#[test]
|
||||
fn wrapping_does_not_split_a_uniform_run() {
|
||||
let size = Size {
|
||||
columns: 5,
|
||||
screen_lines: 3,
|
||||
scrollback: 100,
|
||||
};
|
||||
let (term, _actions) = Terminal::new(size, Fences::ALL);
|
||||
let shared = SharedTerminal::new(term);
|
||||
// Six narrow cells in one style: five fill row 0 and set WRAPLINE on the
|
||||
// last of them, the sixth lands on row 1.
|
||||
shared.feed_fully(b"abcdef");
|
||||
let mut encoder = Encoder::new();
|
||||
let frame = shared.render(&mut encoder);
|
||||
|
||||
let first = frame
|
||||
.rows
|
||||
.iter()
|
||||
.find(|row| row.line == 0)
|
||||
.expect("wrapped row must be present");
|
||||
assert!(
|
||||
first.wrapped,
|
||||
"soft-wrap geometry must survive row encoding"
|
||||
);
|
||||
let texts: Vec<&str> = first.spans.iter().map(|s| s.text.as_str()).collect();
|
||||
assert_eq!(
|
||||
texts,
|
||||
vec!["abcde"],
|
||||
"a wrapped row of one style is one span; WRAPLINE must not break it"
|
||||
);
|
||||
}
|
||||
|
||||
/// A wide glyph at the last usable column wraps to the next row rather than
|
||||
/// straddling the edge. The contract must hold on the wrapped row too.
|
||||
#[test]
|
||||
fn leading_wide_glyph_after_wrap_is_positioned_from_column_zero() {
|
||||
let size = Size {
|
||||
columns: 5,
|
||||
screen_lines: 3,
|
||||
scrollback: 100,
|
||||
};
|
||||
let (term, _actions) = Terminal::new(size, Fences::ALL);
|
||||
let shared = SharedTerminal::new(term);
|
||||
// Four narrow cells fill 0..=3, leaving one column: the wide glyph cannot
|
||||
// fit and moves to the next row.
|
||||
shared.feed_fully("abcd\u{4E00}".as_bytes());
|
||||
let mut encoder = Encoder::new();
|
||||
let frame = shared.render(&mut encoder);
|
||||
|
||||
let second = frame
|
||||
.rows
|
||||
.iter()
|
||||
.find(|row| row.line == 1)
|
||||
.expect("wrapped row must be present");
|
||||
assert_eq!(
|
||||
placements(&second.spans),
|
||||
vec![(0, "\u{4E00}".into())],
|
||||
"a wrapped wide glyph starts at column 0 of the next row"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
use buzz_terminal::damage::Encoder;
|
||||
use buzz_terminal::fences::Fences;
|
||||
use buzz_terminal::{SharedTerminal, Size, Terminal};
|
||||
|
||||
#[test]
|
||||
fn space_over_blank_cell_publishes_cursor_only_frame() {
|
||||
let (terminal, _actions) = Terminal::new(
|
||||
Size {
|
||||
columns: 8,
|
||||
screen_lines: 2,
|
||||
scrollback: 10,
|
||||
},
|
||||
Fences::ALL,
|
||||
);
|
||||
let terminal = SharedTerminal::new(terminal);
|
||||
let mut encoder = Encoder::new();
|
||||
|
||||
let initial = terminal.render(&mut encoder);
|
||||
assert!(!initial.is_empty());
|
||||
assert_eq!(initial.cursor.column, 0);
|
||||
|
||||
terminal.feed_fully(b" ");
|
||||
let after_space = terminal.render(&mut encoder);
|
||||
|
||||
assert!(
|
||||
after_space.rows.is_empty(),
|
||||
"a blank cell overwritten with a space must be row-deduplicated"
|
||||
);
|
||||
assert_eq!(after_space.cursor.column, 1);
|
||||
assert!(after_space.cursor_changed);
|
||||
assert!(
|
||||
!after_space.is_empty(),
|
||||
"cursor movement must make the frame publishable"
|
||||
);
|
||||
|
||||
let idle = terminal.render(&mut encoder);
|
||||
assert!(
|
||||
idle.is_empty(),
|
||||
"an unchanged cursor must not create traffic"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
//! Mutation-sensitive byte fixtures for the two parser fences.
|
||||
//!
|
||||
//! These use the shipping `Terminal::feed` path. Arms that could be masked by
|
||||
//! the other fence disable it explicitly; the switches are runtime values, not
|
||||
//! cargo features, so the default test binary always contains every arm.
|
||||
|
||||
use alacritty_terminal::grid::Dimensions;
|
||||
use alacritty_terminal::index::{Column, Line, Point};
|
||||
use buzz_terminal::fences::{Fences, OSC_BUDGET, SYNC_CAP};
|
||||
use buzz_terminal::{Size, Terminal};
|
||||
|
||||
const CHUNK: usize = 8192;
|
||||
const G1_BYTES: usize = 2 << 20;
|
||||
const G2_FRAMES: usize = 40;
|
||||
const G2_FRAME_BYTES: usize = 1_900 * 1024;
|
||||
|
||||
fn size() -> Size {
|
||||
Size {
|
||||
columns: 120,
|
||||
screen_lines: 40,
|
||||
scrollback: 2000,
|
||||
}
|
||||
}
|
||||
|
||||
fn feed_synchronized(term: &mut Terminal, payload: &[u8], close: bool) {
|
||||
term.feed_fully(b"\x1b[?2026h");
|
||||
for chunk in payload.chunks(CHUNK) {
|
||||
term.feed_fully(chunk);
|
||||
}
|
||||
if close {
|
||||
term.feed_fully(b"\x1b[?2026l");
|
||||
}
|
||||
}
|
||||
|
||||
fn repeated(pattern: &[u8], bytes: usize) -> Vec<u8> {
|
||||
pattern.iter().copied().cycle().take(bytes).collect()
|
||||
}
|
||||
|
||||
fn count_markers(term: &Terminal, markers: usize) -> usize {
|
||||
let grid = term.term().grid();
|
||||
let mut text = String::new();
|
||||
let top = -(grid.history_size() as i32);
|
||||
for line in top..term.size().screen_lines as i32 {
|
||||
for column in 0..term.size().columns {
|
||||
text.push(grid[Point::new(Line(line), Column(column))].c);
|
||||
}
|
||||
text.push('\n');
|
||||
}
|
||||
(0..markers)
|
||||
.filter(|m| text.contains(&format!("MK{m:03}")))
|
||||
.count()
|
||||
}
|
||||
|
||||
fn legitimate_frame(markers: usize, bytes: usize) -> Vec<u8> {
|
||||
let mut payload = Vec::with_capacity(bytes);
|
||||
for marker in 0..markers {
|
||||
payload.extend_from_slice(format!("MK{marker:03}\r\n").as_bytes());
|
||||
let target = bytes * (marker + 1) / markers;
|
||||
while payload.len() < target {
|
||||
payload.extend_from_slice(b"\x1b[1;32mx\x1b[0m");
|
||||
}
|
||||
payload.extend_from_slice(b"\r\n");
|
||||
}
|
||||
payload.truncate(bytes);
|
||||
payload
|
||||
}
|
||||
|
||||
/// G1: every hostile content shape must remain below the deterministic byte
|
||||
/// bound, and the same shape with F1 deleted must cross it. Keeping both arms
|
||||
/// adjacent prevents a simplified fixture from becoming vacuously cheap.
|
||||
#[test]
|
||||
fn g1_sync_abort_bounds_all_hostile_shapes() {
|
||||
let shapes: [(&str, &[u8]); 5] = [
|
||||
("sgr", b"\x1b[1;32mbuzz\x1b[0m\r\n"),
|
||||
("ascii", b"buzz substrate output\r\n"),
|
||||
("emoji", "🐝🚀✨\r\n".as_bytes()),
|
||||
("zalgo", "z\u{0301}\u{0302}\u{0303}\u{0304}\r\n".as_bytes()),
|
||||
("truecolor", b"\x1b[38;2;255;0;128mRGB\x1b[0m\r\n"),
|
||||
];
|
||||
|
||||
for (name, pattern) in shapes {
|
||||
let payload = repeated(pattern, G1_BYTES);
|
||||
let (mut fenced, _) = Terminal::new(size(), Fences::ALL);
|
||||
feed_synchronized(&mut fenced, &payload, false);
|
||||
let fenced_stats = fenced.stats();
|
||||
assert!(fenced_stats.sync_aborts > 0, "{name}: F1 never fired");
|
||||
assert!(
|
||||
fenced_stats.max_release <= 2 * SYNC_CAP,
|
||||
"{name}: fenced release {} exceeds 128 KiB",
|
||||
fenced_stats.max_release
|
||||
);
|
||||
|
||||
let (mut unfenced, _) = Terminal::new(size(), Fences::NONE);
|
||||
feed_synchronized(&mut unfenced, &payload, false);
|
||||
let unfenced_stats = unfenced.stats();
|
||||
assert_eq!(unfenced_stats.sync_aborts, 0, "{name}: control enabled F1");
|
||||
assert!(
|
||||
unfenced_stats.max_release > 2 * SYNC_CAP,
|
||||
"{name}: unfenced release {} stayed inside the gate; fixture is vacuous",
|
||||
unfenced_stats.max_release
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// G2 arm 1: deletion oracle. F1 remains enabled because this arm proves F2
|
||||
/// deletion under the combined production configuration.
|
||||
#[test]
|
||||
fn g2_hostile_unsynchronized_osc_resets_parser() {
|
||||
let (mut term, _) = Terminal::new(size(), Fences::ALL);
|
||||
term.feed_fully(b"\x1b]0;");
|
||||
for chunk in repeated(b"A", OSC_BUDGET * 4).chunks(CHUNK) {
|
||||
term.feed_fully(chunk);
|
||||
}
|
||||
assert!(term.stats().osc_resets > 0, "F2 never rebuilt the parser");
|
||||
}
|
||||
|
||||
/// G2 arm 2: every synchronized release is attributed. F1 is disabled so its
|
||||
/// small abort releases cannot mask an implementation that omits ESU flushes.
|
||||
#[test]
|
||||
fn g2_each_synchronized_flush_is_attributed() {
|
||||
let payload = repeated(b"A", G2_FRAME_BYTES);
|
||||
let (mut term, _) = Terminal::new(size(), Fences::OSC_ONLY);
|
||||
for _ in 0..G2_FRAMES {
|
||||
feed_synchronized(&mut term, &payload, true);
|
||||
}
|
||||
let stats = term.stats();
|
||||
assert_eq!(stats.sync_aborts, 0, "F1 must be disabled in this arm");
|
||||
assert_eq!(
|
||||
stats.osc_resets, G2_FRAMES as u64,
|
||||
"expected one reset for each atomic synchronized release"
|
||||
);
|
||||
assert!(
|
||||
stats.charged_bytes >= (G2_FRAMES * G2_FRAME_BYTES) as u64,
|
||||
"flush bytes were omitted from attribution: {} charged",
|
||||
stats.charged_bytes
|
||||
);
|
||||
}
|
||||
|
||||
/// G2 arm 3: parser-visible attribution preserves a legitimate 1.5 MiB frame.
|
||||
/// F1 is disabled; raw-input counting would reset mid-frame and lose markers.
|
||||
#[test]
|
||||
fn g2_legitimate_large_frame_preserves_all_markers() {
|
||||
let markers = 200;
|
||||
let payload = legitimate_frame(markers, 1_500 * 1024);
|
||||
let (mut term, _) = Terminal::new(size(), Fences::OSC_ONLY);
|
||||
feed_synchronized(&mut term, &payload, true);
|
||||
assert_eq!(
|
||||
count_markers(&term, markers),
|
||||
markers,
|
||||
"legitimate frame lost markers"
|
||||
);
|
||||
}
|
||||
|
||||
/// Legitimacy control: neither fence alone nor the production combination may
|
||||
/// corrupt a normal synchronized frame.
|
||||
#[test]
|
||||
fn g2_legitimate_frame_survives_each_fence_configuration() {
|
||||
let markers = 200;
|
||||
let payload = legitimate_frame(markers, 128 * 1024);
|
||||
for fences in [Fences::SYNC_ONLY, Fences::OSC_ONLY, Fences::ALL] {
|
||||
let (mut term, _) = Terminal::new(size(), fences);
|
||||
feed_synchronized(&mut term, &payload, true);
|
||||
assert_eq!(
|
||||
count_markers(&term, markers),
|
||||
markers,
|
||||
"{fences:?} lost markers"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
//! G3: the renderer's wait for the terminal lock, under flood.
|
||||
//!
|
||||
//! The plan originally required "reader hold < 16.7 ms". That requirement was
|
||||
//! struck: measured under a 180 MB/s flood, reader hold is p50 1 us while
|
||||
//! renderer *acquire* is p50 4245 us. Hold time passes trivially while the
|
||||
//! window is visibly stuck, because 0.389% of feeds carry 96.4% of the lock
|
||||
//! time and the p50 hold never sees them. What a human feels is the wait, so
|
||||
//! that is what is gated here.
|
||||
//!
|
||||
//! F1 is the fence being tested. It is a memory bound *and* a latency fence:
|
||||
//! it turns one ~2 MiB parser release into ~64 KiB pieces, and the renderer's
|
||||
//! wait falls with it.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use buzz_terminal::damage::Encoder;
|
||||
use buzz_terminal::fences::Fences;
|
||||
use buzz_terminal::{SharedTerminal, Size, Terminal};
|
||||
|
||||
/// One frame at 60 Hz. No acquire may exceed this: a single wait this long is
|
||||
/// a dropped frame regardless of how good the distribution looks.
|
||||
///
|
||||
/// Unlike the p95 below, this bound **cannot be protected by headroom**, and
|
||||
/// that asymmetry is why this test is `#[ignore]`d and run only in release on
|
||||
/// an idle host. A quantile discards its worst samples by construction, so it
|
||||
/// degrades gracefully as a machine gets noisy; a maximum over `FRAMES` samples
|
||||
/// is a single observation, and any one scheduler preemption exceeds it. There
|
||||
/// is no budget that makes the max arm robust to contention -- the tail it
|
||||
/// catches belongs to the scheduler, not to this code.
|
||||
///
|
||||
/// Measured on one 16-core host at `FRAMES = 200`: at load average ~6 the gate
|
||||
/// passes; at ~31 it fails with p95 65535 us / max 164889 us. A run at ambient
|
||||
/// load produced p95 1023 us -- 4x *inside* budget -- while max alone blew at
|
||||
/// 38150 us.
|
||||
///
|
||||
/// So the repair for a flake here is to fix the host, never to raise this
|
||||
/// number. Raising it is the one change that silently removes the only assert
|
||||
/// that catches the user-visible failure: a hitch is a max-event, and a
|
||||
/// p95-only gate passes a run containing a 38 ms stall.
|
||||
const FRAME_MICROS: u64 = 16_667;
|
||||
|
||||
/// p95 budget. Measured at 127 us with F1 on -- 31x of headroom, which is the
|
||||
/// margin that lets *this* arm tolerate a loaded machine without becoming a
|
||||
/// coin flip. The reasoning covers the quantile only; see `FRAME_MICROS`.
|
||||
const P95_MICROS: u64 = 4_000;
|
||||
|
||||
/// Frames sampled per arm. Counted rather than timed: sample count under a
|
||||
/// wall-clock budget is a function of how slow the arm is, so a duration-based
|
||||
/// loop gives the *unfenced* arm the fewest samples -- fewest exactly where the
|
||||
/// tail being measured lives. Counting frames makes both arms the same
|
||||
/// experiment.
|
||||
const FRAMES: u32 = 200;
|
||||
|
||||
/// A ~2 MiB synchronized update, closed, replayed in PTY-sized reads.
|
||||
///
|
||||
/// The payload's *shape* is the load-bearing part, and it cost me a wrong
|
||||
/// result to learn it. An earlier version poured 8 KiB blocks of `A` into an
|
||||
/// update that was never closed. It floods just as many bytes per second, and
|
||||
/// it does not discriminate F1 at all: measured p95 63 us fenced vs 63 us
|
||||
/// unfenced. Plain `A` overwrites one line at a few ns per byte, so even a
|
||||
/// 2 MiB release is a short lock hold.
|
||||
///
|
||||
/// What makes a release expensive is work per byte -- SGR state changes and
|
||||
/// `\r\n` line feeds that push rows into scrollback. With that payload the same
|
||||
/// experiment separates by 129x. So this gate is sensitive to input shape and
|
||||
/// not merely to input rate, which is why the control below is not optional.
|
||||
fn flood(shared: &SharedTerminal, stop: &AtomicBool) {
|
||||
let mut payload: Vec<u8> = b"\x1b[?2026h".to_vec();
|
||||
while payload.len() < (2 << 20) {
|
||||
payload.extend_from_slice(b"\x1b[1;32mbuzz\x1b[0m substrate line of output 0123456789\r\n");
|
||||
}
|
||||
payload.extend_from_slice(b"\x1b[?2026l");
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
for chunk in payload.chunks(8192) {
|
||||
if stop.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
shared.feed_fully(chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render at 60 Hz for the duration of the flood, and report the renderer
|
||||
/// plane's acquisition latencies.
|
||||
fn measure(fences: Fences) -> buzz_terminal::AcquireStats {
|
||||
let size = Size {
|
||||
columns: 200,
|
||||
screen_lines: 50,
|
||||
scrollback: 10_000,
|
||||
};
|
||||
let (term, _actions) = Terminal::new(size, fences);
|
||||
let shared = Arc::new(SharedTerminal::new(term));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let writer = {
|
||||
let (shared, stop) = (Arc::clone(&shared), Arc::clone(&stop));
|
||||
thread::spawn(move || flood(&shared, &stop))
|
||||
};
|
||||
|
||||
// Don't measure the ramp: let the flood reach steady state, then clear.
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
shared.renderer_acquire().reset();
|
||||
|
||||
let mut encoder = Encoder::new();
|
||||
for _ in 0..FRAMES {
|
||||
shared.render(&mut encoder);
|
||||
thread::sleep(Duration::from_micros(FRAME_MICROS));
|
||||
}
|
||||
let stats = shared.renderer_acquire().snapshot();
|
||||
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
writer.join().expect("flood thread panicked");
|
||||
|
||||
assert_eq!(stats.acquisitions, FRAMES as u64, "meter lost samples");
|
||||
stats
|
||||
}
|
||||
|
||||
/// G3: with F1 on, the renderer's wait stays inside a frame -- and the
|
||||
/// unfenced control shows the fence is what puts it there.
|
||||
///
|
||||
/// Both arms live in one `#[test]` on purpose. As separate tests they run
|
||||
/// concurrently by default, each with its own flood thread, so each arm's
|
||||
/// measurement includes the other arm's CPU load and the control's ratio
|
||||
/// becomes a race between two floods rather than a statement about F1.
|
||||
#[test]
|
||||
#[ignore = "native performance gate; run release-mode on a known-idle host"]
|
||||
fn g3_renderer_acquire_stays_within_frame_budget() {
|
||||
let fenced = measure(Fences::ALL);
|
||||
let p95 = fenced.percentile_micros(0.95);
|
||||
assert!(
|
||||
p95 <= P95_MICROS,
|
||||
"renderer acquire p95 {p95} us over the {P95_MICROS} us budget (max {} us, n={})",
|
||||
fenced.max_micros,
|
||||
fenced.acquisitions
|
||||
);
|
||||
assert!(
|
||||
fenced.max_micros <= FRAME_MICROS,
|
||||
"renderer waited {} us for the terminal lock -- a dropped frame (p95 {p95} us, n={})",
|
||||
fenced.max_micros,
|
||||
fenced.acquisitions
|
||||
);
|
||||
|
||||
// The control. Without it this gate could pass because the fixture never
|
||||
// contended -- green over an experiment that did not run.
|
||||
let unfenced = measure(Fences::OSC_ONLY);
|
||||
assert!(
|
||||
unfenced.max_micros > fenced.max_micros.max(1) * 4,
|
||||
"unfenced renderer max {} us vs fenced {} us -- F1 is not what holds \
|
||||
renderer latency down, and this gate is measuring something else",
|
||||
unfenced.max_micros,
|
||||
fenced.max_micros
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
//! The resize seam: what a consumer is allowed to rely on across a reflow.
|
||||
//!
|
||||
//! The dedup encoder caches a hash per line. A resize reflows content into
|
||||
//! rows of a different width, so those cached hashes describe a grid that no
|
||||
//! longer exists -- if a resize did not force a full frame, dedup could
|
||||
//! suppress a row whose content genuinely changed and leave the renderer
|
||||
//! showing reflowed-away text.
|
||||
//!
|
||||
//! It does force one: upstream's `TermDamageState::resize` sets `full`
|
||||
//! (`alacritty_terminal-0.26.0` term/mod.rs:240). These fixtures hold that
|
||||
//! behaviour to the seam, because it is upstream's invariant and not ours.
|
||||
|
||||
use buzz_terminal::damage::Encoder;
|
||||
use buzz_terminal::fences::Fences;
|
||||
use buzz_terminal::{Action, SharedTerminal, Size, Terminal};
|
||||
use std::sync::mpsc::Receiver;
|
||||
|
||||
/// The receiver is returned rather than dropped: dropping it disconnects the
|
||||
/// channel, and every subsequent listener send silently fails. These fixtures
|
||||
/// don't assert on actions, but a fixture that quietly disables a code path is
|
||||
/// how a future assertion gets written against a dead one.
|
||||
fn shared(size: Size) -> (SharedTerminal, Receiver<Action>) {
|
||||
let (term, actions) = Terminal::new(size, Fences::ALL);
|
||||
(SharedTerminal::new(term), actions)
|
||||
}
|
||||
|
||||
fn size(columns: usize) -> Size {
|
||||
Size {
|
||||
columns,
|
||||
screen_lines: 10,
|
||||
scrollback: 1000,
|
||||
}
|
||||
}
|
||||
|
||||
fn grid(columns: usize, screen_lines: usize) -> Size {
|
||||
Size {
|
||||
columns,
|
||||
screen_lines,
|
||||
scrollback: 1000,
|
||||
}
|
||||
}
|
||||
|
||||
/// A resize invalidates dedup and republishes the whole grid at the new width.
|
||||
#[test]
|
||||
fn resize_forces_a_full_frame_at_the_new_width() {
|
||||
let (shared, _actions) = shared(size(40));
|
||||
let mut encoder = Encoder::new();
|
||||
shared.feed_fully(b"\x1b[2J\x1b[Hhello world\r\nsecond line\r\n");
|
||||
|
||||
let first = shared.render(&mut encoder);
|
||||
assert!(first.full, "first frame after a fresh Term must be full");
|
||||
assert_eq!(first.viewport.columns, 40);
|
||||
assert_eq!(first.viewport.generation, 0);
|
||||
|
||||
// Nothing changed: dedup suppresses everything. Without this the next
|
||||
// assertion could pass simply because every frame is full.
|
||||
let idle = shared.render(&mut encoder);
|
||||
assert!(!idle.full, "an unchanged grid must not republish");
|
||||
assert!(
|
||||
idle.rows.is_empty(),
|
||||
"dedup let {} unchanged rows through",
|
||||
idle.rows.len()
|
||||
);
|
||||
|
||||
let applied = shared.resize(size(20));
|
||||
assert_eq!(
|
||||
applied.columns, 20,
|
||||
"resize did not report the grid it applied"
|
||||
);
|
||||
assert_eq!(
|
||||
applied.generation, 1,
|
||||
"generation must advance across a resize"
|
||||
);
|
||||
|
||||
let after = shared.render(&mut encoder);
|
||||
assert!(
|
||||
after.full,
|
||||
"a resize must invalidate the renderer's cached rows"
|
||||
);
|
||||
assert_eq!(
|
||||
after.viewport, applied,
|
||||
"frame's viewport disagrees with the one resize reported applying"
|
||||
);
|
||||
assert_eq!(after.rows.len(), 10, "full frame must carry every line");
|
||||
let row0: String = after.rows[0]
|
||||
.spans
|
||||
.iter()
|
||||
.map(|s| s.text.as_str())
|
||||
.collect();
|
||||
assert_eq!(row0.chars().count(), 20, "row emitted at the old width");
|
||||
assert!(
|
||||
row0.starts_with("hello world"),
|
||||
"content lost across reflow: {row0:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A no-op resize is not a resize: it must not burn a generation, or every
|
||||
/// `ResizeObserver` tick would look like a discontinuity to the consumer.
|
||||
#[test]
|
||||
fn identical_resize_is_inert() {
|
||||
let (shared, _actions) = shared(size(40));
|
||||
let mut encoder = Encoder::new();
|
||||
shared.feed_fully(b"hello");
|
||||
shared.render(&mut encoder);
|
||||
|
||||
let applied = shared.resize(size(40));
|
||||
assert_eq!(
|
||||
applied.generation, 0,
|
||||
"a same-size resize advanced the generation"
|
||||
);
|
||||
|
||||
let after = shared.render(&mut encoder);
|
||||
assert_eq!(after.viewport, applied);
|
||||
assert!(
|
||||
!after.full,
|
||||
"a same-size resize forced a needless full repaint"
|
||||
);
|
||||
}
|
||||
|
||||
/// A **full frame must carry every row**, including rows whose content is
|
||||
/// byte-identical to what sat at that index before the resize.
|
||||
///
|
||||
/// This is the arm that catches a dedup cache surviving a full frame, and the
|
||||
/// width-changing fixture above does *not* catch it: changing the width changes
|
||||
/// every row's cell contents, so the hashes differ and the rows are emitted for
|
||||
/// the wrong reason. A **height-only** resize keeps the width, so reflowed rows
|
||||
/// hash exactly as before -- and a stale cache suppresses them right after the
|
||||
/// consumer was told to discard what it had. The result is a renderer holding
|
||||
/// nothing where content should be.
|
||||
///
|
||||
/// Verified concretely: growing 10 -> 20 lines moves "hello world" from row 0
|
||||
/// to row 1, so correctness here is not merely about frame bookkeeping.
|
||||
#[test]
|
||||
fn full_frame_after_height_resize_republishes_unchanged_rows() {
|
||||
let (shared, _actions) = shared(grid(40, 10));
|
||||
let mut encoder = Encoder::new();
|
||||
shared.feed_fully(b"\x1b[2J\x1b[Hhello world\r\nsecond line");
|
||||
let first = shared.render(&mut encoder);
|
||||
assert!(first.full);
|
||||
assert_eq!(first.rows.len(), 10);
|
||||
|
||||
shared.resize(grid(40, 20));
|
||||
let after = shared.render(&mut encoder);
|
||||
assert!(
|
||||
after.full,
|
||||
"a resize must invalidate the renderer's cached rows"
|
||||
);
|
||||
assert_eq!(after.viewport.screen_lines, 20);
|
||||
assert_eq!(
|
||||
after.rows.len(),
|
||||
20,
|
||||
"full frame carried {} of 20 rows -- dedup suppressed rows the consumer \
|
||||
was simultaneously told to discard, leaving them blank",
|
||||
after.rows.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// A frame is stamped with the grid it was **captured on**, and a later resize
|
||||
/// does not retroactively re-label it.
|
||||
///
|
||||
/// This is the cross-transport race in the integration lane: frame delivery and
|
||||
/// the resize call are separate paths, so a generation-N frame can arrive after
|
||||
/// generation N+1 has been applied. Rejecting it requires the stamp to be
|
||||
/// capture-time truth.
|
||||
///
|
||||
/// Note what is and is not proven here. That an owned `Frame` cannot mutate is
|
||||
/// guaranteed by the language, so asserting it against a copy of itself would
|
||||
/// be tautological. What this asserts is that `capture()` stamps the viewport
|
||||
/// as it was **at capture**, against explicit expected values -- a `capture()`
|
||||
/// that read the viewport a moment later, or a `Frame` that carried a handle
|
||||
/// back to the terminal, would fail here.
|
||||
#[test]
|
||||
fn a_frame_is_stamped_with_the_grid_it_was_captured_on() {
|
||||
let (shared, _actions) = shared(grid(40, 10));
|
||||
let mut encoder = Encoder::new();
|
||||
shared.feed_fully(b"\x1b[2J\x1b[Hhello world");
|
||||
|
||||
let in_flight = shared.render(&mut encoder);
|
||||
assert_eq!(in_flight.viewport.generation, 0);
|
||||
assert_eq!(in_flight.viewport.columns, 40);
|
||||
|
||||
let applied = shared.resize(grid(20, 10));
|
||||
assert_eq!(applied.generation, 1);
|
||||
assert_eq!(applied.columns, 20);
|
||||
|
||||
// The held frame still describes the pre-resize grid, so a consumer can
|
||||
// compare the two and discard it rather than paint 40-column rows onto a
|
||||
// 20-column grid.
|
||||
assert_eq!(
|
||||
in_flight.viewport.columns, 40,
|
||||
"a frame captured before the resize describes the post-resize grid; \
|
||||
a stale frame arriving late would be indistinguishable from a fresh one"
|
||||
);
|
||||
assert_eq!(in_flight.viewport.generation, 0);
|
||||
assert_ne!(in_flight.viewport, applied);
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
//! Reaching the scrollback the engine has always been keeping.
|
||||
//!
|
||||
//! The grid retains 10k lines in production and, before this, nothing could
|
||||
//! move the viewport off the live edge. Three things have to hold at once for
|
||||
//! that to become usable, and each one fails silently on its own:
|
||||
//!
|
||||
//! 1. **Direction.** A flipped sign still scrolls, still clamps, and still
|
||||
//! repaints. Only a human notices. So the direction is asserted here, in
|
||||
//! test names, rather than left to the caller to get right.
|
||||
//! 2. **Coordinates.** Capture reads screen rows out of a grid indexed from
|
||||
//! the live edge. Off-by-the-offset shows *some* plausible text.
|
||||
//! 3. **Dedup.** The renderer's per-row hashes describe the screen it last
|
||||
//! saw. Scrolling changes every row without changing the grid, so a scroll
|
||||
//! that consumed the full-damage flag would leave those hashes describing
|
||||
//! a viewport that is no longer shown -- and they would then suppress a row
|
||||
//! that really did change.
|
||||
|
||||
use buzz_terminal::damage::{Encoder, Frame};
|
||||
use buzz_terminal::fences::Fences;
|
||||
use buzz_terminal::{Action, SharedTerminal, Size, Terminal};
|
||||
use std::sync::mpsc::Receiver;
|
||||
|
||||
/// The receiver is returned rather than dropped: dropping it disconnects the
|
||||
/// channel and every subsequent listener send silently fails.
|
||||
fn terminal(
|
||||
columns: usize,
|
||||
screen_lines: usize,
|
||||
scrollback: usize,
|
||||
) -> (SharedTerminal, Receiver<Action>) {
|
||||
let size = Size {
|
||||
columns,
|
||||
screen_lines,
|
||||
scrollback,
|
||||
};
|
||||
let (term, actions) = Terminal::new(size, Fences::ALL);
|
||||
(SharedTerminal::new(term), actions)
|
||||
}
|
||||
|
||||
/// The text of every row the frame carries, indexed by screen row.
|
||||
///
|
||||
/// Blank rows are kept as empty strings rather than filtered out: this suite
|
||||
/// is about *which row shows which line*, and dropping the blanks would
|
||||
/// renumber every row after one.
|
||||
fn rows_by_line(frame: &Frame) -> Vec<(usize, String)> {
|
||||
frame
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
(
|
||||
row.line,
|
||||
row.spans
|
||||
.iter()
|
||||
.map(|span| span.text.as_str())
|
||||
.collect::<String>()
|
||||
.trim_end()
|
||||
.to_string(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Just the text, in screen order. Only meaningful for a full frame.
|
||||
fn screen(frame: &Frame) -> Vec<String> {
|
||||
rows_by_line(frame)
|
||||
.into_iter()
|
||||
.map(|(_, text)| text)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Fill history with numbered lines, then take a caught-up renderer.
|
||||
///
|
||||
/// Returns the terminal and an encoder that has already consumed the damage
|
||||
/// from that output, so anything a later assertion sees is caused by the
|
||||
/// thing under test rather than by the fixture.
|
||||
fn scrolled_terminal(lines: usize) -> (SharedTerminal, Receiver<Action>, Encoder) {
|
||||
let (shared, actions) = terminal(20, 4, 100);
|
||||
let payload = (1..=lines)
|
||||
.map(|n| format!("L{n:02}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\r\n");
|
||||
shared.feed_fully(payload.as_bytes());
|
||||
let mut renderer = Encoder::new();
|
||||
let _ = shared.render(&mut renderer);
|
||||
(shared, actions, renderer)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fixture_starts_at_the_live_edge_showing_the_newest_lines() {
|
||||
let (shared, _actions, _) = scrolled_terminal(10);
|
||||
let mut encoder = Encoder::new();
|
||||
assert_eq!(
|
||||
screen(&shared.snapshot(&mut encoder)),
|
||||
vec!["L07", "L08", "L09", "L10"]
|
||||
);
|
||||
assert_eq!(shared.lock().display_offset(), 0);
|
||||
}
|
||||
|
||||
/// **The direction, at the engine boundary.** Positive goes *into* history.
|
||||
///
|
||||
/// This is upstream's convention and the reason the embedder negates the DOM
|
||||
/// delta exactly once. If this assertion and `terminal_scroll`'s negation are
|
||||
/// ever flipped together the pair still passes -- which is why the embedder's
|
||||
/// own direction test asserts against the DOM sign rather than against this
|
||||
/// one.
|
||||
#[test]
|
||||
fn positive_lines_scroll_backwards_into_history() {
|
||||
let (shared, _actions, _) = scrolled_terminal(10);
|
||||
|
||||
assert!(shared.scroll(2), "two lines of history exist to move into");
|
||||
|
||||
let mut encoder = Encoder::new();
|
||||
assert_eq!(
|
||||
screen(&shared.snapshot(&mut encoder)),
|
||||
vec!["L05", "L06", "L07", "L08"],
|
||||
"scrolling back two lines must show two older lines"
|
||||
);
|
||||
assert_eq!(shared.lock().display_offset(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_lines_scroll_forwards_towards_the_live_edge() {
|
||||
let (shared, _actions, _) = scrolled_terminal(10);
|
||||
assert!(shared.scroll(3));
|
||||
|
||||
assert!(shared.scroll(-1), "one line back towards the edge");
|
||||
|
||||
let mut encoder = Encoder::new();
|
||||
assert_eq!(
|
||||
screen(&shared.snapshot(&mut encoder)),
|
||||
vec!["L05", "L06", "L07", "L08"]
|
||||
);
|
||||
assert_eq!(shared.lock().display_offset(), 2);
|
||||
}
|
||||
|
||||
/// The momentum guard. A trackpad flick keeps delivering events for about a
|
||||
/// second after the fingers lift; once history runs out every one of them
|
||||
/// must be free.
|
||||
#[test]
|
||||
fn scrolling_past_the_oldest_line_clamps_and_reports_no_movement() {
|
||||
let (shared, _actions, _) = scrolled_terminal(10);
|
||||
// Six lines of history: ten written, four on screen.
|
||||
assert!(shared.scroll(6));
|
||||
assert_eq!(shared.lock().display_offset(), 6);
|
||||
|
||||
assert!(
|
||||
!shared.scroll(1),
|
||||
"there is nothing older, so nothing moved"
|
||||
);
|
||||
assert!(
|
||||
!shared.scroll(1_000),
|
||||
"and a whole flick of it still moves nothing"
|
||||
);
|
||||
assert_eq!(shared.lock().display_offset(), 6);
|
||||
|
||||
let mut encoder = Encoder::new();
|
||||
assert_eq!(
|
||||
screen(&shared.snapshot(&mut encoder)),
|
||||
vec!["L01", "L02", "L03", "L04"],
|
||||
"the top of history is the oldest line, not a blank grid"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrolling_forwards_at_the_live_edge_reports_no_movement() {
|
||||
let (shared, _actions, _) = scrolled_terminal(10);
|
||||
assert!(!shared.scroll(-1));
|
||||
assert!(!shared.scroll(-1_000));
|
||||
assert_eq!(shared.lock().display_offset(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapping_to_the_bottom_moves_only_when_scrolled_back() {
|
||||
let (shared, _actions, _) = scrolled_terminal(10);
|
||||
assert!(
|
||||
!shared.scroll_to_bottom(),
|
||||
"already live: a keystroke must not cost a repaint"
|
||||
);
|
||||
|
||||
assert!(shared.scroll(4));
|
||||
assert!(shared.scroll_to_bottom(), "scrolled back: this is the snap");
|
||||
assert_eq!(shared.lock().display_offset(), 0);
|
||||
|
||||
let mut encoder = Encoder::new();
|
||||
assert_eq!(
|
||||
screen(&shared.snapshot(&mut encoder)),
|
||||
vec!["L07", "L08", "L09", "L10"]
|
||||
);
|
||||
}
|
||||
|
||||
/// Why the snap has to exist at all: output does **not** bring the viewport
|
||||
/// back. The grid pins a scrolled-back viewport and piles new lines above it
|
||||
/// (`Grid::scroll_up` advances `display_offset` when it is non-zero), which is
|
||||
/// the behaviour you want while reading -- and means the echo of a keystroke
|
||||
/// would otherwise land on a screen the user cannot see.
|
||||
#[test]
|
||||
fn output_while_scrolled_back_leaves_the_viewport_where_the_reader_put_it() {
|
||||
let (shared, _actions, mut renderer) = scrolled_terminal(10);
|
||||
assert!(shared.scroll(3));
|
||||
|
||||
shared.feed_fully(b"\r\nL11\r\nL12");
|
||||
|
||||
let mut encoder = Encoder::new();
|
||||
assert_eq!(
|
||||
screen(&shared.snapshot(&mut encoder)),
|
||||
vec!["L04", "L05", "L06", "L07"],
|
||||
"the reader stays put while new output accumulates below"
|
||||
);
|
||||
|
||||
// And the snap still returns to the *new* live edge, not the old one.
|
||||
assert!(shared.scroll_to_bottom());
|
||||
let after = shared.render(&mut renderer);
|
||||
assert!(after.full, "a viewport move is a repaint");
|
||||
assert_eq!(screen(&after), vec!["L09", "L10", "L11", "L12"]);
|
||||
}
|
||||
|
||||
/// **The silent-corruption case.**
|
||||
///
|
||||
/// The renderer's `Encoder` holds one content hash per screen row. Scrolling
|
||||
/// changes what every row shows without changing a single cell, so those
|
||||
/// hashes are stale the instant the viewport moves. The engine's protection is
|
||||
/// that `scroll_display` marks the grid fully damaged and the embedder
|
||||
/// republishes via `snapshot`, which does not consume damage -- so the
|
||||
/// full-damage flag survives for the renderer's own next `render()`, which is
|
||||
/// what clears its hashes.
|
||||
///
|
||||
/// The discriminating part is the row content. Row 0 after the scroll holds
|
||||
/// `L04`; if a stale hash for row 0 -- taken when it held `L07` -- survived,
|
||||
/// the row would still ship, because the hashes differ. So the test scrolls to
|
||||
/// a position where the *pre-scroll* text reappears at the *same screen row*:
|
||||
/// scrolling back 4 puts `L03..L06` on screen, and then scrolling forward 4
|
||||
/// restores exactly the rows the hashes describe. A renderer whose hashes were
|
||||
/// never cleared suppresses the whole screen there, and the user is left
|
||||
/// looking at history that has scrolled away.
|
||||
#[test]
|
||||
fn a_scroll_does_not_leave_the_renderer_deduping_against_a_viewport_it_no_longer_shows() {
|
||||
let (shared, _actions, mut renderer) = scrolled_terminal(10);
|
||||
|
||||
// The embedder's scroll path: move, then republish by snapshot.
|
||||
assert!(shared.scroll(4));
|
||||
let mut scroll_encoder = Encoder::new();
|
||||
let republished = shared.snapshot(&mut scroll_encoder);
|
||||
assert_eq!(screen(&republished), vec!["L03", "L04", "L05", "L06"]);
|
||||
|
||||
// The renderer thread's own next capture must still be told to repaint.
|
||||
let after_scroll = shared.render(&mut renderer);
|
||||
assert!(
|
||||
after_scroll.full,
|
||||
"the scroll's snapshot must not have eaten the full-damage flag"
|
||||
);
|
||||
assert_eq!(screen(&after_scroll), vec!["L03", "L04", "L05", "L06"]);
|
||||
|
||||
// Now back to where the renderer's *original* hashes were taken. Every row
|
||||
// matches a hash it already holds, so only a cleared cache ships them.
|
||||
assert!(shared.scroll(-4));
|
||||
let mut back_encoder = Encoder::new();
|
||||
let _ = shared.snapshot(&mut back_encoder);
|
||||
let after_return = shared.render(&mut renderer);
|
||||
assert!(after_return.full);
|
||||
assert_eq!(
|
||||
screen(&after_return),
|
||||
vec!["L07", "L08", "L09", "L10"],
|
||||
"returning to a previously-hashed viewport must still repaint it"
|
||||
);
|
||||
}
|
||||
|
||||
/// A row that genuinely changes while the viewport is scrolled back must
|
||||
/// still reach the renderer. This is the same dedup hazard from the other
|
||||
/// side: content changing under a stale hash rather than a stale hash under
|
||||
/// unchanged content.
|
||||
#[test]
|
||||
fn a_row_that_changes_while_scrolled_back_still_ships() {
|
||||
let (shared, _actions, mut renderer) = scrolled_terminal(10);
|
||||
assert!(shared.scroll(2));
|
||||
let mut scroll_encoder = Encoder::new();
|
||||
let _ = shared.snapshot(&mut scroll_encoder);
|
||||
let _ = shared.render(&mut renderer);
|
||||
|
||||
// Rewrite the top line of the active area, which is screen row 2 while
|
||||
// scrolled back two.
|
||||
shared.feed_fully(b"\x1b[1;1HCHANGED\x1b[K");
|
||||
|
||||
let frame = shared.render(&mut renderer);
|
||||
let changed = rows_by_line(&frame)
|
||||
.into_iter()
|
||||
.find(|(_, text)| text == "CHANGED");
|
||||
assert_eq!(
|
||||
changed,
|
||||
Some((2, "CHANGED".to_string())),
|
||||
"the rewritten active row must ship, at its scrolled screen position; got {:?}",
|
||||
rows_by_line(&frame)
|
||||
);
|
||||
}
|
||||
|
||||
/// The cursor plane travels with the viewport, because the renderer paints it
|
||||
/// at a screen row and the grid stores it at an active-area row.
|
||||
#[test]
|
||||
fn the_cursor_moves_down_the_screen_as_the_viewport_scrolls_back() {
|
||||
let (shared, _actions, _) = scrolled_terminal(10);
|
||||
let mut encoder = Encoder::new();
|
||||
let live = shared.snapshot(&mut encoder);
|
||||
assert_eq!(live.cursor.line, 3, "cursor sits on the last active row");
|
||||
assert!(live.cursor.visible);
|
||||
|
||||
assert!(shared.scroll(2));
|
||||
let mut scrolled_encoder = Encoder::new();
|
||||
let scrolled = shared.snapshot(&mut scrolled_encoder);
|
||||
assert_eq!(
|
||||
scrolled.cursor.line, 3,
|
||||
"row 3 + 2 is off a four-row screen, so it clamps to the last row"
|
||||
);
|
||||
assert!(
|
||||
!scrolled.cursor.visible,
|
||||
"scrolled off the bottom, so it must not be painted on an unrelated line"
|
||||
);
|
||||
}
|
||||
|
||||
/// The clamp above is not the whole story: a cursor that is merely pushed
|
||||
/// *down* -- still on screen -- must report its new row, not its old one. A
|
||||
/// capture that ignored the offset entirely would pass the clamp test above
|
||||
/// (row 3 is where the cursor already was) and fail this one.
|
||||
///
|
||||
/// Parking the cursor on the top row with `ESC[H` is what leaves it room to
|
||||
/// move: at the live edge it is on row 0, and scrolling back two puts it on
|
||||
/// row 2 of a four-row screen, still visible.
|
||||
#[test]
|
||||
fn a_cursor_still_on_screen_reports_its_scrolled_row() {
|
||||
let (shared, _actions, _) = scrolled_terminal(10);
|
||||
shared.feed_fully(b"\x1b[H");
|
||||
|
||||
let mut live_encoder = Encoder::new();
|
||||
let live = shared.snapshot(&mut live_encoder);
|
||||
assert_eq!(live.cursor.line, 0, "parked on the top row");
|
||||
assert!(live.cursor.visible);
|
||||
|
||||
assert!(shared.scroll(2));
|
||||
let mut encoder = Encoder::new();
|
||||
let frame = shared.snapshot(&mut encoder);
|
||||
assert_eq!(
|
||||
frame.cursor.line, 2,
|
||||
"the caret follows the row it is written on down the screen"
|
||||
);
|
||||
assert!(
|
||||
frame.cursor.visible,
|
||||
"still inside the viewport, so still painted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cursor_becomes_visible_again_on_the_way_back() {
|
||||
let (shared, _actions, _) = scrolled_terminal(10);
|
||||
assert!(shared.scroll(3));
|
||||
assert!(shared.scroll_to_bottom());
|
||||
|
||||
let mut encoder = Encoder::new();
|
||||
let frame = shared.snapshot(&mut encoder);
|
||||
assert_eq!(frame.cursor.line, 3);
|
||||
assert!(frame.cursor.visible);
|
||||
}
|
||||
|
||||
/// The alternate screen has no scrollback by construction: `Term::new` builds
|
||||
/// the inactive grid with a zero scroll limit. So scrolling inside `vim` or
|
||||
/// `less` must be a clamped no-op, leaving the application's own scrolling to
|
||||
/// the application. Asserted rather than assumed -- a viewport that drifted
|
||||
/// here would show the primary screen's history behind a full-screen app.
|
||||
#[test]
|
||||
fn the_alternate_screen_has_no_scrollback_to_reach() {
|
||||
let (shared, _actions, _) = scrolled_terminal(10);
|
||||
|
||||
shared.feed_fully(b"\x1b[?1049h");
|
||||
shared.feed_fully(b"ALT");
|
||||
|
||||
assert!(!shared.scroll(1), "no history exists on the alt screen");
|
||||
assert!(!shared.scroll(1_000));
|
||||
assert_eq!(shared.lock().display_offset(), 0);
|
||||
|
||||
// And the primary screen's position is undisturbed on the way back.
|
||||
shared.feed_fully(b"\x1b[?1049l");
|
||||
assert!(shared.scroll(2));
|
||||
assert_eq!(shared.lock().display_offset(), 2);
|
||||
}
|
||||
|
||||
/// A terminal configured with no history cannot scroll at all. The guard is
|
||||
/// upstream's clamp against `history_size()`, and this pins it: without it the
|
||||
/// offset would advance and capture would index above the grid.
|
||||
#[test]
|
||||
fn a_terminal_without_scrollback_never_moves() {
|
||||
let (shared, _actions) = terminal(20, 4, 0);
|
||||
shared.feed_fully(b"a\r\nb\r\nc\r\nd\r\ne\r\nf");
|
||||
|
||||
assert!(!shared.scroll(1));
|
||||
assert!(!shared.scroll(1_000));
|
||||
assert_eq!(shared.lock().display_offset(), 0);
|
||||
|
||||
let mut encoder = Encoder::new();
|
||||
assert_eq!(
|
||||
screen(&shared.snapshot(&mut encoder)),
|
||||
vec!["c", "d", "e", "f"]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,882 @@
|
||||
//! 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}",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
//! Adversarial slicing cases for resize debt, oversized atoms, and arithmetic extremes.
|
||||
//!
|
||||
//! **This is half a suite.** The remaining work-bound cases live in
|
||||
//! `slicing.rs`; the two files are one set of contracts split only for the
|
||||
//! file-size ratchet. A mutation check scoped with `--test slicing_adversarial`
|
||||
//! covers 4 of 76 package tests and can report a confident pass while the
|
||||
//! killing fixture sits in the sibling file. Sizing from the whole budget does
|
||||
//! exactly that, then dies under the package.
|
||||
//!
|
||||
//! Mutation checks run the package, never a file: `cargo test -p buzz-terminal`.
|
||||
|
||||
use buzz_terminal::fences::{
|
||||
max_atom_work, max_drain_work, slice_bytes_remaining, Fences, WORK_BUDGET,
|
||||
};
|
||||
use buzz_terminal::{Size, Terminal};
|
||||
|
||||
/// 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_remaining(
|
||||
shallow.columns,
|
||||
shallow.screen_lines,
|
||||
shallow.scrollback,
|
||||
0,
|
||||
0
|
||||
) > 1,
|
||||
"geometry cannot discriminate: the shallow arm is already floored",
|
||||
);
|
||||
assert_eq!(
|
||||
slice_bytes_remaining(deep.columns, deep.screen_lines, deep.scrollback, 0, 0),
|
||||
1,
|
||||
);
|
||||
|
||||
// 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.
|
||||
//
|
||||
// Every comparison is against the fresh control's own field, never a
|
||||
// literal: a constant or geometry change must move both sides together,
|
||||
// or the fixture starts asserting the arithmetic of the day it was
|
||||
// written.
|
||||
let measure = |term: &mut Terminal| {
|
||||
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,
|
||||
)
|
||||
};
|
||||
|
||||
// The terminal under test stays alive past its measurement, so the
|
||||
// geometry arm below runs on the feeder that actually shrank rather than
|
||||
// on a lookalike that only ever grew.
|
||||
let (mut shrunk_term, _a) = Terminal::new(shallow, Fences::ALL);
|
||||
shrunk_term.resize(deep);
|
||||
shrunk_term.resize(shallow);
|
||||
let shrunk = measure(&mut shrunk_term);
|
||||
|
||||
let (mut fresh_term, _a) = Terminal::new(shallow, Fences::ALL);
|
||||
let fresh = measure(&mut fresh_term);
|
||||
|
||||
assert_eq!(
|
||||
shrunk.3, fresh.3,
|
||||
"no unit may be lost on the way down either"
|
||||
);
|
||||
assert!(
|
||||
shrunk.4 > fresh.4,
|
||||
"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.4,
|
||||
);
|
||||
assert!(
|
||||
shrunk.0 <= fresh.0,
|
||||
"narrower slices retire fewer atoms per drain: {} against {}",
|
||||
shrunk.0,
|
||||
fresh.0,
|
||||
);
|
||||
assert!(
|
||||
shrunk.1 >= fresh.1,
|
||||
"and leave more pending after the first call: {} against {}",
|
||||
shrunk.1,
|
||||
fresh.1,
|
||||
);
|
||||
assert!(
|
||||
shrunk.2 >= fresh.2,
|
||||
"and take more drains to finish: {} against {}",
|
||||
shrunk.2,
|
||||
fresh.2,
|
||||
);
|
||||
|
||||
// The debt survives a later resize on a different axis. Two things make
|
||||
// this arm bite, and it was inert without either:
|
||||
//
|
||||
// * It runs on the terminal that actually went shallow -> deep ->
|
||||
// shallow. A lookalike that only ever grew passes it while an
|
||||
// implementation that retains on shrink and drops on the next geometry
|
||||
// change fails.
|
||||
// * The resize carries the *shallow* depth. Passing the debt's own value
|
||||
// back in means `max(debt, new)` and a plain assignment agree, so the
|
||||
// arm cannot tell them apart -- which is how it survived a mutant that
|
||||
// retained only when columns and lines were unchanged.
|
||||
shrunk_term.resize(Size {
|
||||
columns: shallow.columns * 2,
|
||||
screen_lines: shallow.screen_lines,
|
||||
scrollback: shallow.scrollback,
|
||||
});
|
||||
shrunk_term.reset_stats();
|
||||
shrunk_term.feed_fully(b"\x1bc");
|
||||
assert_eq!(
|
||||
shrunk_term.stats().completed_work,
|
||||
2 * (shallow.columns * 2 * shallow.screen_lines) as u64
|
||||
+ (deep.scrollback * shallow.columns * 2) as u64,
|
||||
"a columns resize must keep the deep scrollback debt, not fall back \
|
||||
to the current shallow depth",
|
||||
);
|
||||
}
|
||||
|
||||
/// 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_remaining` 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_remaining(huge, huge, huge, 0, 0),
|
||||
1,
|
||||
"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_remaining(200, 50, n, 0, 0)) as fn(usize) -> usize,
|
||||
),
|
||||
("columns", |n| slice_bytes_remaining(n.max(1), 50, 0, 0, 0)),
|
||||
("lines", |n| slice_bytes_remaining(200, n.max(1), 0, 0, 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 >= 1);
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
//! The attach contract: what a subscriber that arrives mid-stream is given,
|
||||
//! and what taking it must not cost the subscriber already there.
|
||||
//!
|
||||
//! `render()` reports damage -- what changed since someone last looked. That
|
||||
//! is the right thing for a steady-state renderer and the wrong thing for a
|
||||
//! newcomer, who needs the screen as it stands. `snapshot()` supplies that,
|
||||
//! and the delicate part is that it must do so *without* consuming damage:
|
||||
//! two subscribers share one terminal, and damage is a single shared cursor.
|
||||
|
||||
use buzz_terminal::damage::Encoder;
|
||||
use buzz_terminal::fences::Fences;
|
||||
use buzz_terminal::{Action, SharedTerminal, Size, Terminal};
|
||||
use std::sync::mpsc::Receiver;
|
||||
|
||||
/// The receiver is returned rather than dropped: dropping it disconnects the
|
||||
/// channel and every subsequent listener send silently fails.
|
||||
fn terminal(columns: usize, screen_lines: usize) -> (SharedTerminal, Receiver<Action>) {
|
||||
let size = Size {
|
||||
columns,
|
||||
screen_lines,
|
||||
scrollback: 100,
|
||||
};
|
||||
let (term, actions) = Terminal::new(size, Fences::ALL);
|
||||
(SharedTerminal::new(term), actions)
|
||||
}
|
||||
|
||||
/// Collect the non-blank text of a frame's rows, for comparing what a
|
||||
/// subscriber can actually see.
|
||||
fn visible_text(frame: &buzz_terminal::damage::Frame) -> Vec<String> {
|
||||
frame
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
row.spans
|
||||
.iter()
|
||||
.map(|span| span.text.as_str())
|
||||
.collect::<String>()
|
||||
.trim_end()
|
||||
.to_string()
|
||||
})
|
||||
.filter(|line| !line.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The reason `snapshot` exists. A subscriber that attaches mid-stream and
|
||||
/// starts from `render()` is handed only what changes next -- with a quiet
|
||||
/// terminal that is the cursor's line alone, so the scrollback-visible screen
|
||||
/// never arrives.
|
||||
#[test]
|
||||
fn a_late_render_shows_only_the_next_change_but_a_snapshot_shows_the_screen() {
|
||||
let (shared, _actions) = terminal(20, 4);
|
||||
shared.feed_fully(b"first\r\nsecond\r\nthird");
|
||||
|
||||
// The incumbent consumes the damage from that output.
|
||||
let mut incumbent = Encoder::new();
|
||||
let seen = visible_text(&shared.render(&mut incumbent));
|
||||
assert_eq!(seen, vec!["first", "second", "third"]);
|
||||
|
||||
// A newcomer rendering now sees essentially nothing: damage is spent.
|
||||
let mut latecomer = Encoder::new();
|
||||
let by_render = visible_text(&shared.render(&mut latecomer));
|
||||
assert!(
|
||||
!by_render.contains(&"first".to_string()),
|
||||
"a late render cannot show scrollback it never saw damaged, got {by_render:?}"
|
||||
);
|
||||
|
||||
// The same newcomer snapshotting sees the whole viewport.
|
||||
let mut attaching = Encoder::new();
|
||||
let by_snapshot = shared.snapshot(&mut attaching);
|
||||
assert_eq!(
|
||||
visible_text(&by_snapshot),
|
||||
vec!["first", "second", "third"],
|
||||
"a snapshot must carry the visible viewport"
|
||||
);
|
||||
assert!(by_snapshot.full, "a snapshot is a repaint");
|
||||
}
|
||||
|
||||
/// **The law: `snapshot()` must not consume damage.**
|
||||
///
|
||||
/// Two subscribers share one terminal and damage is one shared cursor, so a
|
||||
/// snapshot taken for an attaching subscriber must leave the incumbent's
|
||||
/// pending rows intact. A naive implementation that calls `damage()` passes a
|
||||
/// full-frame test while freezing every other subscriber -- the newcomer looks
|
||||
/// perfect and the incumbent silently stops updating.
|
||||
///
|
||||
/// The interleaving is the point: write, snapshot, *then* let the incumbent
|
||||
/// render. But the interleaving alone is not enough to discriminate, and the
|
||||
/// reason is this module's own rule 2 -- `Term::damage()` marks the cursor
|
||||
/// line on every call. So an incumbent owed only the line it is sitting on
|
||||
/// gets that line back even when its damage was stolen, and a naive snapshot
|
||||
/// passes.
|
||||
///
|
||||
/// The owed row therefore has to be somewhere the cursor is *not*. Here row 0
|
||||
/// is rewritten and the cursor is parked on row 3, so a theft leaves the
|
||||
/// incumbent holding a blank cursor line and nothing else.
|
||||
#[test]
|
||||
fn a_snapshot_does_not_steal_the_incumbents_damage() {
|
||||
let (shared, _actions) = terminal(20, 4);
|
||||
|
||||
// An established renderer, caught up to a quiet terminal. The initial
|
||||
// content is shorter than its replacement so the rewrite below covers it
|
||||
// completely and no tail of it survives.
|
||||
let mut incumbent = Encoder::new();
|
||||
shared.feed_fully(b"old");
|
||||
let _ = shared.render(&mut incumbent);
|
||||
|
||||
// Rewrite row 0, then park the cursor on row 3. The incumbent is now owed
|
||||
// row 0, which is not the row the cursor will re-damage for free.
|
||||
shared.feed_fully(b"\x1b[1;1HAFTER\x1b[4;1H");
|
||||
|
||||
// A second subscriber attaches and snapshots first.
|
||||
let mut attaching = Encoder::new();
|
||||
let attached = shared.snapshot(&mut attaching);
|
||||
assert_eq!(
|
||||
visible_text(&attached),
|
||||
vec!["AFTER"],
|
||||
"the newcomer sees the whole screen"
|
||||
);
|
||||
|
||||
// The incumbent must still be delivered row 0.
|
||||
let follow_up = shared.render(&mut incumbent);
|
||||
assert!(
|
||||
follow_up.rows.iter().any(|row| row.line == 0),
|
||||
"snapshot consumed the incumbent's damage: row 0 was never delivered, \
|
||||
got rows {:?}",
|
||||
follow_up.rows.iter().map(|r| r.line).collect::<Vec<_>>()
|
||||
);
|
||||
assert!(
|
||||
visible_text(&follow_up).contains(&"AFTER".to_string()),
|
||||
"the incumbent must still see the row written before the snapshot, got {:?}",
|
||||
visible_text(&follow_up)
|
||||
);
|
||||
}
|
||||
|
||||
/// A snapshot stamps the geometry it was captured under and resets the
|
||||
/// consumer's dedup state, so an encoder reused across a resize cannot carry
|
||||
/// hashes describing rows of a different width.
|
||||
#[test]
|
||||
fn a_snapshot_realigns_a_reused_encoders_dedup_state() {
|
||||
let (shared, _actions) = terminal(20, 4);
|
||||
shared.feed_fully(b"wide enough line");
|
||||
|
||||
let mut encoder = Encoder::new();
|
||||
let before = shared.snapshot(&mut encoder);
|
||||
assert_eq!(before.viewport.columns, 20);
|
||||
let first_generation = before.viewport.generation;
|
||||
|
||||
let resized = shared.resize(Size {
|
||||
columns: 10,
|
||||
screen_lines: 4,
|
||||
scrollback: 100,
|
||||
});
|
||||
assert_eq!(resized.columns, 10);
|
||||
assert!(
|
||||
resized.generation > first_generation,
|
||||
"an applied resize advances the generation"
|
||||
);
|
||||
|
||||
// Same encoder, new geometry: every row must be re-sent, not suppressed
|
||||
// as unchanged against hashes taken at the old width.
|
||||
let after = shared.snapshot(&mut encoder);
|
||||
assert_eq!(
|
||||
after.viewport.columns, 10,
|
||||
"the capture-time grid is stamped"
|
||||
);
|
||||
// Columns alone does not identify a grid. `Viewport`'s own doc says the
|
||||
// three fields travel together *because* a consumer comparing two of the
|
||||
// three can be wrong -- and this fixture used to compare one. A resize
|
||||
// that changed only `screen_lines`, or 20 -> 10 -> 20, leaves columns
|
||||
// matching while the generation has moved. `resize.rs` asserts this on
|
||||
// `render()` frames five times and never once on a snapshot, which is
|
||||
// what Sami's T3 mutant walked through; Mari's reattach reads this stamp.
|
||||
assert_eq!(
|
||||
after.viewport, resized,
|
||||
"a snapshot stamps the identity of the grid it actually captured"
|
||||
);
|
||||
assert!(after.full, "a snapshot is a repaint");
|
||||
assert!(
|
||||
!after.rows.is_empty(),
|
||||
"stale hashes must not suppress rows after a resize"
|
||||
);
|
||||
}
|
||||
|
||||
/// A snapshot carries *every* row of the viewport, including the last one,
|
||||
/// and stamps the cursor plane truthfully.
|
||||
///
|
||||
/// Both properties are asserted here rather than in the fixtures above
|
||||
/// because of what those fixtures' helper hides: `visible_text` trims and
|
||||
/// drops empty lines, so a capture that skipped the bottom row of the screen
|
||||
/// reads identically to one that didn't whenever the content sits in the top
|
||||
/// rows -- which it does in every other fixture in this file. Sami's T2
|
||||
/// mutant (`0..screen_lines - 1`) survived all four for exactly that reason.
|
||||
/// So this fixture puts content on the last row and asserts the row *set*,
|
||||
/// not the text.
|
||||
///
|
||||
/// The cursor half is the same shape of gap: nothing checked that a snapshot's
|
||||
/// cursor was the terminal's cursor rather than a plausible default.
|
||||
#[test]
|
||||
fn a_snapshot_carries_every_row_and_the_true_cursor() {
|
||||
let (shared, _actions) = terminal(20, 4);
|
||||
// Write the bottom row of the screen, then park the cursor at line 4,
|
||||
// column 6 (1-based) -- row 3, column 5 to us.
|
||||
shared.feed_fully(b"\x1b[4;1Hbottom\x1b[4;6H");
|
||||
|
||||
let mut attaching = Encoder::new();
|
||||
let frame = shared.snapshot(&mut attaching);
|
||||
|
||||
let lines: Vec<usize> = frame.rows.iter().map(|row| row.line).collect();
|
||||
assert_eq!(
|
||||
lines,
|
||||
vec![0, 1, 2, 3],
|
||||
"a snapshot must carry the whole viewport, last row included"
|
||||
);
|
||||
assert!(
|
||||
visible_text(&frame).contains(&"bottom".to_string()),
|
||||
"content on the last row must reach an attaching subscriber, got {:?}",
|
||||
visible_text(&frame)
|
||||
);
|
||||
|
||||
assert_eq!(frame.cursor.line, 3, "the snapshot's cursor line is real");
|
||||
assert_eq!(
|
||||
frame.cursor.column, 5,
|
||||
"the snapshot's cursor column is real"
|
||||
);
|
||||
assert!(frame.cursor.visible, "the cursor is shown by default");
|
||||
|
||||
// ...and a hidden cursor is reported hidden, so `visible` tracks the mode
|
||||
// rather than being a constant that happens to match the default.
|
||||
shared.feed_fully(b"\x1b[?25l");
|
||||
let mut second = Encoder::new();
|
||||
assert!(
|
||||
!shared.snapshot(&mut second).cursor.visible,
|
||||
"DECTCEM off must reach the attaching subscriber"
|
||||
);
|
||||
}
|
||||
|
||||
/// Taking a snapshot is billed to the renderer plane.
|
||||
///
|
||||
/// The two planes are metered separately because pooling them lets the
|
||||
/// reader's millions of fast acquires dilute the renderer's tail into a false
|
||||
/// pass (`shared.rs` module docs). A full-grid copy is the single most
|
||||
/// expensive thing that takes this lock, so misfiling it under the reader
|
||||
/// would corrupt the very instrument the renderer's budget is judged by --
|
||||
/// and no fixture noticed until Sami's T4.
|
||||
#[test]
|
||||
fn a_snapshot_is_billed_to_the_renderer_plane() {
|
||||
let (shared, _actions) = terminal(20, 4);
|
||||
shared.feed_fully(b"content");
|
||||
|
||||
shared.reader_acquire().reset();
|
||||
shared.renderer_acquire().reset();
|
||||
|
||||
let mut attaching = Encoder::new();
|
||||
let _ = shared.snapshot(&mut attaching);
|
||||
|
||||
assert_eq!(
|
||||
shared.renderer_acquire().snapshot().acquisitions,
|
||||
1,
|
||||
"the snapshot's lock acquisition belongs to the renderer plane"
|
||||
);
|
||||
assert_eq!(
|
||||
shared.reader_acquire().snapshot().acquisitions,
|
||||
0,
|
||||
"a full-grid copy must not be charged to the reader plane"
|
||||
);
|
||||
}
|
||||
|
||||
/// Two consecutive snapshots with no output between them still both carry the
|
||||
/// screen. A snapshot is not a one-shot: reattach may happen repeatedly, and
|
||||
/// nothing about the first may disarm the second.
|
||||
#[test]
|
||||
fn snapshots_are_repeatable() {
|
||||
let (shared, _actions) = terminal(20, 4);
|
||||
shared.feed_fully(b"persistent");
|
||||
|
||||
let mut first = Encoder::new();
|
||||
let mut second = Encoder::new();
|
||||
assert_eq!(
|
||||
visible_text(&shared.snapshot(&mut first)),
|
||||
vec!["persistent"]
|
||||
);
|
||||
assert_eq!(
|
||||
visible_text(&shared.snapshot(&mut second)),
|
||||
vec!["persistent"],
|
||||
"a second subscriber attaching later must see the same screen"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user