Files
benilla-octo/crates/benilla-app/src/crash.rs
T
SamandClaude Fable 5.1 aff5203393 a fresh clone gets the whole project, and the maintainer's tooling moves out
What a clone lacked: the gates went green with a thousand skipped tests and said nothing, every
login script assumed a worktree-pool slot and its probe account, the client defaulted to the
maintainer's account, a docker path and a home directory were in the code, the temp files broke
on GNU coreutils, and two generators could only run against a sibling repo.

Now the test gates print how many data-gated tests skipped and why; a scripted run logs in as
the account its checkout declares in `.probe-identity` or as the three variables name, never a
default, and the client refuses a scripted login on any other account; the event and message
tables regenerate from vendored inputs, byte for byte; the docs state the real prerequisites.

Out of the tree, into the maintainer's own tooling: the worktree pool, its hooks, the perf leg
runner, the cross-platform compile and the Vulkan container leg — a way of working, and setups
not every machine has. One hook stays: no writes into the install.

Two tests that could never run are gone with their subject: the quest-timer geometry diff
compared a copy of the window that no longer exists (and joined a chain path onto a folder, so
it always skipped), and the FrameXML flag gate now reads the stock files off the player's own
patch chain instead of an extracted folder only one machine had.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 17:27:14 +07:00

209 lines
8.5 KiB
Rust

//! **A crash leaves an artefact** (decision 2266 §B2).
//!
//! Until this module, a panic printed to stderr and the process was gone — and with it everything
//! a reporter could have attached. The only log sink was the terminal; a packaged Windows build
//! has no console; of bug B390's four reporters one attached a backtrace, because he had set
//! `RUST_BACKTRACE=1` on his own. `shutdown.rs` says the other half out loud: *"A crash, a
//! `SIGKILL`, a wedged teardown. Nothing survives those."*
//!
//! [`install`] chains a panic hook in front of Rust's default one. The default still prints to
//! stderr exactly as before (so every log a session already greps keeps reading the same); then
//! the report goes to **`benilla-config/Diagnostics/crash-<unix-seconds>.txt`**, resolved through
//! [`crate::local_state`] like every other file benilla writes (the one-folder rule, 0954/1175 —
//! never a platform log dir, never beside the install). The file carries: the build id (the sha
//! is the version, `build_id`), the time and the uptime, the thread, the panic's location and
//! payload, a backtrace captured **regardless of `RUST_BACKTRACE`**, and the last
//! [`benilla_world::log_ring::CAPACITY`] log lines — the minute before, which is what turns a
//! symptom into a story. The path is printed to stderr as the last line, so "attach the file it
//! names" is the whole instruction to a reporter.
//!
//! **What it does not cover, said out loud.** A non-unwinding panic — an allocation failure's
//! abort, a `panic` inside a `Drop` during unwinding, a foreign frame — never reaches any hook;
//! B390's own was one (`thread caused non-unwinding panic. aborting.`). The hook covers the
//! unwinding class (B88, B130, B260, B383's shape: four of the six panics the ledger holds).
//! Flushing saved variables and the CVar diff from inside a panic is a second, riskier item —
//! the world is half-torn by then — and is deliberately not bundled here (2265 §B2).
//!
//! A hermetic capture run (`local_state::home()` answers `None`) writes no file and loses
//! nothing: the harness already has stderr.
use std::fmt::Write as _;
use std::panic::PanicHookInfo;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use crate::BuildId;
/// Chain the crash-report hook in front of the current panic hook. Called once, from
/// [`crate::run`], before the `App` exists — a panic while plugins build is still a crash.
pub(crate) fn install(build: BuildId) {
let started = Instant::now();
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
previous(info);
report(info, build, started.elapsed());
}));
}
// The standing test affordance — `WOW_CRASH_INJECT=<at_secs>`, one deliberate main-thread panic
// mid-run — is `perf::crash_inject`, a dev root: this module ships to players and knows nothing
// of the `dev` seam (run_mode's one-door rule, 1176), and an instrument's home is with the
// instruments. Its module doc carries the cross-platform incident that moved it there.
/// Re-entrancy latch: a panic *inside* the hook (a poisoned lock, a failed write) must not
/// recurse into it. Never cleared on purpose — after one report the process is on its way out,
/// and a second panic's report would only overwrite the useful one with a worse one.
static REPORTING: AtomicBool = AtomicBool::new(false);
fn report(info: &PanicHookInfo<'_>, build: BuildId, uptime: Duration) {
if REPORTING.swap(true, Ordering::SeqCst) {
return;
}
let Some(dir) = crate::local_state::diagnostics_dir() else {
return;
};
let payload = if let Some(s) = info.payload().downcast_ref::<&str>() {
(*s).to_owned()
} else if let Some(s) = info.payload().downcast_ref::<String>() {
s.clone()
} else {
"<non-string panic payload>".to_owned()
};
let location = info
.location()
.map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
.unwrap_or_else(|| "<unknown>".to_owned());
let thread = std::thread::current();
let text = render(&Report {
build,
unix: SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
uptime,
thread: thread.name().unwrap_or("<unnamed>"),
location: &location,
payload: &payload,
backtrace: &std::backtrace::Backtrace::force_capture().to_string(),
log: &benilla_world::log_ring::recent(),
});
if let Some(path) = write(&dir, &text) {
eprintln!("crash report written to {}", path.display());
}
}
/// Everything the report says, gathered before rendering so the renderer is a pure function the
/// test can drive without panicking.
struct Report<'a> {
build: BuildId,
unix: u64,
uptime: Duration,
thread: &'a str,
location: &'a str,
payload: &'a str,
backtrace: &'a str,
log: &'a [String],
}
fn render(r: &Report<'_>) -> String {
let mut out = String::new();
let _ = writeln!(out, "benilla crash report");
let _ = writeln!(
out,
"build: {} (sha {})",
r.build.summary(),
r.build.sha
);
let _ = writeln!(
out,
"time: {} unix, {:.1} s after launch",
r.unix,
r.uptime.as_secs_f64()
);
let _ = writeln!(
out,
"platform: {} {}",
std::env::consts::OS,
std::env::consts::ARCH
);
let _ = writeln!(out, "thread: {}", r.thread);
let _ = writeln!(out, "at: {}", r.location);
let _ = writeln!(out, "panic: {}", r.payload);
let _ = writeln!(out, "\nbacktrace:\n{}", r.backtrace);
let _ = writeln!(out, "last {} log lines (oldest first):", r.log.len());
for line in r.log {
let _ = writeln!(out, "{line}");
}
out
}
/// Write the report under `dir` as `crash-<unix>.txt`, creating the folder on demand (an empty
/// `Diagnostics/` on every run would be this instrument advertising itself, `perf::stall`'s
/// reason). Plain `fs::write`, not `local_state::write_atomic`: the process is dying, and a
/// half-written report beats none.
fn write(dir: &Path, text: &str) -> Option<PathBuf> {
let unix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let path = dir.join(format!("crash-{unix}.txt"));
std::fs::create_dir_all(dir).ok()?;
std::fs::write(&path, text).ok()?;
Some(path)
}
#[cfg(test)]
mod tests {
use super::*;
const BUILD: BuildId = BuildId {
sha: "0123456789abcdef0123456789abcdef01234567",
short: "0123456",
date: "2026-09-16",
profile: "debug",
};
/// The report names the build, the place, the payload and the log — the four things a
/// reporter cannot reconstruct after the fact — and lands in the folder it was given.
#[test]
fn a_report_carries_the_build_the_place_the_payload_and_the_log() {
let log = vec!["+ 1.000s INFO benilla_app::net: entered world".to_owned()];
let text = render(&Report {
build: BUILD,
unix: 1_800_000_000,
uptime: Duration::from_millis(12_345),
thread: "main",
location: "crates/benilla-app/src/net/apply.rs:100:5",
payload: "index out of bounds: the len is 3 but the index is 7",
backtrace: " 0: benilla_app::net::apply::apply_net_updates",
log: &log,
});
for needle in [
"sha 0123456789abcdef0123456789abcdef01234567",
"0123456 · 2026-09-16 · debug",
"12.3 s after launch",
"thread: main",
"at: crates/benilla-app/src/net/apply.rs:100:5",
"panic: index out of bounds: the len is 3 but the index is 7",
"apply_net_updates",
"last 1 log lines",
"entered world",
] {
assert!(text.contains(needle), "missing {needle:?} in:\n{text}");
}
let dir = std::env::temp_dir().join(format!("benilla-crash-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = write(&dir, &text).expect("report written");
assert!(path.starts_with(&dir), "{}", path.display());
assert!(path
.file_name()
.unwrap()
.to_string_lossy()
.starts_with("crash-"));
assert_eq!(std::fs::read_to_string(&path).unwrap(), text);
let _ = std::fs::remove_dir_all(&dir);
}
}