Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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}");
}