From 72bfac1cc8b2b4880f074ef37cd7d9dace9658f2 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Mon, 12 Oct 2026 08:55:27 +0200 Subject: [PATCH] feat(crash): Breakpad-style minidumps on macOS and Windows Ghostty links sentry-native with the breakpad backend on every platform (SharedDeps.zig), so it writes minidumps on macOS and Windows as well. - macOS: a Mach exception port handler thread (structured like Breakpad's exception_handler) catches EXC_BAD_ACCESS/BAD_INSTRUCTION/ ARITHMETIC/BREAKPOINT, suspends other threads, reads the crashed thread state and writes a minidump via minidump-writer as the event.minidump attachment, then restores the previous ports so the BSD signal and default termination follow. The signal path remains the fallback (and handles SIGABRT); a watchdog alarm bounds dumping. - Windows: SetUnhandledExceptionFilter hands off to a pre-started dump thread using minidump-writer's dump_crash_context. - The screen-file open action decodes the escaped path to raw OS bytes (also for the C action passed to embedders). macOS/Windows code is compile-checked (clippy -D warnings) only; the shared Mach message decoding and minidump attachment logic have Linux unit tests. Platform matrix in docs/rewrite/notes/crash-CR.md. Test Plan: - cargo test --workspace (3348 passed) - just check-macos; just check-macos x86_64-apple-darwin; just check-windows --- Cargo.lock | 1 + crates/ghostty-crash/Cargo.toml | 11 +- crates/ghostty-crash/src/darwin.rs | 11 + crates/ghostty-crash/src/exception.rs | 260 +++++++++++++++ crates/ghostty-crash/src/lib.rs | 7 +- crates/ghostty-crash/src/mach_handler.rs | 369 +++++++++++++++++++++ crates/ghostty-crash/src/sentry.rs | 60 +++- crates/ghostty-crash/src/signal.rs | 9 +- crates/ghostty-crash/src/signal_windows.rs | 163 ++++++++- crates/ghostty-lib/src/action_c.rs | 7 +- crates/ghostty-os/src/open.rs | 38 ++- docs/rewrite/notes/crash-CR.md | 12 +- 12 files changed, 914 insertions(+), 34 deletions(-) create mode 100644 crates/ghostty-crash/src/exception.rs create mode 100644 crates/ghostty-crash/src/mach_handler.rs diff --git a/Cargo.lock b/Cargo.lock index 17d125b..9b0b94e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1502,6 +1502,7 @@ dependencies = [ "serde_json", "tempfile", "thiserror 2.0.21", + "windows-sys", ] [[package]] diff --git a/crates/ghostty-crash/Cargo.toml b/crates/ghostty-crash/Cargo.toml index 300b401..cad0086 100644 --- a/crates/ghostty-crash/Cargo.toml +++ b/crates/ghostty-crash/Cargo.toml @@ -22,9 +22,18 @@ libc = "0.2" [target.'cfg(target_vendor = "apple")'.dependencies] mach2 = "0.6" -[target.'cfg(target_os = "linux")'.dependencies] +[target.'cfg(any(target_os = "linux", target_os = "macos", windows))'.dependencies] minidump-writer = "0.13.0" +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_System_Diagnostics_Debug", + "Win32_System_Kernel", + "Win32_System_Threading", +] } + [dev-dependencies] minidump = "0.27.0" tempfile = "3.27.0" diff --git a/crates/ghostty-crash/src/darwin.rs b/crates/ghostty-crash/src/darwin.rs index df8bc38..48e68b1 100644 --- a/crates/ghostty-crash/src/darwin.rs +++ b/crates/ghostty-crash/src/darwin.rs @@ -79,6 +79,17 @@ pub(crate) unsafe fn capture( // state; the kernel built it for this signal frame. let ss = unsafe { uc.uc_mcontext.add(EXCEPTION_STATE_SIZE) }; + // SAFETY: `ss` points at the thread state in the kernel built mcontext. + unsafe { fill_thread_state(n, ss) }; +} + +/// Fill registers and the frame-pointer stack walk from a thread state +/// (`x86_thread_state64_t` / `arm_thread_state64_t`, as found in a +/// `ucontext` or returned by `thread_get_state`). +/// +/// # Safety +/// `ss` must point to a valid, aligned thread state of the native flavor. +pub(crate) unsafe fn fill_thread_state(n: &mut Native, ss: *const u8) { #[cfg(target_arch = "x86_64")] { // SAFETY: as above; the layout is `x86_thread_state64_t`. diff --git a/crates/ghostty-crash/src/exception.rs b/crates/ghostty-crash/src/exception.rs new file mode 100644 index 0000000..f39ec4d --- /dev/null +++ b/crates/ghostty-crash/src/exception.rs @@ -0,0 +1,260 @@ +//! Platform independent pieces of the native exception handlers (macOS Mach +//! exception ports, Windows unhandled-exception filter): decoding of the +//! raw exception messages and mapping of exception codes to the names and +//! descriptions recorded in the event. Unit-tested on every platform. + +// Only used by the macOS/Windows handlers; the tests run everywhere. +#![cfg_attr(not(any(target_os = "macos", windows)), allow(dead_code))] + +use std::fmt::Write as _; + +/// `mach_exception_raise` message id (`EXCEPTION_DEFAULT | MACH_EXCEPTION_CODES`). +pub(crate) const MACH_EXCEPTION_RAISE: i32 = 2405; + +/// `EXC_*` exception types. +pub(crate) const EXC_BAD_ACCESS: u32 = 1; +pub(crate) const EXC_BAD_INSTRUCTION: u32 = 2; +pub(crate) const EXC_ARITHMETIC: u32 = 3; +pub(crate) const EXC_BREAKPOINT: u32 = 6; + +/// A decoded Mach exception. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct MachException { + pub kind: u32, + pub code: i64, + pub subcode: i64, +} + +/// `kern_return_t` values reported as the code of `EXC_BAD_ACCESS`. +fn kern_name(code: i64) -> Option<&'static str> { + Some(match code { + 1 => "KERN_INVALID_ADDRESS", + 2 => "KERN_PROTECTION_FAILURE", + _ => return None, + }) +} + +impl MachException { + /// `EXC_BAD_ACCESS`, ... + pub(crate) fn name(&self) -> &'static str { + match self.kind { + EXC_BAD_ACCESS => "EXC_BAD_ACCESS", + EXC_BAD_INSTRUCTION => "EXC_BAD_INSTRUCTION", + EXC_ARITHMETIC => "EXC_ARITHMETIC", + 4 => "EXC_EMULATION", + 5 => "EXC_SOFTWARE", + EXC_BREAKPOINT => "EXC_BREAKPOINT", + _ => "EXC_UNKNOWN", + } + } + + /// The BSD signal the kernel would turn this exception into + /// (`ux_exception`), recorded as `mechanism.meta.signal`. + pub(crate) fn signal(&self) -> (i32, i32) { + // (signo, si_code) following xnu's `ux_exception`. + match self.kind { + EXC_BAD_ACCESS => match self.code { + 1 => (11, 1), // SIGSEGV, SEGV_MAPERR + 2 => (11, 2), // SIGSEGV, SEGV_ACCERR + _ => (10, 0), // SIGBUS + }, + EXC_BAD_INSTRUCTION => (4, 1), // SIGILL, ILL_ILLOPC + EXC_ARITHMETIC => (8, 0), // SIGFPE + EXC_BREAKPOINT => (5, 1), // SIGTRAP, TRAP_BRKPT + _ => (0, 0), + } + } + + /// The faulting address for `EXC_BAD_ACCESS`. + pub(crate) fn fault_addr(&self) -> u64 { + if self.kind == EXC_BAD_ACCESS { + self.subcode as u64 + } else { + 0 + } + } + + /// Human readable description. + pub(crate) fn describe(&self, out: &mut String) { + let _ = write!(out, "{}", self.name()); + if let Some(k) = (self.kind == EXC_BAD_ACCESS) + .then(|| kern_name(self.code)) + .flatten() + { + let _ = write!(out, " ({k}) at 0x{:x}", self.subcode as u64); + } else { + let _ = write!( + out, + " (code=0x{:x}, subcode=0x{:x})", + self.code, self.subcode + ); + } + } +} + +/// The wire layout of a `mach_exception_raise` request (`exc.defs` with +/// `MACH_EXCEPTION_CODES`), 4 byte packed like all Mach messages: +/// +/// ```text +/// mach_msg_header_t 24 bytes +/// mach_msg_body_t 4 (descriptor count = 2) +/// thread descriptor 12 +/// task descriptor 12 +/// NDR_record_t 8 +/// exception_type_t 4 +/// codeCnt 4 +/// int64 code[codeCnt] +/// ``` +pub(crate) struct RaiseRequest { + pub thread: u32, + pub task: u32, + pub exception: MachException, +} + +const HEADER: usize = 24; +const BODY: usize = 4; +const DESC: usize = 12; +const OFF_THREAD: usize = HEADER + BODY; +const OFF_TASK: usize = OFF_THREAD + DESC; +const OFF_NDR: usize = OFF_TASK + DESC; +const OFF_EXC: usize = OFF_NDR + 8; +const OFF_CNT: usize = OFF_EXC + 4; +const OFF_CODES: usize = OFF_CNT + 4; + +fn u32_at(b: &[u8], off: usize) -> Option { + Some(u32::from_ne_bytes(b.get(off..off + 4)?.try_into().ok()?)) +} + +fn i64_at(b: &[u8], off: usize) -> Option { + Some(i64::from_ne_bytes(b.get(off..off + 8)?.try_into().ok()?)) +} + +/// Decode a received `mach_exception_raise` message. `None` for any other +/// message id or a truncated/inconsistent message. +pub(crate) fn parse_raise(msg: &[u8]) -> Option { + let id = u32_at(msg, 20)? as i32; + if id != MACH_EXCEPTION_RAISE { + return None; + } + // Complex message (bit 31 of msgh_bits) carrying two port descriptors. + if u32_at(msg, 0)? & 0x8000_0000 == 0 || u32_at(msg, HEADER)? != 2 { + return None; + } + let count = u32_at(msg, OFF_CNT)? as usize; + if count > 2 { + return None; + } + let code = if count >= 1 { + i64_at(msg, OFF_CODES)? + } else { + 0 + }; + let subcode = if count >= 2 { + i64_at(msg, OFF_CODES + 8)? + } else { + 0 + }; + Some(RaiseRequest { + thread: u32_at(msg, OFF_THREAD)?, + task: u32_at(msg, OFF_TASK)?, + exception: MachException { + kind: u32_at(msg, OFF_EXC)?, + code, + subcode, + }, + }) +} + +/// Name and description of a Windows `NTSTATUS` exception code. +#[cfg_attr(not(windows), allow(dead_code))] +pub(crate) fn windows_exception(code: u32) -> (&'static str, &'static str) { + match code { + 0xC000_0005 => ("EXCEPTION_ACCESS_VIOLATION", "Access violation"), + 0x8000_0002 => ("EXCEPTION_DATATYPE_MISALIGNMENT", "Datatype misalignment"), + 0x8000_0003 => ("EXCEPTION_BREAKPOINT", "Breakpoint"), + 0xC000_0006 => ("EXCEPTION_IN_PAGE_ERROR", "In-page error"), + 0xC000_001D => ("EXCEPTION_ILLEGAL_INSTRUCTION", "Illegal instruction"), + 0xC000_0025 => ( + "EXCEPTION_NONCONTINUABLE_EXCEPTION", + "Noncontinuable exception", + ), + 0xC000_0094 => ("EXCEPTION_INT_DIVIDE_BY_ZERO", "Integer divide by zero"), + 0xC000_0095 => ("EXCEPTION_INT_OVERFLOW", "Integer overflow"), + 0xC000_008E => ("EXCEPTION_FLT_DIVIDE_BY_ZERO", "Float divide by zero"), + 0xC000_0096 => ("EXCEPTION_PRIV_INSTRUCTION", "Privileged instruction"), + 0xC000_00FD => ("EXCEPTION_STACK_OVERFLOW", "Stack overflow"), + 0xC000_0409 => ("STATUS_STACK_BUFFER_OVERRUN", "Fast fail"), + _ => ("EXCEPTION", "Unhandled exception"), + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + + fn raise(kind: u32, codes: &[i64]) -> Vec { + let mut m = Vec::new(); + m.extend_from_slice(&0x8000_0011u32.to_ne_bytes()); // complex | COPY_SEND + m.extend_from_slice(&0u32.to_ne_bytes()); // size (unused) + m.extend_from_slice(&[0; 12]); // ports + m.extend_from_slice(&MACH_EXCEPTION_RAISE.to_ne_bytes()); + m.extend_from_slice(&2u32.to_ne_bytes()); + for port in [7u32, 9] { + m.extend_from_slice(&port.to_ne_bytes()); + m.extend_from_slice(&[0; 8]); + } + m.extend_from_slice(&[0; 8]); // NDR + m.extend_from_slice(&kind.to_ne_bytes()); + m.extend_from_slice(&(codes.len() as u32).to_ne_bytes()); + for c in codes { + m.extend_from_slice(&c.to_ne_bytes()); + } + m + } + + #[test] + fn parses_bad_access() { + let r = parse_raise(&raise(EXC_BAD_ACCESS, &[1, 0xdead_beef])).unwrap(); + assert_eq!((r.thread, r.task), (7, 9)); + assert_eq!(r.exception.kind, EXC_BAD_ACCESS); + assert_eq!(r.exception.fault_addr(), 0xdead_beef); + assert_eq!(r.exception.signal(), (11, 1)); + let mut s = String::new(); + r.exception.describe(&mut s); + assert_eq!(s, "EXC_BAD_ACCESS (KERN_INVALID_ADDRESS) at 0xdeadbeef"); + } + + #[test] + fn rejects_other_ids_and_truncated() { + let mut m = raise(EXC_BAD_ACCESS, &[1, 2]); + assert!(parse_raise(&m[..40]).is_none()); + m[20] ^= 1; + assert!(parse_raise(&m).is_none()); + assert!(parse_raise(&raise(1, &[1, 2, 3])).is_none()); + } + + #[test] + fn signal_mapping() { + let e = |kind, code| MachException { + kind, + code, + subcode: 0, + }; + assert_eq!(e(EXC_BAD_ACCESS, 2).signal(), (11, 2)); + assert_eq!(e(EXC_BAD_ACCESS, 0x32).signal().0, 10); + assert_eq!(e(EXC_BAD_INSTRUCTION, 1).signal().0, 4); + assert_eq!(e(EXC_ARITHMETIC, 1).signal().0, 8); + assert_eq!(e(EXC_BREAKPOINT, 1).signal().0, 5); + } + + #[test] + fn windows_names() { + assert_eq!( + windows_exception(0xC000_0005).0, + "EXCEPTION_ACCESS_VIOLATION" + ); + assert_eq!(windows_exception(0xC000_00FD).1, "Stack overflow"); + assert_eq!(windows_exception(1).0, "EXCEPTION"); + } +} diff --git a/crates/ghostty-crash/src/lib.rs b/crates/ghostty-crash/src/lib.rs index 66748f6..3d9c098 100644 --- a/crates/ghostty-crash/src/lib.rs +++ b/crates/ghostty-crash/src/lib.rs @@ -4,8 +4,8 @@ //! reports (Sentry envelopes) to disk, and introspecting them. Nothing is //! ever sent over the network. -// Fatal-signal handling does not exist on Windows (Zig skips crash capture -// there), so parts of the shared report writer are unused. +// The Windows exception filter does not use the signal-safe report writer, +// so parts of it are unused there. #![cfg_attr(windows, allow(dead_code))] pub mod context; @@ -13,6 +13,9 @@ pub mod context; mod darwin; pub mod dir; pub mod envelope; +mod exception; +#[cfg(target_os = "macos")] +mod mach_handler; #[cfg(target_os = "linux")] mod minidump; // Pure (platform independent) event-context formatting; only used by the diff --git a/crates/ghostty-crash/src/mach_handler.rs b/crates/ghostty-crash/src/mach_handler.rs new file mode 100644 index 0000000..58b3206 --- /dev/null +++ b/crates/ghostty-crash/src/mach_handler.rs @@ -0,0 +1,369 @@ +//! macOS minidump capture through a Mach exception port, a port of the +//! structure of Breakpad's in-process `ExceptionHandler` (`exception_handler.cc`) +//! which sentry-native's breakpad backend uses upstream. +//! +//! `install` allocates a receive right, remembers the task's previous +//! exception ports (`task_get_exception_ports`), registers the new port for +//! `EXC_BAD_ACCESS`, `EXC_BAD_INSTRUCTION`, `EXC_ARITHMETIC` and +//! `EXC_BREAKPOINT` (`task_set_exception_ports`) and starts a handler thread +//! that blocks in `mach_msg`. When a thread faults, the kernel suspends it and +//! sends a `mach_exception_raise` message; the handler thread (which runs on +//! its own healthy stack, so stack overflows work) then: +//! +//! 1. suspends all other threads, +//! 2. reads the crashed thread's registers with `thread_get_state` for the +//! event's stack trace, +//! 3. writes a minidump with `minidump-writer` for the whole task and attaches +//! it to the envelope (`event.minidump`), +//! 4. restores the previous exception ports and replies `KERN_FAILURE`, so the +//! kernel continues with the next handler: the BSD signal is delivered and +//! the process dies as usual (Apple's crash reporter included). The signal +//! path (`signal.rs`) sees `BUSY` set and writes nothing more. +//! +//! Signals that are not Mach exceptions (`SIGABRT` from `abort()`) keep going +//! through the signal handler. A watchdog `alarm(2)` kills the process if the +//! dump hangs (e.g. a suspended thread held the allocator lock). +//! +//! Unlike Breakpad this does not forward the exception to previously +//! registered handlers; it restores them, which only matters for debuggers +//! attached before `install` (they are re-armed for the next exception). +#![allow(unsafe_code)] + +use std::{ + io::Cursor, + mem::size_of, + sync::{ + OnceLock, + atomic::{AtomicU32, Ordering}, + }, +}; + +use mach2::{ + exception_types::{ + EXC_MASK_ARITHMETIC, + EXC_MASK_BAD_ACCESS, + EXC_MASK_BAD_INSTRUCTION, + EXC_MASK_BREAKPOINT, + EXCEPTION_DEFAULT, + MACH_EXCEPTION_CODES, + exception_behavior_t, + exception_mask_t, + }, + kern_return::{KERN_FAILURE, KERN_SUCCESS}, + mach_types::{thread_act_t, thread_t}, + message::{ + MACH_MSG_TIMEOUT_NONE, + MACH_MSG_TYPE_MAKE_SEND, + MACH_MSGH_BITS, + MACH_MSGH_BITS_REMOTE_MASK, + MACH_RCV_LARGE, + MACH_RCV_MSG, + MACH_RCV_PORT_DIED, + MACH_SEND_MSG, + mach_msg, + mach_msg_header_t, + }, + ndr::{NDR_record, NDR_record_t}, + port::{MACH_PORT_NULL, MACH_PORT_RIGHT_RECEIVE, mach_port_t}, + thread_status::thread_state_flavor_t, +}; +use minidump_writer::{ + crash_context::{CrashContext, ExceptionInfo}, + minidump_writer::MinidumpWriter, +}; + +use crate::{ + exception::{self, MachException}, + native::Native, + sentry, + signal::BUSY, +}; + +/// Maximum number of registered exception handlers (`EXC_TYPES_COUNT`). +const MAX_PORTS: usize = 14; + +/// Seconds the dump may take before the watchdog kills the process. +const WATCHDOG_SECS: libc::c_uint = 20; + +const MASK: exception_mask_t = + EXC_MASK_BAD_ACCESS | EXC_MASK_BAD_INSTRUCTION | EXC_MASK_ARITHMETIC | EXC_MASK_BREAKPOINT; + +/// The task's exception ports before `install`. +struct Previous { + count: u32, + masks: [exception_mask_t; MAX_PORTS], + ports: [mach_port_t; MAX_PORTS], + behaviors: [exception_behavior_t; MAX_PORTS], + flavors: [thread_state_flavor_t; MAX_PORTS], +} + +static PREVIOUS: OnceLock = OnceLock::new(); +static PORT: AtomicU32 = AtomicU32::new(MACH_PORT_NULL); + +#[cfg(target_arch = "x86_64")] +type ThreadState = mach2::structs::x86_thread_state64_t; +#[cfg(target_arch = "aarch64")] +type ThreadState = mach2::structs::arm_thread_state64_t; + +#[cfg(target_arch = "x86_64")] +fn state_flavor() -> thread_state_flavor_t { + mach2::thread_status::x86_THREAD_STATE64 +} +#[cfg(target_arch = "aarch64")] +fn state_flavor() -> thread_state_flavor_t { + mach2::thread_status::ARM_THREAD_STATE64 +} + +/// Register the exception port and start the handler thread. Returns false +/// (leaving the signal path as the only capture) if anything fails. +pub(crate) fn install() -> bool { + if PORT.load(Ordering::Acquire) != MACH_PORT_NULL { + return true; + } + // SAFETY: plain Mach calls with valid out pointers. + unsafe { + let task = mach2::traps::mach_task_self(); + let mut port: mach_port_t = MACH_PORT_NULL; + if mach2::mach_port::mach_port_allocate(task, MACH_PORT_RIGHT_RECEIVE, &raw mut port) + != KERN_SUCCESS + { + return false; + } + if mach2::mach_port::mach_port_insert_right(task, port, port, MACH_MSG_TYPE_MAKE_SEND) + != KERN_SUCCESS + { + mach2::mach_port::mach_port_destroy(task, port); + return false; + } + + let mut prev = Previous { + count: MAX_PORTS as u32, + masks: [0; MAX_PORTS], + ports: [MACH_PORT_NULL; MAX_PORTS], + behaviors: [0; MAX_PORTS], + flavors: [0; MAX_PORTS], + }; + if mach2::task::task_get_exception_ports( + task, + MASK, + prev.masks.as_mut_ptr(), + &raw mut prev.count, + prev.ports.as_mut_ptr(), + prev.behaviors.as_mut_ptr(), + prev.flavors.as_mut_ptr(), + ) != KERN_SUCCESS + { + prev.count = 0; + } + let _ = PREVIOUS.set(prev); + + let spawned = std::thread::Builder::new() + .name("ghostty-exception-handler".into()) + .spawn(move || handler_thread(port)); + if spawned.is_err() { + mach2::mach_port::mach_port_destroy(task, port); + return false; + } + PORT.store(port, Ordering::Release); + mach2::task::task_set_exception_ports( + task, + MASK, + port, + EXCEPTION_DEFAULT as exception_behavior_t + | MACH_EXCEPTION_CODES as exception_behavior_t, + state_flavor(), + ) == KERN_SUCCESS + } +} + +/// Put the previously registered exception ports back. +fn restore_ports() { + let Some(prev) = PREVIOUS.get() else { return }; + // SAFETY: plain Mach calls; `prev` holds ports the kernel gave us. + unsafe { + let task = mach2::traps::mach_task_self(); + // Unregister ours for the masks we took over first. + mach2::task::task_set_exception_ports( + task, + MASK, + MACH_PORT_NULL, + EXCEPTION_DEFAULT as exception_behavior_t, + mach2::thread_status::THREAD_STATE_NONE, + ); + for i in 0..(prev.count as usize).min(MAX_PORTS) { + mach2::task::task_set_exception_ports( + task, + prev.masks[i], + prev.ports[i], + prev.behaviors[i], + prev.flavors[i], + ); + } + } +} + +/// Receive buffer: header, body, descriptors, NDR, codes and the trailer. +#[repr(C, align(8))] +struct Buffer([u8; 1024]); + +#[repr(C, packed(4))] +struct Reply { + head: mach_msg_header_t, + ndr: NDR_record_t, + ret_code: i32, +} + +fn handler_thread(port: mach_port_t) { + // SAFETY: Mach calls with valid pointers. + let handler: thread_t = unsafe { mach2::mach_init::mach_thread_self() }; + let mut buf = Buffer([0; 1024]); + loop { + // SAFETY: `buf` is a writable 1024 byte aligned buffer. + let r = unsafe { + mach_msg( + buf.0.as_mut_ptr().cast(), + MACH_RCV_MSG | MACH_RCV_LARGE, + 0, + buf.0.len() as u32, + port, + MACH_MSG_TIMEOUT_NONE, + MACH_PORT_NULL, + ) + }; + if r == MACH_RCV_PORT_DIED { + return; + } + if r != 0 { + continue; + } + let Some(req) = exception::parse_raise(&buf.0) else { + continue; + }; + handle_exception(&req, handler); + + // The previous ports are back; failing the request makes the kernel + // fall through to the next handler (the BSD signal). + // SAFETY: the first bytes of `buf` are the request's header. + let head = unsafe { *(&raw const buf).cast::() }; + let mut reply = Reply { + head: mach_msg_header_t { + msgh_bits: MACH_MSGH_BITS(head.msgh_bits & MACH_MSGH_BITS_REMOTE_MASK, 0), + msgh_size: size_of::() as u32, + msgh_remote_port: head.msgh_remote_port, + msgh_local_port: MACH_PORT_NULL, + msgh_voucher_port: 0, + msgh_id: head.msgh_id + 100, + }, + // SAFETY: `NDR_record` is a constant provided by libsystem. + ndr: unsafe { NDR_record }, + ret_code: KERN_FAILURE, + }; + // SAFETY: `reply` is a fully initialized message of the given size. + unsafe { + mach_msg( + (&raw mut reply).cast(), + MACH_SEND_MSG, + size_of::() as u32, + 0, + MACH_PORT_NULL, + MACH_MSG_TIMEOUT_NONE, + MACH_PORT_NULL, + ); + } + } +} + +/// Suspend every thread of the task except `keep` (the handler thread). +fn suspend_other_threads(keep: thread_act_t) { + // SAFETY: task_threads returns a vm-allocated array we only read. + unsafe { + let task = mach2::traps::mach_task_self(); + let mut list: mach2::mach_types::thread_act_array_t = std::ptr::null_mut(); + let mut n: u32 = 0; + if mach2::task::task_threads(task, &raw mut list, &raw mut n) != KERN_SUCCESS { + return; + } + for i in 0..n as usize { + let t = *list.add(i); + if t != keep { + mach2::thread_act::thread_suspend(t); + } + } + } +} + +fn handle_exception(req: &exception::RaiseRequest, handler: thread_t) { + if let Some(st) = sentry::state() + && !BUSY.swap(true, Ordering::Acquire) + { + // SAFETY: alarm has no preconditions. SIGALRM's default action ends + // the process if the dump below hangs. + unsafe { libc::alarm(WATCHDOG_SECS) }; + suspend_other_threads(handler); + write_report(st, req, handler); + // SAFETY: as above. + unsafe { libc::alarm(0) }; + } + restore_ports(); +} + +fn write_report(st: &sentry::State, req: &exception::RaiseRequest, handler: thread_t) { + let ex: MachException = req.exception; + + // Registers and frame-pointer walk of the faulting thread. + let mut native = Box::new(Native::new()); + (native.signo, native.code) = ex.signal(); + native.addr = ex.fault_addr(); + let mut state: ThreadState = ThreadState::default(); + let mut count = (size_of::() / 4) as u32; + // SAFETY: `state` is a writable thread state of the requested flavor. + let kr = unsafe { + mach2::thread_act::thread_get_state( + req.thread, + state_flavor(), + (&raw mut state).cast(), + &raw mut count, + ) + }; + if kr == KERN_SUCCESS { + // SAFETY: `state` is a valid, aligned native thread state. + unsafe { crate::darwin::fill_thread_state(&mut native, (&raw const state).cast()) }; + } + + let cc = CrashContext { + task: req.task, + thread: req.thread, + handler_thread: handler, + exception: Some(ExceptionInfo { + kind: ex.kind, + code: ex.code as u64, + subcode: (ex.kind == exception::EXC_BAD_ACCESS).then_some(ex.subcode as u64), + }), + }; + let dump = MinidumpWriter::with_crash_context(cc).dump(&mut Cursor::new(Vec::new())); + let dump = match dump { + Ok(d) => Some(d), + Err(e) => { + log::warn!("minidump failed err={e}"); + None + } + }; + + let mut value = String::new(); + ex.describe(&mut value); + match sentry::write_report_ext( + st, + ex.name(), + &value, + "mach", + Some(&native), + None, + dump.as_deref(), + ) { + Ok(path) => eprintln!( + "warning(sentry): crash report with minidump written to disk path={}", + path.display() + ), + Err(e) => eprintln!("warning(sentry): failed to persist crash report err={e}"), + } +} diff --git a/crates/ghostty-crash/src/sentry.rs b/crates/ghostty-crash/src/sentry.rs index 245318b..b7a79c6 100644 --- a/crates/ghostty-crash/src/sentry.rs +++ b/crates/ghostty-crash/src/sentry.rs @@ -27,7 +27,7 @@ use std::{ use crate::{ context, dir::{self, REPORT_EXTENSION}, - envelope::{Attachment, Envelope, Item, ObjectMap}, + envelope::{Attachment, Item}, signal, }; @@ -97,8 +97,7 @@ pub(crate) fn state() -> Option<&'static State> { /// /// Call from one thread; [`deinit`] should be called from the same thread. pub fn init(opts: &Options) -> Result<(), InitError> { - // Not supported on Windows currently, doesn't build (same as Zig). - if !ENABLED || cfg!(windows) { + if !ENABLED { return Ok(()); } install(opts) @@ -357,6 +356,20 @@ fn write_report( value: &str, mechanism: &str, backtrace: Option<&str>, +) -> io::Result { + write_report_ext(st, kind, value, mechanism, None, backtrace, None) +} + +/// [`write_report`] plus the native crash context and a minidump attachment +/// (`event.minidump`), used by the macOS and Windows exception handlers. +pub(crate) fn write_report_ext( + st: &State, + kind: &str, + value: &str, + mechanism: &str, + native: Option<&crate::native::Native>, + backtrace: Option<&str>, + minidump: Option<&[u8]>, ) -> io::Result { let mut raw = [0u8; 16]; signal::random_bytes(&mut raw); @@ -371,7 +384,7 @@ fn write_report( kind, value, mechanism, - native: None, + native, }; let mut event = String::new(); @@ -380,14 +393,20 @@ fn write_report( write_envelope_head(&mut head, &crash, event.len()).map_err(io::Error::other)?; let mut data = head.into_bytes(); data.extend_from_slice(event.as_bytes()); + let mut attachments: Vec = Vec::new(); if let Some(bt) = backtrace { let mut a = Attachment::new("backtrace.txt", bt.as_bytes().to_vec()); a.attachment_type = Some("event.attachment".into()); - let mut env = Envelope { - headers: ObjectMap::new(), - items: vec![Item::Attachment(a)], - }; - let enc = env.items[0].encode().clone(); + attachments.push(a); + } + if let Some(md) = minidump { + let mut a = Attachment::new("minidump.dmp", md.to_vec()); + a.attachment_type = Some("event.minidump".into()); + attachments.push(a); + } + for a in attachments { + let mut item = Item::Attachment(a); + let enc = item.encode().clone(); data.push(b'\n'); let mut h = enc.headers; h.insert("length".into(), enc.payload.len().into()); @@ -416,7 +435,7 @@ fn write_report( mod tests { #![allow(clippy::unwrap_used)] use super::*; - use crate::envelope::ItemType; + use crate::envelope::{Envelope, ItemType}; #[test] fn civil_dates() { @@ -495,4 +514,25 @@ mod tests { }; assert_eq!(b"bt\nline", a.payload.as_slice()); } + + #[test] + fn report_with_minidump_attachment_parses_back() { + let t = tempfile::tempdir().unwrap(); + let st = State { + dir: t.path().join("c").as_os_str().as_encoded_bytes().to_vec(), + prefix: "\"release\":\"1\",\"environment\":\"production\",\"tags\":{".into(), + }; + let md = b"MDMP\0\x01\xff binary".to_vec(); + let path = + write_report_ext(&st, "EXC_BAD_ACCESS", "x", "mach", None, None, Some(&md)).unwrap(); + let mut env = Envelope::parse_slice(&std::fs::read(path).unwrap()).unwrap(); + assert_eq!(2, env.items.len()); + env.items[1].decode().unwrap(); + let Item::Attachment(a) = &env.items[1] else { + panic!() + }; + assert_eq!("minidump.dmp", a.filename); + assert_eq!(Some("event.minidump"), a.attachment_type.as_deref()); + assert_eq!(md, a.payload); + } } diff --git a/crates/ghostty-crash/src/signal.rs b/crates/ghostty-crash/src/signal.rs index 035a234..9bbe0d0 100644 --- a/crates/ghostty-crash/src/signal.rs +++ b/crates/ghostty-crash/src/signal.rs @@ -49,7 +49,7 @@ static SCRATCH: ScratchCell = ScratchCell(UnsafeCell::new(Scratch { #[cfg(target_vendor = "apple")] native: crate::native::Native::new(), })); -static BUSY: AtomicBool = AtomicBool::new(false); +pub(crate) static BUSY: AtomicBool = AtomicBool::new(false); /// A `fmt::Write` into a fixed byte slice; errors when full. struct SliceWriter<'a> { @@ -302,4 +302,11 @@ pub(crate) fn install_handlers() { libc::sigaction(sig, &raw const sa, std::ptr::null_mut()); } } + // macOS: memory faults are Mach exceptions first; the exception port + // handler writes the minidump (see `mach_handler`). The signal handlers + // above stay as the path for SIGABRT and as a fallback. + #[cfg(target_os = "macos")] + if !crate::mach_handler::install() { + log::warn!("mach exception port handler unavailable; using signal handlers only"); + } } diff --git a/crates/ghostty-crash/src/signal_windows.rs b/crates/ghostty-crash/src/signal_windows.rs index 0fb5942..b28f4bc 100644 --- a/crates/ghostty-crash/src/signal_windows.rs +++ b/crates/ghostty-crash/src/signal_windows.rs @@ -1,8 +1,52 @@ -//! Windows stand-in for `signal.rs`: Zig's `crash/sentry.zig` does not -//! initialise crash capture on Windows, so there are no fatal-signal -//! handlers. Only the helpers the report writer shares are provided. +//! Windows crash capture, mirroring Breakpad's in-process `ExceptionHandler` +//! (which sentry-native's breakpad backend uses upstream): an unhandled +//! exception filter hands the exception to a pre-started handler thread, which +//! writes a minidump of the process with `minidump-writer` +//! (`MiniDumpWriteDump`) and attaches it to the envelope (`event.minidump`). +//! Running the dump on a separate thread with its own stack keeps it working +//! after a stack overflow. After writing, the filter returns +//! `EXCEPTION_CONTINUE_SEARCH` so the normal termination (and Windows Error +//! Reporting) proceeds. +//! +//! Faults raised by `__fastfail` (Rust's `abort`) bypass exception filters +//! and are not captured; panics are covered by the panic hook. +#![allow(unsafe_code)] -use std::hash::{BuildHasher, Hasher, RandomState}; +use std::{ + fs::File, + hash::{BuildHasher, Hasher, RandomState}, + sync::atomic::{AtomicBool, AtomicIsize, AtomicPtr, Ordering}, +}; + +use minidump_writer::{crash_context::CrashContext, minidump_writer::MinidumpWriter}; +use windows_sys::Win32::{ + Foundation::{CloseHandle, FALSE, HANDLE, WAIT_OBJECT_0}, + System::{ + Diagnostics::Debug::{EXCEPTION_POINTERS, SetUnhandledExceptionFilter}, + Threading::{ + CreateEventW, + GetCurrentProcessId, + GetCurrentThreadId, + INFINITE, + SetEvent, + WaitForSingleObject, + }, + }, +}; + +use crate::{exception, sentry}; + +/// How long the faulting thread waits for the dump. +const TIMEOUT_MS: u32 = 30_000; + +/// `EXCEPTION_CONTINUE_SEARCH`. +const CONTINUE_SEARCH: i32 = 0; + +static BUSY: AtomicBool = AtomicBool::new(false); +static REQUEST: AtomicIsize = AtomicIsize::new(0); +static DONE: AtomicIsize = AtomicIsize::new(0); +static POINTERS: AtomicPtr = AtomicPtr::new(std::ptr::null_mut()); +static FAULT_THREAD: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); /// Fill `out` with random bytes (used for the event UUID). The standard /// library's per-instance random hasher keys are good enough for an id. @@ -14,5 +58,112 @@ pub(crate) fn random_bytes(out: &mut [u8; 16]) { } } -/// No fatal-signal handlers on Windows. -pub(crate) fn install_handlers() {} +/// Start the dump thread and register the unhandled exception filter. +pub(crate) fn install_handlers() { + // SAFETY: plain Win32 calls; the events live for the whole process. + let (request, done) = unsafe { + ( + CreateEventW(std::ptr::null(), FALSE, FALSE, std::ptr::null()), + CreateEventW(std::ptr::null(), FALSE, FALSE, std::ptr::null()), + ) + }; + if request.is_null() || done.is_null() { + return; + } + REQUEST.store(request as isize, Ordering::Release); + DONE.store(done as isize, Ordering::Release); + let spawned = std::thread::Builder::new() + .name("ghostty-exception-handler".into()) + .spawn(dump_thread); + if spawned.is_err() { + // SAFETY: the handles were just created and are unused. + unsafe { + CloseHandle(request); + CloseHandle(done); + } + return; + } + // SAFETY: `filter` has the required signature and lives forever. + unsafe { SetUnhandledExceptionFilter(Some(filter)) }; +} + +unsafe extern "system" fn filter(info: *const EXCEPTION_POINTERS) -> i32 { + if sentry::state().is_some() && !BUSY.swap(true, Ordering::AcqRel) { + POINTERS.store(info.cast_mut(), Ordering::Release); + // SAFETY: trivial Win32 calls with handles created at install. + unsafe { + FAULT_THREAD.store(GetCurrentThreadId(), Ordering::Release); + SetEvent(REQUEST.load(Ordering::Acquire) as HANDLE); + let r = WaitForSingleObject(DONE.load(Ordering::Acquire) as HANDLE, TIMEOUT_MS); + let _ = r == WAIT_OBJECT_0; + } + } + CONTINUE_SEARCH +} + +fn dump_thread() { + loop { + // SAFETY: handle created at install. + let r = unsafe { WaitForSingleObject(REQUEST.load(Ordering::Acquire) as HANDLE, INFINITE) }; + if r != WAIT_OBJECT_0 { + return; + } + if let Some(st) = sentry::state() { + let info = POINTERS.load(Ordering::Acquire); + write_report(st, info, FAULT_THREAD.load(Ordering::Acquire)); + } + // SAFETY: handle created at install. + unsafe { SetEvent(DONE.load(Ordering::Acquire) as HANDLE) }; + } +} + +fn write_report(st: &sentry::State, info: *mut EXCEPTION_POINTERS, tid: u32) { + // SAFETY: the faulting thread is blocked in the filter, so the pointers + // it passed stay valid. + let code = unsafe { info.as_ref().and_then(|i| i.ExceptionRecord.as_ref()) } + .map_or(0, |r| r.ExceptionCode) as u32; + let (kind, desc) = exception::windows_exception(code); + + let dir = std::path::PathBuf::from(String::from_utf8_lossy(&st.dir).into_owned()); + let dump = (|| -> Result, String> { + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + let tmp = dir.join(format!("minidump-{}.tmp", std::process::id())); + let mut file = File::create(&tmp).map_err(|e| e.to_string())?; + let cc = CrashContext { + exception_pointers: info.cast_const().cast(), + // SAFETY: trivial Win32 call. + process_id: unsafe { GetCurrentProcessId() }, + thread_id: tid, + exception_code: code as i32, + }; + let r = MinidumpWriter::dump_crash_context(&cc, None, &mut file).map_err(|e| e.to_string()); + drop(file); + let data = r.and_then(|()| std::fs::read(&tmp).map_err(|e| e.to_string())); + let _ = std::fs::remove_file(&tmp); + data + })(); + let dump = match dump { + Ok(d) => Some(d), + Err(e) => { + log::warn!("minidump failed err={e}"); + None + } + }; + + let value = format!("{desc} (code=0x{code:08x})"); + match sentry::write_report_ext( + st, + kind, + &value, + "exceptionfilter", + None, + None, + dump.as_deref(), + ) { + Ok(path) => eprintln!( + "warning(sentry): crash report with minidump written to disk path={}", + path.display() + ), + Err(e) => eprintln!("warning(sentry): failed to persist crash report err={e}"), + } +} diff --git a/crates/ghostty-lib/src/action_c.rs b/crates/ghostty-lib/src/action_c.rs index eeeac05..8dd39e0 100644 --- a/crates/ghostty-lib/src/action_c.rs +++ b/crates/ghostty-lib/src/action_c.rs @@ -183,8 +183,11 @@ pub fn action_to_c(action: &Action, keep: &mut Keep) -> ghostty_action_s { } Action::OpenUrl(u) => { v.open_url.kind = u.kind as c_int; - v.open_url.url = keep.cstr(&u.url); - v.open_url.len = u.url.len(); + // The URL may be the escaped form of a non-UTF-8 path; hand the + // embedder the real bytes. + let url = ghostty_os::osbytes::config_str_to_bytes(&u.url); + v.open_url.url = keep.cstr_bytes(&url); + v.open_url.len = url.len(); } Action::ShowChildExited(c) => { v.child_exited.exit_code = c.exit_code; diff --git a/crates/ghostty-os/src/open.rs b/crates/ghostty-os/src/open.rs index 7c1c12b..3400152 100644 --- a/crates/ghostty-os/src/open.rs +++ b/crates/ghostty-os/src/open.rs @@ -1,4 +1,5 @@ use std::{ + ffi::{OsStr, OsString}, io, process::{Command, Stdio}, }; @@ -17,6 +18,10 @@ pub enum OpenUrlKind { /// Open a URL in the default handling application (via `xdg-open`). /// +/// `url` is the core's "config string" form (see [`crate::osbytes`]): a +/// non-UTF-8 path is carried in an escaped form and is decoded to the real +/// path bytes here, right before it is handed to the opener. +/// /// Any output on stderr is logged as a warning in the application logs. /// Output on stdout is ignored. /// @@ -25,7 +30,8 @@ pub enum OpenUrlKind { /// apprt for Ghostty, you should consider doing something special-cased /// for your platform. pub fn open(kind: OpenUrlKind, url: &str) -> io::Result<()> { - let mut cmd = open_command(kind, url)?; + let url: OsString = crate::osbytes::config_str_to_os(url); + let mut cmd = open_command(kind, &url)?; // Pipe stdout/stderr so we can collect output from the command. cmd.stdin(Stdio::null()) @@ -59,17 +65,17 @@ pub fn open(kind: OpenUrlKind, url: &str) -> io::Result<()> { /// `builtin.os.tag`). #[cfg(windows)] #[allow(clippy::unnecessary_wraps)] -fn open_command(kind: OpenUrlKind, url: &str) -> io::Result { +fn open_command(kind: OpenUrlKind, url: &OsStr) -> io::Result { let _ = kind; let mut cmd = Command::new("rundll32"); - cmd.args(["url.dll,FileProtocolHandler", url]); + cmd.arg("url.dll,FileProtocolHandler").arg(url); Ok(cmd) } /// Linux and the BSDs use `xdg-open`. #[cfg(not(any(windows, target_os = "macos", target_os = "ios")))] #[allow(clippy::unnecessary_wraps)] -fn open_command(kind: OpenUrlKind, url: &str) -> io::Result { +fn open_command(kind: OpenUrlKind, url: &OsStr) -> io::Result { let _ = kind; let mut cmd = Command::new("xdg-open"); cmd.arg(url); @@ -79,7 +85,7 @@ fn open_command(kind: OpenUrlKind, url: &str) -> io::Result { /// macOS uses `open` (`open -t` for text files). #[cfg(target_os = "macos")] #[allow(clippy::unnecessary_wraps)] -fn open_command(kind: OpenUrlKind, url: &str) -> io::Result { +fn open_command(kind: OpenUrlKind, url: &OsStr) -> io::Result { let mut cmd = Command::new("open"); cmd.args(macos_open_args(kind, url)); Ok(cmd) @@ -87,9 +93,9 @@ fn open_command(kind: OpenUrlKind, url: &str) -> io::Result { /// Arguments of the macOS `open` command for a URL kind. #[cfg_attr(not(any(target_os = "macos", all(test, unix))), allow(dead_code))] -fn macos_open_args(kind: OpenUrlKind, url: &str) -> Vec<&str> { +fn macos_open_args(kind: OpenUrlKind, url: &OsStr) -> Vec<&OsStr> { match kind { - OpenUrlKind::Text => vec!["-t", url], + OpenUrlKind::Text => vec![OsStr::new("-t"), url], OpenUrlKind::Html | OpenUrlKind::Unknown => vec![url], } } @@ -97,7 +103,7 @@ fn macos_open_args(kind: OpenUrlKind, url: &str) -> Vec<&str> { /// iOS cannot spawn processes (`error.Unimplemented`). #[cfg(target_os = "ios")] #[allow(clippy::unnecessary_wraps)] -fn open_command(_kind: OpenUrlKind, _url: &str) -> io::Result { +fn open_command(_kind: OpenUrlKind, _url: &OsStr) -> io::Result { Err(io::Error::new(io::ErrorKind::Unsupported, "unimplemented")) } @@ -128,9 +134,19 @@ mod tests { #[test] fn macos_open_command_args() { - assert_eq!(macos_open_args(OpenUrlKind::Text, "/a"), ["-t", "/a"]); - assert_eq!(macos_open_args(OpenUrlKind::Html, "u"), ["u"]); - assert_eq!(macos_open_args(OpenUrlKind::Unknown, "u"), ["u"]); + let a = OsStr::new("/a"); + let u = OsStr::new("u"); + assert_eq!(macos_open_args(OpenUrlKind::Text, a), ["-t", "/a"]); + assert_eq!(macos_open_args(OpenUrlKind::Html, u), ["u"]); + assert_eq!(macos_open_args(OpenUrlKind::Unknown, u), ["u"]); + } + + #[test] + fn encoded_non_utf8_path_is_decoded_for_the_opener() { + use std::os::unix::ffi::OsStrExt as _; + let enc = crate::osbytes::bytes_to_config_string(b"/tmp/a\xffb"); + let os = crate::osbytes::config_str_to_os(&enc); + assert_eq!(os.as_bytes(), b"/tmp/a\xffb"); } #[cfg(not(target_vendor = "apple"))] diff --git a/docs/rewrite/notes/crash-CR.md b/docs/rewrite/notes/crash-CR.md index a4f49ed..185d1bb 100644 --- a/docs/rewrite/notes/crash-CR.md +++ b/docs/rewrite/notes/crash-CR.md @@ -24,5 +24,15 @@ New crate `crates/ghostty-crash` (workspace member; `unsafe_code = "deny"`, allo ## Tests `cargo test -p ghostty-crash` (envelope, dir, event/report parse-back, context) and root `tests/crash.rs` (child process: panic, SIGSEGV, stack-overflow fault on altstack, abort; checks envelope contents and that `ghostty +crash-report` lists the file). +## Platform matrix (Z6) + +| Platform | Memory faults / illegal insn / FPE | abort / panic | Minidump | +|---|---|---|---| +| Linux | signal handler (altstack) | SIGABRT handler / panic hook | yes: forked ptrace helper + `minidump-writer` | +| macOS | Mach exception port handler thread (`mach_handler.rs`, Breakpad `ExceptionHandler` structure): `task_set_exception_ports` for BAD_ACCESS/BAD_INSTRUCTION/ARITHMETIC/BREAKPOINT, suspends other threads, `thread_get_state` for the event stack, `MinidumpWriter::with_crash_context`, restores previous ports and replies `KERN_FAILURE` so the BSD signal follows; signal handlers remain for SIGABRT and as fallback (event with registers/frames, no minidump) | SIGABRT handler / panic hook | yes (Mach path) | +| Windows | `SetUnhandledExceptionFilter` -> pre-started dump thread -> `MinidumpWriter::dump_crash_context` (`MiniDumpWriteDump`), returns `EXCEPTION_CONTINUE_SEARCH` (`signal_windows.rs`) | panic hook (no minidump); `__fastfail`/abort bypasses filters | yes | + +The Mach/Windows code is compile-checked only (`just check-macos` x2, `just check-windows`); it could not be run here. Platform independent parts (Mach exception message decoding, exception to signal/name mapping, Windows exception names, minidump attachment in the envelope via `write_report_ext`) are unit-tested on Linux (`exception.rs`, `sentry::tests`). macOS dump hang protection: `alarm(20)` watchdog. Not done vs Breakpad: forwarding to previously registered handlers (they are restored instead). + ## Known gaps -No minidump/native stack in signal reports; no `+crash-report` viewing (upstream doesn't either). +No minidump for SIGABRT on macOS or panics; no `+crash-report` viewing (upstream doesn't either).