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;
};
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 create_socket_dir() -> Result<(PathBuf, PathBuf), String> {
let dir = std::env::temp_dir().join(format!("fbx-{}", socket_token()));
crate::secure_fs::create_dir_exclusive(&dir)
.map_err(|e| format!("create socket directory {}: {e}", dir.display()))?;
let socket = dir.join("s");
Ok((dir, socket))
}
fn socket_token() -> String {
#[cfg(unix)]
{
use std::io::Read as _;
if let Ok(mut f) = std::fs::File::open("/dev/urandom") {
let mut bytes = [0u8; 16];
if f.read_exact(&mut bytes).is_ok() {
let mut out = String::with_capacity(32);
for b in bytes {
out.push_str(&format!("{b:02x}"));
}
return out;
}
}
}
format!("{}-{}", std::process::id(), crate::now_ms())
}
fn try_install(cfg: &crate::Config) -> Result<Guard, String> {
let exe = std::env::current_exe().map_err(|e| format!("current_exe: {e}"))?;
let (socket_dir, socket) = create_socket_dir()?;
let cleanup = |e: String| {
let _ = std::fs::remove_dir_all(&socket_dir);
e
};
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| cleanup(format!("spawn monitor: {e}")))?;
let client = match connect_with_retry(&socket) {
Ok(client) => client,
Err(e) => {
let _ = child.kill();
let _ = child.wait();
return Err(cleanup(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| cleanup(format!("attach crash handler: {e}")))?;
Ok(Guard { _handler: handler })
}
fn connect_with_retry(socket: &std::path::Path) -> Result<minidumper::Client, String> {
let mut last = String::new();
for _ in 0..200 {
match minidumper::Client::with_name(minidumper::SocketName::Path(socket)) {
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 {
let socket = std::path::Path::new(socket);
let mut server = match minidumper::Server::with_name(minidumper::SocketName::Path(socket)) {
Ok(s) => s,
Err(e) => {
eprintln!("faultbox monitor: bind {}: {e}", socket.display());
return 1;
}
};
let shutdown = std::sync::atomic::AtomicBool::new(false);
let handler = MonitorHandler::from_env();
let code = match server.run(Box::new(handler), &shutdown, None) {
Ok(()) => 0,
Err(e) => {
eprintln!("faultbox monitor: server run: {e}");
1
}
};
if let Some(dir) = socket.parent() {
let _ = std::fs::remove_dir_all(dir);
}
code
}
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::ensure_group_dir(&self.reports_dir, &fingerprint)?;
let path = dir.join("minidump");
let _ = std::fs::remove_file(&path);
let file = crate::secure_fs::create_new_private(&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");
}
}