Publish harness and TUI open-source

initial sync from the monorepo
This commit is contained in:
grokkybara[bot] 2026-07-16 06:46:02 +01:00
commit c68e39f604
2734 changed files with 1437016 additions and 0 deletions

View file

@ -0,0 +1,33 @@
[package]
license = "Apache-2.0"
edition.workspace = true
name = "xai-crash-handler"
version = "0.1.0"
description = "Cross-platform crash handler (Unix signals + Windows SEH) with startup crash detection"
[target.'cfg(unix)'.dependencies]
libc = { workspace = true }
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = [
"Win32_Foundation",
"Win32_Security",
"Win32_Storage_FileSystem",
"Win32_System_Console",
"Win32_System_Diagnostics_Debug",
"Win32_System_IO",
"Win32_System_Kernel",
"Win32_System_SystemInformation",
"Win32_System_Threading",
] }
[dependencies]
backtrace = { workspace = true }
[target.'cfg(unix)'.dev-dependencies]
libc = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "time", "signal", "io-util", "net"] }
tempfile = { workspace = true }
[lints]
workspace = true

View file

@ -0,0 +1,47 @@
# xai-crash-handler
Crash handler for SIGBUS/SIGSEGV with best-effort backtrace capture.
## How it works
`install()` registers a `sigaction` handler. On crash it writes a binary blob (`GCRX` format) to `crash_dir/last-crash.bin` and restores the terminal via pre-computed escape sequences. The handler uses only async-signal-safe operations for file I/O, terminal restore, and re-raise.
On next launch, `check_previous_crash()` reads the blob, resolves IPs to symbols via `backtrace`, writes `last-crash-report.txt`, and archives it (keeping the last 5 reports).
No-ops on non-unix platforms. On musl-based Linux (release builds), the handler still records signal/address/version but skips frame capture since musl does not provide `backtrace()`.
## Limitations
### Frame capture is best-effort
Frame capture uses two fully async-signal-safe techniques:
1. The crash instruction pointer is extracted directly from the `ucontext_t` passed by the kernel.
2. Additional frames are captured by walking the frame-pointer chain (RBP on x86_64, x29 on aarch64) with raw pointer reads.
In release builds without `-C force-frame-pointers`, the frame-pointer chain may be incomplete or empty (the compiler omits frame pointers by default for optimization). The crash PC is always captured. In debug/dev builds, frame pointers are retained by default, producing fuller call stacks.
### sigaltstack is per-thread
The alternate signal stack is installed only on the thread that calls `install()`. Tokio worker threads do not inherit it. Stack overflows on worker threads will still trigger the handler (sigaction is process-wide), but without altstack protection the handler itself may fault on the overflowed stack.
## Usage
```rust
use std::path::PathBuf;
let crash_dir = PathBuf::from("/home/user/.myapp/crash");
// check_previous_crash MUST be called before install(), because
// install() opens last-crash.bin with O_TRUNC.
if let Some(r) = xai_crash_handler::check_previous_crash(&crash_dir) {
eprintln!("Crashed last session: {}", r.signal_name);
eprintln!("Report: {}", r.report_path.display());
}
// install() before any threads or async runtime — sigaltstack is per-thread.
// Creates crash_dir if it does not exist.
xai_crash_handler::install(xai_crash_handler::CrashHandlerConfig {
app_version: env!("CARGO_PKG_VERSION").to_string(),
crash_dir,
});
```

View file

@ -0,0 +1,214 @@
//! Binary crash blob format ("GCRX").
//!
//! The signal handler writes this format using only `libc::write` (no allocation).
//! The startup reader parses it in normal Rust context.
/// Magic bytes identifying a valid crash file.
pub const MAGIC: [u8; 4] = *b"GCRX";
/// Current format version.
pub const VERSION: u8 = 1;
/// Maximum backtrace frames captured in the signal handler.
pub const MAX_FRAMES: usize = 64;
/// Length of the null-padded version string field.
pub const VERSION_STRING_LEN: usize = 32;
/// Fixed header size (before the variable-length frames array).
///
/// Layout:
/// - magic: 4 bytes
/// - version: 1 byte
/// - signal: 1 byte
/// - si_code: 4 bytes (i32, little-endian)
/// - si_addr: 8 bytes (u64, little-endian)
/// - pid: 4 bytes (u32, little-endian)
/// - timestamp: 8 bytes (u64, little-endian)
/// - n_frames: 2 bytes (u16, little-endian)
/// - app_version: 32 bytes (null-padded UTF-8)
pub const HEADER_SIZE: usize = 4 + 1 + 1 + 4 + 8 + 4 + 8 + 2 + VERSION_STRING_LEN;
/// Total maximum file size: header + 64 frames * 8 bytes each.
pub const MAX_FILE_SIZE: usize = HEADER_SIZE + MAX_FRAMES * 8;
/// Parsed crash data from a `last-crash.bin` file.
#[derive(Debug, Clone)]
pub struct CrashBlob {
pub signal: u8,
pub si_code: i32,
pub si_addr: u64,
pub pid: u32,
pub timestamp: u64,
pub frames: Vec<usize>,
pub app_version: String,
}
impl CrashBlob {
/// Parse a crash blob from bytes. Returns `None` if the data is invalid.
pub fn parse(data: &[u8]) -> Option<Self> {
if data.len() < HEADER_SIZE {
return None;
}
if data[0..4] != MAGIC {
return None;
}
if data[4] != VERSION {
return None;
}
let signal = data[5];
let si_code = i32::from_le_bytes([data[6], data[7], data[8], data[9]]);
let si_addr = u64::from_le_bytes([
data[10], data[11], data[12], data[13], data[14], data[15], data[16], data[17],
]);
let pid = u32::from_le_bytes([data[18], data[19], data[20], data[21]]);
let timestamp = u64::from_le_bytes([
data[22], data[23], data[24], data[25], data[26], data[27], data[28], data[29],
]);
let n_frames = u16::from_le_bytes([data[30], data[31]]) as usize;
let version_bytes = &data[32..32 + VERSION_STRING_LEN];
let app_version = std::str::from_utf8(version_bytes)
.unwrap_or("")
.trim_end_matches('\0')
.to_string();
if n_frames > MAX_FRAMES {
return None;
}
let frames_start = HEADER_SIZE;
let frames_end = frames_start + n_frames * 8;
if data.len() < frames_end {
return None;
}
let mut frames = Vec::with_capacity(n_frames);
for i in 0..n_frames {
let offset = frames_start + i * 8;
let addr = u64::from_le_bytes([
data[offset],
data[offset + 1],
data[offset + 2],
data[offset + 3],
data[offset + 4],
data[offset + 5],
data[offset + 6],
data[offset + 7],
]);
frames.push(addr as usize);
}
Some(CrashBlob {
signal,
si_code,
si_addr,
pid,
timestamp,
frames,
app_version,
})
}
}
/// Helpers for writing fields in the signal handler using raw byte copies.
/// These are used by `handler.rs` — all operations are on a pre-allocated
/// static buffer, no allocation involved.
pub mod writer {
use super::{MAGIC, VERSION, VERSION_STRING_LEN};
/// Write the crash blob header into `buf`, returning the number of bytes written.
/// The caller must ensure `buf` is at least `HEADER_SIZE` bytes.
///
/// # Safety
///
/// This is called from a signal handler. The buffer must be valid and large enough.
pub unsafe fn write_header(
buf: &mut [u8],
signal: u8,
si_code: i32,
si_addr: u64,
pid: u32,
timestamp: u64,
n_frames: u16,
app_version: &[u8],
) -> usize {
buf[0..4].copy_from_slice(&MAGIC);
buf[4] = VERSION;
buf[5] = signal;
buf[6..10].copy_from_slice(&si_code.to_le_bytes());
buf[10..18].copy_from_slice(&si_addr.to_le_bytes());
buf[18..22].copy_from_slice(&pid.to_le_bytes());
buf[22..30].copy_from_slice(&timestamp.to_le_bytes());
buf[30..32].copy_from_slice(&n_frames.to_le_bytes());
// Null-pad the version string field.
let version_field = &mut buf[32..32 + VERSION_STRING_LEN];
version_field.fill(0);
let copy_len = app_version.len().min(VERSION_STRING_LEN);
version_field[..copy_len].copy_from_slice(&app_version[..copy_len]);
32 + VERSION_STRING_LEN
}
/// Write a single frame pointer into `buf` at the given offset.
/// Returns the new offset.
///
/// # Safety
///
/// The caller must ensure `buf[offset..offset+8]` is valid.
pub unsafe fn write_frame(buf: &mut [u8], offset: usize, addr: usize) -> usize {
buf[offset..offset + 8].copy_from_slice(&(addr as u64).to_le_bytes());
offset + 8
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip_crash_blob() {
let mut buf = [0u8; MAX_FILE_SIZE];
let version = b"0.1.169-alpha.2";
let frames: &[usize] = &[0xdead_beef, 0xcafe_babe, 0x1234_5678];
unsafe {
let mut offset = writer::write_header(
&mut buf,
10, // SIGBUS on macOS
2, // BUS_ADRERR
0x7f8a_1234_0000,
42,
1_712_678_587,
frames.len() as u16,
version,
);
for &frame in frames {
offset = writer::write_frame(&mut buf, offset, frame);
}
let blob = CrashBlob::parse(&buf[..offset]).expect("parse should succeed");
assert_eq!(blob.signal, 10);
assert_eq!(blob.si_code, 2);
assert_eq!(blob.si_addr, 0x7f8a_1234_0000);
assert_eq!(blob.pid, 42);
assert_eq!(blob.timestamp, 1_712_678_587);
assert_eq!(blob.frames, frames);
assert_eq!(blob.app_version, "0.1.169-alpha.2");
}
}
#[test]
fn rejects_bad_magic() {
let mut buf = [0u8; HEADER_SIZE];
buf[0..4].copy_from_slice(b"NOPE");
assert!(CrashBlob::parse(&buf).is_none());
}
#[test]
fn rejects_truncated_data() {
assert!(CrashBlob::parse(&[]).is_none());
assert!(CrashBlob::parse(&MAGIC).is_none());
}
}

View file

@ -0,0 +1,923 @@
//! Cross-platform crash handler for fatal memory faults.
//!
//! - **Unix**: SIGBUS/SIGSEGV via `sigaction(2)`.
//! - **Windows**: `EXCEPTION_ACCESS_VIOLATION` et al. via `SetUnhandledExceptionFilter`.
//!
//! Captures crash PC + frame-pointer chain. All handler operations are
//! minimal (raw pointer reads, direct file I/O, atomics — no allocation).
//! The crash PC is written to disk before frame walking so a secondary
//! fault during the walk still produces a usable report.
#[cfg(unix)]
mod imp {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
use std::path::Path;
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
use crate::format::{self, MAX_FILE_SIZE, MAX_FRAMES};
use crate::terminal;
// ── Platform-specific ucontext access ────────────────────────────────
//
// The libc crate does not expose ucontext_t on macOS. We define minimal
// repr(C) types covering only the fields we need (PC and frame pointer).
/// Extract the crash instruction pointer and frame pointer from the
/// signal handler's context parameter.
///
/// Returns `(instruction_pointer, frame_pointer)`. Both may be 0 if
/// the context is null or the platform is unsupported.
unsafe fn extract_pc_and_fp(ctx: *mut libc::c_void) -> (usize, usize) {
if ctx.is_null() {
return (0, 0);
}
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
unsafe {
let uc = ctx as *const libc::ucontext_t;
let gregs = &(*uc).uc_mcontext.gregs;
let ip = gregs[libc::REG_RIP as usize] as usize;
let fp = gregs[libc::REG_RBP as usize] as usize;
return (ip, fp);
}
#[cfg(all(target_os = "linux", target_arch = "aarch64"))]
unsafe {
let uc = ctx as *const libc::ucontext_t;
let mc = &(*uc).uc_mcontext;
let ip = mc.pc as usize;
let fp = mc.regs[29] as usize; // x29 = frame pointer
return (ip, fp);
}
// macOS does not expose ucontext_t in the libc crate.
// Define minimal repr(C) types for the fields we need.
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
{
#[repr(C)]
struct Arm64ThreadState {
regs: [u64; 29], // x0-x28
fp: u64, // x29
lr: u64, // x30
sp: u64,
pc: u64,
cpsr: u32,
_pad: u32,
}
#[repr(C)]
struct MachMcontext {
_es: [u8; 16], // __darwin_arm_exception_state64 (far:u64 + esr:u32 + exception:u32)
ss: Arm64ThreadState,
// neon state follows but we don't need it
}
#[repr(C)]
struct DarwinUcontext {
_onstack: i32,
_sigmask: u32,
_stack: libc::stack_t,
_link: *mut libc::c_void,
_mcsize: usize,
mctx: *const MachMcontext,
}
let (ip, fp) = unsafe {
let uc = ctx as *const DarwinUcontext;
let mctx = (*uc).mctx;
if mctx.is_null() {
return (0, 0);
}
((*mctx).ss.pc as usize, (*mctx).ss.fp as usize)
};
return (ip, fp);
}
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
{
#[repr(C)]
struct X86ThreadState {
_rax: u64,
_rbx: u64,
_rcx: u64,
_rdx: u64,
_rdi: u64,
_rsi: u64,
rbp: u64,
_rsp: u64,
_r8: u64,
_r9: u64,
_r10: u64,
_r11: u64,
_r12: u64,
_r13: u64,
_r14: u64,
_r15: u64,
rip: u64,
_rflags: u64,
_cs: u64,
_fs: u64,
_gs: u64,
}
#[repr(C)]
struct MachMcontext {
_es: [u8; 16], // __darwin_x86_exception_state64
ss: X86ThreadState,
}
#[repr(C)]
struct DarwinUcontext {
_onstack: i32,
_sigmask: u32,
_stack: libc::stack_t,
_link: *mut libc::c_void,
_mcsize: usize,
mctx: *const MachMcontext,
}
let (ip, fp) = unsafe {
let uc = ctx as *const DarwinUcontext;
let mctx = (*uc).mctx;
if mctx.is_null() {
return (0, 0);
}
((*mctx).ss.rip as usize, (*mctx).ss.rbp as usize)
};
return (ip, fp);
}
// Unsupported platform — no frames.
#[allow(unreachable_code)]
(0, 0)
}
/// Walk the frame-pointer chain, collecting return addresses.
///
/// Fully async-signal-safe: only raw pointer reads, no library calls.
/// Stops at the first invalid (null, misaligned, or suspiciously small)
/// frame pointer.
unsafe fn walk_frame_pointers(initial_fp: usize, out: &mut [usize], max: usize) -> usize {
let mut fp = initial_fp;
let mut count = 0;
while count < max {
// Validate: non-null, pointer-aligned, not in the zero page.
if fp == 0 || fp < 4096 || !fp.is_multiple_of(core::mem::size_of::<usize>()) {
break;
}
// On both x86_64 and aarch64, the frame layout is:
// [fp+0] = previous frame pointer
// [fp+8] = return address
let prev_fp = unsafe { *(fp as *const usize) };
let ret_addr = unsafe { *((fp + core::mem::size_of::<usize>()) as *const usize) };
if ret_addr == 0 || ret_addr < 4096 {
break;
}
out[count] = ret_addr;
count += 1;
// Frame pointer must move upward (toward higher addresses on
// most architectures) to avoid infinite loops.
if prev_fp <= fp {
break;
}
fp = prev_fp;
}
count
}
/// File descriptor for the pre-opened crash file.
static CRASH_FD: AtomicI32 = AtomicI32::new(-1);
/// Pre-allocated write buffer (lives in .bss, zero cost when not crashing).
static mut CRASH_BUF: [u8; MAX_FILE_SIZE] = [0; MAX_FILE_SIZE];
/// Saved original terminal state for restoration in the signal handler.
static mut ORIGINAL_TERMIOS: libc::termios = unsafe { std::mem::zeroed() };
/// Whether we successfully saved the original termios.
static mut HAS_TERMIOS: bool = false;
/// Application version string, set at install time.
static mut APP_VERSION: [u8; format::VERSION_STRING_LEN] = [0; format::VERSION_STRING_LEN];
/// Alternate signal stack memory (16 KiB via mmap).
const ALT_STACK_SIZE: usize = 16 * 1024;
/// Guards against double-allocating the alternate signal stack when
/// [`install_terminal_restore_only`] is followed by [`install`].
static ALT_STACK_INSTALLED: AtomicBool = AtomicBool::new(false);
/// Save the current terminal state for restoration in signal handlers.
fn save_termios() {
unsafe {
let termios = &mut *std::ptr::addr_of_mut!(ORIGINAL_TERMIOS);
if libc::tcgetattr(0, termios) == 0 {
*std::ptr::addr_of_mut!(HAS_TERMIOS) = true;
}
}
}
/// Allocate an alternate signal stack via mmap (survives stack overflow).
///
/// No-op if already installed (idempotent across
/// [`install_terminal_restore_only`] → [`install`] sequences).
fn setup_alt_stack() {
if ALT_STACK_INSTALLED.swap(true, Ordering::AcqRel) {
return;
}
unsafe {
let stack_mem = libc::mmap(
std::ptr::null_mut(),
ALT_STACK_SIZE,
libc::PROT_READ | libc::PROT_WRITE,
libc::MAP_PRIVATE | libc::MAP_ANONYMOUS,
-1,
0,
);
if stack_mem != libc::MAP_FAILED {
let ss = libc::stack_t {
ss_sp: stack_mem,
ss_flags: 0,
ss_size: ALT_STACK_SIZE,
};
libc::sigaltstack(&ss, std::ptr::null_mut());
}
}
}
/// Restore termios and re-raise. No escape codes.
///
/// # Safety
///
/// Must only be called from a signal handler context.
unsafe fn restore_termios_and_reraise(sig: libc::c_int) {
unsafe {
if *std::ptr::addr_of!(HAS_TERMIOS) {
libc::tcsetattr(0, libc::TCSANOW, std::ptr::addr_of!(ORIGINAL_TERMIOS));
}
let mut sa: libc::sigaction = std::mem::zeroed();
sa.sa_sigaction = libc::SIG_DFL;
sa.sa_flags = 0;
libc::sigemptyset(&mut sa.sa_mask);
libc::sigaction(sig, &sa, std::ptr::null_mut());
libc::raise(sig);
}
}
/// Restore terminal escape codes + termios, then re-raise.
///
/// # Safety
///
/// Must only be called from a signal handler context.
unsafe fn restore_terminal_and_reraise(sig: libc::c_int) {
unsafe {
terminal::restore_in_signal_handler();
restore_termios_and_reraise(sig);
}
}
/// Register a signal handler for SIGBUS and SIGSEGV.
///
/// Flags: `SA_SIGINFO | SA_ONSTACK | SA_RESETHAND`. `SA_RESETHAND`
/// resets disposition to `SIG_DFL` after delivery, preventing recursive
/// faults in the handler from looping.
///
/// # Safety
///
/// `handler` must be a valid `sa_sigaction`-compatible function pointer.
unsafe fn register_crash_signals(
handler: unsafe extern "C" fn(libc::c_int, *mut libc::siginfo_t, *mut libc::c_void),
) {
unsafe {
let mut sa: libc::sigaction = std::mem::zeroed();
sa.sa_sigaction = handler as *const () as usize;
sa.sa_flags = libc::SA_SIGINFO | libc::SA_ONSTACK | libc::SA_RESETHAND;
libc::sigemptyset(&mut sa.sa_mask);
libc::sigaction(libc::SIGBUS, &sa, std::ptr::null_mut());
libc::sigaction(libc::SIGSEGV, &sa, std::ptr::null_mut());
}
}
/// Minimal handler: restore termios only (no escape codes), then re-raise.
unsafe extern "C" fn terminal_restore_handler_basic(
sig: libc::c_int,
_info: *mut libc::siginfo_t,
_ctx: *mut libc::c_void,
) {
unsafe {
restore_termios_and_reraise(sig);
}
}
/// Minimal handler: restore escape codes + termios, then re-raise.
unsafe extern "C" fn terminal_restore_handler(
sig: libc::c_int,
_info: *mut libc::siginfo_t,
_ctx: *mut libc::c_void,
) {
unsafe {
restore_terminal_and_reraise(sig);
}
}
/// Write crash blob to the pre-opened fd. Shared by crash handler variants.
///
/// # Safety
///
/// Signal handler context. Only async-signal-safe operations.
unsafe fn write_crash_blob(
sig: libc::c_int,
info: *mut libc::siginfo_t,
ctx: *mut libc::c_void,
) {
unsafe {
let fd = CRASH_FD.load(Ordering::Relaxed);
if fd >= 0 {
let si_code = if !info.is_null() { (*info).si_code } else { 0 };
#[cfg(target_os = "macos")]
let si_addr = if !info.is_null() {
(*info).si_addr as u64
} else {
0
};
#[cfg(target_os = "linux")]
let si_addr = if !info.is_null() {
(*info).si_addr() as u64
} else {
0
};
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
let si_addr: u64 = 0;
let pid = libc::getpid() as u32;
let timestamp = libc::time(std::ptr::null_mut()) as u64;
let mut frames: [usize; MAX_FRAMES] = [0; MAX_FRAMES];
let mut n_frames: u16 = 0;
let buf = &mut *std::ptr::addr_of_mut!(CRASH_BUF);
let version = &*std::ptr::addr_of!(APP_VERSION);
let (crash_pc, crash_fp) = extract_pc_and_fp(ctx);
if crash_pc != 0 {
frames[0] = crash_pc;
n_frames = 1;
}
// Write the blob with the crash PC before walking frames.
// Frame walking dereferences arbitrary pointers and can fault;
// SA_RESETHAND would kill us without writing anything.
let mut offset = format::writer::write_header(
buf, sig as u8, si_code, si_addr, pid, timestamp, n_frames, version,
);
for frame in frames.iter().take(n_frames as usize) {
offset = format::writer::write_frame(buf, offset, *frame);
}
libc::write(fd, buf.as_ptr() as *const libc::c_void, offset);
// Best-effort: walk frame pointers for additional context.
// If this faults, the 1-frame blob above is already on disk.
if crash_fp != 0 && crash_pc != 0 {
let walked = walk_frame_pointers(crash_fp, &mut frames[1..], MAX_FRAMES - 1);
if walked > 0 {
n_frames += walked as u16;
let mut offset = format::writer::write_header(
buf, sig as u8, si_code, si_addr, pid, timestamp, n_frames, version,
);
for frame in frames.iter().take(n_frames as usize) {
offset = format::writer::write_frame(buf, offset, *frame);
}
libc::lseek(fd, 0, libc::SEEK_SET);
libc::write(fd, buf.as_ptr() as *const libc::c_void, offset);
}
}
CRASH_FD.store(-1, Ordering::Relaxed);
libc::close(fd);
}
}
}
/// Crash handler: blob + termios only (no escape codes).
unsafe extern "C" fn crash_handler_basic(
sig: libc::c_int,
info: *mut libc::siginfo_t,
ctx: *mut libc::c_void,
) {
unsafe {
libc::alarm(3);
write_crash_blob(sig, info, ctx);
restore_termios_and_reraise(sig);
}
}
/// Crash handler: blob + escape codes + termios.
unsafe extern "C" fn crash_handler(
sig: libc::c_int,
info: *mut libc::siginfo_t,
ctx: *mut libc::c_void,
) {
unsafe {
libc::alarm(3);
write_crash_blob(sig, info, ctx);
restore_terminal_and_reraise(sig);
}
}
/// Install a minimal SIGSEGV/SIGBUS handler that restores termios on crash.
///
/// Does NOT write terminal escape codes — call
/// [`enable_terminal_escape_restore`] after TUI modes are enabled.
///
/// If [`install`] is called later, it replaces these handlers.
pub fn install_terminal_restore_only() {
save_termios();
setup_alt_stack();
unsafe { register_crash_signals(terminal_restore_handler_basic) };
}
/// Install the crash handler. Must be called early in `main()`, before any
/// terminal initialization or async runtime setup.
pub fn install(crash_dir: &Path, grok_version: &str) -> bool {
let crash_file = crash_dir.join("last-crash.bin");
// Create the crash directory if it doesn't exist.
if std::fs::create_dir_all(crash_dir).is_err() {
return false;
}
// Open crash file (pre-opened fd for the signal handler).
let c_path = match CString::new(crash_file.as_os_str().as_bytes()) {
Ok(p) => p,
Err(_) => return false,
};
let fd = unsafe {
libc::open(
c_path.as_ptr(),
libc::O_WRONLY | libc::O_CREAT | libc::O_TRUNC,
0o644,
)
};
if fd < 0 {
return false;
}
CRASH_FD.store(fd, Ordering::Relaxed);
// Store version string.
unsafe {
let version = &mut *std::ptr::addr_of_mut!(APP_VERSION);
version.fill(0);
let copy_len = grok_version.len().min(format::VERSION_STRING_LEN);
version[..copy_len].copy_from_slice(&grok_version.as_bytes()[..copy_len]);
}
save_termios();
setup_alt_stack();
unsafe { register_crash_signals(crash_handler_basic) };
true
}
/// Upgrade SIGSEGV/SIGBUS handlers to include terminal escape code
/// restoration. Call when TUI modes are enabled.
pub fn enable_terminal_escape_restore() {
unsafe {
register_crash_signals(if CRASH_FD.load(Ordering::Relaxed) >= 0 {
crash_handler
} else {
terminal_restore_handler
});
}
}
/// Downgrade SIGSEGV/SIGBUS handlers to termios-only restoration.
/// Call when TUI modes are disabled.
pub fn disable_terminal_escape_restore() {
unsafe {
register_crash_signals(if CRASH_FD.load(Ordering::Relaxed) >= 0 {
crash_handler_basic
} else {
terminal_restore_handler_basic
});
}
}
}
#[cfg(unix)]
pub use imp::{
disable_terminal_escape_restore, enable_terminal_escape_restore, install,
install_terminal_restore_only,
};
#[cfg(windows)]
mod win {
use std::ffi::c_void;
use std::path::Path;
use std::sync::atomic::{AtomicPtr, Ordering};
use crate::format::{self, MAX_FILE_SIZE, MAX_FRAMES};
static CRASH_HANDLE: AtomicPtr<c_void> = AtomicPtr::new(std::ptr::null_mut());
static mut CRASH_BUF: [u8; MAX_FILE_SIZE] = [0; MAX_FILE_SIZE];
static mut APP_VERSION: [u8; format::VERSION_STRING_LEN] = [0; format::VERSION_STRING_LEN];
const EXCEPTION_ACCESS_VIOLATION: i32 = 0xC0000005_u32 as i32;
const EXCEPTION_STACK_OVERFLOW: i32 = 0xC00000FD_u32 as i32;
const EXCEPTION_IN_PAGE_ERROR: i32 = 0xC0000006_u32 as i32;
const EXCEPTION_ILLEGAL_INSTRUCTION: i32 = 0xC000001D_u32 as i32;
const EXCEPTION_ARRAY_BOUNDS_EXCEEDED: i32 = 0xC000008C_u32 as i32;
const EXCEPTION_CONTINUE_SEARCH: i32 = 0;
const INVALID_HANDLE_VALUE: *mut c_void = -1isize as *mut c_void;
// CreateFileW constants.
const GENERIC_WRITE: u32 = 0x40000000;
const CREATE_ALWAYS: u32 = 2;
const FILE_ATTRIBUTE_NORMAL: u32 = 0x00000080;
const FILE_BEGIN: u32 = 0;
/// Walk the frame-pointer chain, collecting return addresses.
///
/// [fp+0] = previous frame pointer, [fp+8] = return address.
/// Stops at null, misaligned, or non-ascending frame pointers.
unsafe fn walk_frame_pointers(initial_fp: usize, out: &mut [usize], max: usize) -> usize {
let mut fp = initial_fp;
let mut count = 0;
while count < max {
if fp == 0 || fp < 4096 || !fp.is_multiple_of(core::mem::size_of::<usize>()) {
break;
}
let prev_fp = unsafe { *(fp as *const usize) };
let ret_addr = unsafe { *((fp + core::mem::size_of::<usize>()) as *const usize) };
if ret_addr == 0 || ret_addr < 4096 {
break;
}
out[count] = ret_addr;
count += 1;
if prev_fp <= fp {
break;
}
fp = prev_fp;
}
count
}
/// Map Windows exception code to a Unix signal number for the blob format.
fn exception_to_signal(code: i32) -> u8 {
match code {
EXCEPTION_IN_PAGE_ERROR => 7, // SIGBUS
EXCEPTION_ILLEGAL_INSTRUCTION => 4, // SIGILL
_ => 11, // SIGSEGV
}
}
/// Whether the exception code is a fatal memory/instruction fault that
/// warrants crash handling.
fn is_fatal_exception(code: i32) -> bool {
matches!(
code,
EXCEPTION_ACCESS_VIOLATION
| EXCEPTION_STACK_OVERFLOW
| EXCEPTION_IN_PAGE_ERROR
| EXCEPTION_ILLEGAL_INSTRUCTION
| EXCEPTION_ARRAY_BOUNDS_EXCEEDED
)
}
unsafe extern "system" fn crash_handler(
info: *const windows_sys::Win32::System::Diagnostics::Debug::EXCEPTION_POINTERS,
) -> i32 {
unsafe {
if info.is_null() {
return EXCEPTION_CONTINUE_SEARCH;
}
let exception_record = (*info).ExceptionRecord;
let context_record = (*info).ContextRecord;
if exception_record.is_null() || context_record.is_null() {
return EXCEPTION_CONTINUE_SEARCH;
}
let exception_code = (*exception_record).ExceptionCode;
if !is_fatal_exception(exception_code) {
return EXCEPTION_CONTINUE_SEARCH;
}
let handle = CRASH_HANDLE.load(Ordering::Relaxed);
if handle.is_null() || handle == INVALID_HANDLE_VALUE {
return EXCEPTION_CONTINUE_SEARCH;
}
let signal = exception_to_signal(exception_code);
let si_code = exception_code as i32;
// ExceptionInformation[1] holds the faulting address for ACCESS_VIOLATION.
let si_addr = if exception_code == EXCEPTION_ACCESS_VIOLATION
&& (*exception_record).NumberParameters >= 2
{
(*exception_record).ExceptionInformation[1] as u64
} else {
0
};
let pid = windows_sys::Win32::System::Threading::GetCurrentProcessId();
let mut ft = windows_sys::Win32::Foundation::FILETIME {
dwLowDateTime: 0,
dwHighDateTime: 0,
};
windows_sys::Win32::System::SystemInformation::GetSystemTimeAsFileTime(&mut ft);
let win_ticks = (ft.dwHighDateTime as u64) << 32 | ft.dwLowDateTime as u64;
// FILETIME epoch (1601) → Unix epoch (1970): 116444736000000000 100ns ticks.
let timestamp = win_ticks.saturating_sub(116_444_736_000_000_000) / 10_000_000;
let mut frames: [usize; MAX_FRAMES] = [0; MAX_FRAMES];
let mut n_frames: u16 = 0;
let buf = &mut *std::ptr::addr_of_mut!(CRASH_BUF);
let version = &*std::ptr::addr_of!(APP_VERSION);
#[cfg(target_arch = "x86_64")]
let (crash_pc, crash_fp) = (
(*context_record).Rip as usize,
(*context_record).Rbp as usize,
);
// ARM64 Windows: capture PC only; frame-pointer walking is
// unreliable without verifying the exact windows-sys CONTEXT layout.
#[cfg(not(target_arch = "x86_64"))]
let (crash_pc, crash_fp) = (0usize, 0usize);
if crash_pc != 0 {
frames[0] = crash_pc;
n_frames = 1;
}
// Write crash PC blob first (frame walking can fault).
let mut offset = format::writer::write_header(
buf, signal, si_code, si_addr, pid, timestamp, n_frames, version,
);
for frame in frames.iter().take(n_frames as usize) {
offset = format::writer::write_frame(buf, offset, *frame);
}
write_to_handle(handle, buf, offset);
// Best-effort: walk frame pointers for a full backtrace.
if crash_fp != 0 && crash_pc != 0 {
let walked = walk_frame_pointers(crash_fp, &mut frames[1..], MAX_FRAMES - 1);
if walked > 0 {
n_frames += walked as u16;
let mut offset = format::writer::write_header(
buf, signal, si_code, si_addr, pid, timestamp, n_frames, version,
);
for frame in frames.iter().take(n_frames as usize) {
offset = format::writer::write_frame(buf, offset, *frame);
}
windows_sys::Win32::Storage::FileSystem::SetFilePointer(
handle,
0,
std::ptr::null_mut(),
FILE_BEGIN,
);
write_to_handle(handle, buf, offset);
}
}
CRASH_HANDLE.store(std::ptr::null_mut(), Ordering::Relaxed);
windows_sys::Win32::Foundation::CloseHandle(handle);
EXCEPTION_CONTINUE_SEARCH
}
}
/// Crash handler with escape code restoration (TUI active).
unsafe extern "system" fn crash_handler_with_terminal(
info: *const windows_sys::Win32::System::Diagnostics::Debug::EXCEPTION_POINTERS,
) -> i32 {
let result = unsafe { crash_handler(info) };
crate::terminal::restore_in_signal_handler();
result
}
unsafe fn write_to_handle(handle: *mut c_void, buf: &[u8], len: usize) {
let mut written: u32 = 0;
unsafe {
windows_sys::Win32::Storage::FileSystem::WriteFile(
handle,
buf.as_ptr(),
len as u32,
&mut written,
std::ptr::null_mut(),
);
}
}
/// Minimal exception filter: no-op (no escape codes, no crash reporting).
unsafe extern "system" fn terminal_restore_filter_basic(
_info: *const windows_sys::Win32::System::Diagnostics::Debug::EXCEPTION_POINTERS,
) -> i32 {
EXCEPTION_CONTINUE_SEARCH
}
/// Minimal exception filter: restore terminal escape codes (TUI active).
unsafe extern "system" fn terminal_restore_filter(
info: *const windows_sys::Win32::System::Diagnostics::Debug::EXCEPTION_POINTERS,
) -> i32 {
unsafe {
if info.is_null() {
return EXCEPTION_CONTINUE_SEARCH;
}
let exception_record = (*info).ExceptionRecord;
if exception_record.is_null() {
return EXCEPTION_CONTINUE_SEARCH;
}
let exception_code = (*exception_record).ExceptionCode;
if !is_fatal_exception(exception_code) {
return EXCEPTION_CONTINUE_SEARCH;
}
crate::terminal::restore_in_signal_handler();
EXCEPTION_CONTINUE_SEARCH
}
}
pub fn install_terminal_restore_only() {
unsafe {
windows_sys::Win32::System::Diagnostics::Debug::SetUnhandledExceptionFilter(Some(
terminal_restore_filter_basic,
));
}
}
pub fn install(crash_dir: &Path, grok_version: &str) -> bool {
use std::os::windows::ffi::OsStrExt;
let crash_file = crash_dir.join("last-crash.bin");
if std::fs::create_dir_all(crash_dir).is_err() {
return false;
}
let wide_path: Vec<u16> = crash_file
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
let handle = unsafe {
windows_sys::Win32::Storage::FileSystem::CreateFileW(
wide_path.as_ptr(),
GENERIC_WRITE,
0,
std::ptr::null(),
CREATE_ALWAYS,
FILE_ATTRIBUTE_NORMAL,
std::ptr::null_mut(),
)
};
if handle.is_null() || handle == INVALID_HANDLE_VALUE {
return false;
}
CRASH_HANDLE.store(handle, Ordering::Relaxed);
unsafe {
let version = &mut *std::ptr::addr_of_mut!(APP_VERSION);
version.fill(0);
let copy_len = grok_version.len().min(format::VERSION_STRING_LEN);
version[..copy_len].copy_from_slice(&grok_version.as_bytes()[..copy_len]);
}
unsafe {
windows_sys::Win32::System::Diagnostics::Debug::SetUnhandledExceptionFilter(Some(
crash_handler,
));
}
true
}
pub fn enable_terminal_escape_restore() {
unsafe {
let filter = if !CRASH_HANDLE.load(Ordering::Relaxed).is_null() {
crash_handler_with_terminal
} else {
terminal_restore_filter
};
windows_sys::Win32::System::Diagnostics::Debug::SetUnhandledExceptionFilter(Some(
filter,
));
}
}
pub fn disable_terminal_escape_restore() {
unsafe {
let filter = if !CRASH_HANDLE.load(Ordering::Relaxed).is_null() {
crash_handler
} else {
terminal_restore_filter_basic
};
windows_sys::Win32::System::Diagnostics::Debug::SetUnhandledExceptionFilter(Some(
filter,
));
}
}
}
#[cfg(windows)]
pub use win::{
disable_terminal_escape_restore, enable_terminal_escape_restore, install,
install_terminal_restore_only,
};
#[cfg(not(any(unix, windows)))]
pub fn install(_crash_dir: &std::path::Path, _app_version: &str) -> bool {
false
}
#[cfg(not(any(unix, windows)))]
pub fn install_terminal_restore_only() {}
#[cfg(not(any(unix, windows)))]
pub fn enable_terminal_escape_restore() {}
#[cfg(not(any(unix, windows)))]
pub fn disable_terminal_escape_restore() {}
#[cfg(all(test, unix))]
mod tests {
use std::sync::Mutex;
// SIGSEGV/SIGBUS handlers are process-global. Tests in this binary run on
// parallel threads, so any two tests that install/read these handlers race.
// Serialize them through this lock (poison-tolerant: a real assertion
// failure in one test must not cascade into the other).
static SIGNAL_STATE_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn install_terminal_restore_only_registers_handlers() {
let _guard = SIGNAL_STATE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
super::install_terminal_restore_only();
unsafe {
let mut sa: libc::sigaction = std::mem::zeroed();
assert_eq!(libc::sigaction(libc::SIGSEGV, std::ptr::null(), &mut sa), 0);
assert_ne!(
sa.sa_sigaction,
libc::SIG_DFL,
"SIGSEGV handler should not be SIG_DFL after install"
);
assert_ne!(
sa.sa_flags & libc::SA_ONSTACK,
0,
"SIGSEGV handler must use alternate signal stack"
);
// Note: SA_RESETHAND is set in our sigaction call but macOS XNU
// does not round-trip it through the sigaction query — the kernel
// stores it in ps_sigreset internally but returns sa_flags=0x41
// (SA_SIGINFO|SA_ONSTACK only). The flag IS honored for signal
// delivery. Verified via the integration test
// `sigsegv_produces_valid_crash_blob` which relies on SA_RESETHAND
// to re-raise with SIG_DFL after the handler runs.
assert_eq!(libc::sigaction(libc::SIGBUS, std::ptr::null(), &mut sa), 0);
assert_ne!(
sa.sa_sigaction,
libc::SIG_DFL,
"SIGBUS handler should not be SIG_DFL after install"
);
assert_ne!(
sa.sa_flags & libc::SA_ONSTACK,
0,
"SIGBUS handler must use alternate signal stack"
);
}
}
#[test]
fn full_install_replaces_minimal_handler() {
let _guard = SIGNAL_STATE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
super::install_terminal_restore_only();
let handler_before = unsafe {
let mut sa: libc::sigaction = std::mem::zeroed();
libc::sigaction(libc::SIGSEGV, std::ptr::null(), &mut sa);
sa.sa_sigaction
};
let dir = std::env::temp_dir().join("xai-crash-handler-test-replace");
let _ = std::fs::create_dir_all(&dir);
super::install(&dir, "test-version");
let handler_after = unsafe {
let mut sa: libc::sigaction = std::mem::zeroed();
libc::sigaction(libc::SIGSEGV, std::ptr::null(), &mut sa);
sa.sa_sigaction
};
assert_ne!(
handler_after, handler_before,
"full install should replace the minimal handler"
);
}
}

View file

@ -0,0 +1,178 @@
//! Cross-platform crash handler with startup crash detection.
//!
//! - **Unix**: SIGBUS/SIGSEGV via `sigaction(2)`.
//! - **Windows**: access violations via `SetUnhandledExceptionFilter`.
//!
//! # Usage
//!
//! Call [`check_previous_crash`] first to detect crashes from the previous
//! session, then [`install`] early in `main()`, before any async runtime or
//! thread spawning. `check_previous_crash` must run before `install` because
//! `install` opens `last-crash.bin` with `O_TRUNC`.
//!
//! ```rust,no_run
//! use std::path::PathBuf;
//!
//! let crash_dir = PathBuf::from("/home/user/.myapp/crash");
//!
//! if let Some(report) = xai_crash_handler::check_previous_crash(&crash_dir) {
//! eprintln!("Application crashed during your last session.");
//! eprintln!(" Signal: {}", report.signal_name);
//! eprintln!(" Report: {}", report.report_path.display());
//! }
//!
//! xai_crash_handler::install(xai_crash_handler::CrashHandlerConfig {
//! app_version: "0.1.0".to_string(),
//! crash_dir: crash_dir.clone(),
//! });
//! ```
pub mod format;
mod handler;
pub mod symbolicate;
pub mod terminal;
use std::path::{Path, PathBuf};
pub use symbolicate::ResolvedFrame;
const MAX_HISTORY: usize = 5;
/// Configuration for the crash handler.
pub struct CrashHandlerConfig {
/// Application version string (e.g. "0.1.169-alpha.2").
pub app_version: String,
/// Directory where crash dumps are written.
/// Created if it does not exist.
pub crash_dir: PathBuf,
}
/// Information about a crash from the previous session.
#[derive(Debug)]
pub struct CrashReport {
/// Human-readable signal name (e.g. "SIGBUS (Bus error)").
pub signal_name: &'static str,
/// The `si_code` from `siginfo_t`.
pub si_code: i32,
/// The faulting memory address.
pub faulting_address: u64,
/// Unix timestamp of the crash.
pub timestamp: u64,
/// Application version at crash time.
pub app_version: String,
/// Symbolicated backtrace frames.
pub backtrace: Vec<ResolvedFrame>,
/// Path to the saved human-readable crash report.
pub report_path: PathBuf,
}
/// Install the crash handler for SIGBUS and SIGSEGV.
///
/// Must be called early in `main()`, before any async runtime or thread
/// spawning. Creates `crash_dir` if it does not exist.
///
/// Returns `true` if the handler was installed successfully.
/// On unsupported platforms, this is a no-op that returns `false`.
pub fn install(config: CrashHandlerConfig) -> bool {
handler::install(&config.crash_dir, &config.app_version)
}
/// Install a minimal SIGSEGV/SIGBUS handler that only restores the terminal.
///
/// On Unix, saves the current termios state, allocates an alternate signal
/// stack, and registers a handler that writes terminal restore escape
/// sequences to stderr, restores termios, then re-raises with default
/// disposition (preserving core dumps).
///
/// On Windows, registers an unhandled-exception filter that writes restore
/// sequences; no termios equivalent.
///
/// No-op on unsupported platforms.
///
/// No crash reporting (no file I/O, no stack walking). If [`install`] is
/// called later, it replaces these handlers with full crash-reporting
/// variants.
pub fn install_terminal_restore_only() {
handler::install_terminal_restore_only()
}
/// Upgrade SIGSEGV/SIGBUS handlers to include terminal escape code
/// restoration. Call when TUI modes are enabled.
pub fn enable_terminal_escape_restore() {
handler::enable_terminal_escape_restore()
}
/// Downgrade SIGSEGV/SIGBUS handlers to termios-only restoration.
/// Call when TUI modes are disabled.
pub fn disable_terminal_escape_restore() {
handler::disable_terminal_escape_restore()
}
/// Check for a crash from the previous session.
///
/// Reads `crash_dir/last-crash.bin`, symbolicates the backtrace,
/// writes a human-readable report, and archives it. Returns `Some` if
/// a valid crash file was found, `None` otherwise.
pub fn check_previous_crash(crash_dir: &Path) -> Option<CrashReport> {
let crash_file = crash_dir.join("last-crash.bin");
let data = std::fs::read(&crash_file).ok()?;
let blob = format::CrashBlob::parse(&data)?;
let frames = symbolicate::resolve_frames(&blob);
let report_text = symbolicate::format_report(&blob, &frames);
// Write the human-readable report.
let report_path = crash_dir.join("last-crash-report.txt");
let _ = std::fs::write(&report_path, &report_text);
// Archive to history/ (keep last MAX_HISTORY).
archive_report(crash_dir, &report_text, blob.timestamp);
// Remove the binary blob so it's not re-processed.
let _ = std::fs::remove_file(&crash_file);
Some(CrashReport {
signal_name: symbolicate::signal_name(blob.signal),
si_code: blob.si_code,
faulting_address: blob.si_addr,
timestamp: blob.timestamp,
app_version: blob.app_version,
backtrace: frames,
report_path,
})
}
fn archive_report(crash_dir: &Path, report_text: &str, timestamp: u64) {
let history_dir = crash_dir.join("history");
let _ = std::fs::create_dir_all(&history_dir);
let filename = format!("crash-{}.txt", timestamp);
let _ = std::fs::write(history_dir.join(&filename), report_text);
// Prune old reports beyond MAX_HISTORY.
if let Ok(mut entries) = std::fs::read_dir(&history_dir) {
let mut files: Vec<PathBuf> = entries
.by_ref()
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == "txt"))
.collect();
files.sort();
if files.len() > MAX_HISTORY {
for old in &files[..files.len() - MAX_HISTORY] {
let _ = std::fs::remove_file(old);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn check_previous_crash_returns_none_when_no_file() {
let dir = PathBuf::from("/tmp/xai-crash-handler-test-nonexistent");
assert!(check_previous_crash(&dir).is_none());
}
}

View file

@ -0,0 +1,143 @@
//! Backtrace symbolication for crash reports.
//!
//! Runs at normal startup (not in a signal handler), so full Rust APIs
//! are available. Resolves raw instruction pointer addresses from the
//! crash blob into function names and file locations.
use crate::format::CrashBlob;
/// A resolved backtrace frame.
#[derive(Debug, Clone)]
pub struct ResolvedFrame {
pub ip: usize,
pub symbol_name: Option<String>,
pub filename: Option<String>,
pub lineno: Option<u32>,
}
/// Resolve raw instruction pointers from a crash blob into symbol names.
///
/// Uses the `backtrace` crate's `resolve` function. This works best when
/// the binary has debug info or at least a symbol table. For stripped
/// release binaries, symbol names may still be available (e.g.
/// `my_app::render::draw_frame`) but file/line info will
/// be missing.
pub fn resolve_frames(blob: &CrashBlob) -> Vec<ResolvedFrame> {
blob.frames
.iter()
.map(|&ip| {
let mut resolved = ResolvedFrame {
ip,
symbol_name: None,
filename: None,
lineno: None,
};
backtrace::resolve(ip as *mut std::ffi::c_void, |sym| {
if resolved.symbol_name.is_none() {
resolved.symbol_name = sym.name().map(|n| n.to_string());
resolved.filename = sym.filename().map(|f| f.display().to_string());
resolved.lineno = sym.lineno();
}
});
resolved
})
.collect()
}
/// Format a crash report as human-readable text.
pub fn format_report(blob: &CrashBlob, frames: &[ResolvedFrame]) -> String {
let mut out = String::with_capacity(4096);
out.push_str("=== Grok Crash Report ===\n\n");
out.push_str(&format!("Signal: {}\n", signal_name(blob.signal)));
out.push_str(&format!(
"si_code: {} ({})\n",
blob.si_code,
si_code_name(blob.signal, blob.si_code)
));
out.push_str(&format!("Address: {:#018x}\n", blob.si_addr));
out.push_str(&format!("PID: {}\n", blob.pid));
out.push_str(&format!("Version: {}\n", blob.app_version));
// Format timestamp as ISO 8601 (best-effort without chrono dependency).
out.push_str(&format!("Time: {} (unix)\n", blob.timestamp));
out.push_str(&format!("\nBacktrace ({} frames):\n", frames.len()));
for (i, frame) in frames.iter().enumerate() {
let name = frame.symbol_name.as_deref().unwrap_or("<unknown>");
out.push_str(&format!(" {:>3}: {:#018x} - {}\n", i, frame.ip, name));
if let (Some(file), Some(line)) = (&frame.filename, frame.lineno) {
out.push_str(&format!(" at {}:{}\n", file, line));
}
}
out.push_str("\n=== End Report ===\n");
out
}
pub fn signal_name(sig: u8) -> &'static str {
match sig as i32 {
4 => "SIGILL (Illegal instruction)",
// SIGBUS is 10 on macOS, 7 on Linux
7 | 10 => "SIGBUS (Bus error)",
11 => "SIGSEGV (Segmentation fault)",
_ => "Unknown signal",
}
}
fn si_code_name(sig: u8, code: i32) -> &'static str {
let is_bus = sig == 7 || sig == 10;
if is_bus {
match code {
1 => "BUS_ADRALN - invalid address alignment",
2 => "BUS_ADRERR - non-existent physical address",
3 => "BUS_OBJERR - object-specific hardware error",
_ => "unknown",
}
} else {
match code {
1 => "SEGV_MAPERR - address not mapped",
2 => "SEGV_ACCERR - invalid permissions",
_ => "unknown",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn signal_names() {
assert_eq!(signal_name(10), "SIGBUS (Bus error)");
assert_eq!(signal_name(7), "SIGBUS (Bus error)");
assert_eq!(signal_name(11), "SIGSEGV (Segmentation fault)");
}
#[test]
fn format_report_smoke() {
let blob = CrashBlob {
signal: 10,
si_code: 2,
si_addr: 0x7f8a_1234_0000,
pid: 42,
timestamp: 1_712_678_587,
frames: vec![0xdead_beef],
app_version: "0.1.169".to_string(),
};
let frames = vec![ResolvedFrame {
ip: 0xdead_beef,
symbol_name: Some("xai_grok_pager::main".to_string()),
filename: Some("src/main.rs".to_string()),
lineno: Some(42),
}];
let report = format_report(&blob, &frames);
assert!(report.contains("SIGBUS"));
assert!(report.contains("BUS_ADRERR"));
assert!(report.contains("xai_grok_pager::main"));
assert!(report.contains("src/main.rs:42"));
}
}

View file

@ -0,0 +1,134 @@
//! Terminal restore sequences for signal handler context.
//!
//! See <https://invisible-island.net/xterm/ctlseqs/ctlseqs.html> (DEC
//! Private Mode Reset / "Mouse Tracking" section) for the full spec.
// -----------------------------------------------------------------------
// Canonical list of DEC private modes we enable.
//
// Every mode the pager enables must appear here so that *all* teardown
// paths (normal exit, panic hook, signal handler) disable the same set.
//
// Mode Purpose Enabled by
// ---- ------- ----------
// ?1000 Normal mouse tracking (X11 press/release) EnableMouseCapture
// ?1002 Button-event mouse tracking (cell-motion held) EnableMouseCapture
// ?1003 All-motion mouse tracking (any movement) EnableMouseCapture
// ?1015 RXVT extended mouse reporting (coords >223) EnableMouseCapture
// ?1006 SGR extended mouse reporting format (preferred) EnableMouseCapture
// ?2004 Bracketed paste mode EnableBracketedPaste
// ?1004 Focus reporting (focus in/out events) EnableFocusChange
// ?25 Cursor visibility (show) cursor::Hide
// ?1049 Alternate screen buffer EnterAlternateScreen
// ?2026 Synchronized update BeginSynchronizedUpdate
// CSI<u Kitty keyboard protocol pop PushKeyboardEnhancementFlags
// -----------------------------------------------------------------------
/// Raw CSI sequences to disable every mouse-tracking mode the pager enables
/// (`?1000/?1002/?1003/?1015/?1006`) — the mouse subset of [`MOUSE_PASTE_RESET`],
/// without the bracketed-paste (`?2004l`) reset.
///
/// Use this to assert mouse tracking OFF without disturbing paste — e.g. to
/// clear a terminal left reporting by a prior run (crossterm's Windows
/// `DisableMouseCapture` is winapi-only and never emits this ANSI reset, so an
/// ANSI terminal such as JediTerm keeps reporting until it receives these bytes).
pub const MOUSE_TRACKING_RESET: &[u8] = b"\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1015l\x1b[?1006l";
/// Raw CSI sequences to disable mouse tracking and bracketed paste.
pub const MOUSE_PASTE_RESET: &[u8] =
b"\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1015l\x1b[?1006l\x1b[?2004l";
/// Full escape sequence to restore the terminal to a sane state.
///
/// The kitty CSI-u pop precedes `?1049l` per spec (the protocol stack
/// is per-screen).
pub const RESTORE_SEQ: &[u8] =
b"\x1b[?2026l\x1b[?25h\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1015l\x1b[?1006l\x1b[?2004l\x1b[?1004l\x1b[<u\x1b[?1049l";
/// Write terminal restore sequences to stderr using raw `libc::write`.
///
/// This is async-signal-safe: it only calls `write(2)` on fd 2 (stderr).
/// Called from the signal handler after writing the crash blob.
#[cfg(unix)]
pub fn restore_in_signal_handler() {
unsafe {
libc::write(
2, // stderr
RESTORE_SEQ.as_ptr() as *const libc::c_void,
RESTORE_SEQ.len(),
);
}
}
#[cfg(windows)]
pub fn restore_in_signal_handler() {
unsafe {
let stderr = windows_sys::Win32::System::Console::GetStdHandle(
windows_sys::Win32::System::Console::STD_ERROR_HANDLE,
);
if !stderr.is_null() && stderr != -1isize as *mut std::ffi::c_void {
let mut written: u32 = 0;
windows_sys::Win32::Storage::FileSystem::WriteFile(
stderr,
RESTORE_SEQ.as_ptr(),
RESTORE_SEQ.len() as u32,
&mut written,
std::ptr::null_mut(),
);
}
}
}
#[cfg(not(any(unix, windows)))]
pub fn restore_in_signal_handler() {
// No-op on unsupported platforms.
}
#[cfg(test)]
mod tests {
use super::*;
fn position_of(needle: &[u8]) -> usize {
RESTORE_SEQ
.windows(needle.len())
.position(|w| w == needle)
.unwrap_or_else(|| {
panic!(
"RESTORE_SEQ must contain {:?}",
std::str::from_utf8(needle).unwrap_or("<binary>")
)
})
}
#[test]
fn restore_seq_pops_kitty_before_alt_screen_leave() {
assert!(position_of(b"\x1b[<u") < position_of(b"\x1b[?1049l"));
}
#[test]
fn restore_seq_includes_all_modes() {
for needle in [
b"\x1b[?2026l".as_slice(),
b"\x1b[?25h".as_slice(),
b"\x1b[?1000l".as_slice(),
b"\x1b[?1002l".as_slice(),
b"\x1b[?1003l".as_slice(),
b"\x1b[?1015l".as_slice(),
b"\x1b[?1006l".as_slice(),
b"\x1b[?2004l".as_slice(),
b"\x1b[?1004l".as_slice(),
b"\x1b[<u".as_slice(),
b"\x1b[?1049l".as_slice(),
] {
position_of(needle);
}
}
#[test]
fn restore_seq_ends_synchronized_update_first() {
// Multiplexers (zellij/tmux) must stop buffering before subsequent
// resets arrive, otherwise they get batched onto the wrong screen.
let end_sync = b"\x1b[?2026l";
assert_eq!(&RESTORE_SEQ[..end_sync.len()], end_sync);
}
}

View file

@ -0,0 +1,283 @@
//! Integration tests for xai-crash-handler.
//!
//! These tests verify that installing the crash handler does not interfere
//! with normal program operation (tokio runtime, signal handling, I/O),
//! and that it correctly captures crash data when a fatal signal fires.
//!
//! Tests that send fatal signals use subprocess isolation: the test process
//! re-executes itself with an env var that selects the crash scenario, so
//! the parent can verify outcomes without dying.
#![cfg(unix)]
use std::path::Path;
use std::process::Command;
/// Re-invoke the current test binary as a subprocess with the given scenario.
/// Returns (exit status, stdout, stderr).
fn run_scenario(scenario: &str, crash_dir: &Path) -> (std::process::ExitStatus, String, String) {
let exe = std::env::current_exe().expect("current_exe");
let output = Command::new(exe)
.env("CRASH_TEST_SCENARIO", scenario)
.env("CRASH_TEST_DIR", crash_dir.as_os_str())
.arg("--ignored")
.arg("--exact")
.arg("--nocapture")
.arg("subprocess_entry")
.output()
.expect("failed to spawn subprocess");
(
output.status,
String::from_utf8_lossy(&output.stdout).into_owned(),
String::from_utf8_lossy(&output.stderr).into_owned(),
)
}
// ── Subprocess entry point ──────────────────────────────────────────────
/// This test is `#[ignore]`d so it only runs when invoked as a subprocess
/// by the parent test via `run_scenario`. The `CRASH_TEST_SCENARIO` env
/// var selects which scenario to execute.
#[test]
#[ignore]
fn subprocess_entry() {
let scenario = match std::env::var("CRASH_TEST_SCENARIO") {
Ok(s) => s,
Err(_) => return, // not a subprocess invocation
};
let crash_dir = std::env::var("CRASH_TEST_DIR").expect("CRASH_TEST_DIR");
let crash_dir = std::path::PathBuf::from(crash_dir);
// Install the crash handler before anything else.
let config = xai_crash_handler::CrashHandlerConfig {
app_version: "0.0.0-test".to_string(),
crash_dir,
};
xai_crash_handler::install(config);
match scenario.as_str() {
// Scenario 1: install handler, run tokio runtime with concurrent work, exit cleanly.
"tokio_normal" => {
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("tokio runtime");
rt.block_on(async {
// Spawn several concurrent tasks to stress the runtime.
let mut handles = Vec::new();
for i in 0..20 {
handles.push(tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
i * i
}));
}
let mut sum = 0u64;
for h in handles {
sum += h.await.unwrap();
}
// Also test signal infrastructure coexistence.
// Register a tokio SIGTERM handler (same as the pager does).
#[cfg(unix)]
{
use tokio::signal::unix::{SignalKind, signal};
let _term = signal(SignalKind::terminate())
.expect("tokio SIGTERM handler should work alongside crash handler");
}
eprintln!("tokio_normal: sum={sum}, all tasks completed");
});
}
// Scenario 2: install handler, do sync file I/O and computation, exit cleanly.
"sync_normal" => {
let tmp = tempfile::tempdir().expect("tempdir");
for i in 0..50 {
let path = tmp.path().join(format!("file-{i}.txt"));
std::fs::write(&path, format!("contents {i}")).expect("write");
let data = std::fs::read_to_string(&path).expect("read");
assert!(data.contains(&format!("{i}")));
}
eprintln!("sync_normal: 50 files written and read back");
}
// Scenario 3: install handler, send ourselves SIGBUS, verify crash file written.
"sigbus" => {
// Give the handler a moment to be fully installed, then crash.
unsafe { libc::raise(libc::SIGBUS) };
}
// Scenario 4: install handler, send ourselves SIGSEGV.
"sigsegv" => {
unsafe { libc::raise(libc::SIGSEGV) };
}
// Scenario 5: tokio runtime + signal coexistence, then clean shutdown.
"tokio_signals" => {
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("tokio runtime");
rt.block_on(async {
use tokio::signal::unix::{SignalKind, signal};
let mut usr1 = signal(SignalKind::user_defined1()).expect("SIGUSR1 handler");
// Send ourselves SIGUSR1 and verify tokio receives it
// (proves our SIGBUS/SIGSEGV handler doesn't clobber other signals).
unsafe { libc::raise(libc::SIGUSR1) };
tokio::time::timeout(std::time::Duration::from_secs(2), usr1.recv())
.await
.expect("SIGUSR1 should arrive within 2s");
eprintln!("tokio_signals: SIGUSR1 received, signal coexistence OK");
});
}
other => {
eprintln!("unknown scenario: {other}");
std::process::exit(99);
}
}
}
// ── Parent test cases ───────────────────────────────────────────────────
#[test]
fn handler_does_not_interfere_with_tokio_runtime() {
let tmp = tempfile::tempdir().expect("tempdir");
let (status, _stdout, stderr) = run_scenario("tokio_normal", tmp.path());
assert!(
status.success(),
"tokio_normal should exit 0, got {status:?}\nstderr: {stderr}"
);
assert!(
stderr.contains("all tasks completed"),
"should see completion message\nstderr: {stderr}"
);
// No crash file should exist.
assert!(
!tmp.path().join("last-crash.bin").exists()
|| std::fs::metadata(tmp.path().join("last-crash.bin"))
.map(|m| m.len() == 0)
.unwrap_or(true),
"crash file should not contain data after clean exit"
);
}
#[test]
fn handler_does_not_interfere_with_sync_io() {
let tmp = tempfile::tempdir().expect("tempdir");
let (status, _stdout, stderr) = run_scenario("sync_normal", tmp.path());
assert!(
status.success(),
"sync_normal should exit 0, got {status:?}\nstderr: {stderr}"
);
assert!(
stderr.contains("50 files written"),
"should see completion message\nstderr: {stderr}"
);
}
#[test]
fn handler_does_not_clobber_other_signal_handlers() {
let tmp = tempfile::tempdir().expect("tempdir");
let (status, _stdout, stderr) = run_scenario("tokio_signals", tmp.path());
assert!(
status.success(),
"tokio_signals should exit 0, got {status:?}\nstderr: {stderr}"
);
assert!(
stderr.contains("signal coexistence OK"),
"SIGUSR1 should be delivered through tokio\nstderr: {stderr}"
);
}
#[test]
fn sigbus_produces_valid_crash_blob() {
let tmp = tempfile::tempdir().expect("tempdir");
let (status, _stdout, _stderr) = run_scenario("sigbus", tmp.path());
// Process should have been killed by a signal.
// We expect SIGBUS, but the frame-pointer walker may hit unmapped memory
// and cause a secondary SIGSEGV (SA_RESETHAND ensures it terminates).
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
let sig = status.signal();
assert!(
sig == Some(libc::SIGBUS) || sig == Some(libc::SIGSEGV),
"process should be killed by SIGBUS or SIGSEGV, got signal={sig:?} status={status:?}"
);
}
// The crash file should be parseable.
let crash_file = tmp.path().join("last-crash.bin");
assert!(crash_file.exists(), "crash file should exist after SIGBUS");
let data = std::fs::read(&crash_file).expect("read crash file");
assert!(
data.len() > 4,
"crash file should have data, got {} bytes",
data.len()
);
let blob = xai_crash_handler::format::CrashBlob::parse(&data).expect("crash blob should parse");
// On macOS SIGBUS=10, on Linux SIGBUS=7, SIGSEGV=11 on both.
// The frame-pointer walker may cause a secondary SIGSEGV.
assert!(
blob.signal == 7 || blob.signal == 10 || blob.signal == 11,
"signal should be SIGBUS or SIGSEGV, got {}",
blob.signal
);
assert_eq!(blob.app_version, "0.0.0-test");
assert!(blob.pid > 0, "PID should be nonzero");
assert!(blob.timestamp > 0, "timestamp should be nonzero");
// check_previous_crash should produce a report.
let report =
xai_crash_handler::check_previous_crash(tmp.path()).expect("should produce a crash report");
assert!(report.signal_name.contains("SIGBUS"));
assert_eq!(report.app_version, "0.0.0-test");
assert!(report.report_path.exists(), "report file should be written");
// Crash blob should be consumed (deleted).
assert!(
!crash_file.exists(),
"crash file should be deleted after processing"
);
}
#[test]
fn sigsegv_produces_valid_crash_blob() {
let tmp = tempfile::tempdir().expect("tempdir");
let (status, _stdout, _stderr) = run_scenario("sigsegv", tmp.path());
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
let sig = status.signal();
assert_eq!(
sig,
Some(libc::SIGSEGV),
"process should be killed by SIGSEGV, got signal={sig:?} status={status:?}"
);
}
let crash_file = tmp.path().join("last-crash.bin");
assert!(crash_file.exists(), "crash file should exist after SIGSEGV");
let data = std::fs::read(&crash_file).expect("read crash file");
let blob = xai_crash_handler::format::CrashBlob::parse(&data).expect("crash blob should parse");
assert_eq!(blob.signal, 11, "signal should be SIGSEGV (11)");
assert_eq!(blob.app_version, "0.0.0-test");
}
#[test]
fn clean_exit_does_not_produce_crash_report() {
let tmp = tempfile::tempdir().expect("tempdir");
// Run both scenarios and verify no crash artifacts.
for scenario in &["tokio_normal", "sync_normal", "tokio_signals"] {
let (status, _stdout, stderr) = run_scenario(scenario, tmp.path());
assert!(status.success(), "{scenario} failed: {stderr}");
}
// check_previous_crash should return None.
let report = xai_crash_handler::check_previous_crash(tmp.path());
assert!(report.is_none(), "no crash report after clean exits");
}