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