crates/nornir-crashlog/tests/a_panic_leaves_a_file.rs
//! **The guard for the 2026-08-27 defect: a crash that left nothing behind.**
//!
//! Every assertion here is against a REAL file written by a REAL process that REALLY
//! panicked — never a round-trip through the library's own structs. The library could
//! return a perfect `Installed` and write nothing, and that is precisely the failure
//! that happened in the field.
//!
//! # Its own red
//!
//! `NORDISK_CRASH_LOG=off` reproduces the pre-fix behaviour exactly: the same binary,
//! the same panic, the hook installed — and no file. Every green here is paired with
//! that red on the SAME artifact, so a guard that had quietly stopped looking at the
//! directory would fail [`the_off_switch_really_leaves_nothing`] instead of passing
//! everything.
use std::path::{Path, PathBuf};
use std::process::Command;
/// Build both probe examples once, and hand back the binary directory.
///
/// Uses the test's own `CARGO_BIN_EXE_`-adjacent target dir via `cargo build`, because
/// an `[[example]]` is not exposed to a test as an env var the way a `[[bin]]` is.
fn probe_dir() -> PathBuf {
// `CARGO` and `CARGO_MANIFEST_DIR` are set for every test process by cargo.
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
let manifest = std::env::var("CARGO_MANIFEST_DIR").expect("cargo sets CARGO_MANIFEST_DIR");
let out = Command::new(&cargo)
.args([
"build",
"--manifest-path",
])
.arg(Path::new(&manifest).join("Cargo.toml"))
.args(["--example", "crash_probe", "--example", "crash_probe_gpu"])
.output()
.expect("run cargo build for the crash probes");
assert!(
out.status.success(),
"building the crash probes failed:\n{}",
String::from_utf8_lossy(&out.stderr)
);
// The test binary lives in `<target>/<profile>/deps/`, so its grandparent is the
// profile dir and `examples/` sits beside it. Derived from the running artifact
// rather than assuming `target/debug`, so a `CARGO_TARGET_DIR` elsewhere still works.
let me = std::env::current_exe().expect("the test binary has a path");
let profile = me
.parent()
.and_then(Path::parent)
.expect("<target>/<profile>/deps/<test>");
let dir = profile.join("examples");
assert!(
dir.is_dir(),
"expected the examples dir at {}",
dir.display()
);
dir
}
/// Run one probe and return `(stdout+stderr, the .crash files found)`.
fn run_probe(bin: &Path, mode: &str, crash_dir: &Path, enable: Option<&str>) -> (String, Vec<PathBuf>) {
let mut cmd = Command::new(bin);
cmd.arg(mode)
.env("NORDISK_CRASH_DIR", crash_dir)
// Deliberately UNSET, to reproduce the launch that lost the backtrace: a
// desktop file / systemd unit carries no RUST_BACKTRACE. The report must
// still contain frames, because the hook force-captures.
.env_remove("RUST_BACKTRACE");
match enable {
Some(v) => cmd.env("NORDISK_CRASH_LOG", v),
None => cmd.env_remove("NORDISK_CRASH_LOG"),
};
let out = cmd.output().expect("run the crash probe");
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
(text, crash_files(crash_dir))
}
fn crash_files(dir: &Path) -> Vec<PathBuf> {
let Ok(rd) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut v: Vec<PathBuf> = rd
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == "crash"))
.collect();
v.sort();
v
}
fn scratch(name: &str) -> PathBuf {
// md1, per the oden disk law — never /tmp (that is RAM).
let base = std::env::var("HOME")
.unwrap_or_else(|_| std::env::temp_dir().to_string_lossy().into_owned());
let pid = std::process::id();
let dir = PathBuf::from(base)
.join("scratch")
.join("crashlog-tests")
.join(format!("{name}-{pid}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create the scratch crash dir");
dir
}
/// ★ **A main-thread panic leaves a file that names the payload, the location, the
/// thread, the build and the variant** — asserted by reading the file back off disk.
#[test]
fn a_main_thread_panic_writes_a_findable_report() {
let dir = scratch("main");
let bin = probe_dir().join("crash_probe");
assert!(bin.is_file(), "no probe at {}", bin.display());
// The PREMISE, asserted rather than assumed: with the file switched OFF this same
// binary and this same panic leave the directory EMPTY. Without this the green
// below could be produced by anything at all writing into the directory.
let (off_text, off_files) = run_probe(&bin, "main", &dir, Some("off"));
assert!(
off_files.is_empty(),
"RED BROKEN: `{}=off` still wrote {off_files:?} — the red this test measures \
against does not exist, so its green means nothing",
nornir_crashlog::ENABLE_ENV
);
assert!(
off_text.contains("crash reports are OFF"),
"and the startup line must say so out loud, got:\n{off_text}"
);
// ...and the panic really happened in that run. A probe that silently did nothing
// would also leave an empty directory, which is the same shape as a passing red.
assert!(
off_text.contains("deliberate crash probe: main thread"),
"the OFF run must still have PANICKED (the default hook prints to stderr), got:\n{off_text}"
);
let (text, files) = run_probe(&bin, "main", &dir, None);
assert_eq!(
files.len(),
1,
"one panic ⇒ one report; got {files:?}\n{text}"
);
let path = &files[0];
let body = std::fs::read_to_string(path).expect("read the report back off disk");
// The stderr pointer must name the file that actually exists.
assert!(
text.contains(&path.display().to_string()),
"stderr must name the report it wrote ({}), got:\n{text}",
path.display()
);
for needle in [
"deliberate crash probe: main thread", // the payload
"crash_probe.rs:", // the location, with a line number
"thread main (MAIN)", // which thread, said explicitly
"variant crash_probe", // WHICH artifact
"build probe-build", // WHICH build
] {
assert!(
body.contains(needle),
"the report is missing `{needle}`:\n{body}"
);
}
// The file name itself carries the variant, so `ls` is enough to attribute a crash.
assert!(
path.file_name()
.and_then(|s| s.to_str())
.is_some_and(|n| n.starts_with("crash_probe-")),
"the report file must be named after the variant, got {}",
path.display()
);
// ── the backtrace, and why this assertion is not "contains 'backtrace'" ──
// `Backtrace::capture()` answers `disabled backtrace` unless RUST_BACKTRACE is
// set, and this run REMOVED it from the environment (a desktop launch has none).
// So the section header proves nothing; FRAMES do.
assert!(
!body.contains("disabled backtrace"),
"the backtrace was NOT forced — a desktop launch has no RUST_BACKTRACE and this \
is the exact evidence the measured crash lacked:\n{body}"
);
let frames = body.lines().filter(|l| l.contains("crash_probe::main")).count();
assert!(
frames >= 1,
"the forced backtrace must name the panicking function; got 0 `crash_probe::main` \
frames in:\n{body}"
);
// The index: `tail -1` must find this report without an `ls -t`.
let idx = std::fs::read_to_string(dir.join(nornir_crashlog::INDEX_FILE))
.expect("the index must exist");
let last = idx.lines().last().expect("one line per report");
assert!(
last.contains(&path.display().to_string()) && last.contains("crash_probe"),
"the index line must name the report and the variant, got `{last}`"
);
}
/// ★ **A panic on a NON-MAIN thread writes a report too, and says which thread.**
///
/// This is the case that would have been missed by a `main`-only guard, and the case a
/// UI actually dies in: a worker panics, the window stays up, and the only trace is the
/// file. The report must name the thread, because "main" and "render-worker" need
/// different fixes.
#[test]
fn a_non_main_thread_panic_writes_a_report_naming_that_thread() {
let dir = scratch("thread");
let bin = probe_dir().join("crash_probe");
let (off_text, off_files) = run_probe(&bin, "thread", &dir, Some("off"));
assert!(
off_files.is_empty(),
"RED BROKEN: off still wrote {off_files:?}"
);
assert!(
off_text.contains("deliberate crash probe: non-main thread"),
"the OFF run must still have panicked on the worker:\n{off_text}"
);
let (text, files) = run_probe(&bin, "thread", &dir, None);
assert_eq!(files.len(), 1, "got {files:?}\n{text}");
let body = std::fs::read_to_string(&files[0]).unwrap();
assert!(
body.contains("thread render-worker (not main)"),
"the report must name the panicking thread AND say it was not main:\n{body}"
);
assert!(
body.contains("deliberate crash probe: non-main thread"),
"{body}"
);
// And the frames come from the worker, not from `main`.
assert!(
!body.contains("disabled backtrace"),
"a worker panic must also carry a forced backtrace:\n{body}"
);
// A nested closure two frames deep on an UNNAMED thread still lands.
let dir2 = scratch("nested");
let (t2, f2) = run_probe(&bin, "nested", &dir2, None);
assert_eq!(f2.len(), 1, "got {f2:?}\n{t2}");
let b2 = std::fs::read_to_string(&f2[0]).unwrap();
assert!(
b2.contains("(not main)") && b2.contains("nested closure"),
"an unnamed worker's nested panic must be recorded:\n{b2}"
);
}
/// ★ **The OFF switch really leaves nothing** — the red, standing on its own so it is
/// visible in the test list rather than buried inside a green.
///
/// If this ever fails, every other test in this file is worthless: it would mean the
/// directory gets a file whether or not the hook writes one.
#[test]
fn the_off_switch_really_leaves_nothing() {
let dir = scratch("off");
let bin = probe_dir().join("crash_probe");
let (text, files) = run_probe(&bin, "main", &dir, Some("off"));
assert!(files.is_empty(), "expected NO reports, got {files:?}");
assert!(
!dir.join(nornir_crashlog::INDEX_FILE).exists(),
"and no index either"
);
assert!(
text.contains("NOTHING on disk"),
"an operator must be TOLD the record is off, got:\n{text}"
);
}
/// ★ **No panic ⇒ no file.** The other half of the red: a hook that wrote a report on
/// every exit would satisfy every assertion above and be useless.
#[test]
fn a_clean_run_writes_no_report_at_all() {
let dir = scratch("clean");
let bin = probe_dir().join("crash_probe");
let (text, files) = run_probe(&bin, "clean", &dir, None);
assert!(
files.is_empty(),
"a clean run must leave no report, got {files:?}\n{text}"
);
// But it MUST still have announced where a report would go — that line is what an
// operator reads before anything goes wrong.
assert!(
text.contains(&dir.display().to_string()),
"the startup announcement must name the declared path even on a clean run:\n{text}"
);
}
/// ★ **TWO NAMES OFF ONE SOURCE REPORT TWO VARIANTS** — the T7 discriminator.
///
/// `crash_probe` and `crash_probe_gpu` are the same `examples/crash_probe.rs`, built in
/// ONE `cargo build` invocation. Their reports must name different variants. A feature
/// flag provably cannot do this: cargo unifies features per package, so both targets in
/// one invocation see the same feature set — which is how `dwarves-ui` and
/// `dwarves-ui-gpu` ended up byte-identical on t14s (sha256 `6eb9ec98…` for both) with
/// nothing at runtime able to tell them apart.
#[test]
fn two_names_off_one_source_report_two_variants() {
let dir = probe_dir();
let plain = dir.join("crash_probe");
let gpu = dir.join("crash_probe_gpu");
assert!(plain.is_file() && gpu.is_file(), "both probes must be built");
// The premise that makes the test mean something: the two artifacts came out of ONE
// source file, so any difference in their reports can only come from the target's
// own identity.
let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/crash_probe.rs");
assert!(src.is_file(), "one source file at {}", src.display());
let d1 = scratch("variant-plain");
let d2 = scratch("variant-gpu");
let (_, f1) = run_probe(&plain, "main", &d1, None);
let (_, f2) = run_probe(&gpu, "main", &d2, None);
assert_eq!(f1.len(), 1);
assert_eq!(f2.len(), 1);
let b1 = std::fs::read_to_string(&f1[0]).unwrap();
let b2 = std::fs::read_to_string(&f2[0]).unwrap();
assert!(b1.contains("variant crash_probe\n"), "{b1}");
assert!(b2.contains("variant crash_probe_gpu\n"), "{b2}");
assert!(
!b1.contains("crash_probe_gpu"),
"the plain probe must not claim the gpu name:\n{b1}"
);
// And the FILE NAMES differ, so two crashes in one directory are attributable by
// `ls` alone.
let n1 = f1[0].file_name().unwrap().to_string_lossy().to_string();
let n2 = f2[0].file_name().unwrap().to_string_lossy().to_string();
assert!(n1.starts_with("crash_probe-"), "{n1}");
assert!(n2.starts_with("crash_probe_gpu-"), "{n2}");
}