Files
OneCAD-Simulation/crates/engine/src/autosave.rs
T
jyvikoandClaude Opus 5.5 080ff048c6 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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 00:36:18 -04:00

110 lines
4.2 KiB
Rust

//! Autosave for recovery after a crash.
//!
//! The desktop app keeps an [`Autosaver`] and calls [`Autosaver::save`] on a timer: while the
//! session has unsaved changes not yet autosaved, the whole model is written to the app's entry in
//! the recovery folder ([`fieldcraft_io::recovery`]); once nothing is unsaved the entry goes. After
//! a crash the next launch lists the entry (`file.recovery.list`) and opens it (`file.recover`).
use std::path::Path;
use fieldcraft_io::recovery::Slot;
use crate::{Result, Session};
pub use fieldcraft_io::recovery::default_dir;
/// One app's autosaver.
#[derive(Debug)]
pub struct Autosaver {
slot: Slot,
/// The revision last autosaved.
saved: Option<u64>,
}
impl Autosaver {
/// Start autosaving into `dir` (created if needed).
pub fn new(dir: &Path) -> Result<Autosaver> {
Ok(Autosaver { slot: Slot::new(dir)?, saved: None })
}
/// The id of this app's entry.
pub fn id(&self) -> &str {
self.slot.id()
}
/// Autosave if the session has unsaved changes not written yet, or drop the entry when
/// nothing is unsaved. Returns whether it wrote.
pub fn save(&mut self, s: &Session) -> Result<bool> {
if !s.is_dirty() {
self.slot.clear();
self.saved = None;
return Ok(false);
}
if self.saved == Some(s.revision()) {
return Ok(false);
}
// Geometry paths stay absolute: the entry is not next to the model's own file.
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)
}
/// The app is closing: a last autosave, then the lock is released. Unsaved work stays in the
/// recovery folder for the next launch to offer.
pub fn close(mut self, s: &Session) -> Result<()> {
let saved = self.save(s);
self.slot.close();
saved.map(|_| ())
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn autosaves_unsaved_work_and_recovers_it_after_a_crash() {
let dir = std::env::temp_dir().join(format!("fieldcraft-autosave-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut s = Session::default();
let mut a = Autosaver::new(&dir).unwrap();
assert!(!a.save(&s).unwrap(), "nothing unsaved");
s.execute("mesh.node.add", &json!({"label": 1, "xyz": [1, 2, 3]})).unwrap();
assert!(a.save(&s).unwrap());
assert!(!a.save(&s).unwrap(), "unchanged since the last autosave");
let id = a.id().to_string();
let listed = s.execute("file.recovery.list", &json!({"dir": dir})).unwrap();
assert_eq!(listed, json!([]), "a running app's autosave is not offered");
// The app dies.
drop(a);
let listed = s.execute("file.recovery.list", &json!({"dir": dir})).unwrap();
assert_eq!(listed[0]["id"], id);
assert_eq!(listed[0]["nodes"], 1);
assert!(listed[0]["age"].as_u64().is_some_and(|a| a < 60), "{listed}");
let mut next = Session::default();
let r = next.execute("file.recover", &json!({"id": id, "dir": dir})).unwrap();
assert_eq!(r["nodes"], 1);
assert_eq!(next.model, s.model);
assert_eq!(next.journal, s.journal);
assert!(next.is_dirty(), "recovered work is unsaved");
next.execute("file.recovery.discard", &json!({"all": true, "dir": dir})).unwrap();
assert_eq!(next.execute("file.recovery.list", &json!({"dir": dir})).unwrap(), json!([]));
// Undoing back to the saved state drops the entry; a clean close leaves nothing.
let mut a = Autosaver::new(&dir).unwrap();
let mut s = Session::default();
s.execute("mesh.node.add", &json!({"label": 1, "xyz": [1, 2, 3]})).unwrap();
assert!(a.save(&s).unwrap());
s.execute("edit.undo", &json!({})).unwrap();
assert!(!a.save(&s).unwrap());
a.close(&s).unwrap();
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0);
let _ = std::fs::remove_dir_all(&dir);
}
}