Files
ghostty-rs/crates/ghostty-crash/src/exception.rs
T
ddidderr 5c5d916960 fix: close the RV6 verification gaps
Resolves the 7 gap groups in docs/rewrite/notes/review-rv6-codex.md
(final-Z8.md):
- crash (macOS): request_abort_dump returns false on mach_msg receive
  timeout/error so the signal-path fallback runs; EXC_SOFT_SIGNAL keeps
  the signal number in the minidump exception info.
- Default SHELL read as an OS string and stored in encoded command form.
- macOS executable search works on raw PATH bytes; the default path is
  used only when PATH is unset.
- Windows: config env overrides are decoded; EnvMap compares keys as
  WTF-16 units with per-unit Unicode simple uppercasing (no lossy
  conversion, distinct lone-surrogate keys stay distinct).
- +show-config writes original bytes for encoded OS data, so export +
  reload round-trips; UTF-8 configs are byte-identical as before.
- Zig's ensureLocale chain lives in ghostty-os for all Unix targets and
  is used by both the GTK app and the C API init.

Also makes the quick-terminal GTK replay test independent of compositor
focus: GTK's is-active is read-only, so when niri actually focuses the
layer surface the simulated focus-out cannot drive autohide and that
assertion is skipped (the production logic is unchanged and matches Zig).

Test Plan:
- cargo test --workspace (3390 passed, incl. tests/gtk_replay.rs)
- just check-macos (both), just check-windows, just check-wasm
2026-10-12 10:27:37 +02:00

366 lines
12 KiB
Rust

//! 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_SOFTWARE: u32 = 5;
pub(crate) const EXC_BREAKPOINT: u32 = 6;
/// `EXC_SOFT_SIGNAL`: the code of an `EXC_SOFTWARE` exception that stands for
/// a BSD signal (the subcode is the signal number). This is how an
/// `abort()` (SIGABRT) is described, as Apple's crash reporter does.
pub(crate) const EXC_SOFT_SIGNAL: i64 = 0x10003;
/// Message id of the "write a dump for this signal" request the signal
/// handler sends to the exception handler thread (not a Mach exception id).
pub(crate) const ABORT_MSG_ID: i32 = 0x6768_6f73;
impl MachException {
/// The exception describing a fatal BSD signal that never went through
/// the Mach exception path (`abort()` raises SIGABRT directly).
pub(crate) fn from_signal(signo: i32) -> MachException {
MachException {
kind: EXC_SOFTWARE,
code: EXC_SOFT_SIGNAL,
subcode: i64::from(signo),
}
}
}
/// Whether the timed receive of the abort reply succeeded
/// (`KERN_SUCCESS`); on timeout or error the caller must fall back to the
/// signal-path report.
pub(crate) fn abort_reply_ok(kr: i32) -> bool {
kr == 0
}
/// Wire layout of the abort request after the 24 byte Mach header:
/// `thread: u32`, `signo: i32` (native endian).
pub(crate) const ABORT_BODY_LEN: usize = 8;
/// Encode the body of an abort request.
pub(crate) fn abort_body(thread: u32, signo: i32) -> [u8; ABORT_BODY_LEN] {
let mut b = [0u8; ABORT_BODY_LEN];
b[..4].copy_from_slice(&thread.to_ne_bytes());
b[4..].copy_from_slice(&signo.to_ne_bytes());
b
}
/// Decode an abort request message (header included): the faulting thread
/// and the signal. `None` for any other message.
pub(crate) fn parse_abort(msg: &[u8]) -> Option<(u32, i32)> {
if u32_at(msg, 20)? as i32 != ABORT_MSG_ID {
return None;
}
Some((u32_at(msg, HEADER)?, u32_at(msg, HEADER + 4)? as i32))
}
/// 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 {
/// The subcode recorded in the minidump exception information: kept for
/// `EXC_BAD_ACCESS` (fault address) and for `EXC_SOFTWARE` /
/// `EXC_SOFT_SIGNAL` (the signal number).
pub(crate) fn minidump_subcode(&self) -> Option<u64> {
(self.kind == EXC_BAD_ACCESS || (self.kind == EXC_SOFTWARE && self.code == EXC_SOFT_SIGNAL))
.then_some(self.subcode as u64)
}
/// `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
EXC_SOFTWARE if self.code == EXC_SOFT_SIGNAL => (self.subcode as i32, 0),
_ => (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 abort_request_round_trips() {
let mut msg = vec![0u8; 20];
msg.extend_from_slice(&ABORT_MSG_ID.to_ne_bytes());
msg.extend_from_slice(&abort_body(77, 6));
assert_eq!(parse_abort(&msg), Some((77, 6)));
// A Mach exception message or a truncated one is not an abort request.
assert_eq!(parse_abort(&raise(EXC_BAD_ACCESS, &[1, 2])), None);
assert_eq!(parse_abort(&msg[..26]), None);
}
#[test]
fn sigabrt_is_described_as_a_soft_signal() {
let e = MachException::from_signal(6);
assert_eq!(e.name(), "EXC_SOFTWARE");
assert_eq!(e.signal(), (6, 0));
assert_eq!(e.fault_addr(), 0);
let mut s = String::new();
e.describe(&mut s);
assert_eq!(s, "EXC_SOFTWARE (code=0x10003, subcode=0x6)");
}
#[test]
fn soft_signal_subcode_is_kept_for_minidump() {
assert_eq!(MachException::from_signal(6).minidump_subcode(), Some(6));
let bad = MachException {
kind: EXC_BAD_ACCESS,
code: 1,
subcode: 0x10,
};
assert_eq!(bad.minidump_subcode(), Some(0x10));
let other = MachException {
kind: EXC_SOFTWARE,
code: 1,
subcode: 9,
};
assert_eq!(other.minidump_subcode(), None);
}
#[test]
fn abort_reply_timeout_or_error_is_failure() {
assert!(abort_reply_ok(0));
assert!(!abort_reply_ok(0x10004003)); // MACH_RCV_TIMED_OUT
assert!(!abort_reply_ok(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");
}
}