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 162aa260f1
commit 72bfac1cc8
12 files changed
+914 -34

No files matched your search

+11
View File
@@ -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`.
+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");
}
}
+5 -2
View File
@@ -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
+369
View File
@@ -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<Previous> = 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::<mach_msg_header_t>() };
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::<Reply>() 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::<Reply>() 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::<ThreadState>() / 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}"),
}
}
+50 -10
View File
@@ -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<PathBuf> {
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<PathBuf> {
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<Attachment> = 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);
}
}
+8 -1
View File
@@ -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");
}
}
+157 -6
View File
@@ -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<EXCEPTION_POINTERS> = 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<Vec<u8>, 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}"),
}
}