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

+6 -1
View File
@@ -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 <id>`,
`crashes replay <id>` (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)]`.
+6 -1
View File
@@ -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 <id>`,
`crashes replay <id>` (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)]`.
+3 -1
View File
@@ -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]
+8
View File
@@ -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 <id>`). 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
+103
View File
@@ -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 <id> | replay <id> | dir | clear [<id>]]";
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<Vec<(String, String)>> = 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(())
}
+3
View File
@@ -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 <case-dir>... [--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 <id> | replay <id> | dir | clear [<id>]]
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.
+36 -2
View File
@@ -1,6 +1,6 @@
//! SolveCraft desktop app.
//!
//! Usage: `solvecraft [--control <port>] [--sample] [design.solvecraft | part.step]`
//! Usage: `solvecraft [--control <port>] [--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 {
+74
View File
@@ -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")),
}
}
+2
View File
@@ -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
}
+974
View File
@@ -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/<time>-<signature>/` holding `report.json`, and when known
//! `design.solvecraft` and `replay.json`. Nothing is ever sent anywhere.
use std::backtrace::Backtrace;
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::fs::{self, File, OpenOptions};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Mutex, OnceLock};
use serde_json::{Value, json};
use solvecraft_doc::Document;
use crate::Session;
/// Reports kept (the oldest, and recovered kernel panics, go first).
pub const MAX_REPORTS: usize = 20;
/// Bytes all reports may take together.
pub const MAX_TOTAL_BYTES: u64 = 50 << 20;
/// Reports older than this are deleted.
pub const MAX_AGE_DAYS: u64 = 90;
/// New reports one run of the app may write (repeats are only counted).
pub const MAX_NEW_PER_SESSION: usize = 5;
/// A design bigger than this is not copied into a report.
pub const MAX_DESIGN_BYTES: usize = 10 << 20;
/// A journal stops recording commands at this size (until the next autosave restarts it).
const MAX_JOURNAL_BYTES: u64 = 512 << 10;
/// Command parameters recorded per command.
const MAX_PARAMS_BYTES: usize = 4 << 10;
/// Recent commands shown in a report.
const RECENT: usize = 32;
const MAX_FRAMES: usize = 60;
const MAX_MESSAGE: usize = 2000;
/// The report format.
pub const FORMAT: &str = "solvecraft-crash/1";
/// What the app is (shown in every report).
#[derive(Clone, Debug, Default)]
pub struct Build {
pub version: &'static str,
pub commit: &'static str,
pub built: &'static str,
}
/// One report, as listed.
#[derive(Clone, Debug, PartialEq, serde::Serialize)]
pub struct Summary {
pub id: String,
/// `command_panic`, `panic`, `kernel_panic_recovered` or `unclean_exit`.
pub kind: String,
/// When it first happened (UTC).
pub time: String,
pub message: String,
/// The command running when it happened, if one was.
pub command: Option<String>,
/// Times it happened.
pub count: u64,
pub version: String,
/// The report's folder.
pub path: String,
/// `replay.json` and `design.solvecraft` are there: the crash can be replayed.
pub replayable: bool,
}
struct State {
dir: PathBuf,
build: Build,
/// Renderer, GPU and the like (set by the UI).
environment: Mutex<String>,
journal: Mutex<Journal>,
/// This run's journal (read by the hook without taking the lock).
journal_path: Option<PathBuf>,
/// Signatures already reported (this run's count; the report's folder name).
known: Mutex<HashMap<u64, (u64, String)>>,
written: AtomicUsize,
started: u64,
}
static STATE: OnceLock<State> = OnceLock::new();
/// The hook is writing a report (a panic inside it must not write another).
static IN_HOOK: AtomicBool = AtomicBool::new(false);
static SEQ: AtomicU64 = AtomicU64::new(0);
thread_local! {
/// Commands running on this thread (their panics are reported when the outermost ends).
static IN_COMMAND: Cell<u32> = const { Cell::new(0) };
/// Panics caught inside the running command, waiting for it to end.
static PENDING: RefCell<Vec<Panic>> = const { RefCell::new(Vec::new()) };
}
/// Crash reporting is on in this process.
pub fn enabled() -> bool {
STATE.get().is_some()
}
/// The crash folder, next to the recovery folder. `SOLVECRAFT_CRASH_DIR` overrides it.
pub fn default_dir() -> Option<PathBuf> {
if let Some(s) = STATE.get() {
return Some(s.dir.clone());
}
// Tests never touch the user's real crash folder.
if cfg!(test) {
return Some(std::env::temp_dir().join(format!("solvecraft-test-crashes-{}", std::process::id())));
}
if let Some(d) = std::env::var_os("SOLVECRAFT_CRASH_DIR").filter(|d| !d.is_empty()) {
return Some(PathBuf::from(d));
}
crate::recovery::default_dir().and_then(|d| d.parent().map(|p| p.join("crashes")))
}
fn now() -> u64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs())
}
/// `secs` since 1970 as (year, month, day, hour, minute, second) in UTC.
fn civil(secs: u64) -> (i64, i64, i64, u64, u64, u64) {
let days = i64::try_from(secs / 86_400).unwrap_or(0);
let rem = secs % 86_400;
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
(yoe + era * 400 + i64::from(m <= 2), m, d, rem / 3600, rem % 3600 / 60, rem % 60)
}
/// `2026-10-12T14:03:22Z`.
pub fn utc(secs: u64) -> String {
let (y, m, d, h, mi, s) = civil(secs);
format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
}
fn stamp(secs: u64) -> String {
let (y, m, d, h, mi, s) = civil(secs);
format!("{y:04}{m:02}{d:02}-{h:02}{mi:02}{s:02}")
}
/// A crash's identity: where it happened and what it said, numbers ignored (ids and sizes vary).
pub fn signature(location: &str, message: &str) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
let mut eat = |b: u8| {
h ^= u64::from(b);
h = h.wrapping_mul(0x0100_0000_01b3);
};
for b in location.bytes() {
eat(b);
}
eat(b'|');
let mut digit = false;
for b in message.bytes().take(300) {
if b.is_ascii_digit() {
if !digit {
eat(b'#');
}
digit = true;
} else {
digit = false;
eat(b);
}
}
h
}
fn valid_id(id: &str) -> bool {
!id.is_empty() && id.len() <= 64 && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
}
fn limited(s: &str, n: usize) -> String {
if s.len() <= n {
return s.to_string();
}
let mut end = n;
while !s.is_char_boundary(end) {
end -= 1;
}
format!("{}… ({} bytes)", s.get(..end).unwrap_or_default(), s.len())
}
/// Serialise `v`, giving up past `limit` bytes.
fn bounded_json(v: &Value, limit: usize) -> Option<String> {
struct W(Vec<u8>, usize);
impl Write for W {
fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
if self.0.len() + b.len() > self.1 {
return Err(std::io::Error::other("too long"));
}
self.0.extend_from_slice(b);
Ok(b.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let mut w = W(Vec::new(), limit);
serde_json::to_writer(&mut w, v).ok()?;
String::from_utf8(w.0).ok()
}
// ---------------------------------------------------------------------------------------------
// Panics
/// What a panic said.
#[derive(Clone, Debug)]
struct Panic {
message: String,
location: String,
thread: String,
backtrace: Vec<String>,
recovered: bool,
signature: u64,
}
/// A backtrace as frames `symbol (file:line)`, without the panic machinery, the user's home
/// folder or the toolchain's hash.
fn frames(bt: &str) -> Vec<String> {
let home = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).unwrap_or_default();
let mut out: Vec<String> = Vec::new();
for line in bt.lines() {
let t = line.trim();
if let Some(at) = t.strip_prefix("at ") {
let mut at = at.to_string();
if !home.is_empty() {
at = at.replace(&home, "~");
}
if let Some(i) = at.find("/rustc/")
&& let Some(j) = at.get(i + 7..).and_then(|r| r.find('/'))
{
at = format!("rustc{}", at.get(i + 7 + j..).unwrap_or_default());
}
if let Some(last) = out.last_mut()
&& !last.ends_with(')')
{
last.push_str(&format!(" ({at})"));
}
} else if let Some((n, sym)) = t.split_once(": ")
&& n.chars().all(|c| c.is_ascii_digit())
{
out.push(sym.to_string());
}
}
// Drop the hook and the panic machinery on top (everything up to where the panic began),
// everything below the thread's start, and the standard library's own frames between.
let entry = ["panicking::begin_panic", "panic::panic_any", "rust_begin_unwind", "core::panicking::", "__rust_end_short_backtrace"];
let skip = out.iter().take(40).rposition(|f| entry.iter().any(|e| f.contains(e))).map_or(0, |i| i + 1);
let mut v: Vec<String> = out.into_iter().skip(skip).collect();
if let Some(end) = v.iter().position(|f| f.contains("__rust_begin_short_backtrace")) {
v.truncate(end);
}
v.retain(|f| !f.contains("(rustc/"));
v.truncate(MAX_FRAMES);
v
}
/// Install the panic hook and start this run's journal; reports the runs that crashed without a
/// report (unclean exits). Returns the reports written since the last time this was called,
/// newest first, for the app to tell the user about. Call once, early.
pub fn install(dir: PathBuf, build: Build) -> Vec<Summary> {
let _ = fs::create_dir_all(dir.join("sessions"));
let started = now();
for path in dead_journals(&dir) {
unclean_exit(&dir, &build, &path);
let _ = fs::remove_file(&path);
}
prune(&dir, started);
// Reports the user has been told about (by id).
let seen_file = dir.join(".seen");
let mut seen = String::new();
if let Ok(f) = File::open(&seen_file) {
let _ = f.take(64 << 10).read_to_string(&mut seen);
}
let all = list(&dir);
let fresh: Vec<Summary> = all.iter().filter(|s| !seen.lines().any(|l| l == s.id)).cloned().collect();
let _ = fs::write(&seen_file, all.iter().map(|s| s.id.as_str()).collect::<Vec<_>>().join("\n"));
let known = all.into_iter().filter_map(|s| sig_of(&s.id).map(|sig| (sig, (0, s.id)))).collect();
let journal = Journal::open(&dir, &build, started);
let journal_path = journal.path.clone();
if STATE
.set(State {
dir,
build,
environment: Mutex::new(String::new()),
journal: Mutex::new(journal),
journal_path,
known: Mutex::new(known),
written: AtomicUsize::new(0),
started,
})
.is_ok()
{
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
if !hook(info) {
previous(info);
}
}));
}
fresh
}
/// Tell reports about the renderer, GPU and window (one short line).
pub fn set_environment(text: &str) {
if let Some(s) = STATE.get()
&& let Ok(mut e) = s.environment.lock()
{
*e = limited(text, 500);
}
}
/// The app is closing normally: its journal goes, and repeat counts are written.
pub fn end_session() {
let Some(s) = STATE.get() else { return };
if let Ok(mut j) = s.journal.lock()
&& let Some(path) = j.path.take()
{
j.file = None;
let _ = fs::remove_file(path);
}
let counts: Vec<(u64, String)> = s.known.lock().map(|k| k.values().filter(|(n, _)| *n > 0).cloned().collect()).unwrap_or_default();
for (n, id) in counts {
bump(&s.dir, &id, n);
}
}
/// The panic hook. Returns true when the panic was recorded for a command or the kernel (which
/// will catch it), so the default "thread panicked" message is not printed.
fn hook(info: &std::panic::PanicHookInfo<'_>) -> bool {
let Some(state) = STATE.get() else { return false };
if IN_HOOK.swap(true, Ordering::SeqCst) {
return false;
}
let message = limited(info.payload_as_str().unwrap_or("(not a text message)"), MAX_MESSAGE);
let location = info.location().map_or_else(|| "(unknown)".to_string(), |l| format!("{}:{}", l.file(), l.line()));
let sig = signature(&location, &message);
let recovered = solvecraft_kernel::guarded();
let in_command = IN_COMMAND.with(Cell::get) > 0;
// Already reported (this run or before): count it, nothing else.
let repeat = match state.known.lock() {
Ok(mut k) => match k.get_mut(&sig) {
Some(e) => {
e.0 += 1;
true
}
None => false,
},
Err(_) => true,
};
let quiet = recovered || in_command;
if repeat {
IN_HOOK.store(false, Ordering::SeqCst);
return quiet;
}
let thread = std::thread::current().name().unwrap_or("(unnamed)").to_string();
let backtrace = frames(&Backtrace::force_capture().to_string());
let p = Panic { message, location, thread, backtrace, recovered, signature: sig };
if in_command {
// Reported with the design and the command when the outermost command ends.
PENDING.with(|q| {
let mut q = q.borrow_mut();
if q.len() < 8 {
q.push(p);
}
});
eprintln!("SolidCraft: internal error caught in a command; a crash report will be written");
} else {
// Outside any command: what the journal knows has to do.
let kind = if recovered { "kernel_panic_recovered" } else { "panic" };
if let Some(id) = write_from_journal(state, kind, Some(&p), state.journal_path.as_deref()) {
eprintln!("SolidCraft: internal error; crash report {}", state.dir.join(&id).display());
if !recovered && let Ok(mut j) = state.journal.try_lock() {
j.record(&json!({ "reported": id }));
}
}
}
IN_HOOK.store(false, Ordering::SeqCst);
quiet
}
/// Marks a command running on this thread; panics inside it wait for [`command_done`].
pub(crate) struct Scope(bool);
impl Scope {
pub(crate) fn enter() -> Scope {
if !enabled() {
return Scope(false);
}
IN_COMMAND.with(|c| c.set(c.get() + 1));
Scope(true)
}
}
impl Drop for Scope {
fn drop(&mut self) {
if self.0 {
IN_COMMAND.with(|c| c.set(c.get().saturating_sub(1)));
}
}
}
/// A top-level command is about to run: the journal records it (only for the app's own design,
/// `Session::crash_journal`).
pub(crate) fn command_begin(s: &Session, id: &str, params: &Value) {
let Some(state) = STATE.get() else { return };
if !s.crash_journal {
return;
}
let Ok(mut j) = state.journal.lock() else { return };
let ptr = std::sync::Arc::as_ptr(&s.doc) as usize;
if ptr != j.last_doc {
// The design changed outside commands (opened, a new tab, a dialog): what is it now?
let base = if s.is_dirty() {
json!("unknown")
} else if let Some(p) = &s.path {
json!({ "file": p })
} else if s.doc.features.is_empty() {
json!("new")
} else {
json!("unknown")
};
j.rebase(&base, ptr);
}
j.run(id, params);
}
/// A top-level command ended: the journal records how, and panics caught inside it are reported
/// with `before` (the design it started from) and the command, which together reproduce them.
pub(crate) fn command_done(
journaled: bool,
changed: bool,
before: &Document,
after: &std::sync::Arc<Document>,
id: &str,
params: &Value,
outcome: std::result::Result<(), String>,
) {
let Some(state) = STATE.get() else { return };
if journaled && let Ok(mut j) = state.journal.lock() {
j.done(&outcome, changed);
j.last_doc = std::sync::Arc::as_ptr(after) as usize;
}
if IN_COMMAND.with(Cell::get) > 0 {
return;
}
let pending: Vec<Panic> = PENDING.with(|q| std::mem::take(&mut *q.borrow_mut()));
for p in pending {
let kind = if p.recovered { "kernel_panic_recovered" } else { "command_panic" };
let design = solvecraft_io::write_design(before);
let replay = json!({ "commands": [{ "command": id, "params": params }] });
let recent = if journaled { state.journal_path.as_deref().map(|p| read_journal(p).recent).unwrap_or_default() } else { Vec::new() };
let extra = Extra {
command: Some(json!({ "command": id, "params": params })),
design: Some(design),
replay: Some(replay),
recent,
base: json!("the design as it was when the command started"),
notes: Vec::new(),
};
write_report(state, kind, Some(&p), extra);
}
}
/// An autosave just wrote the design to `file` (or found nothing unsaved: `file` is then the
/// design's own file, or `None` for a new design or one that cannot be named): replays start
/// from there.
pub(crate) fn rebased(s: &Session, file: Option<&Path>, autosave: bool) {
let Some(state) = STATE.get() else { return };
if !s.crash_journal {
return;
}
let Ok(mut j) = state.journal.lock() else { return };
let base = match (file, autosave) {
(Some(f), true) => json!({ "autosave": f.to_string_lossy() }),
(Some(f), false) => json!({ "file": f.to_string_lossy() }),
(None, _) if s.doc.features.is_empty() => json!("new"),
_ => json!("unknown"),
};
j.rebase(&base, std::sync::Arc::as_ptr(&s.doc) as usize);
}
// ---------------------------------------------------------------------------------------------
// The journal
struct Journal {
path: Option<PathBuf>,
file: Option<File>,
bytes: u64,
full: bool,
last_doc: usize,
header: String,
/// Where the running command's line starts (to take it back when it changed nothing).
running_at: Option<u64>,
}
impl Journal {
fn open(dir: &Path, build: &Build, started: u64) -> Journal {
let seq = SEQ.fetch_add(1, Ordering::Relaxed);
let path = dir.join("sessions").join(format!("{}-{}-{seq}.jsonl", stamp(started), std::process::id()));
let file = OpenOptions::new().create(true).append(true).open(&path).ok().filter(|f| f.try_lock().is_ok());
let header = json!({ "start": utc(started), "pid": std::process::id(), "version": build.version, "commit": build.commit }).to_string();
let mut j = Journal { path: file.as_ref().map(|_| path), file, bytes: 0, full: false, last_doc: 0, header, running_at: None };
j.rebase(&json!("new"), 0);
j
}
fn write(&mut self, line: &str) {
let Some(f) = &mut self.file else { return };
if f.write_all(line.as_bytes()).and_then(|()| f.write_all(b"\n")).is_err() {
self.file = None;
}
self.bytes += line.len() as u64 + 1;
}
fn record(&mut self, v: &Value) {
self.write(&v.to_string());
}
fn rebase(&mut self, base: &Value, doc: usize) {
if let Some(f) = &self.file
&& f.set_len(0).is_err()
{
self.file = None;
}
self.bytes = 0;
self.full = false;
self.running_at = None;
self.last_doc = doc;
let h = self.header.clone();
self.write(&h);
self.record(&json!({ "base": base }));
}
fn run(&mut self, id: &str, params: &Value) {
if self.full {
return;
}
if self.bytes > MAX_JOURNAL_BYTES {
self.full = true;
self.record(&json!({ "full": true }));
return;
}
self.running_at = Some(self.bytes);
let id = Value::String(id.to_string()).to_string();
let line = match bounded_json(params, MAX_PARAMS_BYTES) {
Some(p) => format!("{{\"run\":{id},\"params\":{p}}}"),
None => format!("{{\"run\":{id},\"params_omitted\":true}}"),
};
self.write(&line);
}
fn done(&mut self, outcome: &std::result::Result<(), String>, changed: bool) {
let at = self.running_at.take();
if self.full {
return;
}
match outcome {
// Nothing changed: take the command's line back (one truncate, no write).
Ok(()) if !changed => {
if let (Some(at), Some(f)) = (at, &self.file) {
if f.set_len(at).is_ok() {
self.bytes = at;
} else {
self.file = None;
}
}
}
Ok(()) => self.write("{\"ok\":true}"),
Err(e) => self.record(&json!({ "ok": false, "error": limited(e, 300) })),
}
}
}
/// What a journal says.
#[derive(Default)]
struct JournalRead {
header: Value,
base: Value,
/// Commands since the base that changed something or did nothing wrong, in order.
replay: Vec<Value>,
/// The command that was running, if one was.
in_flight: Option<Value>,
recent: Vec<Value>,
complete: bool,
reported: bool,
}
fn read_journal(path: &Path) -> JournalRead {
let mut text = String::new();
if let Ok(f) = File::open(path) {
let _ = f.take(MAX_JOURNAL_BYTES + (64 << 10)).read_to_string(&mut text);
}
let mut r = JournalRead { complete: true, ..Default::default() };
let mut running: Option<Value> = None;
for (i, line) in text.lines().enumerate() {
let Ok(v) = serde_json::from_str::<Value>(line) else { continue };
if i == 0 {
r.header = v;
continue;
}
r.reported = v.get("reported").is_some();
if let Some(b) = v.get("base") {
r.base = b.clone();
r.replay.clear();
r.recent.clear();
running = None;
} else if let Some(id) = v.get("run") {
let c = match v.get("params") {
Some(p) => json!({ "command": id, "params": p }),
None => {
r.complete = false;
json!({ "command": id, "params_omitted": true })
}
};
running = Some(c);
} else if let Some(ok) = v.get("ok").and_then(Value::as_bool) {
if let Some(c) = running.take() {
let mut shown = c.clone();
shown["ok"] = json!(ok);
if let Some(e) = v.get("error") {
shown["error"] = e.clone();
}
r.recent.push(shown);
if ok {
r.replay.push(c);
}
}
} else if v.get("full").is_some() {
r.complete = false;
}
}
r.in_flight = running;
let n = r.recent.len();
r.recent.drain(..n.saturating_sub(RECENT));
r
}
/// Journals of apps that are not running any more (nobody holds their lock).
fn dead_journals(dir: &Path) -> Vec<PathBuf> {
fs::read_dir(dir.join("sessions"))
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|x| x.to_str()) == Some("jsonl"))
.take(100)
.filter(|p| File::open(p).is_ok_and(|f| f.try_lock().is_ok()))
.collect()
}
fn unclean_exit(dir: &Path, build: &Build, journal: &Path) {
let j = read_journal(journal);
if j.reported {
return;
}
let was = j.header.get("version").and_then(Value::as_str).unwrap_or("?");
let when = j.header.get("start").and_then(Value::as_str).unwrap_or("?");
let doing = j.in_flight.as_ref().and_then(|c| c.get("command")).and_then(Value::as_str);
let message = match doing {
Some(c) => format!("SolidCraft {was} (started {when}) ended without a clean exit while running `{c}`"),
None => format!("SolidCraft {was} (started {when}) ended without a clean exit between commands"),
};
// A temporary state just for this report (the app's own is not set up yet).
let state = State {
dir: dir.to_path_buf(),
build: build.clone(),
environment: Mutex::new(String::new()),
journal: Mutex::new(Journal { path: None, file: None, bytes: 0, full: true, last_doc: 0, header: String::new(), running_at: None }),
journal_path: None,
known: Mutex::new(list(dir).into_iter().filter_map(|s| sig_of(&s.id).map(|sig| (sig, (0, s.id)))).collect()),
written: AtomicUsize::new(0),
started: now(),
};
let p = Panic {
message,
location: format!("unclean exit in {}", doing.unwrap_or("(between commands)")),
thread: "(unknown)".into(),
backtrace: Vec::new(),
recovered: false,
signature: signature("unclean exit", doing.unwrap_or("idle")),
};
if let Ok(mut k) = state.known.lock()
&& let Some(e) = k.get_mut(&p.signature)
{
e.0 += 1;
let (n, id) = e.clone();
drop(k);
bump(dir, &id, n);
return;
}
let extra = from_journal(j);
write_report(&state, "unclean_exit", Some(&p), extra);
}
// ---------------------------------------------------------------------------------------------
// Reports
struct Extra {
command: Option<Value>,
design: Option<Vec<u8>>,
replay: Option<Value>,
recent: Vec<Value>,
base: Value,
notes: Vec<String>,
}
/// Design and replay from a journal: the base design (autosave or saved file) and the commands
/// since, ending with the one in flight.
fn from_journal(j: JournalRead) -> Extra {
let mut notes = Vec::new();
let file = j.base.get("autosave").or_else(|| j.base.get("file")).and_then(Value::as_str).map(PathBuf::from);
let design = match &file {
Some(f) => match fs::metadata(f) {
Ok(m) if m.len() <= MAX_DESIGN_BYTES as u64 => fs::read(f).ok(),
Ok(_) => {
notes.push(format!("the design ({}) is larger than {} MB and was not copied", f.display(), MAX_DESIGN_BYTES >> 20));
None
}
Err(_) => {
notes.push(format!("the design the commands start from ({}) is gone", f.display()));
None
}
},
None => None,
};
let fresh = j.base.as_str() == Some("new");
let mut commands = j.replay;
if let Some(c) = &j.in_flight {
commands.push(c.clone());
}
if !j.complete {
notes.push("the journal was cut short (too many or too large commands): the replay is incomplete".into());
}
let replay = (design.is_some() || fresh) && j.complete && !commands.is_empty();
if j.base.as_str() == Some("unknown") {
notes.push("the design changed outside commands before this; it cannot be rebuilt from the journal".into());
}
Extra { command: j.in_flight, design, replay: replay.then(|| json!({ "commands": commands })), recent: j.recent, base: j.base, notes }
}
fn write_from_journal(state: &State, kind: &str, p: Option<&Panic>, journal: Option<&Path>) -> Option<String> {
let j = journal.map(read_journal).unwrap_or_default();
let extra = from_journal(j);
write_report(state, kind, p, extra)
}
fn write_report(state: &State, kind: &str, p: Option<&Panic>, extra: Extra) -> Option<String> {
let sig = p.map_or(0, |p| p.signature);
if let Ok(mut k) = state.known.lock() {
if let Some(e) = k.get_mut(&sig) {
e.0 += 1;
return None;
}
if state.written.load(Ordering::SeqCst) >= MAX_NEW_PER_SESSION {
return None;
}
state.written.fetch_add(1, Ordering::SeqCst);
let t = now();
let id = format!("{}-{sig:016x}", stamp(t));
k.insert(sig, (0, id.clone()));
drop(k);
let folder = state.dir.join(&id);
fs::create_dir_all(&folder).ok()?;
let mut files = serde_json::Map::new();
let mut notes = extra.notes;
if let Some(d) = &extra.design {
if d.len() <= MAX_DESIGN_BYTES {
if solvecraft_io::write_atomic(&folder.join("design.solvecraft"), d, false).is_ok() {
files.insert("design".into(), json!("design.solvecraft"));
}
} else {
notes.push(format!("the design is larger than {} MB and was not copied", MAX_DESIGN_BYTES >> 20));
}
}
if let Some(r) = &extra.replay
&& let Ok(b) = serde_json::to_vec_pretty(r)
&& solvecraft_io::write_atomic(&folder.join("replay.json"), &b, false).is_ok()
{
files.insert("replay".into(), json!("replay.json"));
}
let reproduce = match (files.contains_key("replay"), files.contains_key("design")) {
(true, true) => Some(format!("solidcraft-cli crashes replay {id}")),
(true, false) => Some(format!("solidcraft-cli crashes replay {id} (starts from a new design)")),
_ => None,
};
let environment = state.environment.lock().map(|e| e.clone()).unwrap_or_default();
let report = json!({
"format": FORMAT,
"id": id,
"kind": kind,
"signature": format!("{sig:016x}"),
"count": 1,
"first_seen": utc(t),
"last_seen": utc(t),
"app": { "version": state.build.version, "commit": state.build.commit, "built": state.build.built },
"system": { "os": std::env::consts::OS, "arch": std::env::consts::ARCH, "environment": environment },
"session": { "uptime_s": t.saturating_sub(state.started) },
"message": p.map(|p| p.message.clone()),
"location": p.map(|p| p.location.clone()),
"thread": p.map(|p| p.thread.clone()),
"backtrace": p.map(|p| p.backtrace.clone()).unwrap_or_default(),
"command": extra.command,
"base": extra.base,
"recent_commands": extra.recent,
"files": files,
"reproduce": reproduce,
"notes": notes,
});
let bytes = serde_json::to_vec_pretty(&report).ok()?;
solvecraft_io::write_atomic(&folder.join("report.json"), &bytes, false).ok()?;
prune(&state.dir, t);
return Some(id);
}
None
}
/// Add `n` repeats to a report.
fn bump(dir: &Path, id: &str, n: u64) {
if !valid_id(id) || n == 0 {
return;
}
let path = dir.join(id).join("report.json");
let Some(mut v) = read_report_file(&path) else { return };
let count = v.get("count").and_then(Value::as_u64).unwrap_or(1).saturating_add(n);
v["count"] = json!(count);
v["last_seen"] = json!(utc(now()));
if let Ok(b) = serde_json::to_vec_pretty(&v) {
let _ = solvecraft_io::write_atomic(&path, &b, false);
}
}
fn read_report_file(path: &Path) -> Option<Value> {
let f = File::open(path).ok()?;
let mut s = String::new();
f.take(4 << 20).read_to_string(&mut s).ok()?;
serde_json::from_str(&s).ok().filter(Value::is_object)
}
fn sig_of(id: &str) -> Option<u64> {
id.rsplit('-').next().and_then(|h| u64::from_str_radix(h, 16).ok())
}
/// Seconds since 1970 at which report `id` was made (from its folder's time).
fn report_time(dir: &Path, id: &str) -> u64 {
fs::metadata(dir.join(id).join("report.json"))
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map_or(0, |d| d.as_secs())
}
fn folder_bytes(p: &Path) -> u64 {
fs::read_dir(p).into_iter().flatten().flatten().filter_map(|e| e.metadata().ok()).map(|m| m.len()).sum()
}
/// Report folders in `dir`, oldest first.
fn report_ids(dir: &Path) -> Vec<String> {
let mut v: Vec<String> = fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
.filter_map(|e| e.file_name().to_str().map(str::to_string))
.filter(|n| valid_id(n) && n != "sessions" && sig_of(n).is_some() && n.len() == 32)
.take(1000)
.collect();
v.sort();
v
}
/// Keep the reports within the limits: drop expired ones, then recovered kernel panics, then the
/// oldest, until at most [`MAX_REPORTS`] and [`MAX_TOTAL_BYTES`] remain.
pub fn prune(dir: &Path, now_secs: u64) {
let ids = report_ids(dir);
let mut keep: Vec<(String, bool, u64)> = Vec::new();
for id in ids {
let folder = dir.join(&id);
let t = report_time(dir, &id);
if (t > 0 && now_secs.saturating_sub(t) > MAX_AGE_DAYS * 86_400) || !folder.join("report.json").is_file() {
let _ = fs::remove_dir_all(&folder);
continue;
}
let recovered = read_report_file(&folder.join("report.json"))
.and_then(|v| v.get("kind").and_then(Value::as_str).map(|k| k == "kernel_panic_recovered"))
.unwrap_or(false);
keep.push((id, recovered, folder_bytes(&folder)));
}
let over = |k: &[(String, bool, u64)]| k.len() > MAX_REPORTS || k.iter().map(|e| e.2).sum::<u64>() > MAX_TOTAL_BYTES;
while over(&keep) {
let i = keep.iter().position(|e| e.1).unwrap_or(0);
if i >= keep.len() {
break;
}
let (id, _, _) = keep.remove(i);
let _ = fs::remove_dir_all(dir.join(id));
}
}
/// The reports in `dir`, newest first.
pub fn list(dir: &Path) -> Vec<Summary> {
let mut out: Vec<Summary> = report_ids(dir)
.into_iter()
.rev()
.filter_map(|id| {
let folder = dir.join(&id);
let v = read_report_file(&folder.join("report.json"))?;
let s = |k: &str| v.get(k).and_then(Value::as_str).unwrap_or_default().to_string();
Some(Summary {
kind: s("kind"),
time: s("first_seen"),
message: limited(&s("message"), 300),
command: v.get("command").and_then(|c| c.get("command")).and_then(Value::as_str).map(str::to_string),
count: v.get("count").and_then(Value::as_u64).unwrap_or(1),
version: v.get("app").and_then(|a| a.get("version")).and_then(Value::as_str).unwrap_or_default().to_string(),
path: folder.to_string_lossy().into_owned(),
replayable: folder.join("replay.json").is_file(),
id,
})
})
.collect();
out.truncate(MAX_REPORTS * 2);
out
}
/// One report's `report.json`, with the paths of its files.
pub fn read(dir: &Path, id: &str) -> std::result::Result<Value, String> {
if !valid_id(id) {
return Err(format!("no crash report `{id}`"));
}
let folder = dir.join(id);
let mut v = read_report_file(&folder.join("report.json")).ok_or_else(|| format!("no crash report `{id}`"))?;
v["path"] = json!(folder.to_string_lossy());
for (k, f) in [("design_path", "design.solvecraft"), ("replay_path", "replay.json")] {
if folder.join(f).is_file() {
v[k] = json!(folder.join(f).to_string_lossy());
}
}
Ok(v)
}
/// Delete one report, or all of them.
pub fn clear(dir: &Path, id: Option<&str>) -> usize {
let ids = match id {
Some(i) if valid_id(i) && report_ids(dir).iter().any(|r| r == i) => vec![i.to_string()],
Some(_) => Vec::new(),
None => report_ids(dir),
};
ids.iter().filter(|i| fs::remove_dir_all(dir.join(i)).is_ok()).count()
}
#[cfg(test)]
#[path = "crash_tests.rs"]
mod tests;
+289
View File
@@ -0,0 +1,289 @@
use super::*;
use crate::Session;
fn fresh_dir(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("solvecraft-crash-test-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&d);
d
}
fn build() -> Build {
Build { version: "9.9.9", commit: "abc1234", built: "2026-10-12" }
}
/// The child half of the process tests: does nothing unless a parent asks, then installs the
/// hook and crashes the way `SOLVECRAFT_CRASH_CHILD` says.
#[test]
fn child() {
let (Some(dir), Some(mode)) = (std::env::var_os("SOLVECRAFT_CRASH_CHILD_DIR"), std::env::var("SOLVECRAFT_CRASH_CHILD").ok()) else {
return;
};
let fresh = install(PathBuf::from(dir), build());
println!("FRESH {}", serde_json::to_string(&fresh).unwrap());
set_environment("Renderer: test");
let mut s = Session { crash_journal: true, ..Default::default() };
s.execute("solid.box", &json!({"length": 10, "width": 20, "height": 30})).unwrap();
// Commands that only read are not kept.
for _ in 0..50 {
s.execute("document.inspect", &json!({})).unwrap();
}
match mode.as_str() {
"command" => {
for _ in 0..3 {
let r = s.execute("diagnostics.crash_test", &json!({"mode": "panic"}));
assert!(matches!(r, Err(crate::EngineError::Internal(..))), "{r:?}");
}
assert_eq!(s.doc.features.len(), 1, "the design is unchanged");
}
"kernel" => {
assert!(s.execute("diagnostics.crash_test", &json!({"mode": "kernel"})).is_err());
}
"thread" => {
let r = std::thread::Builder::new()
.name("worker".into())
.spawn(|| std::panic::panic_any(String::from("worker thread 42 failed")))
.unwrap()
.join();
assert!(r.is_err());
}
"abort" => {
let _ = s.execute("diagnostics.crash_test", &json!({"mode": "abort"}));
}
_ => {}
}
end_session();
}
/// Run [`child`] in a new process; returns its stdout and whether it succeeded.
fn run_child(dir: &Path, mode: &str) -> (String, bool) {
let out = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "crash::tests::child", "--test-threads=1", "--nocapture"])
.env("SOLVECRAFT_CRASH_CHILD_DIR", dir)
.env("SOLVECRAFT_CRASH_CHILD", mode)
.env("SOLVECRAFT_CRASH_TEST", "1")
.env("RUST_BACKTRACE", "0")
.output()
.unwrap();
(String::from_utf8_lossy(&out.stdout).into_owned(), out.status.success())
}
fn only_report(dir: &Path) -> (Summary, Value) {
let all = list(dir);
assert_eq!(all.len(), 1, "{all:?}");
let s = all.into_iter().next().unwrap();
let v = read(dir, &s.id).unwrap();
(s, v)
}
#[test]
fn a_command_panic_is_reported_once_with_the_design_and_the_command() {
let d = fresh_dir("command");
let (_, ok) = run_child(&d, "command");
assert!(ok, "the panics were caught");
let (s, v) = only_report(&d);
assert_eq!(s.kind, "command_panic");
assert_eq!(s.count, 3, "repeats are counted, not written again");
assert_eq!(s.command.as_deref(), Some("diagnostics.crash_test"));
assert!(s.replayable);
assert_eq!(v["format"], FORMAT);
assert_eq!(v["app"]["version"], "9.9.9");
assert_eq!(v["app"]["commit"], "abc1234");
assert_eq!(v["system"]["environment"], "Renderer: test");
assert!(v["message"].as_str().unwrap().contains("an internal error in a command"), "{v}");
assert!(v["location"].as_str().unwrap().contains("diagnostics.rs"), "{v}");
let bt = v["backtrace"].as_array().unwrap();
assert!(bt.iter().any(|f| f.as_str().unwrap().contains("crash_test")), "{bt:?}");
assert!(!bt.iter().any(|f| f.as_str().unwrap().contains("crash::hook")), "the hook's own frames are dropped: {bt:?}");
assert_eq!(v["recent_commands"][0]["command"], "solid.box");
// The design from before the command, and the command: together they reproduce the crash.
let design = solvecraft_io::read_design(&fs::read(v["design_path"].as_str().unwrap()).unwrap()).unwrap();
assert_eq!(design.features.len(), 1);
let replay: Value = serde_json::from_slice(&fs::read(v["replay_path"].as_str().unwrap()).unwrap()).unwrap();
assert_eq!(replay["commands"], json!([{"command": "diagnostics.crash_test", "params": {"mode": "panic"}}]));
assert!(fs::read_dir(d.join("sessions")).unwrap().next().is_none(), "a clean exit leaves no journal");
let _ = fs::remove_dir_all(&d);
}
#[test]
fn a_kernel_panic_is_reported_as_recovered() {
let d = fresh_dir("kernel");
let (_, ok) = run_child(&d, "kernel");
assert!(ok);
let (s, v) = only_report(&d);
assert_eq!(s.kind, "kernel_panic_recovered");
assert!(s.replayable);
assert!(v["message"].as_str().unwrap().contains("in the kernel"), "{v}");
let _ = fs::remove_dir_all(&d);
}
#[test]
fn a_panic_outside_commands_is_reported() {
let d = fresh_dir("thread");
let (_, ok) = run_child(&d, "thread");
assert!(ok);
let (s, v) = only_report(&d);
assert_eq!(s.kind, "panic");
assert_eq!(v["thread"], "worker");
assert_eq!(v["message"], "worker thread 42 failed");
// What the journal knew: a new design and the box.
assert_eq!(v["base"], "new");
assert_eq!(v["recent_commands"][0]["command"], "solid.box");
let _ = fs::remove_dir_all(&d);
}
#[test]
fn an_abort_is_reported_at_the_next_start_with_a_replay() {
let d = fresh_dir("abort");
let (_, ok) = run_child(&d, "abort");
assert!(!ok, "the child aborted");
assert!(list(&d).is_empty(), "no hook sees an abort");
// The next start finds the journal nobody holds and tells about it.
let (out, ok) = run_child(&d, "none");
assert!(ok);
let fresh: Vec<Value> = out.lines().find_map(|l| l.split_once("FRESH ").map(|x| x.1)).and_then(|j| serde_json::from_str(j).ok()).unwrap();
assert_eq!(fresh.len(), 1, "{out}");
let (s, v) = only_report(&d);
assert_eq!(s.kind, "unclean_exit");
assert!(s.message.contains("while running `diagnostics.crash_test`"), "{}", s.message);
assert_eq!(v["app"]["version"], "9.9.9");
// From a new design: the box, then the command in flight.
let replay: Value = serde_json::from_slice(&fs::read(v["replay_path"].as_str().unwrap()).unwrap()).unwrap();
let ids: Vec<&str> = replay["commands"].as_array().unwrap().iter().map(|c| c["command"].as_str().unwrap()).collect();
assert_eq!(ids, ["solid.box", "diagnostics.crash_test"], "read-only commands are not journaled");
assert!(v.get("design_path").is_none());
// Told once: the start after that has nothing new.
let (out, _) = run_child(&d, "none");
assert!(out.contains("FRESH []"), "{out}");
let _ = fs::remove_dir_all(&d);
}
#[test]
fn a_journal_reads_back_its_base_commands_and_the_one_in_flight() {
let d = fresh_dir("journal");
fs::create_dir_all(&d).unwrap();
let p = d.join("j.jsonl");
let lines = [
r#"{"start":"2026-10-12T10:00:00Z","version":"1.0"}"#,
r#"{"base":"new"}"#,
r#"{"run":"solid.box","params":{"length":1}}"#,
r#"{"ok":true}"#,
r#"{"base":{"autosave":"/tmp/a.solvecraft"}}"#,
r#"{"run":"solid.fillet","params":{"radius":1}}"#,
r#"{"ok":false,"error":"no edges"}"#,
"not json",
r#"{"run":"solid.shell","params":{"thickness":1}}"#,
r#"{"ok":true}"#,
r#"{"run":"solid.extrude","params_omitted":true}"#,
];
fs::write(&p, lines.join("\n")).unwrap();
let j = read_journal(&p);
assert_eq!(j.header["version"], "1.0");
assert_eq!(j.base, json!({"autosave": "/tmp/a.solvecraft"}));
assert_eq!(j.replay, vec![json!({"command": "solid.shell", "params": {"thickness": 1}})], "failed commands change nothing");
assert_eq!(j.recent.len(), 2);
assert_eq!(j.recent[0]["error"], "no edges");
assert_eq!(j.in_flight.unwrap()["command"], "solid.extrude");
assert!(!j.complete, "omitted parameters make the replay incomplete");
assert!(!j.reported);
let _ = fs::remove_dir_all(&d);
}
fn fake_report(dir: &Path, secs: u64, sig: u64, kind: &str) -> String {
let id = format!("{}-{sig:016x}", stamp(secs));
fs::create_dir_all(dir.join(&id)).unwrap();
let f = dir.join(&id).join("report.json");
fs::write(&f, json!({"format": FORMAT, "id": id, "kind": kind, "message": "m", "count": 1, "first_seen": utc(secs)}).to_string()).unwrap();
let t = std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs);
File::options().write(true).open(&f).unwrap().set_modified(t).unwrap();
id
}
#[test]
fn reports_stay_within_their_limits() {
let d = fresh_dir("limits");
let t0 = 1_790_000_000;
let old = fake_report(&d, t0 - (MAX_AGE_DAYS + 1) * 86_400, 1, "panic");
let recovered = fake_report(&d, t0 - 100, 2, "kernel_panic_recovered");
let mut ids = Vec::new();
for i in 0..MAX_REPORTS as u64 {
ids.push(fake_report(&d, t0 - 50 + i, 100 + i, "panic"));
}
prune(&d, t0);
let left: Vec<String> = list(&d).into_iter().map(|s| s.id).collect();
assert_eq!(left.len(), MAX_REPORTS);
assert!(!left.contains(&old), "expired");
assert!(!left.contains(&recovered), "recovered kernel panics go before real crashes");
// One more: the oldest goes.
let newest = fake_report(&d, t0 + 10, 999, "panic");
prune(&d, t0 + 10);
let left: Vec<String> = list(&d).into_iter().map(|s| s.id).collect();
assert_eq!(left.len(), MAX_REPORTS);
assert_eq!(left.first(), Some(&newest), "newest first");
assert!(!left.contains(&ids[0]));
let _ = fs::remove_dir_all(&d);
}
#[test]
fn hostile_crash_folders_never_panic() {
let d = fresh_dir("hostile");
fs::create_dir_all(d.join("sessions")).unwrap();
let good = fake_report(&d, 1_790_000_000, 7, "panic");
// Junk: a report that is not JSON, one that is an array, stray files and folders.
let broken = format!("{}-{:016x}", stamp(1_790_000_001), 8u64);
fs::create_dir_all(d.join(&broken)).unwrap();
fs::write(d.join(&broken).join("report.json"), b"\xff\xfe{{{").unwrap();
let array = format!("{}-{:016x}", stamp(1_790_000_002), 9u64);
fs::create_dir_all(d.join(&array)).unwrap();
fs::write(d.join(&array).join("report.json"), "[1,2]").unwrap();
fs::create_dir_all(d.join("..evil")).unwrap();
fs::write(d.join("notes.txt"), "x").unwrap();
fs::write(d.join("sessions").join("junk.jsonl"), b"\x00\x01garbage\n{\"run\":5}\n{\"ok\":\"yes\"}").unwrap();
let all = list(&d);
assert_eq!(all.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(), [good.as_str()]);
for id in ["", "../..", "..", "a/b", &"x".repeat(500), &broken] {
assert!(read(&d, id).is_err(), "{id}");
assert_eq!(clear(&d, Some(id)), usize::from(id == broken), "{id}");
}
let j = read_journal(&d.join("sessions").join("junk.jsonl"));
assert!(j.replay.is_empty());
assert!(read_journal(&d.join("missing.jsonl")).replay.is_empty());
for (loc, msg) in [("", ""), ("x", &"é".repeat(5000))] {
let _ = signature(loc, msg);
let _ = limited(msg, 7);
}
assert!(frames("garbage\n at nothing\n 99999999999999999999: x").len() <= MAX_FRAMES);
let _ = fs::remove_dir_all(&d);
}
#[test]
fn signatures_ignore_numbers_but_not_places() {
assert_eq!(signature("a.rs:1", "face 12 of 40"), signature("a.rs:1", "face 7 of 3"));
assert_ne!(signature("a.rs:1", "face 12"), signature("a.rs:2", "face 12"));
assert_ne!(signature("a.rs:1", "edge"), signature("a.rs:1", "face"));
}
#[test]
fn dates_are_formatted_in_utc() {
assert_eq!(utc(0), "1970-01-01T00:00:00Z");
assert_eq!(utc(951_782_400), "2000-02-29T00:00:00Z");
assert_eq!(utc(1_791_656_982), "2026-10-10T18:29:42Z");
assert_eq!(stamp(1_791_656_982), "20261010-182942");
}
#[test]
fn backtraces_lose_the_panic_machinery_and_the_home_folder() {
let home = std::env::var("HOME").unwrap_or_else(|_| "/home/u".into());
let bt = format!(
" 0: std::backtrace::Backtrace::force_capture\n at /rustc/abc123/library/std/src/backtrace.rs:312:13\n 1: hook\n 1: {{closure#3}}\n 1: <alloc::boxed::Box<F,A> as core::ops::function::Fn<Args>>::call\n 2: std::panicking::rust_panic_with_hook\n 3: core::panicking::panic_fmt\n 4: solvecraft_kernel::fillet::blend\n at {home}/src/kernel/fillet.rs:10:5\n 5: do_call<F>\n at /rustc/abc/library/std/src/panicking.rs:1:1\n 5: std::sys::backtrace::__rust_begin_short_backtrace\n 6: main"
);
assert_eq!(frames(&bt), ["solvecraft_kernel::fillet::blend (~/src/kernel/fillet.rs:10:5)"]);
}
#[test]
fn without_install_nothing_is_recorded() {
// Tests, the CLI and the web build never install the hook: commands cost nothing extra.
let mut s = Session { crash_journal: true, ..Default::default() };
s.execute("solid.box", &json!({"length": 1, "width": 1, "height": 1})).unwrap();
assert!(!enabled());
assert!(PENDING.with(|p| p.borrow().is_empty()));
}
+23
View File
@@ -8,6 +8,7 @@
#![forbid(unsafe_code)]
pub mod cmd;
pub mod crash;
pub mod frames;
pub mod fuzz;
pub mod licences;
@@ -217,6 +218,9 @@ pub struct Session {
/// commands are undone however long the undo history is.
pub(crate) checkpoints: Vec<Checkpoint>,
next_checkpoint: u64,
/// Record this session's top-level commands in the crash journal (the app's own design;
/// never previews or other scratch sessions). See [`crash`].
pub crash_journal: bool,
/// Full model evaluations (`refresh`), for tests.
#[cfg(test)]
pub(crate) evaluations: usize,
@@ -317,6 +321,7 @@ impl Session {
visibility: Visibility::default(),
#[cfg(test)]
evaluations: 0,
crash_journal: false,
}
}
@@ -495,15 +500,21 @@ impl Session {
};
let null = Value::Object(Default::default());
let params = if params.is_null() { &null } else { params };
let given = params;
// Picks in the world go into their component's frame (once: commands run by commands
// already have them there).
let (_nested, outermost) = frames::Nested::enter(spec.id);
let mapped = if outermost { frames::to_local(self, spec.id, params) } else { None };
let params = mapped.as_ref().unwrap_or(params);
if outermost {
crash::command_begin(self, spec.id, given);
}
let scope = crash::Scope::enter();
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match live {
Some(run) => run(self, params),
None => (spec.run)(self, params).map(|v| (v, None)),
}));
drop(scope);
let r = match r {
Ok(r) => r,
Err(p) => {
@@ -511,6 +522,17 @@ impl Session {
Err(EngineError::Internal(spec.id.to_string(), msg))
}
};
if outermost && crash::enabled() {
// A failed command leaves the design as it was (restored below).
let failed = r.as_ref().err().map(ToString::to_string);
let after = if failed.is_some() { &before.doc } else { &self.doc };
// Commands that only read leave no trace (the journal keeps what a replay needs).
let changed = !Arc::ptr_eq(&self.doc, &before.doc)
|| self.active_sketch != before.active_sketch
|| self.pending_moves != before.pending_moves
|| spec.id.starts_with("select.");
crash::command_done(self.crash_journal, changed, &before.doc, after, spec.id, given, failed.map_or(Ok(()), Err));
}
let changed = !Arc::ptr_eq(&self.doc, &before.doc);
match r {
Ok((v, live_edit)) => {
@@ -631,6 +653,7 @@ impl Session {
visibility: self.visibility.clone(),
#[cfg(test)]
evaluations: 0,
crash_journal: false,
}
}
+4
View File
@@ -164,6 +164,8 @@ impl Autosaver {
self.has_entry = false;
}
self.last = None;
// Crash replays start from the saved file (or a new design) from here.
crate::crash::rebased(s, s.path.as_deref().map(Path::new), false);
return Ok(false);
}
if self.last.as_ref().is_some_and(|l| Arc::ptr_eq(l, &s.doc) || **l == *s.doc) {
@@ -177,6 +179,8 @@ impl Autosaver {
solvecraft_io::write_atomic(&file(&self.dir, &self.id, "json"), &meta, false).map_err(io)?;
self.last = Some(s.doc.clone());
self.has_entry = true;
// Crash replays start from this autosave from here.
crate::crash::rebased(s, Some(&file(&self.dir, &self.id, "solvecraft")), true);
Ok(true)
}
+21 -1
View File
@@ -116,9 +116,29 @@ pub enum KernelError {
pub type Result<T> = std::result::Result<T, KernelError>;
thread_local! {
static GUARDED: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
}
/// Is this thread inside [`guard`]? (A panic now will be caught and turned into an error; the
/// engine's crash reports mark it recovered.)
pub fn guarded() -> bool {
GUARDED.with(std::cell::Cell::get) > 0
}
/// Run third-party kernel code, converting a panic into an error.
pub fn guard<T>(what: &str, f: impl FnOnce() -> Result<T>) -> Result<T> {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
struct Depth;
impl Drop for Depth {
fn drop(&mut self) {
GUARDED.with(|g| g.set(g.get().saturating_sub(1)));
}
}
GUARDED.with(|g| g.set(g.get() + 1));
let depth = Depth;
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
drop(depth);
match r {
Ok(r) => r,
Err(p) => {
let msg = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
+13
View File
@@ -29,6 +29,18 @@ fn tool(s: &mut Server, name: &str, args: Value) -> Value {
if c["type"] == "text" { serde_json::from_str(c["text"].as_str().unwrap()).unwrap_or(Value::Null) } else { c.clone() }
}
#[test]
fn crash_reports_are_listed_and_read_without_changing_the_design() {
let mut s = server();
let list = tool(&mut s, "crash_reports", json!({"limit": 3}));
assert!(list["reports"].as_array().is_some_and(|r| r.len() <= 3), "{list}");
assert!(list["dir"].is_string(), "{list}");
let r = raw_tool(&mut s, "crash_reports", json!({"id": "../../etc"}));
assert_eq!(r["result"]["isError"], json!(true), "{r}");
let r = raw_tool(&mut s, "crash_reports", json!({"id": 5}));
assert!(r.get("error").is_some() || r["result"]["isError"] == json!(true), "{r}");
}
fn volume(s: &mut Server) -> f64 {
tool(s, "measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap()
}
@@ -67,6 +79,7 @@ fn handshake_and_tools_list() {
"export",
"undo",
"redo",
"crash_reports",
"new_design",
"open",
"save",
+22
View File
@@ -183,6 +183,24 @@ pub fn tool_definitions() -> Vec<Value> {
false,
false,
),
tool(
"crash_reports",
"Crash reports",
"SolidCraft's crash reports on this computer (the app's when connected to it). Without `id`: the list, newest \
first (id, kind, time, message, command, count, version, path, replayable). With `id`: the whole report: \
message, location, backtrace, app version and commit, system, the command that crashed and the recent ones, \
and design_path / replay_path when it can be replayed: `solidcraft-cli crashes replay <id>`, or \
`solidcraft-cli run <replay_path> --in <design_path>`. Reports never leave this computer.",
obj(
json!({
"id": string("A report id from the list"),
"limit": {"type": "integer", "description": "Reports to list (default 20)"},
}),
&[],
),
true,
false,
),
tool("undo", "Undo", "Undo the last change to the design.", obj(json!({}), &[]), false, false),
tool("redo", "Redo", "Redo the last undone change.", obj(json!({}), &[]), false, false),
tool(
@@ -352,6 +370,10 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Map<String, Value>) -> Result<V
exec(b, "parameters.change", p)
}
"export" => exec(b, "file.export", pick(a, &["path", "format", "bodies"])),
"crash_reports" => match a.get("id") {
Some(_) => exec(b, "diagnostics.crash_report", pick(a, &["id"])),
None => exec(b, "diagnostics.crash_reports", pick(a, &["limit"])),
},
"undo" => exec(b, "edit.undo", json!({})),
"redo" => exec(b, "edit.redo", json!({})),
"new_design" => exec(b, "file.new", pick(a, &["name"])),
+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(),
+1 -1
View File
@@ -75,7 +75,7 @@ unit regardless of the design, pass an expression with one: `"value": "50 mm"` o
| `ui.file` | `path`; `insert?`: bool | starts an interactive background Open or Insert; poll `ui.inspect.file_busy` for completion; the window stays responsive |
| `ui.home` | `open?`: bool; `sample?`: index | the start page: show or hide it, open a built-in sample (once built); returns `open` and the recent designs |
| `ui.shortcut` | `command`, `key?`, `replace?` | a command's key, or rebinds it (`""` clears; a key another command uses is refused unless `replace`) |
| `ui.help` | `item?`: `about`, `shortcuts` (read only), `report` (copies diagnostics) | the version, commit, build date and diagnostic text |
| `ui.help` | `item?`: `about`, `shortcuts` (read only), `report` (copies diagnostics), `crashes` (the Crash Reports window) | the version, commit, build date and diagnostic text |
| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `tinkercad`, `openscad`), `zoom_reverse`, `zoom_to_cursor`, `smooth_wheel_zoom` (default true), `trackpad_pan` (default true: two-finger scroll pans, pinch zooms), `orbit_cursor`, `look_at_anim`, `look_at_speed`, `msaa`, `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`; see [localization](localization.md)), `ui_scale`, `up_axis` (`z`, `y`), `offer_stand_up`; `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` |
| `ui.capture` | `action?`: `capture`, `revert`, `cancel` | answers the Capture Position question (asked when a command starts or the design is saved while components were moved without capturing); without `action`, what waits |
| `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar); close asks about unsaved designs first |
+87
View File
@@ -0,0 +1,87 @@
# Crash reports
When the desktop app crashes or hits an internal error, it writes a crash report on this
computer. Each report holds enough to reproduce and fix the problem: in most cases the design
and the exact commands that trigger it. Reports are never sent anywhere. To share one, use
**Report…** in Help ▸ Crash Reports…, which opens a GitHub issue draft. Nothing is shared
until you review and submit it, and the design is not part of the draft.
## Where they are
`<data dir>/solvecraft/crashes/`, next to the recovery folder:
| System | Folder |
|---|---|
| Linux | `~/.local/share/solvecraft/crashes` (or `$XDG_DATA_HOME/solvecraft/crashes`) |
| Windows | `%LOCALAPPDATA%\SolveCraft\crashes` |
| macOS | `~/Library/Application Support/SolveCraft/crashes` |
`SOLVECRAFT_CRASH_DIR` overrides the folder. `solvecraft --crash-log` and
`solidcraft-cli crashes dir` print it.
## For agents
All of these read the same folder:
| From | List | One report | Delete |
|---|---|---|---|
| Engine command (MCP `execute`, control channel, scripts) | `diagnostics.crash_reports {limit?}` | `diagnostics.crash_report {id}` | `diagnostics.crash_reports.clear {id?}` |
| MCP tool | `crash_reports {limit?}` | `crash_reports {id}` | — |
| CLI | `solidcraft-cli crashes [--json]` | `solidcraft-cli crashes show <id>` | `solidcraft-cli crashes clear [<id>]` |
| Control channel (UI) | `ui.help {"item": "crashes"}` opens the window | | |
**Reproduce:** `solidcraft-cli crashes replay <id>` opens the report's design, runs its
commands, and reports whether the same internal error happens again (same place and message).
Without the CLI, run `solidcraft-cli run <report>/replay.json --in <report>/design.solvecraft`.
A fixed crash turns into a regression test with the same two files.
**Check that reporting works:** with `SOLVECRAFT_CRASH_TEST=1` set for the app, the command
`diagnostics.crash_test` has three modes:
- `panic`: an internal error in a command
- `kernel`: an internal error inside the geometry kernel
- `abort`: the app ends at once, and the report appears at the next start
## What a report holds
A folder `crashes/<YYYYMMDD-HHMMSS>-<signature>/`:
- `report.json` (`"format": "solvecraft-crash/1"`):
- `kind`: one of
- `command_panic`: a command hit an internal error; it was caught and the design was left unchanged
- `kernel_panic_recovered`: the third-party geometry kernel panicked and was caught. This is harmless on the desktop, but it would crash the web build.
- `panic`: an internal error outside any command
- `unclean_exit`: the app ended without a hook seeing it (an abort, a stack overflow, a segfault, or the process being killed). It is found at the next start.
- `message`, `location` (file:line), `thread`
- `backtrace`: frames `symbol (file:line)`, without the panic machinery, std internals or the user's home folder
- `app` (version, commit, build date), `system` (OS, architecture, renderer and GPU), `session.uptime_s`
- `command`: the command that crashed, with its parameters
- `recent_commands`: up to 32, with ok or error
- `base`: what the replay starts from (the design before the command, an autosave, a saved file or a new design)
- `count`, `first_seen`, `last_seen`: the same crash repeats only increase `count`
- `notes`: what is missing and why, for example a design too large to copy
- `design.solvecraft`: the design the replay starts from
- `replay.json`: `{"commands": [{command, params}, …]}`
## How it stays cheap and small
- **Before a crash:** the app keeps a journal of the top-level commands run on the design on
screen (`crashes/sessions/*.jsonl`, at most 512 KB). Each command adds two small writes,
with no flush to disk. Previews and other scratch sessions are not recorded. Each autosave
restarts the journal from the autosaved design. A clean exit deletes it. The journal file
is locked while its app runs, so at the next start, a journal nobody holds belongs to an
app that died.
- **A panic** captures its backtrace only the first time its signature is seen. The
signature is the place plus the message, with numbers ignored.
- **Limits:**
- at most 5 new reports per run
- 20 reports
- 50 MB in total
- 90 days
- a design over 10 MB is not copied; the report notes that
When over a limit, recovered kernel panics are deleted before real crashes, and the oldest first.
- **Where it's off:** the CLI, tests and the web build never install the hook. There,
commands pay only a check of one atomic flag.
Release builds keep line tables (`debug = "line-tables-only"`), so backtraces name the file
and line. Stripping a binary (`strip`) removes them, and the function names too.
+1
View File
@@ -58,6 +58,7 @@ Claude Desktop (`claude_desktop_config.json`), bridged to a running app:
| `screenshot` | `view?` (iso, top, front, back, bottom, left, right, home, fit), `path?`, `width?`, `height?`, `source?` (window, model) | A PNG image content block. Headless: CPU render fitted to the model. Bridged: the app window (default) or a model render with the app's camera. |
| `export` | `path`, `format?` (step, 3mf, stl, stla, obj), `bodies?` | STEP, 3MF or mesh export. |
| `undo`, `redo` | — | Undo / redo one change. |
| `crash_reports` | `id?`, `limit?` | The crash reports on this computer, newest first; with `id`, the whole report (backtrace, command, recent commands, `design_path`/`replay_path`). See [crash-reports.md](crash-reports.md). |
| `new_design` | `name?` | Start an empty design. |
| `open` | `path` | Open a `.solvecraft` design, a STEP file (`.step`/`.stp`) as a new design holding its bodies (an Import base feature), or a 3MF/STL file as mesh bodies. |
| `save` | `path?` | Save the design (to its current file without `path`). |