use std::path::PathBuf;
use std::sync::OnceLock;
use crate::report::{Artifact, Env, EventKind, Meta, Report, SCHEMA_VERSION};
use crate::writer;
pub const ENV_SOCKET: &str = "FAULTBOX_CRASH_SOCKET";
const ENV_REPORTS_DIR: &str = "FAULTBOX_CRASH_REPORTS_DIR";
const ENV_PROJECT: &str = "FAULTBOX_CRASH_PROJECT";
const ENV_VERSION: &str = "FAULTBOX_CRASH_VERSION";
const ENV_GIT_SHA: &str = "FAULTBOX_CRASH_GIT_SHA";
const ENV_BUILD_ID: &str = "FAULTBOX_CRASH_BUILD_ID";
#[cfg(feature = "shared-ring")]
const ENV_SHARED_RING: &str = "FAULTBOX_CRASH_SHARED_RING";
static GUARD: OnceLock<Guard> = OnceLock::new();
struct Guard {
_handler: crash_handler::CrashHandler,
}
unsafe impl Send for Guard {}
unsafe impl Sync for Guard {}
#[must_use]
pub fn run_crash_monitor_if_env() -> bool {
let Ok(socket) = std::env::var(ENV_SOCKET) else {
return false;
};
unsafe { std::env::remove_var(ENV_SOCKET) };
let code = run_monitor(&socket);
std::process::exit(code);
}
pub fn install(cfg: &crate::Config) {
if GUARD.get().is_some() {
return;
}
if std::env::var_os(ENV_SOCKET).is_some() {
eprintln!(
"faultbox: FATAL — this process was spawned as the crash monitor, but \
`faultbox::run_crash_monitor_if_env()` was never called, so it resumed \
normal startup as a duplicate instance of the application.\n\
faultbox: add this as the first statement in `main`:\n\
faultbox: if faultbox::run_crash_monitor_if_env() {{ return; }}\n\
faultbox: exiting to avoid spawning further copies."
);
std::process::exit(70);
}
match try_install(cfg) {
Ok(guard) => {
let _ = GUARD.set(guard);
}
Err(e) => {
eprintln!("faultbox: native-crash capture not installed: {e}");
}
}
}
fn try_install(cfg: &crate::Config) -> Result<Guard, String> {
let exe = std::env::current_exe().map_err(|e| format!("current_exe: {e}"))?;
let socket = format!(
"faultbox-{}-{}-{}",
cfg.project,
std::process::id(),
crate::now_ms()
);
let mut cmd = std::process::Command::new(&exe);
cmd.env(ENV_SOCKET, &socket)
.env(ENV_REPORTS_DIR, &cfg.reports_dir)
.env(ENV_PROJECT, &cfg.project)
.env(ENV_VERSION, &cfg.version)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
if let Some(sha) = &cfg.git_sha {
cmd.env(ENV_GIT_SHA, sha);
}
if let Some(bid) = &cfg.build_id {
cmd.env(ENV_BUILD_ID, bid);
}
#[cfg(feature = "shared-ring")]
if let Some(ring) = &cfg.shared_ring {
cmd.env(ENV_SHARED_RING, ring.path());
}
#[cfg(unix)]
{
use std::os::unix::process::CommandExt as _;
cmd.process_group(0);
}
let mut child = cmd.spawn().map_err(|e| format!("spawn monitor: {e}"))?;
let client = match connect_with_retry(&socket) {
Ok(client) => client,
Err(e) => {
let _ = child.kill();
let _ = child.wait();
return Err(format!(
"{e} — the monitor process was killed. Ensure `main` calls \
`faultbox::run_crash_monitor_if_env()` before any other work."
));
}
};
let handler = crash_handler::CrashHandler::attach(unsafe {
crash_handler::make_crash_event(move |cc: &crash_handler::CrashContext| {
let handled = client.request_dump(cc).is_ok();
crash_handler::CrashEventResult::Handled(handled)
})
})
.map_err(|e| format!("attach crash handler: {e}"))?;
Ok(Guard { _handler: handler })
}
fn connect_with_retry(socket: &str) -> Result<minidumper::Client, String> {
#[cfg(not(any(target_os = "linux", target_os = "android")))]
let sock_path = std::env::temp_dir().join(socket);
let mut last = String::new();
for _ in 0..200 {
#[cfg(any(target_os = "linux", target_os = "android"))]
let name = minidumper::SocketName::Abstract(socket);
#[cfg(not(any(target_os = "linux", target_os = "android")))]
let name = minidumper::SocketName::Path(&sock_path);
match minidumper::Client::with_name(name) {
Ok(c) => return Ok(c),
Err(e) => {
last = e.to_string();
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
}
Err(format!("connect to monitor timed out: {last}"))
}
fn run_monitor(socket: &str) -> i32 {
#[cfg(not(any(target_os = "linux", target_os = "android")))]
let sock_path = std::env::temp_dir().join(socket);
#[cfg(any(target_os = "linux", target_os = "android"))]
let name = minidumper::SocketName::Abstract(socket);
#[cfg(not(any(target_os = "linux", target_os = "android")))]
let name = minidumper::SocketName::Path(&sock_path);
let mut server = match minidumper::Server::with_name(name) {
Ok(s) => s,
Err(e) => {
eprintln!("faultbox monitor: bind {socket}: {e}");
return 1;
}
};
let shutdown = std::sync::atomic::AtomicBool::new(false);
let handler = MonitorHandler::from_env();
match server.run(Box::new(handler), &shutdown, None) {
Ok(()) => 0,
Err(e) => {
eprintln!("faultbox monitor: server run: {e}");
1
}
}
}
struct MonitorHandler {
reports_dir: PathBuf,
project: String,
version: String,
git_sha: Option<String>,
build_id: Option<String>,
#[cfg(feature = "shared-ring")]
shared_ring: Option<PathBuf>,
}
impl MonitorHandler {
fn from_env() -> Self {
let opt = |k: &str| std::env::var(k).ok().filter(|s| !s.is_empty());
MonitorHandler {
reports_dir: opt(ENV_REPORTS_DIR)
.map_or_else(|| PathBuf::from("faultbox-crash-reports"), PathBuf::from),
project: opt(ENV_PROJECT).unwrap_or_else(|| "unknown".to_owned()),
version: opt(ENV_VERSION).unwrap_or_else(|| "0.0.0".to_owned()),
git_sha: opt(ENV_GIT_SHA),
build_id: opt(ENV_BUILD_ID),
#[cfg(feature = "shared-ring")]
shared_ring: opt(ENV_SHARED_RING).map(PathBuf::from),
}
}
fn recover_trail(&self) -> Vec<crate::breadcrumbs::Breadcrumb> {
#[cfg(feature = "shared-ring")]
{
let Some(path) = &self.shared_ring else {
return Vec::new();
};
match crate::shared_ring::SharedRing::join(path) {
Ok(Some(ring)) => ring.snapshot(),
Ok(None) => {
eprintln!(
"faultbox monitor: no breadcrumb ring at {}; report will have no trail",
path.display()
);
Vec::new()
}
Err(e) => {
eprintln!("faultbox monitor: cannot read breadcrumb ring: {e}");
Vec::new()
}
}
}
#[cfg(not(feature = "shared-ring"))]
{
Vec::new()
}
}
fn crash_fingerprint(&self) -> (&'static str, String) {
let message = "native crash captured out-of-process (see minidump)";
(
message,
writer::fingerprint(&self.project, EventKind::NativeCrash, "", message),
)
}
fn write_report(&self, report_dir: &std::path::Path) {
let (message, fingerprint) = self.crash_fingerprint();
let now = crate::now_ms();
let (bytes, digest) = writer::digest_of(&report_dir.join("minidump"))
.map_or((None, None), |(b, d)| (Some(b), Some(d)));
let mut report = Report {
schema_version: SCHEMA_VERSION,
kind: EventKind::NativeCrash,
message: message.to_owned(),
meta: Meta {
project: self.project.clone(),
version: self.version.clone(),
git_sha: self.git_sha.clone(),
build_id: self.build_id.clone(),
captured_at_ms: now,
pid: std::process::id(),
ppid: Meta::current_ppid(),
thread: None,
},
error_chain: Vec::new(),
backtrace: Vec::new(),
breadcrumbs: self.recover_trail(),
domain_kind: None,
domain: serde_json::Value::Null,
env: Env::current(),
artifacts: vec![Artifact {
kind: "minidump".to_owned(),
rel_path: "minidump".to_owned(),
note: Some(
"inspect with `minidump-stackwalk minidump` against the \
build-id's symbols"
.to_owned(),
),
digest,
bytes,
}],
fingerprint,
occurrences: 1,
first_seen_ms: now,
last_seen_ms: now,
};
let _ = writer::commit_into(report_dir, &mut report);
}
}
impl minidumper::ServerHandler for MonitorHandler {
fn create_minidump_file(&self) -> Result<(std::fs::File, PathBuf), std::io::Error> {
let (_, fingerprint) = self.crash_fingerprint();
let dir = writer::group_dir(&self.reports_dir, &fingerprint);
std::fs::create_dir_all(&dir)?;
let path = dir.join("minidump");
let file = std::fs::File::create(&path)?;
Ok((file, path))
}
fn on_minidump_created(
&self,
result: Result<minidumper::MinidumpBinary, minidumper::Error>,
) -> minidumper::LoopAction {
match result {
Ok(bin) => {
if let Some(report_dir) = bin.path.parent() {
self.write_report(report_dir);
}
}
Err(e) => eprintln!("faultbox monitor: minidump write failed: {e}"),
}
minidumper::LoopAction::Continue
}
fn on_message(&self, _kind: u32, _buffer: Vec<u8>) {}
fn on_client_disconnected(&self, num_clients: usize) -> minidumper::LoopAction {
if num_clients == 0 {
minidumper::LoopAction::Exit
} else {
minidumper::LoopAction::Continue
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn monitor_writes_native_crash_report_beside_minidump() {
let tmp = tempfile::tempdir().unwrap();
let handler = MonitorHandler {
reports_dir: tmp.path().to_path_buf(),
project: "myapp".to_owned(),
version: "0.1.0".to_owned(),
git_sha: None,
build_id: Some("deadbeef".to_owned()),
#[cfg(feature = "shared-ring")]
shared_ring: None,
};
let report_dir = tmp.path().join("100-abcd1234");
std::fs::create_dir_all(&report_dir).unwrap();
std::fs::write(report_dir.join("minidump"), b"MDMP\0\0\0\0").unwrap();
handler.write_report(&report_dir);
let text = std::fs::read_to_string(report_dir.join("report.json")).unwrap();
let report: Report = serde_json::from_str(&text).unwrap();
assert_eq!(report.kind, EventKind::NativeCrash);
assert_eq!(report.meta.project, "myapp");
assert_eq!(report.meta.build_id.as_deref(), Some("deadbeef"));
assert_eq!(report.artifacts.len(), 1);
assert_eq!(report.artifacts[0].kind, "minidump");
assert_eq!(report.artifacts[0].rel_path, "minidump");
}
#[test]
fn native_crash_reports_share_a_fingerprint_per_build() {
let a = writer::fingerprint("myapp", EventKind::NativeCrash, "", "native crash captured");
let b = writer::fingerprint("myapp", EventKind::NativeCrash, "", "native crash captured");
assert_eq!(a, b);
let other = writer::fingerprint(
"otherapp",
EventKind::NativeCrash,
"",
"native crash captured",
);
assert_ne!(a, other, "different projects fingerprint apart");
}
}