Crash reports: replayable, bounded, local, reachable by agents (#384)
Every panic (in a command, in the kernel, anywhere else) and every unclean exit (abort, stack overflow, kill) leaves a report in a local crash folder: message, file:line, a trimmed backtrace, version, commit, system and renderer, the command and recent commands, and when possible the design and replay.json that reproduce it (solvecraft-cli crashes replay <id>). - Cost: nothing until the app installs the hook (tests, CLI and web never do); then one small journal line per top-level command of the design on screen, taken back with a truncate when the command changed nothing. - Unclean exits: each run holds a lock on its journal; a journal nobody holds at the next start belongs to an app that died. Autosaves restart the journal from the autosaved design. - Bounded: a crash's signature (place + message, numbers ignored) is counted, not rewritten; at most 5 new reports per run, 20 reports, 50 MB, 90 days; recovered kernel panics are pruned first. - Agents: diagnostics.crash_reports / crash_report / crash_reports.clear commands, the MCP crash_reports tool, solvecraft-cli crashes, solvecraft --crash-log; diagnostics.crash_test (with SOLVECRAFT_CRASH_TEST) checks it. - People: the Crash Reports window opens after a crash (Help > Crash Reports…) with a reviewed issue draft per report; nothing is sent. - Release builds keep line tables for file:line in backtraces. Tests: child processes that really panic in a command, in the kernel, on a thread, and abort; dedup counts, limits, hostile folders, journal parsing, the UI window and draft, the MCP tool. docs/crash-reports.md. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
This commit is contained in:
24 files changed
+1998
-9
No files matched your search
@@ -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)]`.
|
||||
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Crash reports, for agents and people (see `crate::crash` and docs/crash-reports.md): list
|
||||
//! them, read one, delete them. They live on this computer and are never sent anywhere.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, str_};
|
||||
use crate::{EngineError, Result, Session, crash};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("diagnostics.crash_reports", "Crash Reports", list).noundo().params(
|
||||
"limit?: number (default 20). Lists this computer's crash reports, newest first: {dir, reports: [{id, kind, time, message, \
|
||||
command, count, version, path, replayable}]}. kind: command_panic | panic | kernel_panic_recovered | unclean_exit. \
|
||||
Read one with diagnostics.crash_report; reproduce it with `solidcraft-cli crashes replay <id>`",
|
||||
),
|
||||
CommandSpec::new("diagnostics.crash_report", "Crash Report", read).noundo().params(
|
||||
"id: report id. The whole report: message, location, backtrace, app version and commit, system, the command that \
|
||||
crashed and the recent ones, notes, and design_path / replay_path when the crash can be replayed \
|
||||
(`solidcraft-cli run <replay_path> --in <design_path>`)",
|
||||
),
|
||||
CommandSpec::new("diagnostics.crash_reports.clear", "Delete Crash Reports", clear)
|
||||
.noundo()
|
||||
.params("id?: one report (default: all). Returns {deleted}"),
|
||||
CommandSpec::new("diagnostics.crash_test", "Crash Test", crash_test).noundo().enabled(crash_test_enabled).params(
|
||||
"mode: panic | kernel | abort. Checks that crash reports work: panic = an internal error in this command (caught; \
|
||||
the design is unchanged), kernel = one inside the geometry kernel (caught), abort = the app ends at once (reported \
|
||||
at the next start). Only when the SOLVECRAFT_CRASH_TEST environment variable is set",
|
||||
),
|
||||
];
|
||||
|
||||
fn dir(cmd: &str) -> Result<std::path::PathBuf> {
|
||||
crash::default_dir().ok_or_else(|| bad(cmd, "no crash folder on this system"))
|
||||
}
|
||||
|
||||
fn list(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = dir("diagnostics.crash_reports")?;
|
||||
let limit = p.get("limit").and_then(Value::as_u64).map_or(20, |n| n.min(1000)) as usize;
|
||||
let mut reports = crash::list(&d);
|
||||
reports.truncate(limit);
|
||||
Ok(json!({ "dir": d.to_string_lossy(), "reports": reports }))
|
||||
}
|
||||
|
||||
fn read(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "diagnostics.crash_report";
|
||||
let id = str_(p, "id").ok_or_else(|| bad(cmd, "`id` is required (see diagnostics.crash_reports)"))?;
|
||||
crash::read(&dir(cmd)?, id).map_err(|e| bad(cmd, e))
|
||||
}
|
||||
|
||||
fn clear(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = dir("diagnostics.crash_reports.clear")?;
|
||||
Ok(json!({ "deleted": crash::clear(&d, str_(p, "id")) }))
|
||||
}
|
||||
|
||||
fn crash_test_enabled(_: &Session) -> std::result::Result<(), String> {
|
||||
if std::env::var_os("SOLVECRAFT_CRASH_TEST").is_some() { Ok(()) } else { Err("set SOLVECRAFT_CRASH_TEST to use the crash test".into()) }
|
||||
}
|
||||
|
||||
// The one deliberate panic: it proves the crash reports work, and runs only when asked for with
|
||||
// SOLVECRAFT_CRASH_TEST.
|
||||
#[allow(clippy::panic)]
|
||||
fn crash_test(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "diagnostics.crash_test";
|
||||
match str_(p, "mode") {
|
||||
Some("panic") => std::panic::panic_any(String::from("crash test: an internal error in a command")),
|
||||
Some("kernel") => {
|
||||
solvecraft_kernel::guard("crash test", || -> solvecraft_kernel::Result<()> {
|
||||
std::panic::panic_any(String::from("an internal error in the kernel"))
|
||||
})?;
|
||||
Err(EngineError::Other("crash test: the kernel error was not caught".into()))
|
||||
}
|
||||
Some("abort") => std::process::abort(),
|
||||
_ => Err(bad(cmd, "`mode` must be panic, kernel or abort")),
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ mod component_clone;
|
||||
mod component_clone_assembly;
|
||||
mod construct;
|
||||
mod delete;
|
||||
mod diagnostics;
|
||||
mod edit;
|
||||
mod face;
|
||||
mod features;
|
||||
@@ -214,6 +215,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
|
||||
v.extend(file::COMMANDS.iter());
|
||||
v.extend(inspect::COMMANDS.iter());
|
||||
v.extend(section::COMMANDS.iter());
|
||||
v.extend(diagnostics::COMMANDS.iter());
|
||||
v
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"])),
|
||||
|
||||
@@ -491,7 +491,7 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
},
|
||||
"ui.prefs" => wrap(crate::prefs::control(app, p)),
|
||||
"ui.help" => match s("item") {
|
||||
Some(i @ ("about" | "shortcuts" | "report")) => {
|
||||
Some(i @ ("about" | "shortcuts" | "report" | "crashes")) => {
|
||||
crate::help::run(app, ctx, i);
|
||||
ok(
|
||||
json!({"diagnostics": crate::help::diagnostics(app), "version": crate::help::VERSION, "commit": crate::help::COMMIT, "built": crate::help::BUILD_DATE}),
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
//! The Crash Reports window (Help > Crash Reports…): the reports `solvecraft_engine::crash`
|
||||
//! keeps on this computer, each with a reviewed issue draft. It opens by itself after a crash.
|
||||
|
||||
use egui::{RichText, vec2};
|
||||
use solvecraft_engine::crash::{self, Summary};
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct CrashUi {
|
||||
pub open: bool,
|
||||
/// The crash folder (default: the engine's, `crash::default_dir`).
|
||||
pub folder: Option<std::path::PathBuf>,
|
||||
reports: Vec<Summary>,
|
||||
dir: Option<String>,
|
||||
note: String,
|
||||
}
|
||||
|
||||
impl CrashUi {
|
||||
/// Read the reports again (when the window opens or changes; never every frame).
|
||||
pub fn refresh(&mut self) {
|
||||
let dir = self.dir_path();
|
||||
self.reports = dir.as_deref().map(crash::list).unwrap_or_default();
|
||||
self.dir = dir.map(|d| d.to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
|
||||
impl CrashUi {
|
||||
fn dir_path(&self) -> Option<std::path::PathBuf> {
|
||||
self.folder.clone().or_else(crash::default_dir)
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_label(kind: &str) -> &'static str {
|
||||
match kind {
|
||||
"command_panic" => "Internal error in a command",
|
||||
"kernel_panic_recovered" => "Kernel error (recovered)",
|
||||
"unclean_exit" => "Closed unexpectedly",
|
||||
_ => "Internal error",
|
||||
}
|
||||
}
|
||||
|
||||
/// The host found these new reports at startup: tell the user about real crashes (recovered
|
||||
/// kernel errors stay quiet; they are listed in the window).
|
||||
pub fn announce(app: &mut SolveApp, fresh: &[Summary]) {
|
||||
let crashes = fresh.iter().filter(|r| r.kind != "kernel_panic_recovered").count();
|
||||
if crashes == 0 {
|
||||
return;
|
||||
}
|
||||
app.crashes.open = true;
|
||||
app.crashes.refresh();
|
||||
app.set_status("SolidCraft closed unexpectedly or hit an internal error last time. A crash report was saved (Help > Crash Reports…).", true);
|
||||
}
|
||||
|
||||
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
if !app.crashes.open {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get();
|
||||
let mut open = true;
|
||||
let mut report: Option<String> = None;
|
||||
let mut delete: Option<Option<String>> = None;
|
||||
crate::frame::window(ctx, "Crash Reports", crate::frame::Width::Wide)
|
||||
.id(egui::Id::new("sc_crash_reports"))
|
||||
.open(&mut open)
|
||||
.default_size(vec2(620.0, 420.0))
|
||||
.show(ctx, |ui| {
|
||||
ui.label(
|
||||
RichText::new(
|
||||
"Crash reports stay on this computer and are never sent anywhere. Report… opens a draft you review before anything is shared.",
|
||||
)
|
||||
.color(t.text_dim),
|
||||
);
|
||||
if let Some(d) = app.crashes.dir.clone() {
|
||||
// A long path is cut short (hover shows it all): it must not widen the window
|
||||
// past its title bar.
|
||||
ui.horizontal(|ui| {
|
||||
if ui.small_button("Copy folder path").clicked() {
|
||||
ctx.copy_text(d.clone());
|
||||
}
|
||||
ui.add(egui::Label::new(RichText::new(&d).monospace().size(11.0)).truncate()).on_hover_text(&d);
|
||||
});
|
||||
}
|
||||
ui.separator();
|
||||
if app.crashes.reports.is_empty() {
|
||||
ui.label("No crash reports.");
|
||||
}
|
||||
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
|
||||
for r in &app.crashes.reports {
|
||||
ui.horizontal(|ui| {
|
||||
let times = if r.count > 1 { format!(" ×{}", r.count) } else { String::new() };
|
||||
ui.label(
|
||||
RichText::new(format!("{} {}{times}", r.time.replace('T', " ").trim_end_matches('Z'), kind_label(&r.kind))).strong(),
|
||||
);
|
||||
if let Some(c) = &r.command {
|
||||
ui.label(RichText::new(c).monospace().size(11.0).color(t.text_dim));
|
||||
}
|
||||
});
|
||||
ui.label(RichText::new(&r.message).size(12.0));
|
||||
ui.horizontal(|ui| {
|
||||
if ui.small_button("Report…").clicked() {
|
||||
report = Some(r.id.clone());
|
||||
}
|
||||
if ui.small_button("Copy path").clicked() {
|
||||
ctx.copy_text(r.path.clone());
|
||||
}
|
||||
if ui.small_button("Delete").clicked() {
|
||||
delete = Some(Some(r.id.clone()));
|
||||
}
|
||||
if r.replayable {
|
||||
ui.label(RichText::new("replayable").size(11.0).color(t.text_dim));
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
});
|
||||
if !app.crashes.note.is_empty() {
|
||||
ui.label(RichText::new(&app.crashes.note).color(t.text_dim));
|
||||
}
|
||||
ui.separator();
|
||||
if !app.crashes.reports.is_empty() && ui.button("Delete all").clicked() {
|
||||
delete = Some(None);
|
||||
}
|
||||
});
|
||||
app.crashes.open = open;
|
||||
if let Some(id) = report
|
||||
&& let Some(dir) = app.crashes.dir_path()
|
||||
{
|
||||
match crash::read(&dir, &id) {
|
||||
Ok(v) => crate::issue_report::begin_crash(app, ctx, &v),
|
||||
Err(e) => app.crashes.note = e,
|
||||
}
|
||||
}
|
||||
if let Some(which) = delete
|
||||
&& let Some(dir) = app.crashes.dir_path()
|
||||
{
|
||||
let n = crash::clear(&dir, which.as_deref());
|
||||
app.crashes.note = format!("Deleted {n} report(s).");
|
||||
app.crashes.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
/// Help > Crash Reports…
|
||||
pub fn open(app: &mut SolveApp) {
|
||||
app.crashes.open = true;
|
||||
app.crashes.note.clear();
|
||||
app.crashes.refresh();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
fn folder(tag: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("solvecraft-crash-ui-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
let id = "20261012-162341-bb60791ed6692f1d";
|
||||
std::fs::create_dir_all(d.join(id)).unwrap();
|
||||
let report = json!({
|
||||
"format": "solvecraft-crash/1", "id": id, "kind": "unclean_exit", "count": 2,
|
||||
"first_seen": "2026-10-12T16:23:41Z", "message": "SolidCraft ended without a clean exit while running `solid.fillet`",
|
||||
"location": "unclean exit in solid.fillet", "thread": "(unknown)", "backtrace": [],
|
||||
"command": {"command": "solid.fillet", "params": {"radius": 2}},
|
||||
"recent_commands": [{"command": "solid.box", "ok": true}],
|
||||
"app": {"version": "0.3.2", "commit": "abc1234", "built": "2026-10-12"},
|
||||
"system": {"os": "linux", "arch": "x86_64", "environment": "Renderer: test"},
|
||||
});
|
||||
std::fs::write(d.join(id).join("report.json"), report.to_string()).unwrap();
|
||||
std::fs::write(d.join(id).join("replay.json"), r#"{"commands":[]}"#).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crash_report_opens_a_reviewed_issue_draft_and_can_be_deleted() {
|
||||
let d = folder("draft");
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.crashes.folder = Some(d.clone());
|
||||
let fresh = solvecraft_engine::crash::list(&d);
|
||||
assert_eq!(fresh.len(), 1);
|
||||
super::announce(&mut h.app, &fresh);
|
||||
assert!(h.app.crashes.open, "a real crash opens the window");
|
||||
assert!(h.app.status.as_ref().is_some_and(|s| s.2), "and says so");
|
||||
h.frames(4);
|
||||
h.step(&json!({"widget": "Report…"})).unwrap();
|
||||
h.frames(2);
|
||||
let draft = h.app.issue_report.draft.as_ref().expect("Report… opens the draft");
|
||||
assert!(draft.title.starts_with("Crash: SolidCraft ended without a clean exit"), "{}", draft.title);
|
||||
for want in [
|
||||
"while running `solid.fillet`",
|
||||
"\"radius\": 2",
|
||||
"solid.box",
|
||||
"solidcraft-cli crashes replay 20261012-162341-bb60791ed6692f1d",
|
||||
"Renderer: test",
|
||||
"Happened 2 time(s)",
|
||||
] {
|
||||
assert!(draft.body.contains(want), "missing {want:?} in {}", draft.body);
|
||||
}
|
||||
assert!(!draft.body.contains(&d.to_string_lossy().into_owned()), "the draft names no local paths");
|
||||
h.app.issue_report.draft = None;
|
||||
h.frames(2);
|
||||
h.step(&json!({"widget": "Delete all"})).unwrap();
|
||||
h.frames(2);
|
||||
assert!(solvecraft_engine::crash::list(&d).is_empty());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_folder_path_does_not_widen_the_window_past_its_title_bar() {
|
||||
let d = folder(&format!("{}-long", "very-long-folder-name-".repeat(6)));
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.crashes.folder = Some(d.clone());
|
||||
super::open(&mut h.app);
|
||||
h.frames(6);
|
||||
let window = h.ctx.memory(|m| m.area_rect(egui::Id::new("sc_crash_reports"))).unwrap();
|
||||
// The frame's margins come on top of the content's maximum width.
|
||||
assert!(window.width() <= crate::frame::WINDOW_WIDE_MAX_W + 40.0, "{} wide", window.width());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovered_kernel_errors_do_not_interrupt() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
let quiet = solvecraft_engine::crash::Summary {
|
||||
id: "x".into(),
|
||||
kind: "kernel_panic_recovered".into(),
|
||||
time: String::new(),
|
||||
message: String::new(),
|
||||
command: None,
|
||||
count: 1,
|
||||
version: String::new(),
|
||||
path: String::new(),
|
||||
replayable: false,
|
||||
};
|
||||
super::announce(&mut h.app, &[quiet]);
|
||||
assert!(!h.app.crashes.open);
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ pub fn diagnostics(app: &SolveApp) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// Run a Help menu item: `docs`, `about`, `shortcuts`, `report`.
|
||||
/// Run a Help menu item: `docs`, `about`, `shortcuts`, `report`, `crashes`.
|
||||
pub fn run(app: &mut SolveApp, ctx: &egui::Context, item: &str) {
|
||||
app.help.menu = false;
|
||||
match item {
|
||||
@@ -53,6 +53,7 @@ pub fn run(app: &mut SolveApp, ctx: &egui::Context, item: &str) {
|
||||
app.keymap.open = true;
|
||||
app.keymap.read_only = true;
|
||||
}
|
||||
"crashes" => crate::crash_ui::open(app),
|
||||
"report" => {
|
||||
let dialog = app.dialog.clone();
|
||||
let error = dialog.as_ref().and_then(|d| d.error.clone()).or_else(|| app.preview.error.clone());
|
||||
@@ -74,6 +75,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context, anchor: egui::Pos2) {
|
||||
("about", "About SolidCraft"),
|
||||
("shortcuts", "Keyboard Shortcuts"),
|
||||
("report", "Report an Issue…"),
|
||||
("crashes", "Crash Reports…"),
|
||||
] {
|
||||
if crate::context_menu::list_row(ui, &crate::context_menu::Item::action(id, label, "")).is_some() {
|
||||
pick = Some(id);
|
||||
@@ -92,6 +94,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context, anchor: egui::Pos2) {
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::new("sc_help_opened"), false));
|
||||
}
|
||||
about(app, ctx);
|
||||
crate::crash_ui::show(app, ctx);
|
||||
}
|
||||
|
||||
fn about(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
|
||||
@@ -289,6 +289,81 @@ pub fn draft_for(app: &SolveApp, dialog: Option<&dialogs::Dialog>, error: Option
|
||||
Draft { title, body, image: None, texture: None, note: String::new(), source_signature: source_signature(app, dialog) }
|
||||
}
|
||||
|
||||
/// A draft from a crash report (`solvecraft_engine::crash`): what happened, where, the command
|
||||
/// and how to replay it. The design stays on this computer; the draft says how to share it.
|
||||
pub fn crash_draft(report: &serde_json::Value) -> Draft {
|
||||
let s = |k: &str| report.get(k).and_then(serde_json::Value::as_str).unwrap_or("?").to_string();
|
||||
let what = match report.get("kind").and_then(serde_json::Value::as_str) {
|
||||
Some("command_panic") => "An internal error stopped a command (the design was left unchanged)",
|
||||
Some("kernel_panic_recovered") => "The geometry kernel hit an internal error (caught; the operation failed)",
|
||||
Some("unclean_exit") => "SolidCraft closed unexpectedly (abort, stack overflow or the process was killed)",
|
||||
_ => "SolidCraft hit an internal error",
|
||||
};
|
||||
let command = report.get("command").and_then(|c| c.get("command")).and_then(serde_json::Value::as_str);
|
||||
let frames: Vec<&str> =
|
||||
report.get("backtrace").and_then(serde_json::Value::as_array).into_iter().flatten().filter_map(serde_json::Value::as_str).take(25).collect();
|
||||
let recent: Vec<String> = report
|
||||
.get("recent_commands")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|c| {
|
||||
let id = c.get("command")?.as_str()?;
|
||||
Some(if c.get("ok").and_then(serde_json::Value::as_bool) == Some(false) { format!("{id} (failed)") } else { id.to_string() })
|
||||
})
|
||||
.collect();
|
||||
let app = report.get("app");
|
||||
let field = |o: Option<&serde_json::Value>, k: &str| o.and_then(|o| o.get(k)).and_then(serde_json::Value::as_str).unwrap_or("?").to_string();
|
||||
let system = report.get("system");
|
||||
let mut body = format!(
|
||||
"## What happened\n{what}{}.\n\nMessage: {}\nLocation: {}\nThread: {}\nHappened {} time(s), first {}\n\n## Backtrace\n{}\n## Command\n{}\n## Recent commands\n{}\n\n## Reproduction\nCrash report `{}` on the reporter's computer holds {}. Replay it with `solidcraft-cli crashes replay {}`.\nOnly attach the design if it can be shared publicly.\n\n## Environment\n{}\n",
|
||||
command.map(|c| format!(" while running `{c}`")).unwrap_or_default(),
|
||||
limited(&s("message"), 2000),
|
||||
s("location"),
|
||||
s("thread"),
|
||||
report.get("count").and_then(serde_json::Value::as_u64).unwrap_or(1),
|
||||
s("first_seen"),
|
||||
block(&frames.join("\n")),
|
||||
block(&json_context(&report.get("command"), 4000)),
|
||||
if recent.is_empty() { "(none recorded)".to_string() } else { recent.join(", ") },
|
||||
s("id"),
|
||||
match (report.get("design_path").is_some(), report.get("replay_path").is_some()) {
|
||||
(true, true) => "the design as it was before the crash (design.solvecraft) and the commands that crash it (replay.json)",
|
||||
(false, true) => "the commands that crash it on a new design (replay.json)",
|
||||
_ => "no replay (see its notes)",
|
||||
},
|
||||
s("id"),
|
||||
block(&format!(
|
||||
"SolidCraft {} ({}, built {})\nOS: {} {}\n{}",
|
||||
field(app, "version"),
|
||||
field(app, "commit"),
|
||||
field(app, "built"),
|
||||
field(system, "os"),
|
||||
field(system, "arch"),
|
||||
field(system, "environment")
|
||||
)),
|
||||
);
|
||||
if body.len() > MAX_BODY {
|
||||
truncate(&mut body, MAX_BODY - 32);
|
||||
body.push_str("\n[report context truncated]\n");
|
||||
}
|
||||
let mut title = format!("Crash: {}", s("message").lines().next().unwrap_or_default());
|
||||
truncate(&mut title, 160);
|
||||
Draft { title, body, image: None, texture: None, note: String::new(), source_signature: 0 }
|
||||
}
|
||||
|
||||
/// Open a reviewed draft for a crash report (no screenshot: the view shows nothing of it).
|
||||
pub fn begin_crash(app: &mut SolveApp, ctx: &egui::Context, report: &serde_json::Value) {
|
||||
if app.issue_report.draft.is_none() {
|
||||
app.issue_report.cad_focus = ctx.memory(|m| m.focused());
|
||||
}
|
||||
cancel_capture(app);
|
||||
let mut draft = crash_draft(report);
|
||||
// A screenshot can still be added of the view as it is now.
|
||||
draft.source_signature = source_signature(app, None);
|
||||
app.issue_report.draft = Some(draft);
|
||||
}
|
||||
|
||||
fn encode(value: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for b in value.bytes() {
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod browser;
|
||||
pub mod canvas;
|
||||
pub mod context_menu;
|
||||
pub mod control;
|
||||
pub mod crash_ui;
|
||||
pub mod delete;
|
||||
pub mod dialogs;
|
||||
pub mod dialogs_appearance;
|
||||
@@ -221,6 +222,8 @@ pub struct SolveApp {
|
||||
pub help: help::HelpState,
|
||||
/// An editable local issue draft and optional captured app frame.
|
||||
pub issue_report: issue_report::ReportState,
|
||||
/// The Crash Reports window.
|
||||
pub crashes: crash_ui::CrashUi,
|
||||
/// Toolbar buttons the user pinned, removed or reordered.
|
||||
pub toolbar_custom: toolbar_custom::ToolbarCustom,
|
||||
/// Preferences kept between runs, and their window.
|
||||
@@ -294,6 +297,7 @@ impl SolveApp {
|
||||
keymap: keymap::Keymap::default(),
|
||||
help: help::HelpState::default(),
|
||||
issue_report: issue_report::ReportState::default(),
|
||||
crashes: crash_ui::CrashUi::default(),
|
||||
toolbar_custom: toolbar_custom::ToolbarCustom::default(),
|
||||
preferences: prefs::Prefs::default(),
|
||||
prefs_window: prefs::PrefsWindow::default(),
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
@@ -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`). |
|
||||
|
||||
Reference in new issue
Block a user