diff --git a/AGENTS.md b/AGENTS.md index 4c78025b..cc43f360 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,12 @@ People trust SolidCraft with their designs; a crash loses their work. **This out `solvecraft_kernel::guard`, which turns a panic into `KernelError::Internal`. - **Last-resort guard:** `Session::execute` runs every command under `catch_unwind`; an escaped panic restores the design and reports `EngineError::Internal`. -- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`). +- **Crash reports:** the desktop app writes every panic and every unclean exit to a local crash folder, + with the design and the commands that reproduce it (`solvecraft_engine::crash`, docs/crash-reports.md). + When someone says the app crashed, look there first: `solidcraft-cli crashes`, `crashes show `, + `crashes replay ` (or the `diagnostics.crash_reports` command / MCP `crash_reports` tool). +- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`); a + crash report's `design.solvecraft` + `replay.json` is the test's starting point. - Every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`. diff --git a/CLAUDE.md b/CLAUDE.md index d6adbec2..2e3c6308 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,12 @@ People trust SolidCraft with their designs; a crash loses their work. **This out `solvecraft_kernel::guard`, which turns a panic into `KernelError::Internal`. - **Last-resort guard:** `Session::execute` runs every command under `catch_unwind`; an escaped panic restores the design and reports `EngineError::Internal`. -- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`). +- **Crash reports:** the desktop app writes every panic and every unclean exit to a local crash folder, + with the design and the commands that reproduce it (`solvecraft_engine::crash`, docs/crash-reports.md). + When someone says the app crashed, look there first: `solidcraft-cli crashes`, `crashes show `, + `crashes replay ` (or the `diagnostics.crash_reports` command / MCP `crash_reports` tool). +- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`); a + crash report's `design.solvecraft` + `replay.json` is the test's starting point. - Every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`. diff --git a/Cargo.toml b/Cargo.toml index 3ea8d49b..3b7ea3c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -92,7 +92,9 @@ opt-level = 2 opt-level = 3 lto = "thin" codegen-units = 16 -debug = 0 +# Line tables only: crash reports name file:line in their backtraces (no runtime cost; a larger +# binary). See docs/crash-reports.md. +debug = "line-tables-only" # truck-shapeops with SolveCraft fixes (Apache-2.0, see vendor/truck-shapeops and ATTRIBUTION.md). [patch.crates-io] diff --git a/ROADMAP.md b/ROADMAP.md index 32a7f861..dfc15f91 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -96,6 +96,14 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings. - The header exposes **Report a bug**, including an accessible compact control on narrow windows. It opens the same editable report and screenshot review without changing the active model or a pending dimension. +- Crash reports: every panic (in a command, in the kernel, anywhere else) and every unclean + exit (abort, stack overflow, kill: found at the next start through a locked per-run command + journal) leaves a local report with the backtrace, version and commit, the command and the + design that reproduce it (`solidcraft-cli crashes replay `). Repeats are counted, not + rewritten; at most 20 reports, 50 MB and 90 days. The app shows them after a crash (Help > + Crash Reports…) with a reviewed issue draft; agents read them through + `diagnostics.crash_reports`, the MCP `crash_reports` tool or the CLI. Nothing is sent + anywhere. See [crash reports](docs/crash-reports.md). ## 0.2.0 release batch diff --git a/apps/solvecraft-cli/src/crashes.rs b/apps/solvecraft-cli/src/crashes.rs new file mode 100644 index 00000000..bae7f60c --- /dev/null +++ b/apps/solvecraft-cli/src/crashes.rs @@ -0,0 +1,103 @@ +//! `solidcraft-cli crashes`: the crash reports the desktop app wrote on this computer +//! (docs/crash-reports.md): list them, show one, replay one, delete them. + +use std::sync::Mutex; + +use serde_json::{Value, json}; +use solvecraft_engine::crash; + +const USAGE: &str = "usage: solidcraft-cli crashes [list [--json] | show | replay | dir | clear []]"; + +pub fn run(args: &[String]) -> Result<(), String> { + let dir = crash::default_dir().ok_or("no crash folder on this system")?; + let id = || args.get(1).map(String::as_str).ok_or_else(|| USAGE.to_string()); + match args.first().map(String::as_str) { + None | Some("list") => { + let reports = crash::list(&dir); + if args.iter().any(|a| a == "--json") { + println!("{}", super::pretty(&json!({ "dir": dir.to_string_lossy(), "reports": reports }))); + return Ok(()); + } + println!("crash reports in {}", dir.display()); + if reports.is_empty() { + println!(" (none)"); + } + for r in reports { + let times = if r.count > 1 { format!(" ×{}", r.count) } else { String::new() }; + let cmd = r.command.map(|c| format!(" in {c}")).unwrap_or_default(); + let replay = if r.replayable { " [replayable]" } else { "" }; + println!(" {} {}{times}{cmd} v{}{replay}\n {}", r.id, r.kind, r.version, r.message); + } + Ok(()) + } + Some("show") => { + println!("{}", super::pretty(&crash::read(&dir, id()?)?)); + Ok(()) + } + Some("replay") => replay(&crash::read(&dir, id()?)?), + Some("dir") => { + println!("{}", dir.display()); + Ok(()) + } + Some("clear") => { + println!("deleted {} report(s)", crash::clear(&dir, args.get(1).map(String::as_str))); + Ok(()) + } + _ => Err(USAGE.to_string()), + } +} + +/// Panics seen while replaying: (location, message). +static PANICS: Mutex> = Mutex::new(Vec::new()); + +/// Run a report's commands on its design and say whether the crash happens again. +fn replay(report: &Value) -> Result<(), String> { + let id = report["id"].as_str().unwrap_or_default(); + let replay_path = report["replay_path"].as_str().ok_or_else(|| format!("report {id} cannot be replayed (see its notes)"))?; + let script = super::read_json(replay_path)?; + let commands = script["commands"].as_array().cloned().unwrap_or_default(); + let mut s = match report["design_path"].as_str() { + Some(d) => super::load(d)?, + None => solvecraft_engine::Session::default(), + }; + std::panic::set_hook(Box::new(|info| { + let loc = info.location().map_or_else(|| "(unknown)".to_string(), |l| format!("{}:{}", l.file(), l.line())); + let msg = info.payload_as_str().unwrap_or("(not a text message)").to_string(); + if let Ok(mut p) = PANICS.lock() { + p.push((loc, msg)); + } + })); + if report["kind"] == "unclean_exit" { + println!("this crash ended the app without a report: if this process ends abruptly, it reproduced"); + } + println!( + "replaying {} command(s) on {}", + commands.len(), + report["design_path"].as_str().map_or_else(|| "a new design".to_string(), |d| d.to_string()) + ); + for (i, c) in commands.iter().enumerate() { + let cmd = c["command"].as_str().unwrap_or_default(); + let r = s.execute(cmd, c.get("params").unwrap_or(&Value::Null)); + match r { + Ok(_) => println!(" {:>3}. {cmd}: ok", i + 1), + Err(e) => println!(" {:>3}. {cmd}: {e}", i + 1), + } + } + let _ = std::panic::take_hook(); + let seen = PANICS.lock().map(|p| p.clone()).unwrap_or_default(); + let want = report["signature"].as_str().unwrap_or_default(); + let same = seen.iter().any(|(l, m)| format!("{:016x}", crash::signature(l, m)) == want); + if same { + println!( + "reproduced: the same internal error ({}) at {}", + report["message"].as_str().unwrap_or_default(), + report["location"].as_str().unwrap_or_default() + ); + } else if let Some((l, m)) = seen.first() { + println!("an internal error happened, but not the reported one: {m} at {l}"); + println!("(the report says: {} at {})", report["message"].as_str().unwrap_or_default(), report["location"].as_str().unwrap_or_default()); + } else { + println!("did not reproduce: no internal error (fixed, or it depends on something the replay does not have)"); + } + Ok(()) +} diff --git a/apps/solvecraft-cli/src/main.rs b/apps/solvecraft-cli/src/main.rs index 8c3dd781..82c9ca61 100644 --- a/apps/solvecraft-cli/src/main.rs +++ b/apps/solvecraft-cli/src/main.rs @@ -16,6 +16,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +mod crashes; mod fuzz; mod oracle; mod recipe; @@ -42,6 +43,7 @@ const USAGE: &str = "usage: solidcraft-cli oracle ... [--json] solidcraft-cli fuzz [--from N] [--count N] [--jobs J] [--timeout S] [--steps N] [--minimise] [--out DIR] [--report FILE] solidcraft-cli mcp [--in design.solvecraft|script.json] [--connect 127.0.0.1:PORT] + solidcraft-cli crashes [list [--json] | show | replay | dir | clear []] solidcraft-cli licences solidcraft-cli icon --out icon.png [--size 256] | --out icon.ico "; @@ -74,6 +76,7 @@ fn main() -> ExitCode { Some("fuzz") => fuzz::run(&args[1..]), Some("fuzz-one") => fuzz::one(&args[1..]), Some("mcp") => cmd_mcp(&args[1..]), + Some("crashes") => crashes::run(&args[1..]), Some("icon") => cmd_icon(&args[1..]), Some("licences" | "licenses") => { // Written, not print!ed: a closed pipe (`| head`) is not a crash. diff --git a/apps/solvecraft/src/main.rs b/apps/solvecraft/src/main.rs index dfc4a42b..0f7266c7 100644 --- a/apps/solvecraft/src/main.rs +++ b/apps/solvecraft/src/main.rs @@ -1,6 +1,6 @@ //! SolveCraft desktop app. //! -//! Usage: `solvecraft [--control ] [--sample] [design.solvecraft | part.step]` +//! Usage: `solvecraft [--control ] [--sample] [--crash-log] [design.solvecraft | part.step]` //! //! A `.step`/`.stp` file opens as a new design holding the file's bodies (an Import feature); a //! `.3mf`/`.stl` file as a new design holding mesh bodies. @@ -30,6 +30,8 @@ struct App { impl eframe::App for App { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // The design on screen is the one whose commands a crash report replays. + self.solve.session.crash_journal = solvecraft_engine::crash::enabled(); // The OS closing the window (Alt+F4, Cmd+Q, the window manager): ask about unsaved // designs first; the window closes once they are answered (`quit_requested`). if ctx.input(|i| i.viewport().close_requested()) && !self.solve.quit_requested { @@ -54,6 +56,7 @@ impl eframe::App for App { } fn on_exit(&mut self) { solvecraft_ui_egui::recovery_ui::close(&mut self.solve); + solvecraft_engine::crash::end_session(); } } @@ -161,13 +164,37 @@ fn main() -> eframe::Result { println!("solidcraft {}", env!("CARGO_PKG_VERSION")); return Ok(()); } + "--crash-log" => { + match solvecraft_engine::crash::default_dir() { + Some(dir) => { + println!("crash reports: {}", dir.display()); + for r in solvecraft_engine::crash::list(&dir).iter().take(5) { + println!(" {} {} {}", r.id, r.kind, r.message); + } + println!("more: solidcraft-cli crashes"); + } + None => println!("no crash folder on this system"), + } + return Ok(()); + } "--help" | "-h" => { - println!("usage: solidcraft [--control PORT] [--sample] [design.solvecraft | part.step]"); + println!("usage: solidcraft [--control PORT] [--sample] [--crash-log] [design.solvecraft | part.step]"); return Ok(()); } _ => files.push(a), } } + // Crashes leave a report in the crash folder (solvecraft_engine::crash, docs/crash-reports.md). + let crashed = solvecraft_engine::crash::default_dir() + .map(|dir| { + let build = solvecraft_engine::crash::Build { + version: solvecraft_ui_egui::help::VERSION, + commit: solvecraft_ui_egui::help::COMMIT, + built: solvecraft_ui_egui::help::BUILD_DATE, + }; + solvecraft_engine::crash::install(dir, build) + }) + .unwrap_or_default(); let options = eframe::NativeOptions { viewport: app_viewport()?, depth_buffer: DEPTH_BITS, @@ -205,6 +232,11 @@ fn main() -> eframe::Result { app.set_wgpu(rs, DEPTH_BITS, STENCIL_BITS, rs.msaa_samples); // A saved anti-aliasing the device can't do: back to the default, with a notice. solvecraft_ui_egui::prefs::check_msaa(&mut app, u32::from(samples)); + let info = rs.adapter.get_info(); + solvecraft_engine::crash::set_environment(&format!( + "Renderer: {:?} {} ({}), MSAA {}x", + info.backend, info.name, info.driver, rs.msaa_samples + )); } if let Some(port) = control_port { let rx = control_server::start(port, cc.egui_ctx.clone()); @@ -232,6 +264,8 @@ fn main() -> eframe::Result { let every = if app.autosave_minutes > 0.0 { app.autosave_minutes * 60.0 } else { f64::MAX / 4.0 }; solvecraft_ui_egui::recovery_ui::start(&mut app, &dir, std::time::Duration::from_secs_f64(every.min(1e9))); } + // Tell the user about crashes since the last start (after the recovery offer is set up). + solvecraft_ui_egui::crash_ui::announce(&mut app, &crashed); #[cfg(any(target_os = "windows", target_os = "linux"))] let space_mouse = space_mouse::SpaceMouse::start(&cc.egui_ctx); Ok(Box::new(App { diff --git a/crates/engine/src/cmd/diagnostics.rs b/crates/engine/src/cmd/diagnostics.rs new file mode 100644 index 00000000..b8423d25 --- /dev/null +++ b/crates/engine/src/cmd/diagnostics.rs @@ -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 `", + ), + 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 --in `)", + ), + 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 { + crash::default_dir().ok_or_else(|| bad(cmd, "no crash folder on this system")) +} + +fn list(_: &mut Session, p: &Value) -> Result { + 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 { + 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 { + 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 { + 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")), + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index c4e9e36e..3a892ae3 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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 } diff --git a/crates/engine/src/crash.rs b/crates/engine/src/crash.rs new file mode 100644 index 00000000..fc2725f5 --- /dev/null +++ b/crates/engine/src/crash.rs @@ -0,0 +1,974 @@ +//! Crash reports: enough to reproduce a crash and fix it, without slowing the app down or +//! filling the disk (docs/crash-reports.md). +//! +//! - **Panics** are caught by a hook. One inside a command becomes a report with the design as +//! it was before the command and the command itself, so `solidcraft-cli run replay.json --in +//! design.solvecraft` reproduces it. Panics the third-party kernel raises (and +//! `solvecraft_kernel::guard` turns into errors) are reported the same way, marked recovered. +//! - **Crashes no hook sees** (an abort, a stack overflow, a segfault, the process being killed) +//! are found at the next start: every running app keeps a small, locked command journal; a +//! journal nobody holds any more belongs to an app that died. Its report carries the design +//! from the last autosave (or the saved file) and the commands run since, ending with the one +//! in flight. +//! - **Cost**: nothing until [`install`] is called (tests, the CLI and the web build never do); +//! after that, two small unbuffered appends per top-level command of the app's own design, and +//! work only when something panics. +//! - **Bounded**: the same crash (its signature: place + message, digits ignored) is counted, not +//! written again; at most [`MAX_NEW_PER_SESSION`] new reports per run, [`MAX_REPORTS`] +//! reports, [`MAX_TOTAL_BYTES`] on disk, nothing older than [`MAX_AGE_DAYS`] days. +//! +//! Reports are folders `crashes/