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:
authored and GitHub committed 2026-10-12 16:50:49 -04:00
1 parent 55f2f29fff
commit 27415e4dd7
24 files changed
+1998 -9

No files matched your search

+1 -1
View File
@@ -491,7 +491,7 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
},
"ui.prefs" => wrap(crate::prefs::control(app, p)),
"ui.help" => match s("item") {
Some(i @ ("about" | "shortcuts" | "report")) => {
Some(i @ ("about" | "shortcuts" | "report" | "crashes")) => {
crate::help::run(app, ctx, i);
ok(
json!({"diagnostics": crate::help::diagnostics(app), "version": crate::help::VERSION, "commit": crate::help::COMMIT, "built": crate::help::BUILD_DATE}),
+238
View File
@@ -0,0 +1,238 @@
//! The Crash Reports window (Help > Crash Reports…): the reports `solvecraft_engine::crash`
//! keeps on this computer, each with a reviewed issue draft. It opens by itself after a crash.
use egui::{RichText, vec2};
use solvecraft_engine::crash::{self, Summary};
use crate::SolveApp;
use crate::theme::Tokens;
#[derive(Default)]
pub struct CrashUi {
pub open: bool,
/// The crash folder (default: the engine's, `crash::default_dir`).
pub folder: Option<std::path::PathBuf>,
reports: Vec<Summary>,
dir: Option<String>,
note: String,
}
impl CrashUi {
/// Read the reports again (when the window opens or changes; never every frame).
pub fn refresh(&mut self) {
let dir = self.dir_path();
self.reports = dir.as_deref().map(crash::list).unwrap_or_default();
self.dir = dir.map(|d| d.to_string_lossy().into_owned());
}
}
impl CrashUi {
fn dir_path(&self) -> Option<std::path::PathBuf> {
self.folder.clone().or_else(crash::default_dir)
}
}
fn kind_label(kind: &str) -> &'static str {
match kind {
"command_panic" => "Internal error in a command",
"kernel_panic_recovered" => "Kernel error (recovered)",
"unclean_exit" => "Closed unexpectedly",
_ => "Internal error",
}
}
/// The host found these new reports at startup: tell the user about real crashes (recovered
/// kernel errors stay quiet; they are listed in the window).
pub fn announce(app: &mut SolveApp, fresh: &[Summary]) {
let crashes = fresh.iter().filter(|r| r.kind != "kernel_panic_recovered").count();
if crashes == 0 {
return;
}
app.crashes.open = true;
app.crashes.refresh();
app.set_status("SolidCraft closed unexpectedly or hit an internal error last time. A crash report was saved (Help > Crash Reports…).", true);
}
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
if !app.crashes.open {
return;
}
let t = Tokens::get();
let mut open = true;
let mut report: Option<String> = None;
let mut delete: Option<Option<String>> = None;
crate::frame::window(ctx, "Crash Reports", crate::frame::Width::Wide)
.id(egui::Id::new("sc_crash_reports"))
.open(&mut open)
.default_size(vec2(620.0, 420.0))
.show(ctx, |ui| {
ui.label(
RichText::new(
"Crash reports stay on this computer and are never sent anywhere. Report… opens a draft you review before anything is shared.",
)
.color(t.text_dim),
);
if let Some(d) = app.crashes.dir.clone() {
// A long path is cut short (hover shows it all): it must not widen the window
// past its title bar.
ui.horizontal(|ui| {
if ui.small_button("Copy folder path").clicked() {
ctx.copy_text(d.clone());
}
ui.add(egui::Label::new(RichText::new(&d).monospace().size(11.0)).truncate()).on_hover_text(&d);
});
}
ui.separator();
if app.crashes.reports.is_empty() {
ui.label("No crash reports.");
}
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
for r in &app.crashes.reports {
ui.horizontal(|ui| {
let times = if r.count > 1 { format!(" ×{}", r.count) } else { String::new() };
ui.label(
RichText::new(format!("{} {}{times}", r.time.replace('T', " ").trim_end_matches('Z'), kind_label(&r.kind))).strong(),
);
if let Some(c) = &r.command {
ui.label(RichText::new(c).monospace().size(11.0).color(t.text_dim));
}
});
ui.label(RichText::new(&r.message).size(12.0));
ui.horizontal(|ui| {
if ui.small_button("Report…").clicked() {
report = Some(r.id.clone());
}
if ui.small_button("Copy path").clicked() {
ctx.copy_text(r.path.clone());
}
if ui.small_button("Delete").clicked() {
delete = Some(Some(r.id.clone()));
}
if r.replayable {
ui.label(RichText::new("replayable").size(11.0).color(t.text_dim));
}
});
ui.add_space(6.0);
}
});
if !app.crashes.note.is_empty() {
ui.label(RichText::new(&app.crashes.note).color(t.text_dim));
}
ui.separator();
if !app.crashes.reports.is_empty() && ui.button("Delete all").clicked() {
delete = Some(None);
}
});
app.crashes.open = open;
if let Some(id) = report
&& let Some(dir) = app.crashes.dir_path()
{
match crash::read(&dir, &id) {
Ok(v) => crate::issue_report::begin_crash(app, ctx, &v),
Err(e) => app.crashes.note = e,
}
}
if let Some(which) = delete
&& let Some(dir) = app.crashes.dir_path()
{
let n = crash::clear(&dir, which.as_deref());
app.crashes.note = format!("Deleted {n} report(s).");
app.crashes.refresh();
}
}
/// Help > Crash Reports…
pub fn open(app: &mut SolveApp) {
app.crashes.open = true;
app.crashes.note.clear();
app.crashes.refresh();
}
#[cfg(test)]
mod tests {
use serde_json::json;
fn folder(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("solvecraft-crash-ui-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
let id = "20261012-162341-bb60791ed6692f1d";
std::fs::create_dir_all(d.join(id)).unwrap();
let report = json!({
"format": "solvecraft-crash/1", "id": id, "kind": "unclean_exit", "count": 2,
"first_seen": "2026-10-12T16:23:41Z", "message": "SolidCraft ended without a clean exit while running `solid.fillet`",
"location": "unclean exit in solid.fillet", "thread": "(unknown)", "backtrace": [],
"command": {"command": "solid.fillet", "params": {"radius": 2}},
"recent_commands": [{"command": "solid.box", "ok": true}],
"app": {"version": "0.3.2", "commit": "abc1234", "built": "2026-10-12"},
"system": {"os": "linux", "arch": "x86_64", "environment": "Renderer: test"},
});
std::fs::write(d.join(id).join("report.json"), report.to_string()).unwrap();
std::fs::write(d.join(id).join("replay.json"), r#"{"commands":[]}"#).unwrap();
d
}
#[test]
fn a_crash_report_opens_a_reviewed_issue_draft_and_can_be_deleted() {
let d = folder("draft");
let mut h = crate::scenario::Harness::new();
h.app.crashes.folder = Some(d.clone());
let fresh = solvecraft_engine::crash::list(&d);
assert_eq!(fresh.len(), 1);
super::announce(&mut h.app, &fresh);
assert!(h.app.crashes.open, "a real crash opens the window");
assert!(h.app.status.as_ref().is_some_and(|s| s.2), "and says so");
h.frames(4);
h.step(&json!({"widget": "Report…"})).unwrap();
h.frames(2);
let draft = h.app.issue_report.draft.as_ref().expect("Report… opens the draft");
assert!(draft.title.starts_with("Crash: SolidCraft ended without a clean exit"), "{}", draft.title);
for want in [
"while running `solid.fillet`",
"\"radius\": 2",
"solid.box",
"solidcraft-cli crashes replay 20261012-162341-bb60791ed6692f1d",
"Renderer: test",
"Happened 2 time(s)",
] {
assert!(draft.body.contains(want), "missing {want:?} in {}", draft.body);
}
assert!(!draft.body.contains(&d.to_string_lossy().into_owned()), "the draft names no local paths");
h.app.issue_report.draft = None;
h.frames(2);
h.step(&json!({"widget": "Delete all"})).unwrap();
h.frames(2);
assert!(solvecraft_engine::crash::list(&d).is_empty());
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_long_folder_path_does_not_widen_the_window_past_its_title_bar() {
let d = folder(&format!("{}-long", "very-long-folder-name-".repeat(6)));
let mut h = crate::scenario::Harness::new();
h.app.crashes.folder = Some(d.clone());
super::open(&mut h.app);
h.frames(6);
let window = h.ctx.memory(|m| m.area_rect(egui::Id::new("sc_crash_reports"))).unwrap();
// The frame's margins come on top of the content's maximum width.
assert!(window.width() <= crate::frame::WINDOW_WIDE_MAX_W + 40.0, "{} wide", window.width());
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn recovered_kernel_errors_do_not_interrupt() {
let mut h = crate::scenario::Harness::new();
let quiet = solvecraft_engine::crash::Summary {
id: "x".into(),
kind: "kernel_panic_recovered".into(),
time: String::new(),
message: String::new(),
command: None,
count: 1,
version: String::new(),
path: String::new(),
replayable: false,
};
super::announce(&mut h.app, &[quiet]);
assert!(!h.app.crashes.open);
}
}
+4 -1
View File
@@ -43,7 +43,7 @@ pub fn diagnostics(app: &SolveApp) -> String {
)
}
/// Run a Help menu item: `docs`, `about`, `shortcuts`, `report`.
/// Run a Help menu item: `docs`, `about`, `shortcuts`, `report`, `crashes`.
pub fn run(app: &mut SolveApp, ctx: &egui::Context, item: &str) {
app.help.menu = false;
match item {
@@ -53,6 +53,7 @@ pub fn run(app: &mut SolveApp, ctx: &egui::Context, item: &str) {
app.keymap.open = true;
app.keymap.read_only = true;
}
"crashes" => crate::crash_ui::open(app),
"report" => {
let dialog = app.dialog.clone();
let error = dialog.as_ref().and_then(|d| d.error.clone()).or_else(|| app.preview.error.clone());
@@ -74,6 +75,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context, anchor: egui::Pos2) {
("about", "About SolidCraft"),
("shortcuts", "Keyboard Shortcuts"),
("report", "Report an Issue…"),
("crashes", "Crash Reports…"),
] {
if crate::context_menu::list_row(ui, &crate::context_menu::Item::action(id, label, "")).is_some() {
pick = Some(id);
@@ -92,6 +94,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context, anchor: egui::Pos2) {
ctx.data_mut(|d| d.insert_temp(egui::Id::new("sc_help_opened"), false));
}
about(app, ctx);
crate::crash_ui::show(app, ctx);
}
fn about(app: &mut SolveApp, ctx: &egui::Context) {
+75
View File
@@ -289,6 +289,81 @@ pub fn draft_for(app: &SolveApp, dialog: Option<&dialogs::Dialog>, error: Option
Draft { title, body, image: None, texture: None, note: String::new(), source_signature: source_signature(app, dialog) }
}
/// A draft from a crash report (`solvecraft_engine::crash`): what happened, where, the command
/// and how to replay it. The design stays on this computer; the draft says how to share it.
pub fn crash_draft(report: &serde_json::Value) -> Draft {
let s = |k: &str| report.get(k).and_then(serde_json::Value::as_str).unwrap_or("?").to_string();
let what = match report.get("kind").and_then(serde_json::Value::as_str) {
Some("command_panic") => "An internal error stopped a command (the design was left unchanged)",
Some("kernel_panic_recovered") => "The geometry kernel hit an internal error (caught; the operation failed)",
Some("unclean_exit") => "SolidCraft closed unexpectedly (abort, stack overflow or the process was killed)",
_ => "SolidCraft hit an internal error",
};
let command = report.get("command").and_then(|c| c.get("command")).and_then(serde_json::Value::as_str);
let frames: Vec<&str> =
report.get("backtrace").and_then(serde_json::Value::as_array).into_iter().flatten().filter_map(serde_json::Value::as_str).take(25).collect();
let recent: Vec<String> = report
.get("recent_commands")
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.filter_map(|c| {
let id = c.get("command")?.as_str()?;
Some(if c.get("ok").and_then(serde_json::Value::as_bool) == Some(false) { format!("{id} (failed)") } else { id.to_string() })
})
.collect();
let app = report.get("app");
let field = |o: Option<&serde_json::Value>, k: &str| o.and_then(|o| o.get(k)).and_then(serde_json::Value::as_str).unwrap_or("?").to_string();
let system = report.get("system");
let mut body = format!(
"## What happened\n{what}{}.\n\nMessage: {}\nLocation: {}\nThread: {}\nHappened {} time(s), first {}\n\n## Backtrace\n{}\n## Command\n{}\n## Recent commands\n{}\n\n## Reproduction\nCrash report `{}` on the reporter's computer holds {}. Replay it with `solidcraft-cli crashes replay {}`.\nOnly attach the design if it can be shared publicly.\n\n## Environment\n{}\n",
command.map(|c| format!(" while running `{c}`")).unwrap_or_default(),
limited(&s("message"), 2000),
s("location"),
s("thread"),
report.get("count").and_then(serde_json::Value::as_u64).unwrap_or(1),
s("first_seen"),
block(&frames.join("\n")),
block(&json_context(&report.get("command"), 4000)),
if recent.is_empty() { "(none recorded)".to_string() } else { recent.join(", ") },
s("id"),
match (report.get("design_path").is_some(), report.get("replay_path").is_some()) {
(true, true) => "the design as it was before the crash (design.solvecraft) and the commands that crash it (replay.json)",
(false, true) => "the commands that crash it on a new design (replay.json)",
_ => "no replay (see its notes)",
},
s("id"),
block(&format!(
"SolidCraft {} ({}, built {})\nOS: {} {}\n{}",
field(app, "version"),
field(app, "commit"),
field(app, "built"),
field(system, "os"),
field(system, "arch"),
field(system, "environment")
)),
);
if body.len() > MAX_BODY {
truncate(&mut body, MAX_BODY - 32);
body.push_str("\n[report context truncated]\n");
}
let mut title = format!("Crash: {}", s("message").lines().next().unwrap_or_default());
truncate(&mut title, 160);
Draft { title, body, image: None, texture: None, note: String::new(), source_signature: 0 }
}
/// Open a reviewed draft for a crash report (no screenshot: the view shows nothing of it).
pub fn begin_crash(app: &mut SolveApp, ctx: &egui::Context, report: &serde_json::Value) {
if app.issue_report.draft.is_none() {
app.issue_report.cad_focus = ctx.memory(|m| m.focused());
}
cancel_capture(app);
let mut draft = crash_draft(report);
// A screenshot can still be added of the view as it is now.
draft.source_signature = source_signature(app, None);
app.issue_report.draft = Some(draft);
}
fn encode(value: &str) -> String {
let mut out = String::new();
for b in value.bytes() {
+4
View File
@@ -14,6 +14,7 @@ pub mod browser;
pub mod canvas;
pub mod context_menu;
pub mod control;
pub mod crash_ui;
pub mod delete;
pub mod dialogs;
pub mod dialogs_appearance;
@@ -221,6 +222,8 @@ pub struct SolveApp {
pub help: help::HelpState,
/// An editable local issue draft and optional captured app frame.
pub issue_report: issue_report::ReportState,
/// The Crash Reports window.
pub crashes: crash_ui::CrashUi,
/// Toolbar buttons the user pinned, removed or reordered.
pub toolbar_custom: toolbar_custom::ToolbarCustom,
/// Preferences kept between runs, and their window.
@@ -294,6 +297,7 @@ impl SolveApp {
keymap: keymap::Keymap::default(),
help: help::HelpState::default(),
issue_report: issue_report::ReportState::default(),
crashes: crash_ui::CrashUi::default(),
toolbar_custom: toolbar_custom::ToolbarCustom::default(),
preferences: prefs::Prefs::default(),
prefs_window: prefs::PrefsWindow::default(),