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
This commit is contained in:
ddidderr committed 2026-10-12 08:55:27 +02:00
1 parent 41e0b9cb68
commit b1ad29fa97
12 files changed
+914 -34

No files matched your search

+260
View File
@@ -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<u32> {
Some(u32::from_ne_bytes(b.get(off..off + 4)?.try_into().ok()?))
}
fn i64_at(b: &[u8], off: usize) -> Option<i64> {
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<RaiseRequest> {
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<u8> {
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");
}
}