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
@@ -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}),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user