Publish harness and TUI open-source
initial sync from the monorepo
This commit is contained in:
commit
c68e39f604
2734 changed files with 1437016 additions and 0 deletions
33
crates/codegen/xai-crash-handler/Cargo.toml
Normal file
33
crates/codegen/xai-crash-handler/Cargo.toml
Normal 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
|
||||
47
crates/codegen/xai-crash-handler/README.md
Normal file
47
crates/codegen/xai-crash-handler/README.md
Normal 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,
|
||||
});
|
||||
```
|
||||
214
crates/codegen/xai-crash-handler/src/format.rs
Normal file
214
crates/codegen/xai-crash-handler/src/format.rs
Normal 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(×tamp.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());
|
||||
}
|
||||
}
|
||||
923
crates/codegen/xai-crash-handler/src/handler.rs
Normal file
923
crates/codegen/xai-crash-handler/src/handler.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
178
crates/codegen/xai-crash-handler/src/lib.rs
Normal file
178
crates/codegen/xai-crash-handler/src/lib.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
143
crates/codegen/xai-crash-handler/src/symbolicate.rs
Normal file
143
crates/codegen/xai-crash-handler/src/symbolicate.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
134
crates/codegen/xai-crash-handler/src/terminal.rs
Normal file
134
crates/codegen/xai-crash-handler/src/terminal.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
283
crates/codegen/xai-crash-handler/tests/integration.rs
Normal file
283
crates/codegen/xai-crash-handler/tests/integration.rs
Normal 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");
|
||||
}
|
||||
Loading…
Reference in a new issue