dwarves-gui/tests/crash_record.rs
//! **A5 — the UI wrote NOTHING when it crashed.** This is the guard.
//!
//! MEASURED on t14s 2026-08-27: `dwarves-ui` died and left no panic message, no
//! backtrace, no file, no journal line. The operator had to report the crash himself.
//!
//! Every assertion here drives the **REAL compiled `dwarves-ui`** (`CARGO_BIN_EXE_…`,
//! the shipped artifact, not a fixture) into a deliberate panic via
//! `DWARVES_UI_PANIC_PROBE`, and then reads the report **back off disk**. A round-trip
//! through the library's own structs would have passed on t14s too.
//!
//! # The three places a UI dies, and why all three are here
//!
//! * `startup` — the main thread. The easy case.
//! * `thread` — a NAMED worker. The process lives on, the window stays up, and the file
//! is the only trace there ever will be.
//! * `render` — inside eframe's own callback, i.e. on the **winit event loop**. This is
//! where an egui UI actually dies, and it is the case a hook could plausibly miss
//! (eframe/winit wrap the callback). It needs a real X display, so this test brings up
//! its OWN Xvfb — never the operator's screen, and never `:99`, which other lanes use.
//!
//! # Its own red
//!
//! `NORDISK_CRASH_LOG=off` reproduces the pre-fix behaviour on the SAME binary: same
//! panic, same probe, hook installed, and **no file**. Each green below is paired with
//! that red, and [`the_off_switch_reproduces_the_t14s_silence`] states it on its own so a
//! guard that had stopped looking at the directory cannot pass everything.
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant};
/// The first executable called `name` on `PATH`, or `None` — the rig probe for a test
/// that needs a program this box may not have. Pure Rust: no `which`, no shell.
fn on_path(name: &str) -> Option<PathBuf> {
std::env::var_os("PATH").and_then(|paths| {
std::env::split_paths(&paths)
.map(|d| d.join(name))
.find(|p| {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(p)
.is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
}
#[cfg(not(unix))]
{
p.is_file()
}
})
})
}
const UI: &str = env!("CARGO_BIN_EXE_dwarves-ui");
fn scratch(name: &str) -> PathBuf {
// md1 per the oden disk law — never /tmp, which is RAM here.
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
let dir = PathBuf::from(home)
.join("scratch")
.join("dwarves-ui-crash-tests")
.join(format!("{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create the scratch crash dir");
dir
}
fn reports(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
}
/// Run the REAL `dwarves-ui` with a panic probe. `display` is `Some(":97")` for the
/// render case; `None` strips every display so no window can open.
fn run_ui(
probe: &str,
crash_dir: &Path,
enable: Option<&str>,
display: Option<&str>,
) -> (String, Vec<PathBuf>) {
let mut cmd = Command::new(UI);
cmd.env("DWARVES_UI_PANIC_PROBE", probe)
.env("NORDISK_CRASH_DIR", crash_dir)
// The launch this reproduces has NO RUST_BACKTRACE: a desktop file, a
// `systemd --user` unit, a double-click. The report must still carry frames,
// which is what `force_capture` is for.
.env_remove("RUST_BACKTRACE")
// Never dial a live forge from a test.
.env("DWARVES_SERVER", "http://127.0.0.1:1")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
// ── NEVER let a test window reach the operator's screen ──────────────────────
// `DISPLAY=:97` ALONE IS NOT ENOUGH. The agent shell on oden carries
// `WAYLAND_DISPLAY=wayland-0` and `XDG_SESSION_TYPE=wayland`, and winit prefers
// Wayland when it sees them — so setting only DISPLAY puts a REAL window on
// The owner's own screen (measured today, ~90 s of one). All four knobs, every time:
// remove both Wayland hints and PIN the backend to x11.
cmd.env_remove("WAYLAND_DISPLAY")
.env_remove("XDG_SESSION_TYPE")
.env("WINIT_UNIX_BACKEND", "x11");
match display {
Some(d) => cmd.env("DISPLAY", d),
None => cmd.env_remove("DISPLAY"),
};
match enable {
Some(v) => cmd.env("NORDISK_CRASH_LOG", v),
None => cmd.env_remove("NORDISK_CRASH_LOG"),
};
let out = cmd.output().expect("run dwarves-ui");
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
(text, reports(crash_dir))
}
/// ★ **A main-thread panic leaves a report that names the payload, the location, the
/// thread, the build AND the variant** — read back off disk.
#[test]
fn a_startup_panic_leaves_a_report_on_disk() {
let dir = scratch("startup");
// ── THE RED, on the same artifact ────────────────────────────────────────────
// With the record switched off this binary panics exactly as before and the
// directory stays EMPTY. Without this the green below could be produced by anything
// writing into the directory.
let (off, off_files) = run_ui("startup", &dir, Some("off"), None);
assert!(
off_files.is_empty(),
"RED BROKEN: NORDISK_CRASH_LOG=off still wrote {off_files:?} — the red this test \
measures against does not exist"
);
assert!(
off.contains("DWARVES_UI_PANIC_PROBE=startup"),
"the OFF run must still have PANICKED, or the empty directory means nothing:\n{off}"
);
// And this is the t14s evidence gap, printed by the stock hook, in one line:
assert!(
off.contains("RUST_BACKTRACE=1"),
"the stock hook's answer without a record is a note about an env var, which is \
what the operator did NOT have:\n{off}"
);
// ── THE GREEN ────────────────────────────────────────────────────────────────
let (text, files) = run_ui("startup", &dir, None, 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");
for needle in [
"DWARVES_UI_PANIC_PROBE=startup", // the payload
"src/main.rs:", // the location, with a line number
"thread main (MAIN)", // which thread
"variant dwarves-ui", // WHICH artifact — the T7 discriminator
env!("CARGO_PKG_VERSION"), // WHICH version
] {
assert!(
body.contains(needle),
"the report is missing `{needle}`:\n{body}"
);
}
// The build stamp — the same string `--version` prints, so the two can be compared
// by eye on a machine nobody is sitting at.
let stamp = dwarves_gui::version_line_for("dwarves-ui");
let hash = stamp
.rsplit_once('(')
.and_then(|(_, r)| r.split_once(')'))
.map(|(h, _)| h.trim().to_string())
.expect("the stamp carries a hash in parens");
assert!(!hash.is_empty() && hash != "unknown", "hash was {hash:?}");
assert!(
body.contains(&format!("build {hash}")),
"the report must name the BUILD ({hash}) — a stack trace against an unknown \
artifact is a guess:\n{body}"
);
// The backtrace, asserted as FRAMES and not as a section header: `Backtrace::capture`
// answers `disabled backtrace` with no RUST_BACKTRACE, and this run removed it.
assert!(
!body.contains("disabled backtrace"),
"the backtrace was not FORCED — this is the exact evidence the t14s crash \
lacked:\n{body}"
);
assert!(
body.lines().any(|l| l.contains("dwarves_ui::main")
|| l.contains("dwarves_gui::main")
|| l.contains("main.rs")),
"the forced backtrace must name a frame in this binary:\n{body}"
);
// stderr must name the file that exists, so an operator with a terminal is told
// where to look without knowing this crate exists.
assert!(
text.contains(&path.display().to_string()),
"stderr must point at the report:\n{text}"
);
// And so must the STARTUP line, which is printed before anything goes wrong.
assert!(
text.contains("crash reports →"),
"the startup announcement must name the declared path:\n{text}"
);
// The index makes it findable with `tail -1` over ssh.
let idx = std::fs::read_to_string(dir.join("crashes.log")).expect("the index exists");
assert!(
idx.lines().last().is_some_and(|l| l.contains("dwarves-ui")),
"the index must name the variant, got: {idx}"
);
}
/// ★ **A panic on a NON-MAIN thread is recorded, and the report says which thread.**
///
/// This is the case a `main`-only hook misses and the case a UI dies in most often: a
/// worker panics, the process carries on, and nothing is on screen.
#[test]
fn a_worker_thread_panic_leaves_a_report_naming_the_thread() {
let dir = scratch("thread");
let (off, off_files) = run_ui("thread", &dir, Some("off"), None);
assert!(off_files.is_empty(), "RED BROKEN: off wrote {off_files:?}");
assert!(
off.contains("DWARVES_UI_PANIC_PROBE=thread"),
"the OFF run must have panicked on the worker:\n{off}"
);
let (text, files) = run_ui("thread", &dir, None, None);
assert_eq!(files.len(), 1, "got {files:?}\n{text}");
let body = std::fs::read_to_string(&files[0]).unwrap();
assert!(
body.contains("thread dwarves-ui-worker (not main)"),
"the report must name the panicking thread and say it was NOT main — `main` and \
`dwarves-ui-worker` need different fixes:\n{body}"
);
assert!(
!body.contains("disabled backtrace"),
"a worker panic must carry a forced backtrace too:\n{body}"
);
assert!(body.contains("variant dwarves-ui"), "{body}");
}
/// ★ **THE EFRAME/EGUI RENDER LOOP** — a panic inside eframe's own callback, on the
/// winit event loop, still writes the report.
///
/// This is the one that needed proving rather than reasoning about: eframe and winit wrap
/// the app callback, and a `catch_unwind` in that path could plausibly have swallowed the
/// panic before anything durable happened. It cannot, because a panic HOOK runs at panic
/// time — before unwinding, before any catch. Asserted here on the real binary, in a real
/// window, rather than argued.
///
/// It brings up its OWN Xvfb on a private display. Never the operator's screen (the LAW),
/// and never `:99`, which other lanes hold.
#[test]
fn a_panic_inside_the_eframe_event_loop_leaves_a_report() {
const DISPLAY: &str = ":97";
// The rig this claim needs is a real X server, and a box without one cannot run it —
// but "Xvfb is not installed here" and "the panic hook lost the report" must never
// read the same in a log. Detected, named, skipped (the `requires:` shape
// nornir_testmatrix grades as `gated`); never a `#[ignore]`, which would hide it on
// the boxes that DO have the rig.
let Some(xvfb_bin) = on_path("Xvfb") else {
eprintln!(
"SKIP requires: an `Xvfb` binary on PATH (a UI claim needs a REAL window, and \
an offscreen surface is not one) — install xserver-xorg-core / xvfb and rerun"
);
return;
};
let mut xvfb = Command::new(xvfb_bin)
.args([DISPLAY, "-screen", "0", "1280x800x24", "-nolisten", "tcp"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.expect(
"Xvfb must be installed — a UI claim needs a REAL window, and an offscreen \
surface is not one",
);
// Wait for the socket, not a fixed sleep.
let sock = PathBuf::from(format!(
"/tmp/.X11-unix/X{}",
DISPLAY.trim_start_matches(':')
));
let deadline = Instant::now() + Duration::from_secs(10);
while !sock.exists() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(50));
}
let up = sock.exists();
let result = std::panic::catch_unwind(|| {
assert!(up, "Xvfb {DISPLAY} never created {}", sock.display());
// ── FIRST: prove this display really carries a REAL dwarves-ui WINDOW ────────
// Not for its own sake — so that a missing crash report below cannot be blamed
// on a dead X server. A run with NO probe is left up until `xwininfo -root
// -children` sees a mapped child, and then killed. An empty server answers
// "0 children"; an offscreen surface would never appear here at all.
let mut alive = Command::new(UI)
.env_remove("DWARVES_UI_PANIC_PROBE")
.env("DISPLAY", DISPLAY)
.env_remove("WAYLAND_DISPLAY")
.env_remove("XDG_SESSION_TYPE")
.env("WINIT_UNIX_BACKEND", "x11")
.env("DWARVES_SERVER", "http://127.0.0.1:1")
.env("NORDISK_CRASH_DIR", scratch("render-window"))
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.expect("spawn dwarves-ui on the private display");
let win_deadline = Instant::now() + Duration::from_secs(60);
let mut children = String::new();
while Instant::now() < win_deadline {
children = Command::new("xwininfo")
.args(["-display", DISPLAY, "-root", "-children"])
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
.unwrap_or_default();
if !children.contains("0 children") && children.contains("children") {
break;
}
std::thread::sleep(Duration::from_millis(250));
}
let mapped = !children.contains("0 children") && children.contains("children");
let _ = alive.kill();
let _ = alive.wait();
assert!(
mapped,
"no REAL window appeared on {DISPLAY} within 60 s — an absent crash report \
below would then say nothing about the event loop:\n{children}"
);
let dir = scratch("render");
// The red first, on the same display and the same binary.
let (off, off_files) = run_ui("render", &dir, Some("off"), Some(DISPLAY));
assert!(
off_files.is_empty(),
"RED BROKEN: off wrote {off_files:?}\n{off}"
);
assert!(
off.contains("DWARVES_UI_PANIC_PROBE=render"),
"the OFF run must have reached eframe's callback and panicked there — if it \
did not, this test proves nothing about the render loop:\n{off}"
);
let (text, files) = run_ui("render", &dir, None, Some(DISPLAY));
assert_eq!(
files.len(),
1,
"a panic on the winit event loop must leave exactly one report; got \
{files:?}\n{text}"
);
let body = std::fs::read_to_string(&files[0]).unwrap();
assert!(
body.contains("DWARVES_UI_PANIC_PROBE=render"),
"and it must be THAT panic:\n{body}"
);
// The proof that it really was the event loop and not the pre-window path: the
// backtrace has to run through eframe/winit. If eframe were never entered the
// probe would not have fired at all (the `render` arm is inside the `run`
// closure), and these frames are what say so out loud.
let framed = body
.lines()
.any(|l| l.contains("eframe") || l.contains("winit") || l.contains("run_native"));
assert!(
framed,
"the forced backtrace must show the eframe/winit frames the panic unwound \
through — otherwise this is not the render-loop case:\n{body}"
);
assert!(body.contains("variant dwarves-ui"), "{body}");
});
let _ = xvfb.kill();
let _ = xvfb.wait();
if let Err(e) = result {
std::panic::resume_unwind(e);
}
}
/// ★ **The RED, on its own line in the test list.** `NORDISK_CRASH_LOG=off` reproduces
/// the t14s silence exactly: the shipped binary, a real panic, and nothing on disk.
///
/// If this ever passes green while the others also pass, the directory is getting files
/// from somewhere other than the hook and every green above is worthless.
#[test]
fn the_off_switch_reproduces_the_t14s_silence() {
let dir = scratch("silence");
let (text, files) = run_ui("startup", &dir, Some("off"), None);
assert!(files.is_empty(), "expected NO reports, got {files:?}");
assert!(!dir.join("crashes.log").exists(), "and no index");
assert!(
text.contains("NOTHING on disk"),
"and the operator must be TOLD the record is off rather than left to assume it \
is on:\n{text}"
);
}
/// ★ **No panic ⇒ no report.** The other half: a hook that wrote a file on every exit
/// would satisfy every assertion above and be useless.
#[test]
fn a_version_run_writes_no_report() {
let dir = scratch("clean");
let out = Command::new(UI)
.arg("--version")
.env("NORDISK_CRASH_DIR", &dir)
.output()
.expect("run dwarves-ui --version");
assert!(out.status.success());
assert!(
reports(&dir).is_empty(),
"a clean `--version` must leave no report, got {:?}",
reports(&dir)
);
}