Engine: crash reports, a log file and bug report drafts (#107)

crash: a panic hook and a locked per-run command journal (adapted from
SolidCraft's crash reports). A panic in a command becomes a report with
the model from before it and the command; one outside commands, or an
unclean exit found at the next start (abort, segfault, a crash in a
graphics driver, a kill: what #248 needs), a report with the model the
journal starts from (autosave, file or a copy) and the commands since,
the last control request and the dead run's log lines. Reports are
bounded, deduplicated by signature and replayable (crash::replay, with
writes confined to a scratch folder). The crash folder follows the
automation temp area in --control and CI runs.

logfile: a small size-rotated file logger behind the log facade.

bug_report: GitHub bug-form drafts. Version, OS and what happened by
default, home folder shown as ~; the command journal only with Share my
session, the model only with Include the model as well; a session too
long for the link becomes a file to attach and the clipboard text.

Commands: diagnostics.crash_reports, diagnostics.crash_report,
diagnostics.bug_report (Tools › Help, with its icon) and
diagnostics.crash_test (FIELDCRAFT_CRASH_TEST=1 only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-15 00:36:18 -04:00
1 parent 3d51bde9dd
commit 080ff048c6
15 files changed
+2200 -2

No files matched your search

+1
View File
@@ -47,6 +47,7 @@ impl Autosaver {
let bytes = s.file_bytes(None)?;
self.slot.write(&bytes, s.path.as_deref(), s.model.nodes().len(), s.model.elements().len())?;
self.saved = Some(s.revision());
crate::crash::autosaved(s, &self.slot.file());
Ok(true)
}
+381
View File
@@ -0,0 +1,381 @@
//! Bug report drafts (Help › Report a Bug, and Report it after a crash): a GitHub new-issue link
//! that fills in the bug form (`.github/ISSUE_TEMPLATE/bug.yml`), and the files to attach.
//!
//! Nothing here sends anything. The user reviews the draft, then their browser opens the issue
//! form, which they submit or not.
//!
//! What a draft holds by default: the version, the OS, what went wrong (the user's words, the
//! last error or the crash's message) and, for a crash, where it happened. The user's home
//! folder is shown as `~`. Only with **Share my session** does it also carry the commands that
//! built the model (the replayable journal, which can name the user's files), and only with
//! **Include the model** as well the model file. A session too long for the link (GitHub
//! refuses long new-issue links) goes out as a file to attach and on the clipboard instead; the
//! model file is always one to attach.
use serde_json::{Value, json};
use crate::crash::without_home;
/// The issue form.
pub const ISSUE_URL: &str = "https://github.com/jyviko/fieldcraft/issues/new";
/// The longest link a draft opens: GitHub refuses new-issue links much past 8 KB.
pub const MAX_URL: usize = 7000;
/// The session file a draft attaches.
pub const SESSION_FILE: &str = "fieldcraft-session.json";
/// The model file a draft attaches.
pub const MODEL_FILE: &str = "model.fieldcraft";
/// Backtrace frames a crash's draft quotes.
const FRAMES: usize = 12;
/// A field's text is cut at this many bytes.
const MAX_FIELD: usize = 3000;
/// What to put in a draft.
#[derive(Clone, Debug, Default)]
pub struct Input {
pub title: String,
/// What happened: the user's words, or the last error.
pub observed: String,
/// A crash report (`crash::read`), when reporting one.
pub crash: Option<Value>,
/// Attach the replayable commands.
pub share_session: bool,
/// The commands (each `{command, params}`) that rebuild the model from `model` (or from an
/// empty model when there is none).
pub commands: Vec<Value>,
/// Attach the model file too (only with `share_session`).
pub include_model: bool,
pub model: Option<Vec<u8>>,
}
/// A file the user attaches to the issue.
#[derive(Clone, Debug, PartialEq)]
pub struct Attachment {
pub name: String,
pub bytes: Vec<u8>,
/// What it is, for the review.
pub what: String,
}
/// A draft ready for review.
#[derive(Clone, Debug, PartialEq)]
pub struct Draft {
pub title: String,
/// The bug form's fields by id (`observed`, `reproduction`, `evidence`, `version`), as the
/// link fills them.
pub fields: Vec<(&'static str, String)>,
/// The link that opens the filled form.
pub url: String,
/// Files to attach (the session when too long for the link, the model).
pub attachments: Vec<Attachment>,
/// Text to put on the clipboard (the session, when it is an attachment).
pub clipboard: Option<String>,
}
impl Draft {
/// The draft as JSON (attachments by name, size and what they are).
pub fn to_json(&self) -> Value {
json!({
"title": self.title,
"fields": self.fields.iter().map(|(k, v)| (k.to_string(), json!(v))).collect::<serde_json::Map<String, Value>>(),
"url": self.url,
"attachments": self.attachments.iter().map(|a| json!({"name": a.name, "bytes": a.bytes.len(), "what": a.what})).collect::<Vec<_>>(),
"clipboard": self.clipboard.as_ref().map(String::len),
})
}
}
/// `v0.5.8 (abc1234), macos aarch64`.
pub fn version_line(version: &str, commit: &str) -> String {
let commit = if commit.is_empty() { String::new() } else { format!(" ({commit})") };
format!("v{version}{commit}, {} {}", std::env::consts::OS, std::env::consts::ARCH)
}
/// Percent-encode `s` for a query value (RFC 3986 unreserved characters stay).
pub fn encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
out.push(char::from(b));
} else {
out.push_str(&format!("%{b:02X}"));
}
}
out
}
fn cut(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!("{}\n[cut: {} bytes in all]", s.get(..end).unwrap_or_default(), s.len())
}
fn link(title: &str, fields: &[(&'static str, String)]) -> String {
let mut url = format!("{ISSUE_URL}?template=bug.yml&title={}", encode(title));
for (k, v) in fields {
if !v.is_empty() {
url.push_str(&format!("&{k}={}", encode(v)));
}
}
url
}
/// What a crash report says, for the evidence field: its kind, message, place, command (its id;
/// its parameters only with the session) and the top of its backtrace.
fn crash_evidence(report: &Value, share: bool) -> String {
let s = |k: &str| report.get(k).and_then(Value::as_str).unwrap_or("?");
let mut out =
format!("Crash report `{}` ({}), seen {} time(s)\n\n", s("id"), s("kind"), report.get("count").and_then(Value::as_u64).unwrap_or(1));
out.push_str(&format!("Message: {}\nAt: {}\n", without_home(s("message")), without_home(s("location"))));
if let Some(c) = report.get("command").filter(|c| !c.is_null()) {
let id = c.get("command").and_then(Value::as_str).unwrap_or("?");
match c.get("params").filter(|_| share) {
Some(p) => out.push_str(&format!("Command: `{id}` {}\n", without_home(&p.to_string()))),
None => out.push_str(&format!("Command: `{id}`\n")),
}
}
if let Some(a) = report.get("last_activity").and_then(Value::as_str) {
out.push_str(&format!("Last activity: {a}\n"));
}
let frames: Vec<&str> = report.get("backtrace").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).take(FRAMES).collect();
if !frames.is_empty() {
out.push_str("\nBacktrace (top):\n```\n");
for f in frames {
out.push_str(&without_home(f));
out.push('\n');
}
out.push_str("```\n");
}
out
}
/// The draft for `input`, by the [`version_line`] `version`.
pub fn draft(input: &Input, version: &str) -> Draft {
let crash = input.crash.as_ref();
let mut title = input.title.trim().to_string();
if title.is_empty() {
title = match crash.and_then(|c| c.get("kind")).and_then(Value::as_str) {
Some("unclean_exit") => "FieldCraft closed unexpectedly".to_string(),
Some(_) => "Internal error".to_string(),
None => match input.observed.trim().lines().next().filter(|l| !l.is_empty()) {
Some(line) => format!("Bug: {}", line.chars().take(100).collect::<String>()),
None => "Bug report".to_string(),
},
};
if let Some(c) = crash.and_then(|c| c.get("command")).and_then(|c| c.get("command")).and_then(Value::as_str) {
title.push_str(&format!(" in {c}"));
}
}
let title = cut(&without_home(&title), 200);
let mut observed = without_home(input.observed.trim());
if observed.is_empty()
&& let Some(m) = crash.and_then(|c| c.get("message")).and_then(Value::as_str)
{
observed = without_home(m);
}
let observed = cut(&observed, MAX_FIELD);
let share = input.share_session;
let mut evidence = crash.map(|c| crash_evidence(c, share)).unwrap_or_default();
let mut attachments = Vec::new();
let mut clipboard = None;
let mut reproduction = String::new();
// The session: the replayable script, from the attached model when there is one.
let model = input.model.as_ref().filter(|_| share && input.include_model);
let script = share.then(|| {
let mut steps = Vec::new();
if model.is_some() {
steps.push(json!({"command": "file.open", "params": {"path": MODEL_FILE}}));
}
steps.extend(input.commands.iter().cloned());
Value::Array(steps)
});
if let Some(m) = model {
attachments.push(Attachment { name: MODEL_FILE.into(), bytes: m.clone(), what: format!("the model ({} KB)", m.len().div_ceil(1024)) });
}
if let Some(script) = &script {
let n = script.as_array().map_or(0, Vec::len);
reproduction = format!("Replay the attached session ({n} command(s)): `fieldcraft-cli run {SESSION_FILE}`");
if model.is_some() {
reproduction.push_str(&format!(" in a folder holding `{MODEL_FILE}` too"));
} else if crash.is_some_and(|c| c.get("files").and_then(|f| f.get("model")).is_some()) {
reproduction.push_str(" (the crash replays from the model, which is not attached)");
}
reproduction.push('.');
}
let inline = |evidence: &str, script: &Value| {
let text = serde_json::to_string_pretty(script).unwrap_or_default();
format!("{evidence}\nSession ({SESSION_FILE}):\n```json\n{text}\n```\n")
};
let fields = |evidence: String| {
vec![("observed", observed.clone()), ("reproduction", reproduction.clone()), ("evidence", evidence), ("version", version.to_string())]
};
// The session in the link when it fits, else a file to attach (also on the clipboard).
let mut url = String::new();
if let Some(script) = &script {
let candidate = fields(inline(&evidence, script));
let u = link(&title, &candidate);
if u.len() <= MAX_URL {
evidence = inline(&evidence, script);
url = u;
} else {
let text = serde_json::to_string_pretty(script).unwrap_or_default();
evidence.push_str(&format!(
"\nThe session is too long for this form: FieldCraft saved it as `{SESSION_FILE}` and copied it to the clipboard. Attach the file here (drag it in) or paste it.\n"
));
attachments.insert(0, Attachment { name: SESSION_FILE.into(), bytes: text.clone().into_bytes(), what: "the session's commands".into() });
clipboard = Some(text);
}
}
if model.is_some() {
evidence.push_str(&format!("\nThe model is attached as `{MODEL_FILE}` (drag it in from the folder FieldCraft saved it to).\n"));
}
if url.is_empty() || model.is_some() {
let mut f = fields(evidence.clone());
url = link(&title, &f);
// Still too long (a very long message): cut the longest field until it fits.
while url.len() > MAX_URL {
let Some((i, len)) = f.iter().enumerate().map(|(i, (_, v))| (i, v.len())).max_by_key(|(_, l)| *l) else { break };
if len < 200 {
break;
}
if let Some((_, v)) = f.get_mut(i) {
*v = cut(v, len.saturating_sub(url.len() - MAX_URL + 64).min(len / 2 + 1));
}
url = link(&title, &f);
}
evidence = f.get(2).map(|(_, v)| v.clone()).unwrap_or_default();
}
let fields = fields(evidence);
// The fields as the link carries them (a cut field included).
let fields = if url == link(&title, &fields) { fields } else { decode_fields(&url) };
Draft { title, fields, url, attachments, clipboard }
}
/// The fields a link carries (for the review, when a field had to be cut).
fn decode_fields(url: &str) -> Vec<(&'static str, String)> {
let query = url.split_once('?').map_or("", |(_, q)| q);
["observed", "reproduction", "evidence", "version"]
.into_iter()
.map(|k| {
let v = query.split('&').find_map(|kv| kv.strip_prefix(k).and_then(|r| r.strip_prefix('='))).unwrap_or_default();
(k, decode(v))
})
.collect()
}
fn decode(s: &str) -> String {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while let Some(&c) = b.get(i) {
let hex = b.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok());
match (c, hex) {
(b'%', Some(v)) => {
out.push(v);
i += 3;
}
_ => {
out.push(c);
i += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
fn home() -> String {
std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).unwrap_or_else(|_| "/home/someone".into())
}
fn crash() -> Value {
json!({
"id": "20261014-101500-00000000deadbeef", "kind": "command_panic", "count": 2,
"message": format!("index out of bounds in {}/model.fieldcraft", home()),
"location": "crates/mesh/src/tet.rs:42",
"command": {"command": "mesh.tet.generate", "params": {"body": "Plate", "path": format!("{}/secret/plate.step", home())}},
"backtrace": ["fieldcraft_mesh::tet::insert (crates/mesh/src/tet.rs:42)"],
"files": {"model": "model.fieldcraft", "replay": "replay.json"},
})
}
#[test]
fn without_sharing_a_draft_names_no_paths_commands_or_model() {
let input = Input {
observed: format!("cannot open {}/parts/bracket.solidcraft", home()),
crash: Some(crash()),
commands: vec![json!({"command": "geometry.open", "params": {"path": format!("{}/parts/bracket.solidcraft", home())}})],
model: Some(b"model bytes".to_vec()),
include_model: true,
..Default::default()
};
let d = draft(&input, "v1.2.3, testos x");
assert!(d.url.starts_with("https://github.com/jyviko/fieldcraft/issues/new?template=bug.yml&title="), "{}", d.url);
assert!(d.attachments.is_empty() && d.clipboard.is_none(), "nothing attached without Share my session");
let all = format!("{} {:?} {}", d.title, d.fields, decode(&d.url));
assert!(!all.contains(&home()), "no home folder: {all}");
assert!(!all.contains("secret") && !all.contains("geometry.open"), "no parameters or commands: {all}");
assert!(all.contains("~/parts/bracket.solidcraft"), "the error, its home shown as ~: {all}");
assert!(all.contains("mesh.tet.generate") && all.contains("tet.rs:42") && all.contains("v1.2.3, testos x"), "{all}");
assert_eq!(d.title, "Internal error in mesh.tet.generate");
assert_eq!(decode_fields(&d.url), d.fields, "the review shows what the link carries");
}
#[test]
fn a_short_session_goes_in_the_link() {
let input = Input {
title: "Solve fails".into(),
share_session: true,
commands: vec![json!({"command": "file.sample", "params": {"name": "cantilever"}}), json!({"command": "solve.run", "params": {}})],
..Default::default()
};
let d = draft(&input, "v1");
assert!(d.attachments.is_empty() && d.clipboard.is_none());
let evidence = &d.fields.iter().find(|(k, _)| *k == "evidence").unwrap().1;
assert!(evidence.contains("\"command\": \"solve.run\""), "{evidence}");
assert!(d.url.len() <= MAX_URL);
assert!(decode(&d.url).contains("file.sample"));
}
#[test]
fn a_long_session_and_the_model_are_attachments() {
let commands: Vec<Value> = (0..400).map(|i| json!({"command": "mesh.node.add", "params": {"label": i + 1, "xyz": [i, 0, 0]}})).collect();
let input =
Input { share_session: true, include_model: true, commands, model: Some(vec![7; 3000]), crash: Some(crash()), ..Default::default() };
let d = draft(&input, "v1");
assert!(d.url.len() <= MAX_URL, "{}", d.url.len());
let names: Vec<&str> = d.attachments.iter().map(|a| a.name.as_str()).collect();
assert_eq!(names, [SESSION_FILE, MODEL_FILE]);
let session: Value = serde_json::from_slice(&d.attachments[0].bytes).unwrap();
assert_eq!(session[0], json!({"command": "file.open", "params": {"path": MODEL_FILE}}), "the replay opens the attached model");
assert_eq!(session.as_array().unwrap().len(), 401);
assert_eq!(d.clipboard.as_deref().map(str::as_bytes), Some(d.attachments[0].bytes.as_slice()));
let text = decode(&d.url);
assert!(text.contains("too long for this form") && text.contains(MODEL_FILE) && !text.contains("\"label\": 399"), "{text}");
// With the session shared, the crash's command parameters are shown (home as ~).
assert!(text.contains("~/secret/plate.step"), "{text}");
}
#[test]
fn a_huge_message_is_cut_to_fit_the_link() {
let input = Input { observed: "é".repeat(20_000), ..Default::default() };
let d = draft(&input, "v1");
assert!(d.url.len() <= MAX_URL, "{}", d.url.len());
assert!(d.fields[0].1.contains("[cut:"));
assert_eq!(decode_fields(&d.url), d.fields);
}
#[test]
fn encoding_round_trips() {
let s = "a b&c=d/é\n```json";
assert_eq!(decode(&encode(s)), s);
assert_eq!(encode("a b&"), "a%20b%26");
}
}
+8
View File
@@ -591,6 +591,14 @@ pub static CATALOGUE: &[Capability] = &[
),
ready("edit.undo", "Undo", Tools, "History", "Undo the last command that changed the model"),
ready("edit.redo", "Redo", Tools, "History", "Redo the command undone last"),
ready_more(
"diagnostics.bug_report",
"Report a Bug",
Tools,
"Help",
"Draft a GitHub bug report with the version, the OS and the last error; you review it, and nothing is sent until you submit it",
&["diagnostics.crash_reports", "diagnostics.crash_report", "diagnostics.crash_test"],
),
];
/// The catalogue entry with this id.
+133
View File
@@ -0,0 +1,133 @@
//! Crash reports and bug report drafts (docs/crash-reports.md): list and read the reports the
//! desktop app wrote, draft a bug report, and (with `FIELDCRAFT_CRASH_TEST=1`) crash on purpose
//! to check that reporting works.
use std::path::PathBuf;
use fieldcraft_io::roots::Access;
use serde_json::{Value, json};
use super::{file_param, opt, req};
use crate::{CommandKind, CommandSpec, EngineError, Result, Session, bug_report, crash};
pub const CRASH_REPORTS: CommandSpec = CommandSpec {
id: "diagnostics.crash_reports",
label: "Crash Reports",
params: r#"{"limit"?: 20, "dir"?: "…"} — the crash reports the desktop app wrote on this computer, newest first: id, kind (command_panic, recovered_panic, panic, unclean_exit), time, message, command, count, version, path and whether it can be replayed (default folder: FIELDCRAFT_CRASH_DIR, a temporary folder in runs driven by automation, else the per-user data folder)"#,
form: Some(super::forms::CRASH_REPORTS),
kind: CommandKind::Session,
requires: &[],
run: crash_reports,
};
pub const CRASH_REPORT: CommandSpec = CommandSpec {
id: "diagnostics.crash_report",
label: "Crash Report",
params: r#"{"id": "20261014-101500-…", "dir"?: "…"} — one crash report: what happened, where, the backtrace, the command and recent commands, and the paths of its model, replay and log files"#,
form: Some(super::forms::CRASH_REPORT),
kind: CommandKind::Session,
requires: &[],
run: crash_report,
};
pub const BUG_REPORT: CommandSpec = CommandSpec {
id: "diagnostics.bug_report",
label: "Report a Bug",
params: r#"{"title"?: "…", "observed"?: "what happened", "crash"?: "<crash report id>", "share_session"?: false, "include_model"?: false} — drafts a GitHub bug report: the issue link (the bug form filled with the version, the OS, what happened and, for a crash, where), and the files to attach. Only with share_session does it carry the model's commands, and only with include_model as well the model file. Nothing is sent: the desktop app's Help › Report a Bug shows the draft and opens the link when the user asks"#,
form: Some(super::forms::BUG_REPORT),
kind: CommandKind::Session,
requires: &[],
run: bug_report_draft,
};
pub const CRASH_TEST: CommandSpec = CommandSpec {
id: "diagnostics.crash_test",
label: "Crash Test",
params: r#"{"mode": "panic|recovered|abort"} — crashes on purpose to check that crash reports work (only with FIELDCRAFT_CRASH_TEST=1): panic, an internal error in this command; recovered, one caught inside it; abort, the app ends at once (reported at its next start)"#,
form: Some(super::forms::CRASH_TEST),
kind: CommandKind::Session,
requires: &[],
run: crash_test,
};
fn crash_dir(s: &Session, p: &Value) -> Result<PathBuf> {
if p.get("dir").is_some() {
return Ok(PathBuf::from(file_param(s, p, "dir", Access::Read)?));
}
crash::default_dir().ok_or_else(|| EngineError::Params("no crash folder: set FIELDCRAFT_CRASH_DIR or give `dir`".into()))
}
fn crash_reports(s: &mut Session, p: &Value) -> Result<Value> {
let dir = crash_dir(s, p)?;
let limit = opt::<usize>(p, "limit")?.unwrap_or(crash::MAX_REPORTS);
let mut reports = crash::list(&dir);
reports.truncate(limit);
Ok(json!({"dir": dir.to_string_lossy(), "reports": reports}))
}
fn crash_report(s: &mut Session, p: &Value) -> Result<Value> {
let dir = crash_dir(s, p)?;
let id = req::<String>(p, "id")?;
crash::read(&dir, &id).map_err(EngineError::Params)
}
/// The draft input for `p` from the session: its commands and model (when shared), or a crash
/// report's replay and model.
pub fn bug_report_input(s: &Session, p: &Value) -> Result<bug_report::Input> {
let share = opt::<bool>(p, "share_session")?.unwrap_or(false);
let include_model = opt::<bool>(p, "include_model")?.unwrap_or(false);
let mut input = bug_report::Input {
title: opt::<String>(p, "title")?.unwrap_or_default(),
observed: opt::<String>(p, "observed")?.unwrap_or_default(),
share_session: share,
include_model,
..Default::default()
};
match opt::<String>(p, "crash")? {
Some(id) => {
let report = crash::read(&crash_dir(s, p)?, &id).map_err(EngineError::Params)?;
if share {
let read = |k: &str| {
report
.get(k)
.and_then(Value::as_str)
.and_then(|f| fieldcraft_io::fs::read_capped(std::path::Path::new(f), crash::MAX_MODEL_BYTES as u64).ok())
};
let replay: Value = read("replay_path").and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or(Value::Null);
input.commands = replay.get("commands").and_then(Value::as_array).cloned().unwrap_or_default();
input.model = if include_model { read("model_path") } else { None };
}
input.crash = Some(report);
}
None if share => {
input.commands = s.journal.iter().map(|(command, params)| json!({"command": command, "params": params})).collect();
input.model = if include_model { Some(s.file_bytes(None)?) } else { None };
}
None => {}
}
Ok(input)
}
fn bug_report_draft(s: &mut Session, p: &Value) -> Result<Value> {
let input = bug_report_input(s, p)?;
Ok(bug_report::draft(&input, &crash::Build::current().version_line()).to_json())
}
// The one deliberate panic and abort: they prove that crash reports work, and run only when
// asked for with FIELDCRAFT_CRASH_TEST=1.
#[allow(clippy::panic)]
fn crash_test(_: &mut Session, p: &Value) -> Result<Value> {
let mode = req::<String>(p, "mode")?;
if std::env::var_os("FIELDCRAFT_CRASH_TEST").is_none_or(|v| v != "1") {
return Err(EngineError::Params("the crash test runs only with FIELDCRAFT_CRASH_TEST=1".into()));
}
match mode.as_str() {
"panic" => std::panic::panic_any(String::from("crash test: an internal error in a command")),
"recovered" => {
let caught = std::panic::catch_unwind(|| std::panic::panic_any(String::from("crash test: an internal error caught inside a command")));
Ok(json!({"caught": caught.is_err()}))
}
"abort" => std::process::abort(),
_ => Err(EngineError::invalid("mode", "must be panic, recovered or abort")),
}
}
+26 -1
View File
@@ -1433,6 +1433,23 @@ pub const FILE_SAVE: &[Field] = &[path("path", "File", true).help("Where to save
pub const FILE_SAVE_AS: &[Field] = &[path("path", "File", true).help("The file to save to.").req()];
pub const RECOVERY_LIST: &[Field] =
&[path("dir", "Folder", false).help("The recovery folder; the default one when empty.").hint("the recovery folder")];
pub const CRASH_REPORTS: &[Field] = &[
int("limit", "Limit", 1, 1000).help("At most this many reports, newest first.").hint("20"),
path("dir", "Folder", false).help("The crash folder; the default one when empty.").hint("the crash folder"),
];
pub const CRASH_REPORT: &[Field] = &[
text("id", "Report").help("The crash report to show.").req(),
path("dir", "Folder", false).help("The crash folder; the default one when empty.").hint("the crash folder"),
];
pub const BUG_REPORT: &[Field] = &[
text("title", "Title").help("The issue's title; one is made up when empty."),
text("observed", "What happened").help("What went wrong; the last error when empty."),
text("crash", "Crash report").help("A crash report to report (its id)."),
flag("share_session", "Share my session").help("Attach the commands that built the model (they can name your files).").def("false"),
flag("include_model", "Include the model").help("Attach the model file too (with Share my session).").def("false"),
];
pub const CRASH_TEST: &[Field] =
&[choice("mode", "Mode", &[("panic", "Panic"), ("recovered", "Recovered panic"), ("abort", "Abort")]).help("How to crash.").req()];
pub const RECOVER: &[Field] = &[
text("id", "Autosave").help("The autosave to recover.").req(),
path("dir", "Folder", false).help("The recovery folder; the default one when empty.").hint("the recovery folder"),
@@ -1697,7 +1714,15 @@ mod tests {
}
}
const STATE: &[&str] = &["open a design first", "`boundary_conditions` or `loads`", "give one of", "is not an explicit", "has a result"];
const STATE: &[&str] = &[
"open a design first",
"`boundary_conditions` or `loads`",
"give one of",
"is not an explicit",
"has a result",
"no crash report",
"FIELDCRAFT_CRASH_TEST",
];
/// Fill top-level required names (a fresh one) and references (the first existing item).
fn fill_required(s: &Session, form: &[Field], p: &mut Value) {
+5
View File
@@ -6,6 +6,7 @@ mod contact;
pub(crate) mod datum;
pub(crate) mod deck;
pub(crate) mod define;
pub mod diagnostics;
mod edit;
pub(crate) mod file;
pub(crate) mod forms;
@@ -153,6 +154,10 @@ pub static ALL: &[CommandSpec] = &[
results::FRAME,
results::REACTIONS,
results::CLEAR,
diagnostics::CRASH_REPORTS,
diagnostics::CRASH_REPORT,
diagnostics::BUG_REPORT,
diagnostics::CRASH_TEST,
];
/// The problem with required parameter `key`: [`ProblemKind::Required`] when it is left out,
File diff suppressed because it is too large. Load diff
+277
View File
@@ -0,0 +1,277 @@
use super::*;
use crate::Session;
fn fresh_dir(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("fieldcraft-crash-test-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&d);
d
}
fn build() -> Build {
Build { version: "9.9.9", commit: "abc1234" }
}
fn node(label: u32) -> Value {
json!({"label": label, "xyz": [f64::from(label), 0.0, 0.0]})
}
/// The child half of the process tests: does nothing unless a parent asks, then installs the
/// hook and crashes the way `FIELDCRAFT_CRASH_CHILD` says.
#[test]
fn child() {
let (Some(dir), Ok(mode)) = (std::env::var_os("FIELDCRAFT_CRASH_CHILD_DIR"), std::env::var("FIELDCRAFT_CRASH_CHILD")) else {
return;
};
let dir = PathBuf::from(dir);
if mode == "replay" {
// Replay the one report (in a process of its own: the replay replaces the panic hook).
let id = list(&dir)[0].id.clone();
let r = replay(&read(&dir, &id).unwrap(), &std::env::temp_dir()).unwrap();
let steps: Vec<_> = r.steps.iter().map(|(c, r)| json!({"command": c, "ok": r.is_ok()})).collect();
println!("REPLAY {}", json!({"reproduced": r.reproduced, "nodes": r.session.model.nodes().len(), "steps": steps}));
return;
}
let fresh = install(dir.clone(), build());
println!("FRESH {}", serde_json::to_string(&fresh).unwrap());
if mode == "idle" {
end_session();
return;
}
let mut s = Session { crash_journal: true, ..Default::default() };
s.execute("mesh.node.add", &node(1)).unwrap();
s.execute("mesh.node.add", &node(2)).unwrap();
// Commands that only read are not kept.
for _ in 0..50 {
s.execute("model.info", &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.model.nodes().len(), 2, "the model is unchanged");
}
"recovered" => {
assert_eq!(s.execute("diagnostics.crash_test", &json!({"mode": "recovered"})).unwrap()["caught"], true);
}
"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" => {
activity("control ui.capture");
let _ = s.execute("diagnostics.crash_test", &json!({"mode": "abort"}));
}
"autosave" => {
// The journal starts again from each autosave.
let mut a = crate::autosave::Autosaver::new(&dir.join("recovery")).unwrap();
assert!(a.save(&s).unwrap());
s.execute("mesh.node.add", &node(3)).unwrap();
activity("control ui.capture");
std::process::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("FIELDCRAFT_CRASH_CHILD_DIR", dir)
.env("FIELDCRAFT_CRASH_CHILD", mode)
.env("FIELDCRAFT_CRASH_TEST", "1")
.env("RUST_BACKTRACE", "0")
.output()
.unwrap();
(String::from_utf8_lossy(&out.stdout).into_owned(), out.status.success())
}
fn printed(out: &str, tag: &str) -> Value {
let line = out.lines().find_map(|l| l.split_once(tag).map(|(_, rest)| rest)).unwrap_or_else(|| panic!("no {tag} in {out}"));
serde_json::from_str(line.trim()).unwrap()
}
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)
}
fn nodes_in(model: &str) -> usize {
let mut s = Session::default();
s.execute("file.open", &json!({"path": model})).unwrap();
s.model.nodes().len()
}
#[test]
fn a_command_panic_is_reported_once_with_the_model_and_the_command_and_replays() {
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["system"]["os"], std::env::consts::OS);
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"], "mesh.node.add");
assert_eq!(v["recent_commands"].as_array().unwrap().len(), 3, "read-only commands are not kept: {v}");
assert_eq!(v["recent_commands"][2]["ok"], false);
// The model from before the command, and the command: together they reproduce the crash.
assert_eq!(nodes_in(v["model_path"].as_str().unwrap()), 2);
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_eq!(v["reproduce"], format!("fieldcraft-cli crashes replay {}", s.id));
assert!(fs::read_dir(d.join("sessions")).unwrap().next().is_none(), "a clean exit leaves no journal");
// The replay reaches the same internal error, from the same model.
let (out, ok) = run_child(&d, "replay");
assert!(ok, "{out}");
let r = printed(&out, "REPLAY ");
assert_eq!(r["reproduced"], true, "{r}");
assert_eq!(r["nodes"], 2, "the replay ends in the state the crash left: {r}");
let _ = fs::remove_dir_all(&d);
}
#[test]
fn a_panic_caught_inside_a_command_is_reported_as_recovered() {
let d = fresh_dir("recovered");
let (_, ok) = run_child(&d, "recovered");
assert!(ok);
let (s, v) = only_report(&d);
assert_eq!(s.kind, "recovered_panic");
assert!(!s.replayable, "the command did not fail");
assert!(v["message"].as_str().unwrap().contains("caught inside"), "{v}");
let _ = fs::remove_dir_all(&d);
}
#[test]
fn a_panic_outside_commands_is_reported_from_the_journal() {
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!(s.message.contains("worker thread 42 failed"));
// The journal's commands from a new model rebuild it.
let replay: Value = serde_json::from_slice(&fs::read(v["replay_path"].as_str().unwrap()).unwrap()).unwrap();
assert_eq!(replay["commands"].as_array().unwrap().len(), 2, "{replay}");
// A panic the hook reported is no unclean exit at the next start.
let (out, _) = run_child(&d, "abort");
let fresh = printed(&out, "FRESH ");
assert_eq!(fresh.as_array().unwrap().len(), 1, "the earlier report, seen once: {fresh}");
let _ = fs::remove_dir_all(&d);
}
#[test]
fn an_unclean_exit_is_found_at_the_next_start_with_its_commands() {
let d = fresh_dir("abort");
let (_, ok) = run_child(&d, "abort");
assert!(!ok, "the child aborted");
assert!(list(&d).is_empty(), "nothing wrote a report while it died");
assert_eq!(fs::read_dir(d.join("sessions")).unwrap().count(), 1, "its journal is left behind");
let (out, ok) = run_child(&d, "idle");
assert!(ok);
let fresh = printed(&out, "FRESH ");
assert_eq!(fresh[0]["kind"], "unclean_exit", "{fresh}");
let (s, v) = only_report(&d);
assert_eq!(s.command.as_deref(), Some("diagnostics.crash_test"), "the command in flight");
assert!(s.message.contains("ended without a clean exit while running `diagnostics.crash_test`"), "{}", s.message);
assert_eq!(v["last_activity"], "control ui.capture");
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, ["mesh.node.add", "mesh.node.add", "diagnostics.crash_test"]);
assert!(fs::read_dir(d.join("sessions")).unwrap().next().is_none(), "the dead journal is gone");
// Told once: the next start offers nothing new.
let (out, _) = run_child(&d, "idle");
assert_eq!(printed(&out, "FRESH "), json!([]));
let _ = fs::remove_dir_all(&d);
}
#[test]
fn an_unclean_exit_after_an_autosave_starts_from_the_autosave() {
let d = fresh_dir("autosave");
let (_, ok) = run_child(&d, "autosave");
assert!(!ok);
run_child(&d, "idle");
let (s, v) = only_report(&d);
assert_eq!(s.kind, "unclean_exit");
assert!(s.message.contains("between commands (last: control ui.capture)"), "{}", s.message);
assert_eq!(nodes_in(v["model_path"].as_str().unwrap()), 2, "the autosaved model");
let replay: Value = serde_json::from_slice(&fs::read(v["replay_path"].as_str().unwrap()).unwrap()).unwrap();
assert_eq!(replay["commands"], json!([{"command": "mesh.node.add", "params": node(3)}]), "only what came after the autosave");
let (out, ok) = run_child(&d, "replay");
assert!(ok, "{out}");
assert_eq!(printed(&out, "REPLAY ")["nodes"], 3, "the replay rebuilds the model the app had");
let _ = fs::remove_dir_all(&d);
}
#[test]
fn journals_parse_and_hostile_ids_are_refused() {
let text = [
r#"{"start":"x","pid":1,"version":"1"}"#,
r#"{"base":"new"}"#,
r#"{"run":"a","params":{}}"#,
r#"{"ok":true}"#,
r#"{"activity":"control ui.capture"}"#,
r#"{"run":"b","params":{"x":1}}"#,
r#"{"ok":false,"error":"no"}"#,
"not json",
r#"{"run":"c","params":{}}"#,
]
.join("\n");
let j = parse_journal(&text);
assert_eq!(j.replay, vec![json!({"command": "a", "params": {}})]);
assert_eq!(j.recent.len(), 2);
assert_eq!(j.in_flight, Some(json!({"command": "c", "params": {}})));
assert_eq!(j.activity.as_deref(), Some("control ui.capture"));
assert!(j.complete && !j.reported);
let d = fresh_dir("hostile");
fs::create_dir_all(&d).unwrap();
for id in ["", "../x", "a/b", &"f".repeat(65)] {
assert!(read(&d, id).is_err(), "{id}");
assert_eq!(clear(&d, Some(id)), 0);
}
let _ = fs::remove_dir_all(&d);
}
#[test]
fn signatures_ignore_numbers() {
assert_eq!(signature("a.rs:1", "node 12 of 400"), signature("a.rs:1", "node 7 of 3"));
assert_ne!(signature("a.rs:1", "node 12"), signature("a.rs:2", "node 12"));
assert_eq!(utc(0), "1970-01-01T00:00:00Z");
assert_eq!(stamp(1_760_436_000), "20251014-100000");
}
#[test]
fn reports_are_pruned_to_the_limits() {
let d = fresh_dir("prune");
for i in 0..(MAX_REPORTS + 5) {
let id = format!("20261014-1000{:02}-{:016x}", i % 60, i + 1);
fs::create_dir_all(d.join(&id)).unwrap();
let kind = if i == MAX_REPORTS + 4 { "recovered_panic" } else { "panic" };
fs::write(d.join(&id).join("report.json"), json!({"kind": kind, "message": "m"}).to_string()).unwrap();
}
prune(&d, now());
let left = list(&d);
assert_eq!(left.len(), MAX_REPORTS);
assert!(left.iter().all(|r| r.kind == "panic"), "recovered ones go first");
assert_eq!(clear(&d, None), MAX_REPORTS);
let _ = fs::remove_dir_all(&d);
}
+11
View File
@@ -13,8 +13,11 @@
#![forbid(unsafe_code)]
pub mod autosave;
pub mod bug_report;
pub mod catalogue;
pub mod cmd;
pub mod crash;
pub mod logfile;
pub use cmd::analytic_surfaces;
pub use cmd::replace::{MESHERS, Replacement, replacement};
@@ -244,6 +247,9 @@ pub struct Session {
pub view: fieldcraft_io::View,
/// The view as last saved (or opened): a change to it is unsaved too.
saved_view: fieldcraft_io::View,
/// This is the app's own model: its top-level commands go into the crash journal
/// ([`crash`]), so an unclean exit can be replayed. Off for scratch sessions and tests.
pub crash_journal: bool,
}
impl Session {
@@ -256,6 +262,9 @@ impl Session {
// Session commands (open, save, undo, redo) change the session only in their last step,
// after everything that can fail, so they need no copy of the model to restore.
let before = (spec.kind == CommandKind::Model).then(|| (self.snapshot(), self.document()));
let scope = crash::Scope::enter();
let journaled = crash::command_begin(&scope, self, id, params);
let (revision, opened) = (self.revision, self.opened);
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, params)));
let result = match outcome {
Ok(Err(EngineError::Param(p))) => Err(EngineError::Param(match spec.form {
@@ -285,6 +294,8 @@ impl Session {
cmd::view::forget_missing(self);
}
}
drop(scope);
crash::command_done(self, journaled, self.revision != revision || self.opened != opened, id, params, &result);
result
}
+180
View File
@@ -0,0 +1,180 @@
//! The log file: what the desktop app logs (through the `log` facade) goes to
//! `<data folder>/logs/fieldcraft.log`, one line per record:
//! `2026-10-14T10:03:22Z WARN [4242] fieldcraft_engine: message`. Warnings and errors also go to
//! the standard error stream.
//!
//! Bounded: past [`MAX_BYTES`] the file becomes `fieldcraft.log.1` (replacing the one before) and
//! a new one starts, so the logs never take more than twice that. A record is cut at
//! [`MAX_LINE`] bytes. The process id on every line lets a crash report take only the lines of
//! the run that crashed ([`tail`]), even when two apps share the file.
//!
//! Nothing is installed in tests, the CLI or the web build (which logs to the browser console).
use std::fs::{self, File, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
/// The log file's name.
pub const FILE_NAME: &str = "fieldcraft.log";
/// The log file's size limit.
pub const MAX_BYTES: u64 = 2 << 20;
/// A record's size limit.
pub const MAX_LINE: usize = 4 << 10;
struct FileLogger {
path: PathBuf,
level: log::LevelFilter,
out: Mutex<Out>,
}
struct Out {
file: Option<File>,
bytes: u64,
}
static LOGGER: OnceLock<FileLogger> = OnceLock::new();
/// Send the `log` records at `level` and above to the log file in `dir` (created if needed).
/// Returns the file's path. Call once; later calls change nothing.
pub fn install(dir: &Path, level: log::LevelFilter) -> Result<PathBuf, String> {
fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let path = dir.join(FILE_NAME);
let file = open(&path).map_err(|e| format!("{}: {e}", path.display()))?;
let bytes = file.metadata().map_or(0, |m| m.len());
let logger = FileLogger { path: path.clone(), level, out: Mutex::new(Out { file: Some(file), bytes }) };
if LOGGER.set(logger).is_err() {
return Err("a log file is already installed".into());
}
if let Some(l) = LOGGER.get() {
log::set_logger(l).map_err(|e| e.to_string())?;
log::set_max_level(level);
}
Ok(path)
}
fn open(path: &Path) -> std::io::Result<File> {
OpenOptions::new().create(true).append(true).open(path)
}
/// The log file, once [`install`]ed.
pub fn path() -> Option<&'static Path> {
LOGGER.get().map(|l| l.path.as_path())
}
/// The last `max` bytes of the log file's lines written by process `pid` (every line when
/// `None`), from the rotated file too when the current one is short. `None` without a log file.
pub fn tail(pid: Option<u32>, max: usize) -> Option<String> {
let path = path().map(Path::to_path_buf).or_else(default_path)?;
Some(tail_of(&path, pid, max))
}
/// [`tail`] of the log file at `path`.
fn tail_of(path: &Path, pid: Option<u32>, max: usize) -> String {
let mut text = String::new();
for p in [rotated(path), path.to_path_buf()] {
// The whole file (it is bounded): another process's lines may come between.
text.push_str(&read_end(&p, MAX_BYTES));
}
let mark = pid.map(|p| format!(" [{p}] "));
let mut lines: Vec<&str> = text.lines().filter(|l| mark.as_deref().is_none_or(|m| l.contains(m))).collect();
let mut total = 0;
let mut keep = lines.len();
for (i, l) in lines.iter().enumerate().rev() {
total += l.len() + 1;
if total > max {
break;
}
keep = i;
}
lines.drain(..keep);
lines.join("\n")
}
/// The log file of this computer's app (for a crash report written before [`install`]).
fn default_path() -> Option<PathBuf> {
fieldcraft_io::recovery::data_dir().map(|d| d.join("logs").join(FILE_NAME))
}
/// The file the log rotates into.
pub fn rotated(path: &Path) -> PathBuf {
let mut s = path.as_os_str().to_owned();
s.push(".1");
PathBuf::from(s)
}
/// The last `max` bytes of a file, from the start of a line.
fn read_end(path: &Path, max: u64) -> String {
let Ok(mut f) = File::open(path) else { return String::new() };
let len = f.metadata().map_or(0, |m| m.len());
let from = len.saturating_sub(max);
let mut buf = Vec::new();
if f.seek(SeekFrom::Start(from)).is_err() || f.take(max).read_to_end(&mut buf).is_err() {
return String::new();
}
let text = String::from_utf8_lossy(&buf).into_owned();
match (from > 0, text.find('\n')) {
(true, Some(i)) => text.get(i + 1..).unwrap_or_default().to_string(),
_ => text,
}
}
impl log::Log for FileLogger {
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
metadata.level() <= self.level
}
fn log(&self, record: &log::Record<'_>) {
if !self.enabled(record.metadata()) {
return;
}
let message = crate::crash::limited(&record.args().to_string(), MAX_LINE).replace('\n', "\n ");
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs());
let line = format!("{} {:<5} [{}] {}: {message}\n", crate::crash::utc(now), record.level(), std::process::id(), record.target());
if record.level() <= log::Level::Warn {
eprint!("{line}");
}
let Ok(mut out) = self.out.lock() else { return };
if out.bytes + line.len() as u64 > MAX_BYTES {
out.file = None;
let _ = fs::rename(&self.path, rotated(&self.path));
out.file = open(&self.path).ok();
out.bytes = 0;
}
if let Some(f) = &mut out.file
&& f.write_all(line.as_bytes()).is_err()
{
out.file = None;
}
out.bytes += line.len() as u64;
}
fn flush(&self) {
if let Ok(mut out) = self.out.lock()
&& let Some(f) = &mut out.file
{
let _ = f.flush();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tail_keeps_one_process_and_the_end() {
let d = std::env::temp_dir().join(format!("fieldcraft-logtail-{}", std::process::id()));
let _ = fs::remove_dir_all(&d);
fs::create_dir_all(&d).unwrap();
let p = d.join(FILE_NAME);
fs::write(rotated(&p), "t INFO [7] a: old seven\nt INFO [8] a: old eight\n").unwrap();
fs::write(&p, "t INFO [7] a: one\nt WARN [8] a: other\nt INFO [7] a: two\n").unwrap();
assert_eq!(tail_of(&p, Some(7), 1000), "t INFO [7] a: old seven\nt INFO [7] a: one\nt INFO [7] a: two");
assert_eq!(tail_of(&p, Some(7), 40), "t INFO [7] a: one\nt INFO [7] a: two");
assert_eq!(tail_of(&p, None, 1000).lines().count(), 5);
// From the middle of a line, the partial line is dropped.
assert_eq!(read_end(&p, 20), "t INFO [7] a: two\n");
let _ = fs::remove_dir_all(&d);
}
}