Files
oh-my-pi/crates/pi-natives/src/crash_handler.rs
T

499 lines
16 KiB
Rust

//! Native crash diagnostics.
//!
//! Installs Rust-side panic and allocation-error hooks the first time the
//! native module loads, so any crash inside `pi-natives` writes an actionable
//! record (thread, payload, backtrace) to disk and to stderr before the host
//! process exits.
//!
//! Without these hooks, Bun receives only the bare
//! `memory allocation of N bytes failed` line and aborts with no stack —
//! see issue #2211 ("Windows crash: Rust allocator failure after tasklist.exe
//! popup"). The hooks do not change the abort behavior (the cdylib release
//! profile uses `panic = "abort"`); they make the next crash diagnosable.
//!
//! Notes:
//! - Backtraces are captured via [`Backtrace::force_capture`], so they work
//! regardless of `RUST_BACKTRACE`.
//! - The crash log path mirrors the JS side (`packages/utils/src/dirs.ts`):
//! `$XDG_STATE_HOME/omp/logs/` on Linux / macOS when the user has migrated to
//! XDG (i.e. that directory already exists and `PI_CODING_AGENT_DIR` isn't
//! pointed somewhere custom), otherwise `<home>/<PI_CONFIG_DIR>/logs/`
//! (defaulting to `~/.omp/logs/`).
//! - Hook installation is idempotent across repeated module loads.
use std::{
alloc::Layout,
backtrace::Backtrace,
ffi::OsStr,
fmt::Write as _,
fs::{self, OpenOptions},
io::Write as _,
path::{Path, PathBuf},
process,
sync::{
Once,
atomic::{AtomicBool, Ordering},
},
thread,
time::{SystemTime, UNIX_EPOCH},
};
/// Default directory name for OMP's per-user state (overridable via
/// `PI_CONFIG_DIR`, matching `packages/utils/src/dirs.ts`).
const DEFAULT_CONFIG_DIR: &str = ".omp";
/// App name used as the XDG-root subdirectory (`$XDG_STATE_HOME/omp/`),
/// matching `APP_NAME` in `packages/utils/src/dirs.ts`.
const APP_NAME: &str = "omp";
static INSTALL: Once = Once::new();
static ALLOC_HOOK_ACTIVE: AtomicBool = AtomicBool::new(false);
/// Install the panic and allocation-error hooks. Idempotent.
pub fn install() {
INSTALL.call_once(|| {
let prev_panic = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let report = format_panic_report(info);
persist(&report, CrashKind::Panic);
prev_panic(info);
}));
std::alloc::set_alloc_error_hook(|layout| {
// Print the canonical line before doing anything allocation-prone.
// If this is genuine process-wide OOM, report formatting/path work may
// recursively enter this hook; the secondary entry writes the same
// stack-only fallback and aborts immediately.
write_alloc_failure_line(std::io::stderr(), layout.size());
if ALLOC_HOOK_ACTIVE.swap(true, Ordering::AcqRel) {
process::abort();
}
let report = format_alloc_report(layout);
persist(&report, CrashKind::Alloc);
process::abort();
});
});
}
#[derive(Clone, Copy)]
enum CrashKind {
Panic,
Alloc,
}
impl CrashKind {
const fn as_str(self) -> &'static str {
match self {
Self::Panic => "panic",
Self::Alloc => "alloc",
}
}
}
fn format_panic_report(info: &std::panic::PanicHookInfo<'_>) -> String {
let bt = Backtrace::force_capture();
let location = info.location().map_or_else(
|| String::from("<unknown>"),
|l| format!("{}:{}:{}", l.file(), l.line(), l.column()),
);
let mut out = report_header(CrashKind::Panic);
let _ = writeln!(out, "location: {location}");
let _ = writeln!(out, "message: {}", panic_payload(info.payload()));
let _ = writeln!(out, "backtrace:\n{bt}");
out
}
fn format_alloc_report(layout: Layout) -> String {
// Capturing a backtrace allocates. If the global allocator is in a state
// where small allocations keep failing this will recurse into the hook —
// `Backtrace::force_capture` swallows the secondary failure internally and
// returns an empty backtrace, which is still strictly more useful than the
// nothing the default handler prints.
let bt = Backtrace::force_capture();
let mut out = report_header(CrashKind::Alloc);
let _ = writeln!(out, "size: {} bytes", layout.size());
let _ = writeln!(out, "alignment: {} bytes", layout.align());
let _ = writeln!(out, "backtrace:\n{bt}");
out
}
fn report_header(kind: CrashKind) -> String {
let thread_name = thread::current().name().unwrap_or("<unnamed>").to_owned();
let now_ms = unix_millis();
format!(
"pi-natives {kind} crash\npid: {pid}\nthread: {thread_name}\ntimestamp: {now_ms} \
(unix ms)\n",
kind = kind.as_str(),
pid = process::id(),
)
}
fn write_alloc_failure_line(mut out: impl std::io::Write, size: usize) {
let _ = out.write_all(b"memory allocation of ");
let mut digits = [0u8; usize::MAX.ilog10() as usize + 1];
let mut pos = digits.len();
let mut value = size;
if value == 0 {
pos -= 1;
digits[pos] = b'0';
} else {
while value > 0 {
pos -= 1;
digits[pos] = b'0' + (value % 10) as u8;
value /= 10;
}
}
let _ = out.write_all(&digits[pos..]);
let _ = out.write_all(b" bytes failed\n");
}
fn panic_payload(payload: &(dyn std::any::Any + Send)) -> String {
if let Some(s) = payload.downcast_ref::<&'static str>() {
(*s).to_owned()
} else if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else {
String::from("<non-string panic payload>")
}
}
fn persist(report: &str, kind: CrashKind) {
// Echo to stderr unconditionally so the user still sees something even
// when the file write fails (read-only home, missing $HOME, etc.).
let _ = writeln!(std::io::stderr(), "{report}");
let Some(path) = crash_log_path(kind) else {
return;
};
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(&path) {
let _ = f.write_all(report.as_bytes());
let _ = f.flush();
let _ = f.sync_data();
let _ = writeln!(std::io::stderr(), "pi-natives crash report written to {}", path.display());
}
}
fn crash_log_path(kind: CrashKind) -> Option<PathBuf> {
let dir = logs_dir()?;
Some(build_crash_log_path(&dir, kind, process::id(), unix_millis()))
}
fn build_crash_log_path(dir: &Path, kind: CrashKind, pid: u32, now_ms: u128) -> PathBuf {
dir.join(format!("native-{}-{pid}-{now_ms}.log", kind.as_str()))
}
fn logs_dir() -> Option<PathBuf> {
let home = home_dir()?;
let config_override = std::env::var_os("PI_CONFIG_DIR");
let xdg_logs = xdg_state_logs_from_env(&home, config_override.as_deref());
Some(resolve_logs_dir(&home, config_override.as_deref(), xdg_logs))
}
fn resolve_logs_dir(
home: &Path,
config_dir_override: Option<&OsStr>,
xdg_state_logs: Option<PathBuf>,
) -> PathBuf {
// XDG takes precedence so users who migrated to `$XDG_STATE_HOME/omp/logs/`
// see native crash reports in the same directory the JS logger rotates.
if let Some(p) = xdg_state_logs {
return p;
}
let config_dir = config_dir_override
.filter(|s| !s.is_empty())
.unwrap_or_else(|| OsStr::new(DEFAULT_CONFIG_DIR));
let base = config_root_dir(home, config_dir);
base.join("logs")
}
/// Compute the XDG-state logs dir if the runtime environment matches the
/// JS-side eligibility rules in `packages/utils/src/dirs.ts`: linux/macos,
/// `$XDG_STATE_HOME` set, `$XDG_STATE_HOME/omp` exists on disk, and
/// `PI_CODING_AGENT_DIR` is unset or pointing at the default agent dir.
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn xdg_state_logs_from_env(home: &Path, config_dir_override: Option<&OsStr>) -> Option<PathBuf> {
let default_agent_dir = default_agent_dir(home, config_dir_override);
let agent_override = std::env::var_os("PI_CODING_AGENT_DIR");
let xdg_state_home = std::env::var_os("XDG_STATE_HOME");
xdg_state_logs(
xdg_state_home.as_deref(),
agent_override.as_deref(),
&default_agent_dir,
Path::exists,
)
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
#[allow(clippy::missing_const_for_fn, reason = "windows/non-xdg platforms keep the signature")]
fn xdg_state_logs_from_env(_home: &Path, _config_dir_override: Option<&OsStr>) -> Option<PathBuf> {
None
}
/// Pure XDG-eligibility computation extracted for unit testing — no env
/// reads, no fs reads. `omp_dir_exists` decides whether the candidate
/// `<xdg_state_home>/omp` actually lives on disk.
fn xdg_state_logs(
xdg_state_home: Option<&OsStr>,
agent_dir_override: Option<&OsStr>,
default_agent_dir: &Path,
omp_dir_exists: impl FnOnce(&Path) -> bool,
) -> Option<PathBuf> {
if let Some(ov) = agent_dir_override.filter(|s| !s.is_empty()) {
// `path.resolve(value)` on the JS side: make absolute against cwd
// without touching the filesystem. Anything that diverges from the
// default agent dir disables XDG, matching `isDefault === false`.
let resolved = std::path::absolute(Path::new(ov)).ok()?;
if resolved != default_agent_dir {
return None;
}
}
let xdg = xdg_state_home.filter(|s| !s.is_empty())?;
let omp_dir = Path::new(xdg).join(APP_NAME);
if !omp_dir_exists(&omp_dir) {
return None;
}
Some(omp_dir.join("logs"))
}
fn default_agent_dir(home: &Path, config_dir_override: Option<&OsStr>) -> PathBuf {
let config_dir = config_dir_override
.filter(|s| !s.is_empty())
.unwrap_or_else(|| OsStr::new(DEFAULT_CONFIG_DIR));
let base = config_root_dir(home, config_dir);
base.join("agent")
}
fn config_root_dir(home: &Path, config_dir: &OsStr) -> PathBuf {
let mut base = PathBuf::from(home);
for component in Path::new(config_dir).components() {
match component {
std::path::Component::Prefix(_) | std::path::Component::RootDir => {},
std::path::Component::CurDir => {},
std::path::Component::ParentDir => {
base.pop();
},
std::path::Component::Normal(part) => base.push(part),
}
}
base
}
fn home_dir() -> Option<PathBuf> {
#[cfg(unix)]
{
std::env::var_os("HOME").map(PathBuf::from)
}
#[cfg(windows)]
{
if let Some(profile) = std::env::var_os("USERPROFILE") {
return Some(PathBuf::from(profile));
}
let drive = std::env::var_os("HOMEDRIVE")?;
let path = std::env::var_os("HOMEPATH")?;
let mut combined = drive;
combined.push(path);
Some(PathBuf::from(combined))
}
}
fn unix_millis() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_millis())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn alloc_report_contains_size_alignment_and_backtrace() {
let layout = Layout::from_size_align(7714, 8).unwrap();
let report = format_alloc_report(layout);
assert!(report.contains("pi-natives alloc crash"), "report missing header: {report}");
assert!(report.contains("size: 7714 bytes"), "report missing size: {report}");
assert!(report.contains("alignment: 8 bytes"), "report missing alignment: {report}");
assert!(report.contains("backtrace:"), "report missing backtrace section: {report}");
assert!(
report.contains(&format!("pid: {}", process::id())),
"report missing pid: {report}"
);
assert!(report.contains("thread:"), "report missing thread: {report}");
}
#[test]
fn alloc_failure_line_matches_rust_default_text_without_heap_formatting() {
let mut buf = Vec::new();
write_alloc_failure_line(&mut buf, 7714);
assert_eq!(buf, b"memory allocation of 7714 bytes failed\n");
buf.clear();
write_alloc_failure_line(&mut buf, usize::MAX);
assert_eq!(buf, format!("memory allocation of {} bytes failed\n", usize::MAX).as_bytes());
}
#[test]
fn panic_payload_handles_str_string_and_other() {
let static_str: Box<dyn std::any::Any + Send> = Box::new("static panic");
assert_eq!(panic_payload(&*static_str), "static panic");
let owned: Box<dyn std::any::Any + Send> = Box::new(String::from("owned panic"));
assert_eq!(panic_payload(&*owned), "owned panic");
let other: Box<dyn std::any::Any + Send> = Box::new(42u32);
assert_eq!(panic_payload(&*other), "<non-string panic payload>");
}
#[test]
fn resolve_logs_dir_defaults_under_dot_omp() {
let dir = resolve_logs_dir(Path::new("/tmp/pi-natives-test-home"), None, None);
assert_eq!(dir, PathBuf::from("/tmp/pi-natives-test-home/.omp/logs"));
}
#[test]
fn resolve_logs_dir_honors_relative_pi_config_dir() {
let dir = resolve_logs_dir(
Path::new("/tmp/pi-natives-test-home"),
Some(OsStr::new(".omp-dev")),
None,
);
assert_eq!(dir, PathBuf::from("/tmp/pi-natives-test-home/.omp-dev/logs"));
}
#[test]
fn resolve_logs_dir_reroots_absolute_pi_config_dir_under_home() {
// JS resolves the config root via `path.join(os.homedir(),
// getConfigDirName())`, which never honors an absolute PI_CONFIG_DIR — it is
// always re-rooted under `$HOME` (and `..` components are normalized away).
let dir = resolve_logs_dir(
Path::new("/tmp/pi-natives-test-home"),
Some(OsStr::new("/var/tmp/pi-natives-state")),
None,
);
assert_eq!(dir, PathBuf::from("/tmp/pi-natives-test-home/var/tmp/pi-natives-state/logs"));
}
#[test]
fn resolve_logs_dir_normalizes_parent_components_like_path_join() {
let dir = resolve_logs_dir(
Path::new("/tmp/pi-natives-test-home"),
Some(OsStr::new("nested/../.omp-dev")),
None,
);
assert_eq!(dir, PathBuf::from("/tmp/pi-natives-test-home/.omp-dev/logs"));
}
#[test]
fn xdg_state_logs_ignores_empty_agent_dir_override() {
// An empty PI_CODING_AGENT_DIR is "unset", not a divergent override; it
// must not disable XDG resolution.
let dir = xdg_state_logs(
Some(OsStr::new("/xdg/state")),
Some(OsStr::new("")),
Path::new("/tmp/pi-natives-test-home/.omp/agent"),
|_p| true,
);
assert_eq!(dir, Some(PathBuf::from("/xdg/state/omp/logs")));
}
#[test]
fn resolve_logs_dir_ignores_empty_pi_config_dir() {
let dir =
resolve_logs_dir(Path::new("/tmp/pi-natives-test-home"), Some(OsStr::new("")), None);
assert_eq!(dir, PathBuf::from("/tmp/pi-natives-test-home/.omp/logs"));
}
#[test]
fn resolve_logs_dir_prefers_xdg_when_provided() {
let dir = resolve_logs_dir(
Path::new("/tmp/pi-natives-test-home"),
None,
Some(PathBuf::from("/xdg/state/omp/logs")),
);
assert_eq!(dir, PathBuf::from("/xdg/state/omp/logs"));
}
#[test]
fn xdg_state_logs_resolves_when_dir_exists_and_no_agent_override() {
let dir = xdg_state_logs(
Some(OsStr::new("/xdg/state")),
None,
Path::new("/tmp/pi-natives-test-home/.omp/agent"),
|_p| true,
);
assert_eq!(dir, Some(PathBuf::from("/xdg/state/omp/logs")));
}
#[test]
fn xdg_state_logs_skipped_when_omp_dir_missing() {
let dir = xdg_state_logs(
Some(OsStr::new("/xdg/state")),
None,
Path::new("/tmp/pi-natives-test-home/.omp/agent"),
|_p| false,
);
assert_eq!(dir, None);
}
#[test]
fn xdg_state_logs_skipped_when_xdg_state_home_unset_or_empty() {
let default_agent = Path::new("/tmp/pi-natives-test-home/.omp/agent");
assert_eq!(xdg_state_logs(None, None, default_agent, |_p| true), None);
assert_eq!(xdg_state_logs(Some(OsStr::new("")), None, default_agent, |_p| true), None);
}
#[test]
fn xdg_state_logs_skipped_when_agent_dir_overridden() {
// `PI_CODING_AGENT_DIR` pointing elsewhere mirrors the JS `isDefault === false`
// branch in `packages/utils/src/dirs.ts` and must disable XDG.
let dir = xdg_state_logs(
Some(OsStr::new("/xdg/state")),
Some(OsStr::new("/some/custom/agent")),
Path::new("/tmp/pi-natives-test-home/.omp/agent"),
|_p| true,
);
assert_eq!(dir, None);
}
#[test]
fn xdg_state_logs_honored_when_agent_override_matches_default() {
let default_agent = std::path::absolute(Path::new("./.omp/agent")).unwrap();
let dir = xdg_state_logs(
Some(OsStr::new("/xdg/state")),
Some(OsStr::new("./.omp/agent")),
&default_agent,
|_p| true,
);
assert_eq!(dir, Some(PathBuf::from("/xdg/state/omp/logs")));
}
#[test]
fn default_agent_dir_uses_dot_omp_by_default() {
let dir = default_agent_dir(Path::new("/tmp/pi-natives-test-home"), None);
assert_eq!(dir, PathBuf::from("/tmp/pi-natives-test-home/.omp/agent"));
}
#[test]
fn default_agent_dir_respects_pi_config_dir() {
let dir =
default_agent_dir(Path::new("/tmp/pi-natives-test-home"), Some(OsStr::new(".omp-dev")));
assert_eq!(dir, PathBuf::from("/tmp/pi-natives-test-home/.omp-dev/agent"));
}
#[test]
fn build_crash_log_path_tags_kind_and_pid() {
let dir = Path::new("/tmp/pi-natives-test-home/.omp/logs");
let panic_log = build_crash_log_path(dir, CrashKind::Panic, 4242, 1_700_000_000_000);
assert_eq!(
panic_log,
PathBuf::from("/tmp/pi-natives-test-home/.omp/logs/native-panic-4242-1700000000000.log")
);
let alloc_log = build_crash_log_path(dir, CrashKind::Alloc, 99, 1);
assert_eq!(
alloc_log,
PathBuf::from("/tmp/pi-natives-test-home/.omp/logs/native-alloc-99-1.log")
);
}
}