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/src/lib.rs

//! **The ONE durable panic record.** A `std::panic` hook that writes a findable file.
//!
//! # The defect this exists for, measured 2026-08-27
//!
//! `dwarves-ui` crashed on t14s and left **nothing**: no panic message, no backtrace,
//! no file, no journal entry. The owner had to report the crash himself, from the other
//! side of a LAN, and there was no artifact on that machine to look at afterwards.
//!
//! Three separate reasons, and each one alone is enough:
//!
//! 1. **A GUI's stderr goes nowhere.** The default panic hook prints to stderr. Launched
//!    from a desktop file, a `.service`, an `ssh -f`, or a double-click, that stream is
//!    closed or discarded. The message was *emitted* and *lost*, which looks exactly
//!    like never having been emitted — and is what "the UI wrote nothing" actually was.
//! 2. **`RUST_BACKTRACE` is not set for such a launch**, so even a captured stderr would
//!    have carried `note: run with RUST_BACKTRACE=1` instead of a backtrace. The one
//!    piece of evidence that names the failing frame is off by default at exactly the
//!    moment it is needed. [`Backtrace::force_capture`](std::backtrace::Backtrace::force_capture)
//!    is what this module calls, deliberately: a crash report is not a debug convenience.
//! 3. **Nothing recorded WHICH BUILD crashed.** A stack trace against an unknown
//!    artifact is a guess. Every report here carries the build stamp and the **variant
//!    name** — see [`the variant`](CrashApp::variant).
//!
//! # What it does
//!
//! [`install`] (or the [`install_crash_hook!`] macro, which fills in the variant name
//! for you) sets a panic hook that, for a panic on **any** thread:
//!
//! * writes `<dir>/<variant>-<epoch>-<pid>.crash` — payload, location, thread name and
//!   whether it was the main thread, the forced backtrace, the build stamp, the variant,
//!   the full argv and the working directory;
//! * appends ONE line to `<dir>/crashes.log`, so `tail -1` finds the newest report
//!   without an `ls -t`;
//! * prints `CRASH — report written to <path>` on stderr, for the case where stderr IS
//!   attached;
//! * then calls the hook that was installed before it, so the standard message is not
//!   lost either.
//!
//! [`Installed::announce`] returns the line a binary prints **at startup**, before any
//! crash: an operator who is not sitting at the machine has to be able to find the
//! report without knowing this crate exists.
//!
//! # The declared path
//!
//! [`crash_dir`], in order: `$NORDISK_CRASH_DIR`, `$XDG_STATE_HOME/nordisk/crash`,
//! `$HOME/.local/state/nordisk/crash`, `/tmp/nordisk-crash`. It is a *declared* path in
//! the Skidbladnir sense — the program says where it writes, and says it out loud at
//! startup rather than leaving the operator to guess.
//!
//! `NORDISK_CRASH_LOG=off` disables the file entirely (the pre-2026-08-27 behaviour, kept
//! only so the guard has a red to see), and [`Installed::announce`] says so out loud.
//!
//! # Zero dependencies
//!
//! `std::panic`, `std::backtrace` and `std::fs`. A crash writer with a dependency tree is
//! one that gets left out of a lean build, and the artifact that left nothing behind was
//! the artifact nobody thought needed it.

use std::io::Write as _;
use std::path::{Path, PathBuf};

/// The env var that OVERRIDES the crash directory.
pub const DIR_ENV: &str = "NORDISK_CRASH_DIR";
/// The env var that DISABLES the file (`off`/`0`/`no`/`false`). Everything else enables.
pub const ENABLE_ENV: &str = "NORDISK_CRASH_LOG";
/// The one-line index every report is appended to, inside the crash dir.
pub const INDEX_FILE: &str = "crashes.log";
/// The report file extension.
pub const REPORT_EXT: &str = "crash";

/// **Who is crashing** — the identity that has to be inside the report.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CrashApp {
    /// **The variant name — the artifact's own `[[bin]]` name.**
    ///
    /// This is the field that makes a report attributable, and it is the reason
    /// [`install_crash_hook!`] exists: the only value that can fill it truthfully is
    /// `env!("CARGO_BIN_NAME")`, expanded **in the binary crate**, and that is a
    /// different string for every `[[bin]]` even when they share one `main.rs`.
    ///
    /// It must not be derived from a feature flag. MEASURED on dwarves 2026-08-27:
    /// `dwarves-ui` and `dwarves-ui-gpu` are two `[[bin]]`s off ONE `src/main.rs`, and
    /// `cfg!(feature = "map3d-gpu")`-style tests answered "gpu" for BOTH of them when
    /// cargo built them in one invocation — feature unification is per-PACKAGE, so a
    /// feature cannot name a bin. `CARGO_BIN_NAME` is per-target and survives it.
    pub variant: String,
    /// The package version (`CARGO_PKG_VERSION`).
    pub version: String,
    /// The build stamp — a git sha, ideally the same string `--version` prints, so the
    /// report and the operator's `--version` can be compared by eye.
    pub build: String,
}

impl CrashApp {
    /// Name an app by its three identity strings.
    pub fn new(
        variant: impl Into<String>,
        version: impl Into<String>,
        build: impl Into<String>,
    ) -> Self {
        CrashApp {
            variant: variant.into(),
            version: version.into(),
            build: build.into(),
        }
    }

    /// `<variant> v<version> (<build>)` — the identity line, one place.
    pub fn identity(&self) -> String {
        format!("{} v{} ({})", self.variant, self.version, self.build)
    }
}

/// What [`install`] did — returned so the caller can SAY it at startup.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Installed {
    /// The declared directory reports land in.
    pub dir: PathBuf,
    /// `false` when `NORDISK_CRASH_LOG` switched the file off.
    pub enabled: bool,
    /// The app identity baked into the hook.
    pub app: CrashApp,
    /// Was the directory creatable? A crash dir that cannot be created is worth saying
    /// at startup rather than discovering during the crash — which is the ONE moment
    /// nobody is watching.
    pub dir_writable: bool,
}

impl Installed {
    /// **The startup line.** Printed before any crash, because the operator who needs
    /// the report is the one who is not sitting at the machine.
    ///
    /// It names the directory even when writing is off or impossible, and says WHICH of
    /// those it is — "crash reports: off" and "crash reports: /somewhere" are different
    /// facts and a single hopeful sentence would blur them.
    pub fn announce(&self) -> String {
        if !self.enabled {
            return format!(
                "{}: crash reports are OFF ({ENABLE_ENV} is set to off) — a panic will \
                 leave NOTHING on disk",
                self.app.variant
            );
        }
        if !self.dir_writable {
            return format!(
                "{}: crash reports CANNOT be written — {} is not creatable; a panic will \
                 print to stderr and nothing else (set {DIR_ENV} to a writable path)",
                self.app.variant,
                self.dir.display()
            );
        }
        format!(
            "{}: crash reports → {} (a panic writes <variant>-<epoch>-<pid>.{REPORT_EXT} \
             there; `tail -1 {}` names the newest)",
            self.app.variant,
            self.dir.display(),
            self.dir.join(INDEX_FILE).display()
        )
    }
}

/// Is the file half switched on? `NORDISK_CRASH_LOG=off|0|no|false` turns it off.
pub fn enabled() -> bool {
    match std::env::var(ENABLE_ENV) {
        Ok(v) => !matches!(
            v.trim().to_ascii_lowercase().as_str(),
            "off" | "0" | "no" | "false"
        ),
        Err(_) => true,
    }
}

/// **The declared crash directory**, resolved in order:
///
/// 1. `$NORDISK_CRASH_DIR` — an operator or a `.service` unit said so;
/// 2. `$XDG_STATE_HOME/nordisk/crash` — state, not cache: a crash report must survive
///    a cache sweep;
/// 3. `$HOME/.local/state/nordisk/crash` — the XDG default, spelled out;
/// 4. `/tmp/nordisk-crash` — last resort, so a daemon with no HOME still leaves a trace.
///
/// Never the current directory: a GUI's cwd is `/` under a desktop launcher, and a
/// report in `/` is a report nobody finds.
pub fn crash_dir() -> PathBuf {
    if let Some(d) = non_empty_env(DIR_ENV) {
        return PathBuf::from(d);
    }
    if let Some(d) = non_empty_env("XDG_STATE_HOME") {
        return PathBuf::from(d).join("nordisk").join("crash");
    }
    if let Some(h) = non_empty_env("HOME") {
        return PathBuf::from(h)
            .join(".local")
            .join("state")
            .join("nordisk")
            .join("crash");
    }
    PathBuf::from("/tmp/nordisk-crash")
}

fn non_empty_env(key: &str) -> Option<String> {
    std::env::var(key).ok().filter(|v| !v.trim().is_empty())
}

/// **Install the hook.** Prefer [`install_crash_hook!`], which fills in the variant.
///
/// The previously installed hook is CHAINED, not replaced: on a machine where stderr is
/// attached the standard message is still what a developer expects to see, and this
/// module's job is to add a durable copy, not to take the familiar one away.
pub fn install(app: CrashApp) -> Installed {
    let dir = crash_dir();
    let on = enabled();
    let dir_writable = if on {
        std::fs::create_dir_all(&dir).is_ok()
    } else {
        false
    };

    let hook_app = app.clone();
    let hook_dir = dir.clone();
    let previous = std::panic::take_hook();
    std::panic::set_hook(Box::new(move |info| {
        // Every field is gathered HERE, on the panicking thread, while the panic is
        // still happening — `thread::current()` after unwinding is a different thread.
        if on {
            match write_report(&hook_dir, &hook_app, info) {
                Ok(path) => {
                    let _ = writeln!(
                        std::io::stderr(),
                        "{}: CRASH — report written to {}",
                        hook_app.variant,
                        path.display()
                    );
                }
                Err(e) => {
                    // Say it. A crash writer that fails silently is worse than none:
                    // it is a place the operator has been told to look and will find
                    // empty.
                    let _ = writeln!(
                        std::io::stderr(),
                        "{}: CRASH — could NOT write a report under {} ({e}); the panic \
                         text below is the only record",
                        hook_app.variant,
                        hook_dir.display()
                    );
                }
            }
        }
        previous(info);
    }));

    Installed {
        dir,
        enabled: on,
        app,
        dir_writable,
    }
}

/// Install the hook, naming the variant from the BINARY's own `CARGO_BIN_NAME`.
///
/// ```ignore
/// nornir_crashlog::install_crash_hook!(
///     env!("CARGO_PKG_VERSION"),
///     env!("DWARVES_GIT_HASH"),
/// );
/// ```
///
/// The macro exists so the variant string is expanded in the *caller's* crate, where
/// `CARGO_BIN_NAME` is the name of the `[[bin]]` being compiled. Two `[[bin]]`s off one
/// `main.rs` therefore get two different reports, which a feature-flag test provably
/// cannot deliver (see [`CrashApp::variant`]).
///
/// `option_env!` because `CARGO_BIN_NAME` is unset for lib/test targets; those fall back
/// to the package name rather than failing to compile.
#[macro_export]
macro_rules! install_crash_hook {
    ($version:expr, $build:expr) => {
        $crate::install($crate::CrashApp::new(
            option_env!("CARGO_BIN_NAME").unwrap_or(env!("CARGO_PKG_NAME")),
            $version,
            $build,
        ))
    };
}

/// Render + write one report. Public so a test can assert the FILE, not a round-trip.
///
/// Returns the path written.
pub fn write_report(
    dir: &Path,
    app: &CrashApp,
    info: &std::panic::PanicHookInfo<'_>,
) -> std::io::Result<PathBuf> {
    std::fs::create_dir_all(dir)?;
    let pid = std::process::id();
    let epoch = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0);
    let path = dir.join(format!("{}-{epoch}-{pid}.{REPORT_EXT}", app.variant));
    let body = render(app, info, pid, epoch);
    // Create-new-if-possible is deliberately NOT used: two panics in one second on two
    // threads of one process would then lose the second report, and a lost report is
    // the whole defect. Append, so both land in one file with a separator.
    let mut f = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(&path)?;
    f.write_all(body.as_bytes())?;
    f.flush()?;

    // The index: one line per report, so `tail -1` beats `ls -t` and works over ssh
    // without a shell loop.
    if let Ok(mut idx) = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(dir.join(INDEX_FILE))
    {
        let _ = writeln!(
            idx,
            "{epoch}\t{}\t{}\t{}\t{}",
            app.variant,
            app.build,
            location_of(info),
            path.display()
        );
    }
    Ok(path)
}

/// The report text. Split out so a test can assert the exact CONTENT without a panic.
pub fn render(
    app: &CrashApp,
    info: &std::panic::PanicHookInfo<'_>,
    pid: u32,
    epoch: u64,
) -> String {
    let thread = std::thread::current();
    let tname = thread.name().unwrap_or("<unnamed>").to_string();
    // The MAIN thread is the one std names "main"; a name is the only portable handle,
    // and saying which it was matters because an eframe/egui render-loop panic and a
    // background-worker panic need different fixes.
    let is_main = tname == "main";
    let mut s = String::new();
    s.push_str("── NORDISK CRASH REPORT ──────────────────────────────────────────\n");
    s.push_str(&format!("variant    {}\n", app.variant));
    s.push_str(&format!("version    {}\n", app.version));
    s.push_str(&format!("build      {}\n", app.build));
    s.push_str(&format!("identity   {}\n", app.identity()));
    s.push_str(&format!("pid        {pid}\n"));
    s.push_str(&format!("epoch      {epoch}\n"));
    s.push_str(&format!(
        "thread     {tname}{}\n",
        if is_main { " (MAIN)" } else { " (not main)" }
    ));
    s.push_str(&format!("location   {}\n", location_of(info)));
    s.push_str(&format!("payload    {}\n", payload_of(info)));
    s.push_str(&format!(
        "argv       {}\n",
        std::env::args().collect::<Vec<_>>().join(" ")
    ));
    s.push_str(&format!(
        "cwd        {}\n",
        std::env::current_dir()
            .map(|p| p.display().to_string())
            .unwrap_or_else(|e| format!("<unreadable: {e}>"))
    ));
    s.push_str("── backtrace (forced — RUST_BACKTRACE is unset for a GUI launch) ──\n");
    // `force_capture`, never `capture`: `capture()` answers `Disabled` unless
    // RUST_BACKTRACE is set in the environment, and a desktop/systemd launch has no
    // such environment. A crash report whose backtrace section says "disabled" is the
    // measured 2026-08-27 outcome with extra steps.
    s.push_str(&format!("{}\n", std::backtrace::Backtrace::force_capture()));
    s.push_str("── end of report ─────────────────────────────────────────────────\n\n");
    s
}

/// `file:line:col` of the panic, or `<no location>`.
pub fn location_of(info: &std::panic::PanicHookInfo<'_>) -> String {
    match info.location() {
        Some(l) => format!("{}:{}:{}", l.file(), l.line(), l.column()),
        None => "<no location>".to_string(),
    }
}

/// The panic message. `&str` and `String` payloads are the two `panic!` produces; any
/// other `Box<dyn Any>` is named as such rather than dropped.
pub fn payload_of(info: &std::panic::PanicHookInfo<'_>) -> String {
    let p = info.payload();
    if let Some(s) = p.downcast_ref::<&str>() {
        (*s).to_string()
    } else if let Some(s) = p.downcast_ref::<String>() {
        s.clone()
    } else {
        "<non-string panic payload>".to_string()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// ★ The declared path really is read from the env — asserted as a PATH.
    #[test]
    fn the_declared_dir_comes_from_the_env_override() {
        // `crash_dir` reads the process env; this test sets and reads only its own key.
        let want = "/home/alice/scratch/crash-probe-dir";
        // SAFETY: single-threaded assertion over a key no other test touches.
        std::env::set_var(DIR_ENV, want);
        assert_eq!(crash_dir(), PathBuf::from(want));
        std::env::remove_var(DIR_ENV);
        // And without it, never the cwd — the GUI-launched cwd is `/`.
        let fallback = crash_dir();
        assert_ne!(fallback, PathBuf::from("."));
        assert!(
            fallback.is_absolute(),
            "a declared path must be absolute, got {}",
            fallback.display()
        );
    }

    /// ★ RED for the OFF switch: it must really turn the file off, because that switch
    /// is what reproduces the pre-fix behaviour the guard is measured against. A knob
    /// that silently keeps writing would make every red in this suite a false one.
    #[test]
    fn the_off_switch_is_read() {
        for off in ["off", "0", "no", "FALSE"] {
            std::env::set_var(ENABLE_ENV, off);
            assert!(!enabled(), "`{off}` must disable the crash file");
        }
        for on in ["1", "on", "yes", ""] {
            std::env::set_var(ENABLE_ENV, on);
            // An EMPTY value is "set but empty", which systemd means as set — and the
            // default is on, so it stays on.
            assert!(enabled(), "`{on}` must leave the crash file on");
        }
        std::env::remove_var(ENABLE_ENV);
        assert!(enabled(), "unset ⇒ on");
    }

    /// ★ The identity line carries all three strings — an unattributable stack trace is
    /// a guess, which is the third of the three reasons in the module docs.
    #[test]
    fn the_identity_names_variant_version_and_build() {
        let a = CrashApp::new("dwarves-ui-gpu", "0.1.4", "c9dc909");
        let line = a.identity();
        for part in ["dwarves-ui-gpu", "0.1.4", "c9dc909"] {
            assert!(line.contains(part), "identity `{line}` must carry `{part}`");
        }
    }

    /// ★ `announce` says WHICH of the three states it is in, and never blurs them.
    #[test]
    fn announce_distinguishes_off_from_unwritable_from_ready() {
        let app = CrashApp::new("probe-ui", "0.0.1", "deadbee");
        let dir = PathBuf::from("/home/alice/scratch/crash-announce");

        let off = Installed {
            dir: dir.clone(),
            enabled: false,
            app: app.clone(),
            dir_writable: false,
        }
        .announce();
        assert!(off.contains("OFF"), "{off}");
        assert!(
            off.contains("NOTHING on disk"),
            "an operator must be told the file is gone, got: {off}"
        );

        let broken = Installed {
            dir: dir.clone(),
            enabled: true,
            app: app.clone(),
            dir_writable: false,
        }
        .announce();
        assert!(broken.contains("CANNOT be written"), "{broken}");
        assert!(broken.contains(DIR_ENV), "and it must name the fix: {broken}");

        let ready = Installed {
            dir: dir.clone(),
            enabled: true,
            app,
            dir_writable: true,
        }
        .announce();
        assert!(
            ready.contains(&dir.display().to_string()),
            "the ready line must name the directory: {ready}"
        );
        assert!(ready.contains(INDEX_FILE), "and the index: {ready}");
        // The three lines must be genuinely different sentences — a shared prefix that
        // reads the same is how "off" gets mistaken for "ready" at 02:00.
        assert_ne!(off, broken);
        assert_ne!(broken, ready);
        assert_ne!(off, ready);
    }
}