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
Generated
+1
@@ -1502,6 +1502,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.21",
|
"thiserror 2.0.21",
|
||||||
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -22,9 +22,18 @@ libc = "0.2"
|
|||||||
[target.'cfg(target_vendor = "apple")'.dependencies]
|
[target.'cfg(target_vendor = "apple")'.dependencies]
|
||||||
mach2 = "0.6"
|
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"
|
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]
|
[dev-dependencies]
|
||||||
minidump = "0.27.0"
|
minidump = "0.27.0"
|
||||||
tempfile = "3.27.0"
|
tempfile = "3.27.0"
|
||||||
|
|||||||
@@ -79,6 +79,17 @@ pub(crate) unsafe fn capture(
|
|||||||
// state; the kernel built it for this signal frame.
|
// state; the kernel built it for this signal frame.
|
||||||
let ss = unsafe { uc.uc_mcontext.add(EXCEPTION_STATE_SIZE) };
|
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")]
|
#[cfg(target_arch = "x86_64")]
|
||||||
{
|
{
|
||||||
// SAFETY: as above; the layout is `x86_thread_state64_t`.
|
// 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
|
//! reports (Sentry envelopes) to disk, and introspecting them. Nothing is
|
||||||
//! ever sent over the network.
|
//! ever sent over the network.
|
||||||
|
|
||||||
// Fatal-signal handling does not exist on Windows (Zig skips crash capture
|
// The Windows exception filter does not use the signal-safe report writer,
|
||||||
// there), so parts of the shared report writer are unused.
|
// so parts of it are unused there.
|
||||||
#![cfg_attr(windows, allow(dead_code))]
|
#![cfg_attr(windows, allow(dead_code))]
|
||||||
|
|
||||||
pub mod context;
|
pub mod context;
|
||||||
@@ -13,6 +13,9 @@ pub mod context;
|
|||||||
mod darwin;
|
mod darwin;
|
||||||
pub mod dir;
|
pub mod dir;
|
||||||
pub mod envelope;
|
pub mod envelope;
|
||||||
|
mod exception;
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
mod mach_handler;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod minidump;
|
mod minidump;
|
||||||
// Pure (platform independent) event-context formatting; only used by the
|
// 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::{
|
use crate::{
|
||||||
context,
|
context,
|
||||||
dir::{self, REPORT_EXTENSION},
|
dir::{self, REPORT_EXTENSION},
|
||||||
envelope::{Attachment, Envelope, Item, ObjectMap},
|
envelope::{Attachment, Item},
|
||||||
signal,
|
signal,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -97,8 +97,7 @@ pub(crate) fn state() -> Option<&'static State> {
|
|||||||
///
|
///
|
||||||
/// Call from one thread; [`deinit`] should be called from the same thread.
|
/// Call from one thread; [`deinit`] should be called from the same thread.
|
||||||
pub fn init(opts: &Options) -> Result<(), InitError> {
|
pub fn init(opts: &Options) -> Result<(), InitError> {
|
||||||
// Not supported on Windows currently, doesn't build (same as Zig).
|
if !ENABLED {
|
||||||
if !ENABLED || cfg!(windows) {
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
install(opts)
|
install(opts)
|
||||||
@@ -357,6 +356,20 @@ fn write_report(
|
|||||||
value: &str,
|
value: &str,
|
||||||
mechanism: &str,
|
mechanism: &str,
|
||||||
backtrace: Option<&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> {
|
) -> io::Result<PathBuf> {
|
||||||
let mut raw = [0u8; 16];
|
let mut raw = [0u8; 16];
|
||||||
signal::random_bytes(&mut raw);
|
signal::random_bytes(&mut raw);
|
||||||
@@ -371,7 +384,7 @@ fn write_report(
|
|||||||
kind,
|
kind,
|
||||||
value,
|
value,
|
||||||
mechanism,
|
mechanism,
|
||||||
native: None,
|
native,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut event = String::new();
|
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)?;
|
write_envelope_head(&mut head, &crash, event.len()).map_err(io::Error::other)?;
|
||||||
let mut data = head.into_bytes();
|
let mut data = head.into_bytes();
|
||||||
data.extend_from_slice(event.as_bytes());
|
data.extend_from_slice(event.as_bytes());
|
||||||
|
let mut attachments: Vec<Attachment> = Vec::new();
|
||||||
if let Some(bt) = backtrace {
|
if let Some(bt) = backtrace {
|
||||||
let mut a = Attachment::new("backtrace.txt", bt.as_bytes().to_vec());
|
let mut a = Attachment::new("backtrace.txt", bt.as_bytes().to_vec());
|
||||||
a.attachment_type = Some("event.attachment".into());
|
a.attachment_type = Some("event.attachment".into());
|
||||||
let mut env = Envelope {
|
attachments.push(a);
|
||||||
headers: ObjectMap::new(),
|
}
|
||||||
items: vec![Item::Attachment(a)],
|
if let Some(md) = minidump {
|
||||||
};
|
let mut a = Attachment::new("minidump.dmp", md.to_vec());
|
||||||
let enc = env.items[0].encode().clone();
|
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');
|
data.push(b'\n');
|
||||||
let mut h = enc.headers;
|
let mut h = enc.headers;
|
||||||
h.insert("length".into(), enc.payload.len().into());
|
h.insert("length".into(), enc.payload.len().into());
|
||||||
@@ -416,7 +435,7 @@ fn write_report(
|
|||||||
mod tests {
|
mod tests {
|
||||||
#![allow(clippy::unwrap_used)]
|
#![allow(clippy::unwrap_used)]
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::envelope::ItemType;
|
use crate::envelope::{Envelope, ItemType};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn civil_dates() {
|
fn civil_dates() {
|
||||||
@@ -495,4 +514,25 @@ mod tests {
|
|||||||
};
|
};
|
||||||
assert_eq!(b"bt\nline", a.payload.as_slice());
|
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")]
|
#[cfg(target_vendor = "apple")]
|
||||||
native: crate::native::Native::new(),
|
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.
|
/// A `fmt::Write` into a fixed byte slice; errors when full.
|
||||||
struct SliceWriter<'a> {
|
struct SliceWriter<'a> {
|
||||||
@@ -302,4 +302,11 @@ pub(crate) fn install_handlers() {
|
|||||||
libc::sigaction(sig, &raw const sa, std::ptr::null_mut());
|
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
|
//! Windows crash capture, mirroring Breakpad's in-process `ExceptionHandler`
|
||||||
//! initialise crash capture on Windows, so there are no fatal-signal
|
//! (which sentry-native's breakpad backend uses upstream): an unhandled
|
||||||
//! handlers. Only the helpers the report writer shares are provided.
|
//! 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
|
/// 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.
|
/// 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.
|
/// Start the dump thread and register the unhandled exception filter.
|
||||||
pub(crate) fn install_handlers() {}
|
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}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -183,8 +183,11 @@ pub fn action_to_c(action: &Action, keep: &mut Keep) -> ghostty_action_s {
|
|||||||
}
|
}
|
||||||
Action::OpenUrl(u) => {
|
Action::OpenUrl(u) => {
|
||||||
v.open_url.kind = u.kind as c_int;
|
v.open_url.kind = u.kind as c_int;
|
||||||
v.open_url.url = keep.cstr(&u.url);
|
// The URL may be the escaped form of a non-UTF-8 path; hand the
|
||||||
v.open_url.len = u.url.len();
|
// 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) => {
|
Action::ShowChildExited(c) => {
|
||||||
v.child_exited.exit_code = c.exit_code;
|
v.child_exited.exit_code = c.exit_code;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use std::{
|
use std::{
|
||||||
|
ffi::{OsStr, OsString},
|
||||||
io,
|
io,
|
||||||
process::{Command, Stdio},
|
process::{Command, Stdio},
|
||||||
};
|
};
|
||||||
@@ -17,6 +18,10 @@ pub enum OpenUrlKind {
|
|||||||
|
|
||||||
/// Open a URL in the default handling application (via `xdg-open`).
|
/// 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.
|
/// Any output on stderr is logged as a warning in the application logs.
|
||||||
/// Output on stdout is ignored.
|
/// Output on stdout is ignored.
|
||||||
///
|
///
|
||||||
@@ -25,7 +30,8 @@ pub enum OpenUrlKind {
|
|||||||
/// apprt for Ghostty, you should consider doing something special-cased
|
/// apprt for Ghostty, you should consider doing something special-cased
|
||||||
/// for your platform.
|
/// for your platform.
|
||||||
pub fn open(kind: OpenUrlKind, url: &str) -> io::Result<()> {
|
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.
|
// Pipe stdout/stderr so we can collect output from the command.
|
||||||
cmd.stdin(Stdio::null())
|
cmd.stdin(Stdio::null())
|
||||||
@@ -59,17 +65,17 @@ pub fn open(kind: OpenUrlKind, url: &str) -> io::Result<()> {
|
|||||||
/// `builtin.os.tag`).
|
/// `builtin.os.tag`).
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
#[allow(clippy::unnecessary_wraps)]
|
#[allow(clippy::unnecessary_wraps)]
|
||||||
fn open_command(kind: OpenUrlKind, url: &str) -> io::Result<Command> {
|
fn open_command(kind: OpenUrlKind, url: &OsStr) -> io::Result<Command> {
|
||||||
let _ = kind;
|
let _ = kind;
|
||||||
let mut cmd = Command::new("rundll32");
|
let mut cmd = Command::new("rundll32");
|
||||||
cmd.args(["url.dll,FileProtocolHandler", url]);
|
cmd.arg("url.dll,FileProtocolHandler").arg(url);
|
||||||
Ok(cmd)
|
Ok(cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Linux and the BSDs use `xdg-open`.
|
/// Linux and the BSDs use `xdg-open`.
|
||||||
#[cfg(not(any(windows, target_os = "macos", target_os = "ios")))]
|
#[cfg(not(any(windows, target_os = "macos", target_os = "ios")))]
|
||||||
#[allow(clippy::unnecessary_wraps)]
|
#[allow(clippy::unnecessary_wraps)]
|
||||||
fn open_command(kind: OpenUrlKind, url: &str) -> io::Result<Command> {
|
fn open_command(kind: OpenUrlKind, url: &OsStr) -> io::Result<Command> {
|
||||||
let _ = kind;
|
let _ = kind;
|
||||||
let mut cmd = Command::new("xdg-open");
|
let mut cmd = Command::new("xdg-open");
|
||||||
cmd.arg(url);
|
cmd.arg(url);
|
||||||
@@ -79,7 +85,7 @@ fn open_command(kind: OpenUrlKind, url: &str) -> io::Result<Command> {
|
|||||||
/// macOS uses `open` (`open -t` for text files).
|
/// macOS uses `open` (`open -t` for text files).
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
#[allow(clippy::unnecessary_wraps)]
|
#[allow(clippy::unnecessary_wraps)]
|
||||||
fn open_command(kind: OpenUrlKind, url: &str) -> io::Result<Command> {
|
fn open_command(kind: OpenUrlKind, url: &OsStr) -> io::Result<Command> {
|
||||||
let mut cmd = Command::new("open");
|
let mut cmd = Command::new("open");
|
||||||
cmd.args(macos_open_args(kind, url));
|
cmd.args(macos_open_args(kind, url));
|
||||||
Ok(cmd)
|
Ok(cmd)
|
||||||
@@ -87,9 +93,9 @@ fn open_command(kind: OpenUrlKind, url: &str) -> io::Result<Command> {
|
|||||||
|
|
||||||
/// Arguments of the macOS `open` command for a URL kind.
|
/// Arguments of the macOS `open` command for a URL kind.
|
||||||
#[cfg_attr(not(any(target_os = "macos", all(test, unix))), allow(dead_code))]
|
#[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 {
|
match kind {
|
||||||
OpenUrlKind::Text => vec!["-t", url],
|
OpenUrlKind::Text => vec![OsStr::new("-t"), url],
|
||||||
OpenUrlKind::Html | OpenUrlKind::Unknown => vec![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`).
|
/// iOS cannot spawn processes (`error.Unimplemented`).
|
||||||
#[cfg(target_os = "ios")]
|
#[cfg(target_os = "ios")]
|
||||||
#[allow(clippy::unnecessary_wraps)]
|
#[allow(clippy::unnecessary_wraps)]
|
||||||
fn open_command(_kind: OpenUrlKind, _url: &str) -> io::Result<Command> {
|
fn open_command(_kind: OpenUrlKind, _url: &OsStr) -> io::Result<Command> {
|
||||||
Err(io::Error::new(io::ErrorKind::Unsupported, "unimplemented"))
|
Err(io::Error::new(io::ErrorKind::Unsupported, "unimplemented"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,9 +134,19 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn macos_open_command_args() {
|
fn macos_open_command_args() {
|
||||||
assert_eq!(macos_open_args(OpenUrlKind::Text, "/a"), ["-t", "/a"]);
|
let a = OsStr::new("/a");
|
||||||
assert_eq!(macos_open_args(OpenUrlKind::Html, "u"), ["u"]);
|
let u = OsStr::new("u");
|
||||||
assert_eq!(macos_open_args(OpenUrlKind::Unknown, "u"), ["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"))]
|
#[cfg(not(target_vendor = "apple"))]
|
||||||
|
|||||||
@@ -24,5 +24,15 @@ New crate `crates/ghostty-crash` (workspace member; `unsafe_code = "deny"`, allo
|
|||||||
## Tests
|
## 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).
|
`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
|
## 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).
|
||||||
Reference in new issue
Block a user