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:
1 parent
41e0b9cb68
commit
b1ad29fa97
12 files changed
+914
-34
No files matched your search
@@ -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`.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}"),
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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}"),
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user