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

nordisk/dwarves

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