diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index c339624..540a2f0 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -36,6 +36,7 @@ is committed and listed here with its exports. | assets/icons/deck.write.abaqus.svg, assets/icons/deck.write.abaqus.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/deck.write.abaqus.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | | assets/icons/deck.write.lsdyna.svg, assets/icons/deck.write.lsdyna.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/deck.write.lsdyna.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | | assets/icons/deck.write.nastran.svg, assets/icons/deck.write.nastran.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/deck.write.nastran.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | +| assets/icons/diagnostics.bug_report.svg, assets/icons/diagnostics.bug_report.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/diagnostics.bug_report.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | | assets/icons/edit.redo.svg, assets/icons/edit.redo.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/edit.redo.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | | assets/icons/edit.undo.svg, assets/icons/edit.undo.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/edit.undo.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | | assets/icons/file.open.svg, assets/icons/file.open.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/file.open.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | @@ -186,6 +187,7 @@ is committed and listed here with its exports. | crates/ui-egui/src/fields.rs (form rows: a label column of fixed width, then the field) | SolidCraft `crates/ui-egui/src/dialogs.rs` | MIT OR Apache-2.0 | | crates/io/src/fs.rs (atomic writes) | SolidCraft `crates/io/src/safe_write.rs` | MIT OR Apache-2.0 | | crates/io/src/recovery.rs (recovery folder, entries, locks) | SolidCraft `crates/engine/src/recovery.rs` | MIT OR Apache-2.0 | +| crates/engine/src/crash.rs, crates/engine/src/crash_tests.rs (crash reports: panic hook, locked command journal, unclean-exit detection, report folders and limits, replay), apps/fieldcraft-cli/src/crashes.rs, docs/crash-reports.md (structure) | SolidCraft `crates/engine/src/crash.rs`, `crash_tests.rs`, `apps/solidcraft-cli/src/crashes.rs`, `docs/crash-reports.md` | MIT OR Apache-2.0 | | apps/fieldcraft/src/control_server.rs | SolidCraft `apps/solidcraft/src/control_server.rs` | MIT OR Apache-2.0 | | crates/mcp/src/ (JSON-RPC framing, MCP lifecycle, tool schema checks, the control-channel backend, base64) | SolidCraft `crates/mcp/src/` | MIT OR Apache-2.0 | | crates/io/src/roots.rs (the rules of read and write roots) | VectorCraft's automation roots (docs/mcp.md there) | MIT OR Apache-2.0 | diff --git a/assets/icons/diagnostics.bug_report.svg b/assets/icons/diagnostics.bug_report.svg new file mode 100644 index 0000000..dbff238 --- /dev/null +++ b/assets/icons/diagnostics.bug_report.svg @@ -0,0 +1,15 @@ + + + diagnostics.bug_report + + + + + + + + + + + + diff --git a/assets/icons/diagnostics.bug_report.vectorcraft b/assets/icons/diagnostics.bug_report.vectorcraft new file mode 100644 index 0000000..5a04bc0 Binary files /dev/null and b/assets/icons/diagnostics.bug_report.vectorcraft differ diff --git a/assets/icons/src/diagnostics.bug_report.json b/assets/icons/src/diagnostics.bug_report.json new file mode 100644 index 0000000..0a7324e --- /dev/null +++ b/assets/icons/src/diagnostics.bug_report.json @@ -0,0 +1 @@ +{"shapes": [{"rect": [8, 8.5, 8, 11], "radius": 4, "stroke": "line", "width": 1.6}, {"circle": [12, 5.8, 2.1], "stroke": "line", "width": 1.6}, {"line": [12, 10.5, 12, 18], "stroke": "accent", "width": 1.5}, {"line": [8, 11.5, 4.8, 9.8], "stroke": "line"}, {"line": [8, 14.5, 4.4, 14.5], "stroke": "line"}, {"line": [8.3, 17.4, 5, 19.4], "stroke": "line"}, {"line": [16, 11.5, 19.2, 9.8], "stroke": "line"}, {"line": [16, 14.5, 19.6, 14.5], "stroke": "line"}, {"line": [15.7, 17.4, 19, 19.4], "stroke": "line"}]} diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs index f385169..100593c 100644 --- a/crates/engine/src/autosave.rs +++ b/crates/engine/src/autosave.rs @@ -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) } diff --git a/crates/engine/src/bug_report.rs b/crates/engine/src/bug_report.rs new file mode 100644 index 0000000..94c6048 --- /dev/null +++ b/crates/engine/src/bug_report.rs @@ -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, + /// 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, + /// Attach the model file too (only with `share_session`). + pub include_model: bool, + pub model: Option>, +} + +/// A file the user attaches to the issue. +#[derive(Clone, Debug, PartialEq)] +pub struct Attachment { + pub name: String, + pub bytes: Vec, + /// 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, + /// Text to put on the clipboard (the session, when it is an attachment). + pub clipboard: Option, +} + +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::>(), + "url": self.url, + "attachments": self.attachments.iter().map(|a| json!({"name": a.name, "bytes": a.bytes.len(), "what": a.what})).collect::>(), + "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::()), + 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 = (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"); + } +} diff --git a/crates/engine/src/catalogue.rs b/crates/engine/src/catalogue.rs index 3b22a32..0b961ad 100644 --- a/crates/engine/src/catalogue.rs +++ b/crates/engine/src/catalogue.rs @@ -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. diff --git a/crates/engine/src/cmd/diagnostics.rs b/crates/engine/src/cmd/diagnostics.rs new file mode 100644 index 0000000..ac977fe --- /dev/null +++ b/crates/engine/src/cmd/diagnostics.rs @@ -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"?: "", "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 { + 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 { + let dir = crash_dir(s, p)?; + let limit = opt::(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 { + let dir = crash_dir(s, p)?; + let id = req::(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 { + let share = opt::(p, "share_session")?.unwrap_or(false); + let include_model = opt::(p, "include_model")?.unwrap_or(false); + let mut input = bug_report::Input { + title: opt::(p, "title")?.unwrap_or_default(), + observed: opt::(p, "observed")?.unwrap_or_default(), + share_session: share, + include_model, + ..Default::default() + }; + match opt::(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 { + 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 { + let mode = req::(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")), + } +} diff --git a/crates/engine/src/cmd/forms.rs b/crates/engine/src/cmd/forms.rs index 6cb5dcd..9359c38 100644 --- a/crates/engine/src/cmd/forms.rs +++ b/crates/engine/src/cmd/forms.rs @@ -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) { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 3b32799..ead6954 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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, diff --git a/crates/engine/src/crash.rs b/crates/engine/src/crash.rs new file mode 100644 index 0000000..287b912 --- /dev/null +++ b/crates/engine/src/crash.rs @@ -0,0 +1,1158 @@ +//! 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 model as it +//! was before the command and the command itself, so `fieldcraft-cli crashes replay ` +//! reproduces it. A panic something inside the command caught and turned into an error is +//! reported the same way, marked recovered. +//! - **Crashes no hook sees** (an abort, a stack overflow, a segfault, a crash inside a graphics +//! driver, 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 model the journal starts from (the last autosave, the model's file, +//! or a copy taken when the model was opened) and the commands run since, ending with the one +//! in flight, plus the app's last control request and the dead run's lines of the log file +//! ([`crate::logfile`]). +//! - **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 model +//! ([`Session::crash_journal`]), 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/