Crash reports: replayable, bounded, local, reachable by agents (#384)
Every panic (in a command, in the kernel, anywhere else) and every unclean exit (abort, stack overflow, kill) leaves a report in a local crash folder: message, file:line, a trimmed backtrace, version, commit, system and renderer, the command and recent commands, and when possible the design and replay.json that reproduce it (solvecraft-cli crashes replay <id>). - Cost: nothing until the app installs the hook (tests, CLI and web never do); then one small journal line per top-level command of the design on screen, taken back with a truncate when the command changed nothing. - Unclean exits: each run holds a lock on its journal; a journal nobody holds at the next start belongs to an app that died. Autosaves restart the journal from the autosaved design. - Bounded: a crash's signature (place + message, numbers ignored) is counted, not rewritten; at most 5 new reports per run, 20 reports, 50 MB, 90 days; recovered kernel panics are pruned first. - Agents: diagnostics.crash_reports / crash_report / crash_reports.clear commands, the MCP crash_reports tool, solvecraft-cli crashes, solvecraft --crash-log; diagnostics.crash_test (with SOLVECRAFT_CRASH_TEST) checks it. - People: the Crash Reports window opens after a crash (Help > Crash Reports…) with a reviewed issue draft per report; nothing is sent. - Release builds keep line tables for file:line in backtraces. Tests: child processes that really panic in a command, in the kernel, on a thread, and abort; dedup counts, limits, hostile folders, journal parsing, the UI window and draft, the MCP tool. docs/crash-reports.md. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
This commit is contained in:
24 files changed
+1998
-9
No files matched your search
@@ -0,0 +1,74 @@
|
||||
//! Crash reports, for agents and people (see `crate::crash` and docs/crash-reports.md): list
|
||||
//! them, read one, delete them. They live on this computer and are never sent anywhere.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, str_};
|
||||
use crate::{EngineError, Result, Session, crash};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("diagnostics.crash_reports", "Crash Reports", list).noundo().params(
|
||||
"limit?: number (default 20). Lists this computer's crash reports, newest first: {dir, reports: [{id, kind, time, message, \
|
||||
command, count, version, path, replayable}]}. kind: command_panic | panic | kernel_panic_recovered | unclean_exit. \
|
||||
Read one with diagnostics.crash_report; reproduce it with `solidcraft-cli crashes replay <id>`",
|
||||
),
|
||||
CommandSpec::new("diagnostics.crash_report", "Crash Report", read).noundo().params(
|
||||
"id: report id. The whole report: message, location, backtrace, app version and commit, system, the command that \
|
||||
crashed and the recent ones, notes, and design_path / replay_path when the crash can be replayed \
|
||||
(`solidcraft-cli run <replay_path> --in <design_path>`)",
|
||||
),
|
||||
CommandSpec::new("diagnostics.crash_reports.clear", "Delete Crash Reports", clear)
|
||||
.noundo()
|
||||
.params("id?: one report (default: all). Returns {deleted}"),
|
||||
CommandSpec::new("diagnostics.crash_test", "Crash Test", crash_test).noundo().enabled(crash_test_enabled).params(
|
||||
"mode: panic | kernel | abort. Checks that crash reports work: panic = an internal error in this command (caught; \
|
||||
the design is unchanged), kernel = one inside the geometry kernel (caught), abort = the app ends at once (reported \
|
||||
at the next start). Only when the SOLVECRAFT_CRASH_TEST environment variable is set",
|
||||
),
|
||||
];
|
||||
|
||||
fn dir(cmd: &str) -> Result<std::path::PathBuf> {
|
||||
crash::default_dir().ok_or_else(|| bad(cmd, "no crash folder on this system"))
|
||||
}
|
||||
|
||||
fn list(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = dir("diagnostics.crash_reports")?;
|
||||
let limit = p.get("limit").and_then(Value::as_u64).map_or(20, |n| n.min(1000)) as usize;
|
||||
let mut reports = crash::list(&d);
|
||||
reports.truncate(limit);
|
||||
Ok(json!({ "dir": d.to_string_lossy(), "reports": reports }))
|
||||
}
|
||||
|
||||
fn read(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "diagnostics.crash_report";
|
||||
let id = str_(p, "id").ok_or_else(|| bad(cmd, "`id` is required (see diagnostics.crash_reports)"))?;
|
||||
crash::read(&dir(cmd)?, id).map_err(|e| bad(cmd, e))
|
||||
}
|
||||
|
||||
fn clear(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = dir("diagnostics.crash_reports.clear")?;
|
||||
Ok(json!({ "deleted": crash::clear(&d, str_(p, "id")) }))
|
||||
}
|
||||
|
||||
fn crash_test_enabled(_: &Session) -> std::result::Result<(), String> {
|
||||
if std::env::var_os("SOLVECRAFT_CRASH_TEST").is_some() { Ok(()) } else { Err("set SOLVECRAFT_CRASH_TEST to use the crash test".into()) }
|
||||
}
|
||||
|
||||
// The one deliberate panic: it proves the crash reports work, and runs only when asked for with
|
||||
// SOLVECRAFT_CRASH_TEST.
|
||||
#[allow(clippy::panic)]
|
||||
fn crash_test(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "diagnostics.crash_test";
|
||||
match str_(p, "mode") {
|
||||
Some("panic") => std::panic::panic_any(String::from("crash test: an internal error in a command")),
|
||||
Some("kernel") => {
|
||||
solvecraft_kernel::guard("crash test", || -> solvecraft_kernel::Result<()> {
|
||||
std::panic::panic_any(String::from("an internal error in the kernel"))
|
||||
})?;
|
||||
Err(EngineError::Other("crash test: the kernel error was not caught".into()))
|
||||
}
|
||||
Some("abort") => std::process::abort(),
|
||||
_ => Err(bad(cmd, "`mode` must be panic, kernel or abort")),
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ mod component_clone;
|
||||
mod component_clone_assembly;
|
||||
mod construct;
|
||||
mod delete;
|
||||
mod diagnostics;
|
||||
mod edit;
|
||||
mod face;
|
||||
mod features;
|
||||
@@ -214,6 +215,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
|
||||
v.extend(file::COMMANDS.iter());
|
||||
v.extend(inspect::COMMANDS.iter());
|
||||
v.extend(section::COMMANDS.iter());
|
||||
v.extend(diagnostics::COMMANDS.iter());
|
||||
v
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user