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

+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);
}