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

+10
View File
@@ -49,6 +49,16 @@ pub fn run(b: &mut dyn Benchmark, mode: RunMode) -> Result<RunResult> {
fn run_steps(b: &mut dyn Benchmark, mode: RunMode) -> Result<RunResult> {
let mut result = RunResult::default();
// On Darwin, emit an Instruments signpost interval around the run so the
// results are easy to find in Instruments (ends when dropped).
#[cfg(target_vendor = "apple")]
let _signpost = crate::signpost::Interval::begin(
c"com.mitchellh.ghostty",
c"ghostty",
(&raw const *b).cast::<()>() as usize,
);
let start = Instant::now();
loop {
b.step()?;
+2
View File
@@ -10,5 +10,7 @@
pub mod args;
pub mod benches;
pub mod benchmark;
#[cfg(target_vendor = "apple")]
pub mod signpost;
mod simd_width;
pub mod synthetic;
+92
View File
@@ -0,0 +1,92 @@
//! Darwin Instruments signposts (port of `pkg/macos/os/{log,signpost}.zig`,
//! the subset used by `benchmark/Benchmark.zig`). macOS/iOS only.
//!
//! The `os_signpost_*` C macros expand to calls of
//! `_os_signpost_emit_with_name_impl`, which is what is declared here.
#![allow(unsafe_code)]
use std::ffi::{CStr, c_char, c_void};
/// `OS_SIGNPOST_INTERVAL_BEGIN` / `OS_SIGNPOST_INTERVAL_END`.
const INTERVAL_BEGIN: u8 = 1;
const INTERVAL_END: u8 = 2;
/// `os_log_t` handle category that shows up as the "Points of Interest" track
/// in Instruments.
pub const CATEGORY_POINTS_OF_INTEREST: &CStr = c"PointsOfInterest";
unsafe extern "C" {
/// The Mach-O header of the image this code is linked into, provided by
/// the linker. Unlike Zig (ziglang/zig#24370) Rust can reference it.
static __dso_handle: c_void;
fn os_log_create(subsystem: *const c_char, category: *const c_char) -> *mut c_void;
fn os_release(object: *mut c_void);
fn os_signpost_id_make_with_pointer(log: *mut c_void, ptr: *const c_void) -> u64;
fn _os_signpost_emit_with_name_impl(
dso: *const c_void,
log: *mut c_void,
typ: u8,
spid: u64,
name: *const c_char,
format: *const c_char,
buf: *mut u8,
size: u32,
);
}
/// An `os_log_t` plus the signpost id of one interval. The interval ends (and
/// the log is released) on drop.
pub struct Interval {
log: *mut c_void,
id: u64,
name: &'static CStr,
}
impl Interval {
/// Begin an interval named `name` in `subsystem`/"PointsOfInterest",
/// correlated by the address `key` (Zig: `Id.forPointer(log, self.ptr)`).
#[must_use]
pub fn begin(subsystem: &CStr, name: &'static CStr, key: usize) -> Option<Interval> {
// SAFETY: both strings are NUL-terminated and outlive the call.
let log =
unsafe { os_log_create(subsystem.as_ptr(), CATEGORY_POINTS_OF_INTEREST.as_ptr()) };
if log.is_null() {
return None;
}
// SAFETY: `log` is a valid os_log_t; `key` is only used as an opaque key.
let id = unsafe { os_signpost_id_make_with_pointer(log, key as *const c_void) };
let i = Interval { log, id, name };
i.emit(INTERVAL_BEGIN);
Some(i)
}
fn emit(&self, typ: u8) {
// Two zero bytes: summary flags and argument count (no arguments).
let mut buf = [0u8; 2];
// SAFETY: `log` is valid for the lifetime of `self`, `name` is a
// NUL-terminated static string, `format` is "" and `buf` is the
// 2-byte empty argument buffer the C macro would build.
unsafe {
_os_signpost_emit_with_name_impl(
&raw const __dso_handle,
self.log,
typ,
self.id,
self.name.as_ptr(),
c"".as_ptr(),
buf.as_mut_ptr(),
2,
);
}
}
}
impl Drop for Interval {
fn drop(&mut self) {
self.emit(INTERVAL_END);
// SAFETY: `log` came from `os_log_create` (+1 retained) and is not
// used afterwards.
unsafe { os_release(self.log) };
}
}
+4
View File
@@ -19,7 +19,11 @@ thiserror = "2.0.21"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[target.'cfg(target_os = "linux")'.dependencies]
minidump-writer = "0.13.0"
[dev-dependencies]
minidump = "0.27.0"
tempfile = "3.27.0"
# The fatal-signal handler needs sigaction/sigaltstack/raw syscalls. Unsafe is
+2
View File
@@ -11,6 +11,8 @@
pub mod context;
pub mod dir;
pub mod envelope;
#[cfg(target_os = "linux")]
mod minidump;
pub mod sentry;
#[cfg(unix)]
mod signal;
+326
View File
@@ -0,0 +1,326 @@
//! Minidump capture for fatal signals (Linux), the counterpart of what
//! sentry-native's Breakpad/crashpad backend does upstream.
//!
//! A process cannot ptrace itself, so (like Breakpad's `ExceptionHandler`)
//! the signal handler forks a short-lived child, allows it to ptrace the
//! crashing process (`PR_SET_PTRACER`), and waits. The child uses
//! `minidump-writer` to snapshot the parent -- every thread's registers and
//! stack, the module list, memory mappings and, for the crashing thread, the
//! `siginfo` + machine context captured by the handler -- and writes the final
//! envelope: the event item followed by the minidump as an
//! `event.minidump` attachment. If anything fails (ptrace forbidden by a
//! sandbox, the child dies or times out) the caller falls back to the plain
//! event-only envelope written from the signal-safe path.
//!
//! The fork uses the raw `clone` syscall without `CLONE_VM`, so no `atfork`
//! handlers (which could take locks the crashed thread holds) run. The child
//! allocates, which could in theory deadlock on a copied locked malloc arena;
//! the parent therefore enforces a timeout and kills the child.
#![allow(unsafe_code)]
use std::{
cell::UnsafeCell,
io::{Cursor, Write},
mem::MaybeUninit,
os::fd::FromRawFd,
time::{Duration, Instant},
};
use minidump_writer::{
CrashContextExt,
crash_context::{CrashContext, ucontext_t},
minidump_writer::MinidumpWriterConfig,
};
/// Stack size of the dumping child.
const STACK: usize = 4 << 20;
/// How long the crashing process waits for the dumping child.
const TIMEOUT: Duration = Duration::from_secs(20);
/// Everything the child needs; borrowed from the handler's scratch space.
pub(crate) struct Capture<'a> {
/// NUL-terminated destination path of the envelope.
pub path: &'a [u8],
/// Envelope header line + event item header line.
pub head: &'a [u8],
/// The event JSON payload.
pub event: &'a [u8],
}
/// The crash context lives in static memory: the handler may run on a tiny
/// alternate stack (Rust's default is a few KiB) that cannot hold a ~2 KiB
/// struct plus the temporaries of building it.
struct CcCell(UnsafeCell<MaybeUninit<CrashContext>>);
// SAFETY: access is serialized by the caller (`signal::BUSY`).
unsafe impl Sync for CcCell {}
static CC: CcCell = CcCell(UnsafeCell::new(MaybeUninit::zeroed()));
/// Fill the static `crash-context` description of the fault from the
/// handler's arguments and return it.
///
/// # Safety
/// `info` and `uc` must be the pointers the kernel passed to an `SA_SIGINFO`
/// handler (or null), and the caller must have exclusive access to the static
/// (the crash flag `signal::BUSY`).
pub(crate) unsafe fn crash_context(
info: *const libc::siginfo_t,
uc: *const libc::c_void,
) -> &'static CrashContext {
// SAFETY: exclusive access per the contract; CrashContext is plain old
// data for which the all-zero pattern is valid.
let cc: &'static mut CrashContext = unsafe { (*CC.0.get()).assume_init_mut() };
// SAFETY: gettid/getpid have no preconditions.
cc.tid = unsafe { libc::syscall(libc::SYS_gettid) } as libc::pid_t;
// SAFETY: as above.
cc.pid = unsafe { libc::getpid() };
// SAFETY: per the function contract; the kernel's siginfo is valid.
if let Some(info) = unsafe { info.as_ref() } {
cc.siginfo.ssi_signo = info.si_signo as u32;
cc.siginfo.ssi_code = info.si_code;
cc.siginfo.ssi_errno = info.si_errno;
// SAFETY: si_addr is valid for fault signals; for others it is
// unspecified but reading it is harmless.
cc.siginfo.ssi_addr = unsafe { info.si_addr() } as u64;
}
if !uc.is_null() {
// SAFETY: the kernel passes a ucontext whose layout is arch-specific
// and identical to `crash_context::ucontext_t` (the premise of that
// crate); `uc` is valid for reads of that size and `cc.context` for
// writes; they cannot overlap (static vs. signal frame).
unsafe {
std::ptr::copy_nonoverlapping(uc.cast::<ucontext_t>(), &raw mut cc.context, 1);
}
#[cfg(not(target_arch = "arm"))]
{
let fp = cc.context.uc_mcontext.fpregs;
if !fp.is_null() {
// SAFETY: the kernel points fpregs at the saved FP state in
// the signal frame; the destination is our static.
unsafe { std::ptr::copy_nonoverlapping(fp, &raw mut cc.float_state, 1) };
}
}
}
cc
}
/// Fork the dumping child and wait for it. Returns true if the envelope with
/// the minidump has been written to `cap.path`.
pub(crate) fn capture(cap: &Capture<'_>, cc: &CrashContext) -> bool {
// Allow any process (our child) to ptrace us under Yama's ptrace_scope=1.
// SAFETY: prctl with these arguments only changes our own ptracer policy.
unsafe {
libc::prctl(
libc::PR_SET_PTRACER,
libc::PR_SET_PTRACER_ANY as libc::c_ulong,
0,
0,
0,
)
};
// The handler runs on a small alternate stack, so the child gets its own.
// SAFETY: anonymous private mapping; checked for failure below.
let stack = unsafe {
libc::mmap(
std::ptr::null_mut(),
STACK,
libc::PROT_READ | libc::PROT_WRITE,
libc::MAP_PRIVATE | libc::MAP_ANONYMOUS | libc::MAP_NORESERVE | libc::MAP_STACK,
-1,
0,
)
};
if stack == libc::MAP_FAILED {
return false;
}
let mut arg = ChildArg { cap, cc };
// SAFETY: fork-style clone (no CLONE_VM, so the child gets a private
// copy-on-write image and `arg` stays valid in it) running `child_entry`
// on the fresh stack; glibc's wrapper runs no atfork handlers.
let pid = unsafe {
libc::clone(
child_entry,
stack.cast::<u8>().add(STACK).cast(),
libc::SIGCHLD,
(&raw mut arg).cast(),
)
};
if pid < 0 {
// SAFETY: unmapping our own mapping.
unsafe { libc::munmap(stack, STACK) };
return false;
}
let r = wait_for(pid);
// SAFETY: the child is gone; unmapping our own mapping.
unsafe { libc::munmap(stack, STACK) };
r
}
fn wait_for(pid: libc::pid_t) -> bool {
let start = Instant::now();
loop {
let mut status = 0;
// SAFETY: waitpid with a valid out pointer.
let r = unsafe { libc::waitpid(pid, &raw mut status, libc::WNOHANG) };
if r == pid {
return libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0;
}
if r < 0 && std::io::Error::last_os_error().kind() != std::io::ErrorKind::Interrupted {
return false;
}
if start.elapsed() > TIMEOUT {
// SAFETY: kill/waitpid on our own child.
unsafe {
libc::kill(pid, libc::SIGKILL);
libc::waitpid(pid, &raw mut status, 0);
}
return false;
}
let ts = libc::timespec {
tv_sec: 0,
tv_nsec: 5_000_000,
};
// SAFETY: nanosleep with valid pointers.
unsafe { libc::nanosleep(&raw const ts, std::ptr::null_mut()) };
}
}
struct ChildArg<'a, 'b> {
cap: &'a Capture<'b>,
cc: &'a CrashContext,
}
extern "C" fn child_entry(arg: *mut libc::c_void) -> libc::c_int {
// SAFETY: `arg` points to the parent's `ChildArg`, copied into this
// process by the clone.
let arg = unsafe { &*arg.cast::<ChildArg<'_, '_>>() };
i32::from(!child_main(arg.cap, arg.cc))
}
fn child_main(cap: &Capture<'_>, cc: &CrashContext) -> bool {
#[allow(clippy::result_large_err)]
let attempt = |cc: &CrashContext| {
let mut writer = MinidumpWriterConfig::new(cc.pid, cc.tid);
writer.set_crash_context(CrashContextExt { inner: cc.clone() });
let mut sink = Cursor::new(Vec::new());
writer
.write(&mut sink)
.map(|d| if d.is_empty() { sink.into_inner() } else { d })
};
let dump = match attempt(cc) {
Ok(d) => d,
Err(e) => {
// The child may allocate; say why the first try failed.
let _ = writeln!(
std::io::stderr(),
"warning(sentry): minidump failed err={e}"
);
// A stack overflow faults with the stack pointer in the guard
// page, which the writer cannot read a stack from. Retry with
// the pointer moved to the first readable address above it so
// the thread's stack is still captured.
let mut fixed = cc.clone();
if !clamp_stack_pointer(&mut fixed) {
return false;
}
match attempt(&fixed) {
Ok(d) => d,
Err(e) => {
let _ = writeln!(
std::io::stderr(),
"warning(sentry): minidump retry failed err={e}"
);
return false;
}
}
}
};
write_envelope(cap, &dump)
}
#[cfg(target_arch = "x86_64")]
fn sp_mut(cc: &mut CrashContext) -> &mut i64 {
&mut cc.context.uc_mcontext.gregs[libc::REG_RSP as usize]
}
#[cfg(target_arch = "aarch64")]
fn sp_mut(cc: &mut CrashContext) -> &mut u64 {
&mut cc.context.uc_mcontext.sp
}
/// If the page holding the stack pointer cannot be read (a stack overflow
/// faults in/at the guard page), move the pointer up to the first readable page
/// above it (within the 1 MiB the kernel's stack guard gap can span). Returns
/// whether it was changed.
#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))]
fn clamp_stack_pointer(cc: &mut CrashContext) -> bool {
const PAGE: u64 = 4096;
let pid = cc.pid;
let readable = |addr: u64| {
let mut b = [0u8; 8];
let local = libc::iovec {
iov_base: b.as_mut_ptr().cast(),
iov_len: b.len(),
};
let remote = libc::iovec {
iov_base: addr as *mut libc::c_void,
iov_len: b.len(),
};
// SAFETY: both iovecs describe valid ranges (the remote one is only
// interpreted by the kernel in the other process).
unsafe { libc::process_vm_readv(pid, &raw const local, 1, &raw const remote, 1, 0) == 8 }
};
let sp = *sp_mut(cc) as u64;
let base = sp & !(PAGE - 1);
if readable(base) {
return false;
}
let mut addr = base + PAGE;
while addr <= base + (1 << 20) {
if readable(addr) {
*sp_mut(cc) = addr as _;
return true;
}
addr += PAGE;
}
false
}
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
fn clamp_stack_pointer(_cc: &mut CrashContext) -> bool {
false
}
fn write_envelope(cap: &Capture<'_>, dump: &[u8]) -> bool {
// SAFETY: `path` is NUL terminated by the caller.
let fd = unsafe {
libc::open(
cap.path.as_ptr().cast(),
libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC,
0o644,
)
};
if fd < 0 {
return false;
}
// SAFETY: we own the fd just opened.
let mut f = unsafe { std::fs::File::from_raw_fd(fd) };
let item_head = format!(
"\n{{\"type\":\"attachment\",\"length\":{},\"filename\":\"minidump.dmp\",\"attachment_type\":\"event.minidump\"}}\n",
dump.len()
);
let ok = f
.write_all(cap.head)
.and_then(|()| f.write_all(cap.event))
.and_then(|()| f.write_all(item_head.as_bytes()))
.and_then(|()| f.write_all(dump))
.and_then(|()| f.write_all(b"\n"))
.is_ok();
drop(f);
if !ok {
// SAFETY: remove the partial file so the caller can fall back.
unsafe { libc::unlink(cap.path.as_ptr().cast()) };
}
ok
}
+27 -3
View File
@@ -133,7 +133,7 @@ fn mkdir_p(path: &mut [u8], len: usize) {
/// Write a crash report for `sig`. Returns without doing anything if the
/// crash handler isn't active or another crash is being written.
fn write_signal_report(name: &str, desc: &str) {
fn write_signal_report(name: &str, desc: &str, info: *mut libc::siginfo_t, uc: *mut libc::c_void) {
let Some(st) = sentry::state() else { return };
if BUSY.swap(true, Ordering::Acquire) {
return;
@@ -191,6 +191,30 @@ fn write_signal_report(name: &str, desc: &str) {
// The directory must exist for writing to work.
mkdir_p(&mut sc.path, st.dir.len());
// Preferred: an envelope with a minidump of the whole process, written by
// a forked helper that ptraces us (Linux only; see `minidump`).
#[cfg(target_os = "linux")]
{
// SAFETY: `info`/`uc` are the pointers the kernel gave the handler.
let cc = unsafe { crate::minidump::crash_context(info, uc) };
let cap = crate::minidump::Capture {
path: &sc.path[..=o],
head: &sc.head[..head_len],
event: &sc.event[..ev_len],
};
if crate::minidump::capture(&cap, cc) {
write_all(
2,
b"warning(sentry): crash report with minidump written to disk path=",
);
write_all(2, &sc.path[..o]);
write_all(2, b"\n");
return;
}
}
#[cfg(not(target_os = "linux"))]
let _ = (info, uc);
// SAFETY: path is NUL terminated.
let fd = unsafe {
libc::open(
@@ -212,9 +236,9 @@ fn write_signal_report(name: &str, desc: &str) {
}
}
extern "C" fn handler(sig: libc::c_int, _info: *mut libc::siginfo_t, _ctx: *mut libc::c_void) {
extern "C" fn handler(sig: libc::c_int, info: *mut libc::siginfo_t, ctx: *mut libc::c_void) {
if let Some((_, name, desc)) = SIGNALS.iter().find(|(s, ..)| *s == sig) {
write_signal_report(name, desc);
write_signal_report(name, desc, info, ctx);
}
// Restore the default action and re-raise so the process dies with the
// original signal (core dump, exit status) as if we were never here.
+165
View File
@@ -0,0 +1,165 @@
//! The fatal-signal path writes an envelope whose second item is a minidump
//! (`event.minidump` attachment) of the crashed process. A child process
//! (this test executable re-run with `GHOSTTY_MD_CHILD`) crashes on a spawned
//! thread; the parent parses the minidump with the `minidump` crate and checks
//! that it contains the crashing thread, its stack and the exception.
#![cfg(target_os = "linux")]
#![allow(clippy::unwrap_used, unsafe_code)]
use std::{os::unix::process::ExitStatusExt, path::Path, process::Command};
use ghostty_crash::{
Options,
envelope::{Envelope, Item, ItemType},
};
use minidump::{
Minidump,
MinidumpException,
MinidumpMemoryList,
MinidumpModuleList,
MinidumpSystemInfo,
MinidumpThreadList,
Module,
};
fn child_main(mode: &str) {
let dir = std::env::var("GHOSTTY_CRASH_DIR").unwrap();
let tid_file = std::env::var("GHOSTTY_MD_TID").unwrap();
ghostty_crash::install(&Options {
dir: Some(dir.into()),
..Options::default()
})
.unwrap();
let mode = mode.to_owned();
// Crash on a non-main thread so "the crashing thread" is distinguishable
// from the thread that is merely waiting.
let h = std::thread::Builder::new()
.name("crasher".into())
.spawn(move || {
// SAFETY: gettid has no preconditions.
let tid = unsafe { libc::syscall(libc::SYS_gettid) };
std::fs::write(tid_file, tid.to_string()).unwrap();
match mode.as_str() {
"segv" => {
// SAFETY: intentionally invalid write to fault.
unsafe { std::ptr::write_volatile(8 as *mut u8, 1) };
}
"abort" => std::process::abort(),
"overflow" => {
// Unbounded on purpose: overflowing the stack is the test.
#[allow(unconditional_recursion)]
fn recurse(n: u64) -> u64 {
let pad = [n as u8; 256];
std::hint::black_box(&pad);
recurse(n + 1) + u64::from(pad[0])
}
std::process::exit(i32::from(recurse(0) == 1));
}
_ => unreachable!(),
}
})
.unwrap();
let _ = h.join();
std::thread::sleep(std::time::Duration::from_secs(30));
}
#[test]
fn minidump_child() {
if let Ok(mode) = std::env::var("GHOSTTY_MD_CHILD") {
child_main(&mode);
}
}
/// Run the crashing child; returns (exit status, crashing tid, temp dir).
fn crash(mode: &str) -> (std::process::ExitStatus, i32, tempfile::TempDir) {
let t = tempfile::tempdir().unwrap();
let dir = t.path().join("crash");
let tid_file = t.path().join("tid");
let st = Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"minidump_child",
"--nocapture",
"--test-threads=1",
])
.env("GHOSTTY_MD_CHILD", mode)
.env("GHOSTTY_CRASH_DIR", &dir)
.env("GHOSTTY_MD_TID", &tid_file)
.env("RUST_BACKTRACE", "0")
.status()
.unwrap();
let tid = std::fs::read_to_string(tid_file).unwrap().parse().unwrap();
(st, tid, t)
}
fn only_report(dir: &Path) -> Vec<u8> {
let files: Vec<_> = std::fs::read_dir(dir)
.unwrap()
.map(|e| e.unwrap().path())
.collect();
assert_eq!(1, files.len(), "{files:?}");
std::fs::read(&files[0]).unwrap()
}
fn check(mode: &str, signal: i32) {
let (st, tid, t) = crash(mode);
assert_eq!(Some(signal), st.signal(), "{st:?}");
// Envelope: event + minidump attachment.
let data = only_report(&t.path().join("crash"));
let mut env = Envelope::parse_slice(&data).unwrap();
assert_eq!(2, env.items.len(), "no minidump attached");
assert_eq!(ItemType::Event, env.items[0].item_type());
env.items[1].decode().unwrap();
let Item::Attachment(a) = &env.items[1] else {
panic!("second item is not an attachment")
};
assert_eq!("minidump.dmp", a.filename);
assert_eq!(Some("event.minidump"), a.attachment_type.as_deref());
// The minidump parses.
let dump = Minidump::read(a.payload.clone()).expect("minidump parses");
// The crashing thread is in the thread list, with stack memory.
let threads = dump.get_stream::<MinidumpThreadList>().unwrap();
let ids: Vec<u32> = threads.threads.iter().map(|t| t.raw.thread_id).collect();
let crasher = threads
.threads
.iter()
.find(|th| th.raw.thread_id == tid as u32)
.unwrap_or_else(|| panic!("tid {tid} not in {ids:?}"));
assert!(crasher.raw.stack.memory.data_size > 0, "no stack captured");
assert!(ids.len() >= 2, "main thread missing: {ids:?}");
// The exception stream blames that thread and carries registers + signal.
let exc = dump.get_stream::<MinidumpException>().unwrap();
assert_eq!(tid as u32, exc.get_crashing_thread_id());
assert_eq!(signal as u32, exc.raw.exception_record.exception_code);
let sys = dump.get_stream::<MinidumpSystemInfo>().unwrap();
let ctx = exc
.context(&sys, None)
.expect("exception context (registers)");
assert!(ctx.valid_registers().count() > 10);
// Modules and memory were captured (needed to symbolicate/stack-walk).
let modules = dump.get_stream::<MinidumpModuleList>().unwrap();
assert!(modules.iter().count() > 1);
assert!(modules.iter().any(|m| m.code_file().contains("minidump")));
let mem = dump.get_stream::<MinidumpMemoryList>().unwrap();
assert!(mem.iter().count() > 0);
}
#[test]
fn sigsegv_report_contains_minidump() {
check("segv", libc::SIGSEGV);
}
#[test]
fn abort_report_contains_minidump() {
check("abort", libc::SIGABRT);
}
#[test]
fn stack_overflow_report_contains_minidump() {
check("overflow", libc::SIGSEGV);
}
+232 -1
View File
@@ -371,7 +371,118 @@ pub fn msl_from_compiled(compiled: &Compiled) -> Result<String, Error> {
let (msl, _info) = msl_out::write_string(&module, &compiled.info, &options, &pipeline)
.map_err(|e| fail(&e))?;
Ok(msl)
Ok(fix_uniform_array_strides(&module, msl))
}
/// A uniform-block member that is an array of scalars. std140 gives it a
/// 16-byte element stride (what we upload and what spirv-cross emits padded
/// types for), but naga's MSL writer ignores the array `stride` and emits a
/// tightly packed `float[N]`.
struct StridedArray {
member: String,
/// naga's MSL name of the array wrapper struct (`type_<handle index>`).
type_name: String,
/// MSL scalar element type.
scalar: &'static str,
len: u32,
/// Lanes of the padded element (stride / scalar size).
lanes: u32,
}
fn strided_uniform_arrays(module: &naga::Module) -> Vec<StridedArray> {
let mut out = Vec::new();
for (_, gv) in module.global_variables.iter() {
if gv.space != naga::AddressSpace::Uniform {
continue;
}
let naga::TypeInner::Struct { members, .. } = &module.types[gv.ty].inner else {
continue;
};
for m in members {
let naga::TypeInner::Array {
base,
size: naga::ArraySize::Constant(n),
stride,
} = &module.types[m.ty].inner
else {
continue;
};
let naga::TypeInner::Scalar(sc) = &module.types[*base].inner else {
continue;
};
let scalar = match (sc.kind, sc.width) {
(naga::ScalarKind::Float, 4) => "float",
(naga::ScalarKind::Sint, 4) => "int",
(naga::ScalarKind::Uint, 4) => "uint",
_ => continue,
};
let size = u32::from(sc.width);
if *stride <= size {
continue;
}
let (Some(name), true) = (m.name.clone(), stride % size == 0) else {
continue;
};
out.push(StridedArray {
member: name,
type_name: format!("type_{}", m.ty.index()),
scalar,
len: n.get(),
lanes: stride / size,
});
}
}
out
}
/// Make the MSL uniform block match the std140 layout we upload: scalar
/// arrays become arrays of `vecN` (stride 16 for std140) and element reads
/// take `.x`. The writer ignores array strides, so this is done on the text.
fn fix_uniform_array_strides(module: &naga::Module, mut msl: String) -> String {
for a in strided_uniform_arrays(module) {
let wrapper = format!("{}_std140", a.type_name);
// 1. The block member uses the padded wrapper.
let decl = format!(" {} {};", a.type_name, a.member);
if !msl.contains(&decl) {
continue;
}
msl = msl.replace(&decl, &format!(" {wrapper} {};", a.member));
// 2. Define the wrapper before the struct that uses it.
let def = format!(
"struct {wrapper} {{\n metal::vec<{}, {}> inner[{}];\n}};\n",
a.scalar, a.lanes, a.len
);
if let Some(at) = msl.find(&format!("\n {wrapper} {};", a.member))
&& let Some(start) = msl[..at].rfind("\nstruct ")
{
msl.insert_str(start + 1, &def);
}
// 3. Element reads: `<expr>.member.inner[idx]` -> `... [idx].x`.
let needle = format!(".{}.inner[", a.member);
let mut from = 0;
while let Some(rel) = msl[from..].find(&needle) {
let open = from + rel + needle.len() - 1;
let mut depth = 0usize;
let mut close = None;
for (k, ch) in msl[open..].char_indices() {
match ch {
'[' => depth += 1,
']' => {
depth -= 1;
if depth == 0 {
close = Some(open + k);
break;
}
}
_ => {}
}
}
let Some(close) = close else { break };
msl.insert_str(close + 1, ".x");
from = close + 3;
}
}
msl
}
/// Convert a compiled shader to GLSL 430 core (`glslFromSpv`).
@@ -628,4 +739,124 @@ mod tests {
assert_eq!(offset_of!(Uniforms, selection_foreground_color), 4480);
assert_eq!(size_of::<Uniforms>(), 4496);
}
/// (size, align) of an MSL type as emitted by naga for the shadertoy
/// uniform block, resolving the wrapper structs defined in `msl`.
fn msl_size_align(msl: &str, ty: &str) -> (usize, usize) {
match ty {
"float" | "int" | "uint" => (4, 4),
"metal::packed_float3" => (12, 4),
"metal::float2" => (8, 8),
"metal::float3" | "metal::float4" => (16, 16),
_ => {
if let Some(rest) = ty.strip_prefix("metal::vec<") {
let (scalar, n) = rest.trim_end_matches('>').split_once(", ").unwrap();
let n: usize = n.parse().unwrap();
assert!(matches!(scalar, "float" | "int" | "uint"));
let n = if n == 3 { 4 } else { n };
return (4 * n, 4 * n);
}
// A wrapper struct `struct <ty> { <elem> inner[N]; };`.
let head = format!("struct {ty} {{\n ");
let at = msl.find(&head).unwrap_or_else(|| panic!("no struct {ty}"));
let body = &msl[at + head.len()..];
let line = &body[..body.find(';').unwrap()];
let (elem, count) = line.rsplit_once(" inner[").unwrap();
let count: usize = count.trim_end_matches(']').parse().unwrap();
let (es, ea) = msl_size_align(msl, elem);
(es * count, ea)
}
}
}
/// The `(offset, name)` of each member of `struct Globals` in `msl`,
/// skipping `char _padN[k]` fillers, plus the struct size.
fn msl_globals_layout(msl: &str) -> (Vec<(usize, String)>, usize) {
let at = msl.find("struct Globals {\n").unwrap();
let body = &msl[at + "struct Globals {\n".len()..];
let body = &body[..body.find("\n};").unwrap()];
let mut off = 0usize;
let mut max_align = 1;
let mut members = Vec::new();
for line in body.lines() {
let line = line.trim().trim_end_matches(';');
let (ty, name) = line.rsplit_once(' ').unwrap();
if ty == "char" {
let n: usize = name
.split('[')
.nth(1)
.unwrap()
.trim_end_matches(']')
.parse()
.unwrap();
off += n;
continue;
}
let (size, align) = msl_size_align(msl, ty);
off = off.next_multiple_of(align);
max_align = max_align.max(align);
members.push((off, name.to_owned()));
off += size;
}
(members, off.next_multiple_of(max_align))
}
#[test]
fn msl_uniform_block_matches_std140_upload_layout() {
use std::mem::offset_of as o;
// A shader that reads the scalar array so the accessor path is used.
let user = "void mainImage(out vec4 c, in vec2 p) { \
int i = int(p.x) & 3; c = vec4(iChannelTime[i], iChannelTime[2], iTime, iSampleRate); }";
let compiled = compile_shadertoy(user, None).unwrap();
let msl = msl_from_compiled(&compiled).unwrap();
let (members, size) = msl_globals_layout(&msl);
let expect = [
("iResolution", o!(Uniforms, resolution)),
("iTime", o!(Uniforms, time)),
("iTimeDelta", o!(Uniforms, time_delta)),
("iFrameRate", o!(Uniforms, frame_rate)),
("iFrame", o!(Uniforms, frame)),
("iChannelTime", o!(Uniforms, channel_time)),
("iChannelResolution", o!(Uniforms, channel_resolution)),
("iMouse", o!(Uniforms, mouse)),
("iDate", o!(Uniforms, date)),
("iSampleRate", o!(Uniforms, sample_rate)),
("iCurrentCursor", o!(Uniforms, current_cursor)),
("iPreviousCursor", o!(Uniforms, previous_cursor)),
("iCurrentCursorColor", o!(Uniforms, current_cursor_color)),
("iPreviousCursorColor", o!(Uniforms, previous_cursor_color)),
("iCurrentCursorStyle", o!(Uniforms, current_cursor_style)),
("iPreviousCursorStyle", o!(Uniforms, previous_cursor_style)),
("iCursorVisible", o!(Uniforms, cursor_visible)),
("iTimeCursorChange", o!(Uniforms, cursor_change_time)),
("iTimeFocus", o!(Uniforms, time_focus)),
("iFocus", o!(Uniforms, focus)),
("iPalette", o!(Uniforms, palette)),
("iBackgroundColor", o!(Uniforms, background_color)),
("iForegroundColor", o!(Uniforms, foreground_color)),
("iCursorColor", o!(Uniforms, cursor_color)),
("iCursorText", o!(Uniforms, cursor_text)),
// Upstream quirk: the Zig struct (and so ours) has the selection
// colors swapped relative to the GLSL block; both are vec3 so the
// offsets agree.
(
"iSelectionForegroundColor",
o!(Uniforms, selection_background_color),
),
(
"iSelectionBackgroundColor",
o!(Uniforms, selection_foreground_color),
),
];
assert_eq!(members.len(), expect.len(), "{msl}");
for ((off, name), (ename, eoff)) in members.iter().zip(expect) {
assert_eq!(name, ename);
assert_eq!(*off, eoff, "MSL offset of {name}\n{msl}");
}
assert_eq!(size, std::mem::size_of::<Uniforms>());
// Reads of the padded array take the first lane.
assert!(msl.contains(".iChannelTime.inner["), "{msl}");
assert!(msl.contains("].x"), "{msl}");
}
}
@@ -18,9 +18,12 @@
//!
//! Limitations (documented, reported as compile errors by naga): a sampler
//! argument must be a plain identifier (no ternaries or struct members),
//! samplers cannot be stored in local variables/arrays/struct fields, and
//! overloads of one function name must agree on which parameters are
//! samplers.
//! samplers cannot be stored in local variables/arrays/struct fields.
//!
//! Overloads are keyed by their full signature (the per-parameter sampler
//! layout), not by name. At a call site the overload is chosen by arity and by
//! which arguments are samplers (`iChannelN` or a sampler parameter of the
//! enclosing function), which is what GLSL overload resolution would see.
use std::collections::HashMap;
@@ -171,10 +174,11 @@ struct Def {
samplers: Vec<Option<String>>,
}
struct FnInfo {
/// Per parameter: the sampler name, if the parameter is a `sampler2D`.
samplers: Vec<Option<String>>,
}
/// Overload signature: per parameter, whether it is a `sampler2D`.
type SamplerLayout = Vec<bool>;
/// The combined samplers the host provides.
const GLOBAL_SAMPLERS: [&str; 4] = ["iChannel0", "iChannel1", "iChannel2", "iChannel3"];
/// Rewrite the user's ShaderToy source. Returns the source unchanged if it
/// needs no adaptation other than `texture2D(` -> `texture(`.
@@ -188,7 +192,7 @@ pub fn rewrite(src: &str) -> String {
.collect();
// Pass 1: find top-level functions with sampler2D parameters.
let mut funcs: HashMap<&str, FnInfo> = HashMap::new();
let mut funcs: HashMap<&str, Vec<SamplerLayout>> = HashMap::new();
let mut defs: Vec<Def> = Vec::new();
let mut k = 0;
let mut brace = 0i32;
@@ -211,7 +215,6 @@ pub fn rewrite(src: &str) -> String {
if next == Some("{") || next == Some(";") {
let params = split_args(&toks, &sig, k + 3, close);
let mut samplers = Vec::new();
let mut any = false;
for &(a, b) in &params {
let has = (a..b).any(|q| toks[sig[q]].text == "sampler2D");
if has {
@@ -220,7 +223,6 @@ pub fn rewrite(src: &str) -> String {
.map(|q| &toks[sig[q]])
.find(|t| t.kind == Kind::Ident && t.text != "sampler2D")
.map(|t| t.text.to_owned());
any |= name.is_some();
samplers.push(name);
} else {
samplers.push(None);
@@ -231,12 +233,12 @@ pub fn rewrite(src: &str) -> String {
} else {
None
};
if any {
funcs
.entry(toks[sig[name_k]].text)
.or_insert_with(|| FnInfo {
samplers: samplers.clone(),
});
// Every overload is recorded (also the ones without
// samplers) so call sites can tell them apart.
let layout: SamplerLayout = samplers.iter().map(Option::is_some).collect();
let list = funcs.entry(toks[sig[name_k]].text).or_default();
if !list.contains(&layout) {
list.push(layout);
}
defs.push(Def {
name_k,
@@ -293,18 +295,32 @@ pub fn rewrite(src: &str) -> String {
if def_names.contains(&k) {
continue;
}
let Some(info) = funcs.get(t.text) else {
let Some(layouts) = funcs.get(t.text) else {
continue;
};
let Some(close) = matching(&toks, &sig, k + 1) else {
continue;
};
let args = split_args(&toks, &sig, k + 2, close);
for (ai, &(a, b)) in args.iter().enumerate() {
if info.samplers.get(ai).is_some_and(Option::is_some)
&& b == a + 1
// Sampler parameters of the function this call is in.
let enclosing: Vec<&str> = defs
.iter()
.filter(|d| d.body.is_some_and(|(o, c)| o < k && k < c))
.flat_map(|d| d.samplers.iter().flatten().map(String::as_str))
.collect();
let is_sampler_arg = |&(a, b): &(usize, usize)| {
b == a + 1
&& toks[sig[a]].kind == Kind::Ident
{
&& (GLOBAL_SAMPLERS.contains(&toks[sig[a]].text)
|| enclosing.contains(&toks[sig[a]].text))
};
let actual: SamplerLayout = args.iter().map(is_sampler_arg).collect();
// Pick the overload with this arity and sampler positions.
if !layouts.contains(&actual) {
continue;
}
for (ai, &(a, _)) in args.iter().enumerate() {
if actual[ai] {
let n = toks[sig[a]].text;
repl.insert(sig[a], format!("{n}_tex, {n}_smp"));
}
@@ -336,6 +352,35 @@ mod tests {
assert!(out.contains("fetch(iChannel0_tex, iChannel0_smp, p)"));
}
#[test]
fn overloads_are_keyed_by_signature() {
let out = rewrite(
"vec4 f(sampler2D s, vec2 uv) { return texture(s, uv); }\n\
vec4 f(vec2 uv, sampler2D s) { return texture(s, uv); }\n\
vec4 f(vec2 uv, float x) { return vec4(uv, x, 1.0); }\n\
void mainImage(out vec4 c, in vec2 p) {\n\
c = f(iChannel0, p) + f(p, iChannel1) + f(p, 1.0);\n\
}",
);
assert!(out.contains("vec4 f(texture2D s_tex, sampler s_smp, vec2 uv)"));
assert!(out.contains("vec4 f(vec2 uv, texture2D s_tex, sampler s_smp)"));
assert!(out.contains("vec4 f(vec2 uv, float x)"));
assert!(out.contains("f(iChannel0_tex, iChannel0_smp, p)"));
assert!(out.contains("f(p, iChannel1_tex, iChannel1_smp)"));
assert!(out.contains("f(p, 1.0)"));
}
#[test]
fn overloads_through_sampler_parameters() {
let out = rewrite(
"float g(sampler2D t) { return texture(t, vec2(0.0)).x; }\n\
float g(float t) { return t; }\n\
float h(sampler2D u, float v) { return g(u) + g(v); }\n",
);
assert!(out.contains("g(u_tex, u_smp)"));
assert!(out.contains("+ g(v)"));
}
#[test]
fn leaves_plain_code_and_rewrites_legacy_texture2d() {
let src = "void mainImage(out vec4 c, in vec2 p) { c = texture2D(iChannel0, p); }";
@@ -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();
}
}
+150 -2
View File
@@ -3,12 +3,22 @@
//! Wrapper objects handed out through the C API (parsers, events, encoders)
//! and the SGR parameter copy are allocated with the caller's allocator when
//! one is given and with the Rust global allocator otherwise (Zig: libc
//! `c_allocator` / `wasm_allocator`). Interior Rust collections (e.g. the OSC
//! parser's buffer) always use the global allocator.
//! `c_allocator` / `wasm_allocator`). Parser-owned byte storage (the OSC
//! payload buffer, [`CByteStorage`]) and the copies handed back to C (the OSC
//! window title, [`CBlock`]) are allocated through the same allocator, so the
//! caller's limits and failures govern them: a failed allocation surfaces as
//! `GHOSTTY_OUT_OF_MEMORY` where Zig can report it (object creation) and
//! invalidates the OSC being parsed where Zig's writer fails.
//!
//! Transient values built while a command is parsed (the payload copies the
//! per-command parsers make before `ghostty_osc_end` returns) use the global
//! allocator; nothing of them outlives that call.
#![allow(unsafe_code)]
use std::{alloc::Layout, ffi::c_void, ptr::NonNull};
use ghostty_vt::osc::{ByteStorage, StorageFull};
/// C: `GhosttyAllocatorVtable`
#[repr(C)]
pub struct AllocatorVtable {
@@ -133,3 +143,141 @@ impl Alloc {
}
}
}
/// A block of bytes allocated through an [`Alloc`] (Zig: an allocator-owned
/// slice). Freed with the same allocator on drop.
pub struct CBlock {
alloc: Alloc,
ptr: NonNull<u8>,
cap: usize,
}
impl CBlock {
/// Allocate `cap` bytes (alignment 1); `None` when the allocator fails.
pub fn new(alloc: Alloc, cap: usize) -> Option<CBlock> {
let layout = Layout::from_size_align(cap, 1).ok()?;
let ptr = NonNull::new(alloc.alloc_raw(layout))?;
if cap > 0 {
// SAFETY: freshly allocated `cap` bytes; zeroing makes the whole
// block initialized so slices over it are sound.
unsafe { ptr.as_ptr().write_bytes(0, cap) };
}
Some(CBlock { alloc, ptr, cap })
}
/// A copy of `bytes` followed by a NUL terminator.
pub fn from_bytes_z(alloc: Alloc, bytes: &[u8]) -> Option<CBlock> {
let mut b = CBlock::new(alloc, bytes.len() + 1)?;
b.as_mut_slice()[..bytes.len()].copy_from_slice(bytes);
b.as_mut_slice()[bytes.len()] = 0;
Some(b)
}
pub fn as_ptr(&self) -> *const u8 {
self.ptr.as_ptr()
}
pub fn capacity(&self) -> usize {
self.cap
}
pub fn as_slice(&self) -> &[u8] {
// SAFETY: `ptr` is a live, zero-initialized allocation of `cap` bytes.
unsafe { std::slice::from_raw_parts(self.ptr.as_ptr(), self.cap) }
}
pub fn as_mut_slice(&mut self) -> &mut [u8] {
// SAFETY: as above, with unique access through `&mut self`.
unsafe { std::slice::from_raw_parts_mut(self.ptr.as_ptr(), self.cap) }
}
}
impl Drop for CBlock {
fn drop(&mut self) {
// SAFETY: allocated by `self.alloc` with this layout in `new`.
unsafe {
self.alloc.free_raw(
self.ptr.as_ptr(),
Layout::from_size_align_unchecked(self.cap, 1),
);
}
}
}
/// OSC payload storage backed by the C allocator. Starts with `base` bytes
/// (the parser's fixed buffer, allocated together with the parser so creation
/// can fail with out-of-memory) and grows by allocate-copy-free.
pub struct CByteStorage {
block: CBlock,
len: usize,
base: usize,
}
// SAFETY: the storage is uniquely owned by its parser. Moving a parser to
// another thread is allowed under the same contract as Zig's: the caller's
// allocator must be usable from the thread that uses the parser.
unsafe impl Send for CByteStorage {}
impl std::fmt::Debug for CByteStorage {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CByteStorage")
.field("len", &self.len)
.field("cap", &self.block.capacity())
.field("base", &self.base)
.finish()
}
}
impl CByteStorage {
/// `None` if the initial `base` bytes cannot be allocated.
pub fn new(alloc: Alloc, base: usize) -> Option<CByteStorage> {
Some(CByteStorage {
block: CBlock::new(alloc, base)?,
len: 0,
base,
})
}
fn grow_to(&mut self, needed: usize) -> Result<(), StorageFull> {
if needed <= self.block.capacity() {
return Ok(());
}
let cap = needed.max(self.block.capacity().saturating_mul(2));
let mut new = CBlock::new(self.block.alloc, cap).ok_or(StorageFull)?;
new.as_mut_slice()[..self.len].copy_from_slice(&self.block.as_slice()[..self.len]);
self.block = new;
Ok(())
}
}
impl ByteStorage for CByteStorage {
fn bytes(&self) -> &[u8] {
&self.block.as_slice()[..self.len]
}
fn try_reserve(&mut self, additional: usize) -> Result<(), StorageFull> {
self.grow_to(self.len.checked_add(additional).ok_or(StorageFull)?)
}
fn try_extend(&mut self, bytes: &[u8]) -> Result<(), StorageFull> {
self.grow_to(self.len.checked_add(bytes.len()).ok_or(StorageFull)?)?;
self.block.as_mut_slice()[self.len..self.len + bytes.len()].copy_from_slice(bytes);
self.len += bytes.len();
Ok(())
}
fn reset(&mut self) {
self.len = 0;
// Give back memory acquired by growth (Zig deinits the allocating
// writer). If the small block cannot be allocated, keep the big one.
if self.block.capacity() > self.base
&& let Some(b) = CBlock::new(self.block.alloc, self.base)
{
self.block = b;
}
}
fn clone_box(&self) -> Box<dyn ByteStorage> {
Box::new(self.bytes().to_vec())
}
}
+27 -16
View File
@@ -9,7 +9,7 @@ use std::{
use ghostty_vt::osc::{Command, Parser};
use crate::{
alloc::{Alloc, Allocator},
alloc::{Alloc, Allocator, CBlock, CByteStorage},
result::GhosttyResult,
};
@@ -28,9 +28,11 @@ pub struct OscParser {
/// string payloads, as Zig's sentinel slices are) and stays valid until the
/// next `ghostty_osc_end`/`ghostty_osc_reset`/`ghostty_osc_free`.
pub struct OscCommand {
command: Command,
/// NUL-terminated window title (only for `ChangeWindowTitle`).
title_z: Vec<u8>,
/// `GhosttyOscCommandType` of the last command.
key: c_int,
/// NUL-terminated window title (only for `ChangeWindowTitle`), allocated
/// with the caller's allocator.
title_z: Option<CBlock>,
}
/// C: `GhosttyOscCommandData`
@@ -62,12 +64,18 @@ pub unsafe extern "C" fn ghostty_osc_new(
let alloc = unsafe { Alloc::from_c(allocator) };
// C-built parsers have an allocator (Zig: `.init(alloc)`), so commands
// that need unbounded buffers (OSC 52, ...) are accepted.
// The parser's payload buffer (Zig's inline fixed buffer / allocating
// writer) lives in memory from the same allocator; if either allocation
// fails the caller gets OUT_OF_MEMORY.
let Some(storage) = CByteStorage::new(alloc, Parser::MAX_BUF) else {
return GhosttyResult::OutOfMemory;
};
let Some(p) = alloc.create(OscParser {
alloc,
parser: Parser::init(true),
parser: Parser::with_storage(Box::new(storage)),
command: OscCommand {
command: Command::Invalid,
title_z: Vec::new(),
key: 0,
title_z: None,
},
}) else {
return GhosttyResult::OutOfMemory;
@@ -124,12 +132,16 @@ pub unsafe extern "C" fn ghostty_osc_end(
return std::ptr::null_mut();
};
let cmd = p.parser.end(Some(terminator)).unwrap_or(Command::Invalid);
p.command.title_z.clear();
p.command.title_z = None;
p.command.key = cmd.key() as c_int;
if let Command::ChangeWindowTitle(t) = &cmd {
p.command.title_z.extend_from_slice(t);
p.command.title_z.push(0);
match CBlock::from_bytes_z(p.alloc, t) {
Some(b) => p.command.title_z = Some(b),
// The title cannot be materialized with the caller's allocator:
// report an invalid command rather than a dangling title.
None => p.command.key = Command::Invalid.key() as c_int,
}
}
p.command.command = cmd;
&raw mut p.command
}
@@ -141,7 +153,7 @@ pub unsafe extern "C" fn ghostty_osc_end(
pub unsafe extern "C" fn ghostty_osc_command_type(command: *mut OscCommand) -> c_int {
// SAFETY: caller guarantees validity (or null).
match unsafe { command.as_ref() } {
Some(c) => c.command.key() as c_int,
Some(c) => c.key,
None => 0, // invalid
}
}
@@ -165,12 +177,11 @@ pub unsafe extern "C" fn ghostty_osc_command_data(
};
match data {
OscCommandData::Invalid => false,
OscCommandData::ChangeWindowTitleStr => match &command.command {
Command::ChangeWindowTitle(_) if !out.is_null() => {
OscCommandData::ChangeWindowTitleStr => match &command.title_z {
Some(title) if !out.is_null() => {
// SAFETY: caller guarantees `out` is a `const char **`.
unsafe {
out.cast::<*const u8>()
.write_unaligned(command.title_z.as_ptr());
out.cast::<*const u8>().write_unaligned(title.as_ptr());
}
true
}
+164
View File
@@ -0,0 +1,164 @@
// Counting / failing / limiting GhosttyAllocator exercised through the OSC
// parser: every byte the parser owns must come from (and return to) the
// caller's allocator, and its failures must surface like Zig's.
#include <assert.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ghostty/vt.h>
typedef struct {
size_t allocs, frees;
size_t live; // bytes currently allocated
size_t limit; // max live bytes (0 = unlimited)
size_t fail_after; // fail every allocation after this many succeed (SIZE_MAX = never)
} Counter;
static void *c_alloc(void *ctx, size_t len, uint8_t alignment, uintptr_t ret) {
(void)alignment; (void)ret;
Counter *c = ctx;
if (c->allocs >= c->fail_after) return NULL;
if (c->limit && c->live + len > c->limit) return NULL;
void *p = malloc(len ? len : 1);
if (!p) return NULL;
c->allocs++;
c->live += len;
return p;
}
static bool c_resize(void *ctx, void *m, size_t ml, uint8_t a, size_t nl, uintptr_t r) {
(void)ctx; (void)m; (void)ml; (void)a; (void)nl; (void)r;
return false;
}
static void *c_remap(void *ctx, void *m, size_t ml, uint8_t a, size_t nl, uintptr_t r) {
(void)ctx; (void)m; (void)ml; (void)a; (void)nl; (void)r;
return NULL;
}
static void c_free(void *ctx, void *m, size_t len, uint8_t a, uintptr_t r) {
(void)a; (void)r;
Counter *c = ctx;
c->frees++;
c->live -= len;
free(m);
}
static const GhosttyAllocatorVtable VT = {c_alloc, c_resize, c_remap, c_free};
static Counter counter(size_t limit, size_t fail_after) {
Counter c = {0, 0, 0, limit, fail_after};
return c;
}
static void feed(GhosttyOscParser p, const char *s, size_t n) {
for (size_t i = 0; i < n; i++) ghostty_osc_next(p, (uint8_t)s[i]);
}
static int parse_clipboard(Counter *c, size_t payload_len, int *type_out) {
GhosttyAllocator a = {.ctx = c, .vtable = &VT};
GhosttyOscParser p;
if (ghostty_osc_new(&a, &p) != GHOSTTY_SUCCESS) return -1;
feed(p, "52;c;", 5);
for (size_t i = 0; i < payload_len; i++) ghostty_osc_next(p, 'A');
*type_out = ghostty_osc_command_type(ghostty_osc_end(p, 0x07));
ghostty_osc_free(p);
return 0;
}
int main(void) {
// 1. Everything the parser allocates goes through the allocator and is
// returned on free.
{
Counter c = counter(0, SIZE_MAX);
GhosttyAllocator a = {.ctx = &c, .vtable = &VT};
GhosttyOscParser p;
assert(ghostty_osc_new(&a, &p) == GHOSTTY_SUCCESS);
assert(c.allocs >= 2); // the parser object and its buffer
assert(c.live >= 2048); // the fixed payload buffer is ours
feed(p, "0;hello", 7);
GhosttyOscCommand cmd = ghostty_osc_end(p, 0x07);
assert(ghostty_osc_command_type(cmd) == GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE);
const char *title = NULL;
assert(ghostty_osc_command_data(cmd, GHOSTTY_OSC_DATA_CHANGE_WINDOW_TITLE_STR, &title));
assert(strcmp(title, "hello") == 0);
size_t before_free = c.live;
assert(before_free > 2048); // + the title copy
ghostty_osc_free(p);
assert(c.live == 0);
assert(c.allocs == c.frees);
}
// 2. Creation failure at each allocation point is OUT_OF_MEMORY and leaks
// nothing.
for (size_t fail_after = 0; fail_after < 2; fail_after++) {
Counter c = counter(0, fail_after);
GhosttyAllocator a = {.ctx = &c, .vtable = &VT};
GhosttyOscParser p = NULL;
assert(ghostty_osc_new(&a, &p) == GHOSTTY_OUT_OF_MEMORY);
assert(c.live == 0);
assert(c.allocs == c.frees);
}
// 3. A live-bytes limit governs payload growth: a 1 KiB clipboard payload
// fits; a 64 KiB one does not (the OSC becomes invalid, like Zig's
// WriteFailed) and nothing leaks.
{
Counter c = counter(0, SIZE_MAX);
int unlimited = -1;
assert(parse_clipboard(&c, 65536, &unlimited) == 0);
assert(unlimited == GHOSTTY_OSC_COMMAND_CLIPBOARD_CONTENTS);
assert(c.live == 0);
size_t peak_needed = 65536;
Counter small = counter(1024 * 1024, SIZE_MAX);
int ok = -1;
assert(parse_clipboard(&small, 1024, &ok) == 0);
assert(ok == GHOSTTY_OSC_COMMAND_CLIPBOARD_CONTENTS);
assert(small.live == 0);
Counter tight = counter(16 * 1024, SIZE_MAX);
int limited = -1;
assert(parse_clipboard(&tight, peak_needed, &limited) == 0);
assert(limited == GHOSTTY_OSC_COMMAND_INVALID);
assert(tight.live == 0);
assert(tight.allocs == tight.frees);
}
// 4. If the allocator fails after creation, the title copy cannot be made
// and the command is reported invalid.
{
Counter c = counter(0, SIZE_MAX);
GhosttyAllocator a = {.ctx = &c, .vtable = &VT};
GhosttyOscParser p;
assert(ghostty_osc_new(&a, &p) == GHOSTTY_SUCCESS);
c.fail_after = c.allocs; // no further allocations succeed
feed(p, "0;hello", 7);
GhosttyOscCommand cmd = ghostty_osc_end(p, 0x07);
assert(ghostty_osc_command_type(cmd) == GHOSTTY_OSC_COMMAND_INVALID);
const char *title = NULL;
assert(!ghostty_osc_command_data(cmd, GHOSTTY_OSC_DATA_CHANGE_WINDOW_TITLE_STR, &title));
ghostty_osc_free(p);
assert(c.live == 0);
}
// 5. A failing growth in the middle of a payload invalidates only that OSC;
// the parser is reusable afterwards.
{
Counter c = counter(0, SIZE_MAX);
GhosttyAllocator a = {.ctx = &c, .vtable = &VT};
GhosttyOscParser p;
assert(ghostty_osc_new(&a, &p) == GHOSTTY_SUCCESS);
c.fail_after = c.allocs;
feed(p, "52;c;", 5);
for (int i = 0; i < 5000; i++) ghostty_osc_next(p, 'A');
assert(ghostty_osc_command_type(ghostty_osc_end(p, 0x07)) == GHOSTTY_OSC_COMMAND_INVALID);
c.fail_after = SIZE_MAX;
ghostty_osc_reset(p);
feed(p, "0;ok", 4);
GhosttyOscCommand cmd = ghostty_osc_end(p, 0x07);
assert(ghostty_osc_command_type(cmd) == GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE);
ghostty_osc_free(p);
assert(c.live == 0);
}
puts("ok");
return 0;
}
@@ -0,0 +1,27 @@
//! Compiles `tests/c/alloc_test.c` (a counting / failing / limiting C
//! allocator driving the OSC parser) against the Rust `libghostty-vt`.
#![cfg(unix)]
#![allow(clippy::unwrap_used)]
mod common;
use std::process::Command;
#[test]
fn c_allocator_governs_osc_parser_storage() {
let lib = common::install_native_lib();
let scratch = common::scratch_dir("c_allocator");
let src = common::manifest_dir().join("tests/c/alloc_test.c");
let exe = scratch.join("alloc_test");
// No -DNDEBUG: the test relies on assert().
common::compile_c(&lib, &[&src], &exe, &["-Wall", "-O0"]);
let out = Command::new(&exe).output().expect("run alloc_test");
assert!(
out.status.success(),
"alloc_test failed: {:?}\n{}{}",
out.status,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(String::from_utf8(out.stdout).unwrap(), "ok\n");
}
+195 -11
View File
@@ -351,6 +351,114 @@ enum Writer {
Allocating,
}
/// The payload storage ran out of memory (the allocator refused to grow).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StorageFull;
/// Allocator abstraction for the parser's payload buffer (Zig: the
/// `Allocator` the parser was initialized with). The default storage is a
/// `Vec<u8>` on the global allocator; the C API supplies storage that is
/// allocated through the caller's `GhosttyAllocator`, so allocation limits and
/// failures of that allocator govern the parser's memory like they do in Zig.
///
/// A failed `try_extend`/`try_reserve` invalidates the OSC being parsed
/// (Zig: the writer's `error.WriteFailed` sets the state to invalid; a failed
/// initial allocating-writer allocation falls back to the fixed buffer).
pub trait ByteStorage: std::fmt::Debug + Send {
/// The bytes accumulated so far.
fn bytes(&self) -> &[u8];
/// Make room for `additional` more bytes.
fn try_reserve(&mut self, additional: usize) -> Result<(), StorageFull>;
/// Append bytes; on error nothing is appended.
fn try_extend(&mut self, bytes: &[u8]) -> Result<(), StorageFull>;
/// Drop the contents and release any memory acquired by growth (Zig
/// `reset` deinits the allocating writer).
fn reset(&mut self);
/// For `Parser: Clone`.
fn clone_box(&self) -> Box<dyn ByteStorage>;
}
impl ByteStorage for Vec<u8> {
fn bytes(&self) -> &[u8] {
self
}
fn try_reserve(&mut self, additional: usize) -> Result<(), StorageFull> {
Vec::try_reserve(self, additional).map_err(|_| StorageFull)
}
fn try_extend(&mut self, bytes: &[u8]) -> Result<(), StorageFull> {
Vec::try_reserve(self, bytes.len()).map_err(|_| StorageFull)?;
self.extend_from_slice(bytes);
Ok(())
}
fn reset(&mut self) {
self.clear();
}
fn clone_box(&self) -> Box<dyn ByteStorage> {
Box::new(self.clone())
}
}
impl Clone for Box<dyn ByteStorage> {
fn clone(&self) -> Self {
self.clone_box()
}
}
/// Payload buffer: a plain `Vec` (fast path) or custom allocator-backed
/// storage.
#[derive(Debug, Clone)]
enum Buffer {
Vec(Vec<u8>),
Custom(Box<dyn ByteStorage>),
}
impl Buffer {
#[inline]
fn bytes(&self) -> &[u8] {
match self {
Buffer::Vec(v) => v,
Buffer::Custom(c) => c.bytes(),
}
}
#[inline]
fn len(&self) -> usize {
self.bytes().len()
}
#[inline]
fn try_extend(&mut self, b: &[u8]) -> Result<(), StorageFull> {
match self {
Buffer::Vec(v) => {
v.extend_from_slice(b);
Ok(())
}
Buffer::Custom(c) => c.try_extend(b),
}
}
fn try_reserve(&mut self, n: usize) -> Result<(), StorageFull> {
match self {
Buffer::Vec(v) => {
v.reserve(n);
Ok(())
}
Buffer::Custom(c) => c.try_reserve(n),
}
}
fn reset(&mut self) {
match self {
Buffer::Vec(v) => v.clear(),
Buffer::Custom(c) => c.reset(),
}
}
fn to_vec(&self) -> Vec<u8> {
self.bytes().to_vec()
}
}
#[derive(Debug, Clone)]
pub struct Parser {
/// Whether an allocator is available, i.e. whether data longer than
@@ -363,7 +471,7 @@ pub struct Parser {
pub state: State,
/// Buffer for temporary storage of OSC data
buffer: Vec<u8>,
buffer: Buffer,
writer: Writer,
}
@@ -381,7 +489,18 @@ impl Parser {
Parser {
alloc,
state: State::Start,
buffer: Vec::new(),
buffer: Buffer::Vec(Vec::new()),
writer: Writer::None,
}
}
/// Like [`Parser::init`] with an allocator-backed payload buffer
/// (`alloc` is true: an allocator is available).
pub fn with_storage(storage: Box<dyn ByteStorage>) -> Parser {
Parser {
alloc: true,
state: State::Start,
buffer: Buffer::Custom(storage),
writer: Writer::None,
}
}
@@ -389,7 +508,7 @@ impl Parser {
/// Reset the parser state.
pub fn reset(&mut self) {
self.state = State::Start;
self.buffer.clear();
self.buffer.reset();
self.writer = Writer::None;
}
@@ -420,6 +539,12 @@ impl Parser {
self.write_to_fixed();
return;
}
// Zig: `Writer.Allocating.initCapacity(alloc, 2048) catch` falls back
// to the fixed buffer.
if self.buffer.try_reserve(Self::MAX_BUF).is_err() {
self.write_to_fixed();
return;
}
self.writer = Writer::Allocating;
}
@@ -438,7 +563,7 @@ impl Parser {
self.state = State::Invalid;
return None;
}
Some(self.buffer.clone())
Some(self.buffer.to_vec())
}
/// Zig `writer.writeByte(0)` capacity check: when the fixed buffer is
@@ -460,7 +585,7 @@ impl Parser {
self.state = State::Invalid;
return None;
}
Some(self.buffer.clone())
Some(self.buffer.to_vec())
}
/// Whether any payload writer has been set up (Zig `parser.writer != null`).
@@ -468,6 +593,15 @@ impl Parser {
self.writer != Writer::None
}
/// Append to the buffer; an allocation failure invalidates the parser
/// (Zig: `error.WriteFailed` -> `.invalid`).
#[inline]
fn push_or_invalid(&mut self, bytes: &[u8]) {
if self.buffer.try_extend(bytes).is_err() {
self.state = State::Invalid;
}
}
#[inline]
fn write_byte(&mut self, c: u8) {
match self.writer {
@@ -477,10 +611,10 @@ impl Parser {
// Overflow: discard any further input.
self.state = State::Invalid;
} else {
self.buffer.push(c);
self.push_or_invalid(&[c]);
}
}
Writer::Allocating => self.buffer.push(c),
Writer::Allocating => self.push_or_invalid(&[c]),
}
}
@@ -519,16 +653,16 @@ impl Parser {
bytes = rest;
}
Writer::Allocating => {
self.buffer.extend_from_slice(bytes);
self.push_or_invalid(bytes);
return;
}
Writer::Fixed => {
let room = Self::MAX_BUF.saturating_sub(self.buffer.len());
if bytes.len() <= room {
self.buffer.extend_from_slice(bytes);
self.push_or_invalid(bytes);
} else {
// Overflow: keep what fits, discard the rest.
self.buffer.extend_from_slice(&bytes[..room]);
self.push_or_invalid(&bytes[..room]);
self.state = State::Invalid;
}
return;
@@ -795,6 +929,56 @@ impl Parser {
mod next_slice_tests {
use super::*;
/// Storage that holds at most `cap` bytes (an allocator with a limit).
#[derive(Debug, Clone)]
struct Limited {
data: Vec<u8>,
cap: usize,
}
impl ByteStorage for Limited {
fn bytes(&self) -> &[u8] {
&self.data
}
fn try_reserve(&mut self, additional: usize) -> Result<(), StorageFull> {
if self.data.len() + additional > self.cap {
return Err(StorageFull);
}
Ok(())
}
fn try_extend(&mut self, bytes: &[u8]) -> Result<(), StorageFull> {
self.try_reserve(bytes.len())?;
self.data.extend_from_slice(bytes);
Ok(())
}
fn reset(&mut self) {
self.data.clear();
}
fn clone_box(&self) -> Box<dyn ByteStorage> {
Box::new(self.clone())
}
}
#[test]
fn storage_failure_invalidates_osc() {
let mut p = Parser::with_storage(Box::new(Limited {
data: Vec::new(),
cap: 8,
}));
for b in b"0;abcdefghijkl" {
p.next(*b);
}
assert_eq!(p.state, State::Invalid);
assert!(p.end(Some(0x07)).is_none());
// Within the limit it parses as usual.
p.reset();
for b in b"0;abc" {
p.next(*b);
}
let cmd = p.end(Some(0x07)).expect("command");
assert!(matches!(cmd, Command::ChangeWindowTitle(t) if t == b"abc"));
}
#[test]
fn next_slice_matches_next() {
for alloc in [false, true] {
@@ -811,7 +995,7 @@ mod next_slice_tests {
b.next_slice(&input[..mid]);
b.next_slice(&input[mid..]);
assert_eq!(a.state, b.state);
assert_eq!(a.buffer, b.buffer);
assert_eq!(a.buffer.bytes(), b.buffer.bytes());
assert_eq!(a.writer, b.writer);
}
}