fix: C allocator contract, shadertoy overloads/MSL stride, minidumps

Closes the remaining library rows of the final completeness audit
(docs/rewrite/notes/final-Z3.md):
- libghostty-vt: OSC parser storage goes through the caller-supplied C
  allocator via a ByteStorage abstraction; ghostty_osc_new reports
  GHOSTTY_OUT_OF_MEMORY and failed grows invalidate the OSC like Zig.
  Tested with a counting/failing/limiting C allocator.
- shadertoy: sampler parameter rewriting is keyed by full signature so
  overloads work; the Metal uniform block keeps std140 strides for
  iChannelTime[4] (MSL offsets checked against the upload layout).
- tripwire.zig ported (named trip points, hit counts, verification) and
  used to port the previously skipped allocation-failure tests that have
  a fallible Rust path; the rest are documented.
- crash: fatal signals capture siginfo + registers and a forked helper
  writes a minidump (minidump-writer) as an envelope attachment, like
  sentry-native/Breakpad; tests parse it with the minidump crate.
- kitty LoadingImage::debug_dump (debug builds) and the Darwin
  Instruments signpost around benchmark runs.

Test Plan:
- cargo test -p ghostty-vt-capi --test c_allocator
- cargo test -p ghostty-crash --test minidump
This commit is contained in:
ddidderr committed 2026-10-12 08:14:02 +02:00
1 parent 5568f99bae
commit 8af6ab68d3
22 files changed
+2079 -57

No files matched your search

@@ -353,6 +353,42 @@ impl LoadingImage {
Ok(())
}
/// The file name `debug_dump` writes (Zig `@tagName` spellings).
#[cfg(debug_assertions)]
#[must_use]
pub fn debug_dump_filename(&self) -> String {
let format = match self.image.format {
TransmissionFormat::Rgb => "rgb",
TransmissionFormat::Rgba => "rgba",
TransmissionFormat::Png => "png",
TransmissionFormat::GrayAlpha => "gray_alpha",
TransmissionFormat::Gray => "gray",
};
let compression = match self.image.compression {
TransmissionCompression::None => "none",
TransmissionCompression::ZlibDeflate => "zlib_deflate",
};
format!(
"image-{format}-{compression}-{}x{}-{}.data",
self.image.width, self.image.height, self.image.id
)
}
/// Debug function to write the data to a file in `dir`. This is useful
/// for capturing some test data for unit tests. Only exists in debug
/// builds (Zig: `@compileError("debugDump in non-debug")`).
#[cfg(debug_assertions)]
pub fn debug_dump_to(&self, dir: &std::path::Path) -> std::io::Result<()> {
std::fs::write(dir.join(self.debug_dump_filename()), &self.data)
}
/// Same as [`Self::debug_dump_to`] using the current working directory,
/// like Zig's `debugDump`.
#[cfg(debug_assertions)]
pub fn debug_dump(&self) -> std::io::Result<()> {
self.debug_dump_to(std::path::Path::new("."))
}
/// Adds a chunk of data to the image. Use this if the image
/// is coming in chunks (the "m" parameter in the protocol).
pub fn add_data(&mut self, data: &[u8]) -> Result<(), Error> {
@@ -623,6 +659,37 @@ mod tests {
use super::*;
#[cfg(debug_assertions)]
#[test]
fn loading_image_debug_dump_writes_data() {
let mut li = LoadingImage {
image: Image {
id: 7,
width: 3,
height: 2,
format: TransmissionFormat::GrayAlpha,
compression: TransmissionCompression::ZlibDeflate,
..Image::default()
},
data: vec![1, 2, 3, 4],
display: None,
quiet: Quiet::default(),
};
assert_eq!(
"image-gray_alpha-zlib_deflate-3x2-7.data",
li.debug_dump_filename()
);
let dir = std::env::temp_dir().join(format!("ghostty-dump-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
li.debug_dump_to(&dir).unwrap();
let got = std::fs::read(dir.join(li.debug_dump_filename())).unwrap();
assert_eq!(vec![1, 2, 3, 4], got);
li.image.format = TransmissionFormat::Png;
li.image.compression = TransmissionCompression::None;
assert_eq!("image-png-none-3x2-7.data", li.debug_dump_filename());
std::fs::remove_dir_all(&dir).unwrap();
}
const RGB_NONE_20X15: &[u8] =
include_bytes!("testdata/image-rgb-none-20x15-2147483647-raw.data");
const RGB_ZLIB_128X96: &[u8] =
+1
View File
@@ -22,3 +22,4 @@ pub mod style;
pub mod tabstops;
pub mod terminal;
pub mod tmux;
pub mod tripwire;
+81
View File
@@ -196,6 +196,29 @@ pub struct PageList {
pin_serial: u64,
pause_checks: usize,
}
/// Failure points of [`PageList::init`] (Zig: `init_tw`). The Zig
/// `init_memory_pool` point has no counterpart: Rust has no up-front memory
/// pool, the slabs start empty and grow with the fallible reserves below.
/// `viewport_pin` and `viewport_pin_track` are two allocations in Zig (pool
/// create + set insert) that are one reserve in `track_pin` here; both points
/// are checked before it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum InitPoint {
InitPages,
ViewportPin,
ViewportPinTrack,
}
crate::tripwire_module!(init_tw, InitPoint, Error);
/// Failure points of the page creation done by `init` (Zig: `initPages_tw`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum InitPagesPoint {
Node,
BufStd,
BufNonStd,
}
crate::tripwire_module!(init_pages_tw, InitPagesPoint, Error);
fn std_size() -> usize {
Page::layout(page::STD_CAPACITY).total_size
}
@@ -242,12 +265,15 @@ impl PageList {
pin_serial: 0,
pause_checks: 0,
};
init_tw::check(InitPoint::InitPages)?;
while s.total_rows < usize::from(rows) {
s.grow()?;
}
let pin = s
.get_top_left(Tag::Screen)
.ok_or(Error::InvalidCoordinate)?;
init_tw::check(InitPoint::ViewportPin)?;
init_tw::check(InitPoint::ViewportPinTrack)?;
s.viewport_pin = Some(s.track_pin(pin)?);
Ok(s)
}
@@ -269,6 +295,7 @@ impl PageList {
if Page::layout(cap).total_size > u32::MAX as usize {
return Err(Error::OutOfSpace);
}
init_pages_tw::check(InitPagesPoint::Node)?;
self.nodes.try_reserve(1).map_err(|_| Error::OutOfMemory)?;
let mut data = if let Some(mut p) = self.clean_pool.pop().filter(|p| p.capacity == cap) {
p.size = page::Size {
@@ -281,6 +308,11 @@ impl PageList {
p.reinit()?;
p
} else {
if Page::layout(cap).total_size <= std_size() {
init_pages_tw::check(InitPagesPoint::BufStd)?;
} else {
init_pages_tw::check(InitPagesPoint::BufNonStd)?;
}
Page::init(cap)?
};
data.size.rows = 0;
@@ -2972,6 +3004,55 @@ mod tests {
s.clear_dirty();
}
#[test]
fn page_list_init_error() {
use crate::tripwire::ResetMode;
// Test every failure point in `init` and ensure we exit early
// without panicking or leaving the tripwire armed.
for tag in [
InitPoint::InitPages,
InitPoint::ViewportPin,
InitPoint::ViewportPinTrack,
] {
init_tw::error_always(tag, Error::OutOfMemory);
assert_eq!(
Err(Error::OutOfMemory),
PageList::init(80, 24, None).map(drop)
);
init_tw::end(ResetMode::Reset).unwrap();
}
// init creates pages transitively, so check each failure there too.
let big = page::STD_CAPACITY.max_cols().unwrap() + 1;
for tag in [
InitPagesPoint::Node,
InitPagesPoint::BufStd,
InitPagesPoint::BufNonStd,
] {
init_pages_tw::error_always(tag, Error::OutOfMemory);
let cols = if tag == InitPagesPoint::BufStd {
80
} else {
big
};
assert_eq!(
Err(Error::OutOfMemory),
PageList::init(cols, 24, None).map(drop)
);
init_pages_tw::end(ResetMode::Reset).unwrap();
}
// Non-standard pages: fail on the second one.
init_pages_tw::error_after(InitPagesPoint::BufNonStd, Error::OutOfMemory, 1);
assert_eq!(
Err(Error::OutOfMemory),
PageList::init(big, page::STD_CAPACITY.rows + 1, None).map(drop)
);
init_pages_tw::end(ResetMode::Reset).unwrap();
// And with nothing armed init still works.
PageList::init(80, 24, None).unwrap();
}
#[test]
fn page_list_init_rows_across_two_pages() {
let mut cols = 50;
while initial_capacity(cols).rows >= 100 {
@@ -30,6 +30,14 @@ use crate::{
size::CellCountInt,
};
/// Failure points of [`ScreenSearch::reload_active`] (Zig: `reloadActive_tw`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ReloadActivePoint {
HistoryAppendNew,
HistoryAppendExisting,
}
crate::tripwire_module!(reload_active_tw, ReloadActivePoint, Error);
#[derive(Debug)]
pub struct ScreenSearch {
/// Identity of the screen being searched (its address). Only used to
@@ -568,6 +576,7 @@ impl ScreenSearch {
if hl.chunks[0].node == history_node {
continue;
}
reload_active_tw::check(ReloadActivePoint::HistoryAppendNew)?;
results.push(hl);
}
@@ -584,6 +593,7 @@ impl ScreenSearch {
// Matches! Reverse our list then append all the remaining
// history items that didn't start on our original node.
results.reverse();
reload_active_tw::check(ReloadActivePoint::HistoryAppendExisting)?;
results.append(&mut self.history_results);
self.history_results = results;
@@ -1086,6 +1096,75 @@ mod tests {
search.deinit(t.screen_mut());
}
/// Shared setup of the two `reload_active partial history cleanup` tests:
/// several "Fizz" matches in history, then more that move into history on
/// the next reload.
fn reload_failure_setup() -> (Terminal, ScreenSearch) {
let mut t = terminal_scrollback(10, 2, usize::MAX);
feed(&mut t, b"Fizz\r\nFizz\r\n");
while total_pages(&t) < 3 {
feed(&mut t, b"\r\n");
}
for _ in 0..t.screen().pages.rows {
feed(&mut t, b"\r\n");
}
feed(&mut t, b"Fizz.");
// Complete initial search
let mut search = ScreenSearch::new(t.screen_mut(), b"Fizz").unwrap();
search.search_all(t.screen_mut()).unwrap();
// Trigger reload by adding more content that changes the
// active/history boundary.
feed(&mut t, b"\r\nFizz\r\nFizz\r\n");
while total_pages(&t) < 4 {
feed(&mut t, b"\r\n");
}
for _ in 0..t.screen().pages.rows {
feed(&mut t, b"\r\n");
}
(t, search)
}
#[test]
fn reload_active_partial_history_cleanup_on_append_slice_error() {
use crate::tripwire::ResetMode;
let (mut t, mut search) = reload_failure_setup();
let before = search.history_results.len();
// Fail at the final append (after the loop completes). Nothing may be
// half-applied to the existing history results.
reload_active_tw::error_always(
ReloadActivePoint::HistoryAppendExisting,
Error::OutOfMemory,
);
// reload_active is called by select(), which triggers the error path.
assert_eq!(
Err(Error::OutOfMemory),
search.select(t.screen_mut(), Select::Next)
);
reload_active_tw::end(ResetMode::Reset).unwrap();
assert_eq!(before, search.history_results.len());
search.deinit(t.screen_mut());
}
#[test]
fn reload_active_partial_history_cleanup_on_loop_append_error() {
use crate::tripwire::ResetMode;
let (mut t, mut search) = reload_failure_setup();
let before = search.history_results.len();
// Fail after the first loop append succeeded.
reload_active_tw::error_after(ReloadActivePoint::HistoryAppendNew, Error::OutOfMemory, 1);
assert_eq!(
Err(Error::OutOfMemory),
search.select(t.screen_mut(), Select::Next)
);
reload_active_tw::end(ResetMode::Reset).unwrap();
assert_eq!(before, search.history_results.len());
search.deinit(t.screen_mut());
}
#[test]
fn select_into_history() {
let mut t = terminal_scrollback(10, 2, usize::MAX);
+26 -3
View File
@@ -59,6 +59,13 @@ pub struct Tabstops {
dynamic_stops: Vec<Unit>,
}
/// Failure points of [`Tabstops::resize`] (Zig: `resize_tw`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ResizePoint {
DynamicAlloc,
}
crate::tripwire_module!(resize_tw, ResizePoint, TryReserveError);
impl Default for Tabstops {
fn default() -> Self {
Self {
@@ -141,6 +148,7 @@ impl Tabstops {
}
// Allocate first so that failure leaves our state untouched.
resize_tw::check(ResizePoint::DynamicAlloc)?;
let mut new: Vec<Unit> = Vec::new();
new.try_reserve_exact(size)?;
new.resize(size, 0);
@@ -175,6 +183,7 @@ impl Tabstops {
#[cfg(test)]
mod tests {
use super::*;
use crate::tripwire::ResetMode;
#[test]
fn tabstops_basic() {
@@ -238,7 +247,21 @@ mod tests {
assert_eq!(9, count);
}
// "Tabstops: resize alloc failure preserves state" is skipped: it uses
// Zig tripwire allocation failure injection. The Rust resize allocates
// before mutating, so a failure leaves `cols` unchanged by construction.
#[test]
fn tabstops_resize_alloc_failure_preserves_state() {
// If resize() fails during allocation, the original cols value is
// preserved (not corrupted).
let mut t = Tabstops::new(80, 8).unwrap();
let original_cols = t.cols;
// Trigger allocation failure when resizing beyond prealloc
let oom = Vec::<u8>::new().try_reserve(usize::MAX).unwrap_err();
resize_tw::error_always(ResizePoint::DynamicAlloc, oom.clone());
let result = t.resize(PREALLOC_COLUMNS * 2);
assert_eq!(Err(oom), result);
resize_tw::end(ResetMode::Reset).unwrap();
// cols should be unchanged after failed resize
assert_eq!(original_cols, t.cols);
}
}
+264
View File
@@ -0,0 +1,264 @@
//! Failure injection for testing error handling paths (port of
//! `tripwire.zig`).
//!
//! Improper cleanup on error paths is one of the highest sources of bugs.
//! Many of those points are hard to exercise in unit tests and rare to
//! encounter in production. This module injects errors at named points
//! during unit tests so that every error path can be exercised.
//!
//! # Usage
//!
//! Declare a module per fallible function with [`tripwire_module!`]. The
//! point enum is hand-curated; the error type is the function's own error.
//! Sprinkle `tw::check(Point::X)?` before every operation that you want to be
//! able to fail, then in a unit test configure expected failures with
//! [`error_always`](TripwireState::error_always) /
//! [`error_after`](TripwireState::error_after) and finish with
//! `tw::end(ResetMode::Reset)` to verify the expectations were met.
//!
//! ```ignore
//! tripwire_module!(resize_tw, ResizePoint, TryReserveError);
//!
//! fn resize() -> Result<(), E> {
//! resize_tw::check(ResizePoint::DynamicAlloc)?;
//! // ...
//! }
//!
//! #[test]
//! fn resize_fails() {
//! resize_tw::error_always(ResizePoint::DynamicAlloc, e);
//! assert!(resize().is_err());
//! resize_tw::end(ResetMode::Reset).unwrap();
//! }
//! ```
//!
//! Outside of unit tests (`cfg(test)` of the crate that expands the macro)
//! `check` is a no-op that the optimizer removes.
//!
//! State is per module *and per thread*. Rust runs tests on separate threads
//! in parallel, so this gives each test the same isolation Zig gets from
//! running tests sequentially.
use std::fmt::Debug;
/// Whether to reset expectations in [`TripwireState::end`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResetMode {
Reset,
Retain,
}
/// Returned from `end` if a configured point was never tripped.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("untripped tripwire")]
pub struct UntrippedError;
#[derive(Debug, Clone)]
struct Tripwire<E> {
/// Error to return when tripped.
err: E,
/// The number of times this tripwire has been reached. This is NOT the
/// number of times it has tripped, since we may have `min`.
reached: usize,
/// The minimum number of times this must be reached before tripping.
/// After this point it trips every time. This is a "before" check, so if
/// this is 1 it trips the second time it is reached.
min: usize,
/// True if this has been tripped at least once.
tripped: bool,
}
/// The configured tripwires of one module.
#[derive(Debug, Clone)]
pub struct TripwireState<P, E> {
wires: Vec<(P, Tripwire<E>)>,
}
impl<P, E> Default for TripwireState<P, E> {
fn default() -> Self {
Self { wires: Vec::new() }
}
}
impl<P: Copy + PartialEq + Debug, E: Clone> TripwireState<P, E> {
/// Check for a failure at the given failure point. Place directly before
/// the operation that may fail.
pub fn check(&mut self, point: P) -> Result<(), E> {
let Some((_, w)) = self.wires.iter_mut().find(|(p, _)| *p == point) else {
return Ok(());
};
w.reached += 1;
if w.reached <= w.min {
return Ok(());
}
w.tripped = true;
Err(w.err.clone())
}
/// Mark a failure point to always trip with the given error.
pub fn error_always(&mut self, point: P, err: E) {
self.error_after(point, err, 0);
}
/// Mark a failure point to trip with the given error after it has been
/// reached at least `min` times. Zero is equivalent to `error_always`.
pub fn error_after(&mut self, point: P, err: E, min: usize) {
let wire = Tripwire {
err,
reached: 0,
min,
tripped: false,
};
if let Some(slot) = self.wires.iter_mut().find(|(p, _)| *p == point) {
slot.1 = wire;
} else {
self.wires.push((point, wire));
}
}
/// Ends the tripwire session. Errors if there were untripped
/// expectations. Expectations are reset (per `mode`) even on error.
pub fn end(&mut self, mode: ResetMode) -> Result<(), UntrippedError> {
let mut untripped = false;
for (p, w) in &self.wires {
if !w.tripped {
log::warn!("untripped point={p:?}");
untripped = true;
}
}
if mode == ResetMode::Reset {
self.reset();
}
if untripped {
Err(UntrippedError)
} else {
Ok(())
}
}
/// Unset all the tripwires. You should usually call `end` instead.
pub fn reset(&mut self) {
self.wires.clear();
}
}
/// Declare a tripwire module `$name` with failure points `$point` (a `Copy +
/// PartialEq + Debug` enum) and error type `$err` (`Clone`). Generates
/// `$name::{check, error_always, error_after, end, reset}`.
#[macro_export]
macro_rules! tripwire_module {
($vis:vis $name:ident, $point:ty, $err:ty) => {
#[allow(dead_code)]
$vis mod $name {
use super::*;
use $crate::tripwire::{ResetMode, TripwireState, UntrippedError};
::std::thread_local! {
static STATE: ::std::cell::RefCell<TripwireState<$point, $err>> =
::std::cell::RefCell::new(TripwireState::default());
}
/// Check for a failure at `point`. A no-op outside of tests.
#[inline(always)]
pub fn check(point: $point) -> ::std::result::Result<(), $err> {
#[cfg(test)]
{
STATE.with(|s| s.borrow_mut().check(point))
}
#[cfg(not(test))]
{
let _ = point;
Ok(())
}
}
pub fn error_always(point: $point, err: $err) {
STATE.with(|s| s.borrow_mut().error_always(point, err));
}
pub fn error_after(point: $point, err: $err, min: usize) {
STATE.with(|s| s.borrow_mut().error_after(point, err, min));
}
pub fn end(mode: ResetMode) -> ::std::result::Result<(), UntrippedError> {
STATE.with(|s| s.borrow_mut().end(mode))
}
pub fn reset() {
STATE.with(|s| s.borrow_mut().reset());
}
}
};
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Io {
Read,
Write,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum E {
Oom,
}
crate::tripwire_module!(io, Io, E);
#[test]
fn tripwire_basic() {
// Reset should work
io::end(ResetMode::Reset).unwrap();
// By default, it's pass-through
io::check(Io::Read).unwrap();
// Always trip
io::error_always(Io::Read, E::Oom);
assert_eq!(Err(E::Oom), io::check(Io::Read));
// Happens again
assert_eq!(Err(E::Oom), io::check(Io::Read));
// Other points are unaffected
io::check(Io::Write).unwrap();
io::end(ResetMode::Reset).unwrap();
}
#[test]
fn tripwire_error_after() {
// Trip after 2 calls (on the 3rd call)
io::error_after(Io::Read, E::Oom, 2);
io::check(Io::Read).unwrap();
io::check(Io::Read).unwrap();
assert_eq!(Err(E::Oom), io::check(Io::Read));
assert_eq!(Err(E::Oom), io::check(Io::Read));
io::end(ResetMode::Reset).unwrap();
}
#[test]
fn tripwire_error_after_untripped_if_min_not_reached() {
io::error_after(Io::Read, E::Oom, 2);
io::check(Io::Read).unwrap();
assert_eq!(Err(UntrippedError), io::end(ResetMode::Reset));
// Expectations are reset even when end() fails.
io::check(Io::Read).unwrap();
io::end(ResetMode::Reset).unwrap();
}
#[test]
fn tripwire_untripped_never_reached_and_retain() {
io::error_always(Io::Write, E::Oom);
assert_eq!(Err(UntrippedError), io::end(ResetMode::Retain));
// Retained: still armed.
assert_eq!(Err(E::Oom), io::check(Io::Write));
io::end(ResetMode::Reset).unwrap();
}
#[test]
fn tripwire_state_is_per_thread() {
io::error_always(Io::Read, E::Oom);
std::thread::spawn(|| io::check(Io::Read).unwrap())
.join()
.unwrap();
assert_eq!(Err(E::Oom), io::check(Io::Read));
io::end(ResetMode::Reset).unwrap();
}
}