Never lose work: atomic saves (temporary file, flush, rename) keeping the replaced version as <file>.bak; autosave of unsaved changes into a recovery folder (every 5 min, preference autosave_minutes, and after commands over 1.5 s; skipped when unchanged; entries locked while their app runs); Recover unsaved design? at launch; doc.recovery_list / doc.recover / doc.recovery_discard; kill-mid-save test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 11:37:33 +00:00
1 parent 01b349842c
commit f40356a74f
11 files changed
+679 -1

No files matched your search

+50 -1
View File
@@ -25,6 +25,13 @@ pub static COMMANDS: &[CommandSpec] = &[
.params("path: .3mf or .stl file (its meshes join the design as mesh bodies); name?"),
CommandSpec::new("SaveDocumentCommand", "Save", save).icon("save").key("Ctrl+S").noundo().params("path? (default: current file)"),
CommandSpec::new("SaveDocumentAsCommand", "Save As", save_as).icon("save").noundo().params("path"),
CommandSpec::new("doc.recovery_list", "Recoverable Designs", recovery_list)
.noundo()
.params("dir? (default: the recovery folder) → designs autosaved by apps that crashed or closed with unsaved changes"),
CommandSpec::new("doc.recover", "Recover Design", recover)
.noundo()
.params("id (from doc.recovery_list); dir?; discard?: bool (delete the entry once open) — opens it with its file path, unsaved"),
CommandSpec::new("doc.recovery_discard", "Discard Recovered Design", recovery_discard).noundo().params("id, or all: true; dir?"),
CommandSpec::new("ExportCommand", "Export", export)
.icon("export")
.noundo()
@@ -139,7 +146,8 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
fn write(s: &mut Session, path: &str) -> Result<Value> {
let bytes = solvecraft_io::write_design(&s.doc);
std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
// Atomic, keeping the version being replaced as `<file>.bak`.
solvecraft_io::write_atomic(std::path::Path::new(path), &bytes, true).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
s.path = Some(path.to_string());
s.mark_saved();
Ok(json!({"path": path, "bytes": bytes.len()}))
@@ -182,3 +190,44 @@ fn save_stl(s: &mut Session, p: &Value) -> Result<Value> {
let f = if bool_(p, "ascii").unwrap_or(false) { Format::StlAscii } else { Format::StlBinary };
export_to(s, path, f, &string_list(p, "bodies"))
}
fn recovery_dir(p: &Value, cmd: &str) -> Result<std::path::PathBuf> {
match str_(p, "dir").filter(|d| !d.trim().is_empty() && d.len() < 4096) {
Some(d) => Ok(d.into()),
None => crate::recovery::default_dir().ok_or_else(|| bad(cmd, "no recovery folder on this system (give `dir`)")),
}
}
fn recovery_list(_s: &mut Session, p: &Value) -> Result<Value> {
let dir = recovery_dir(p, "doc.recovery_list")?;
Ok(json!({"dir": dir, "designs": crate::recovery::orphans(&dir)}))
}
fn recover(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "doc.recover";
let dir = recovery_dir(p, cmd)?;
let id = str_(p, "id").ok_or_else(|| bad(cmd, "`id` is required (see doc.recovery_list)"))?;
let (doc, entry) = crate::recovery::load(&dir, id)?;
*s = Session::new(doc);
s.path = entry.path.clone();
s.mark_unsaved();
if bool_(p, "discard").unwrap_or(false) {
crate::recovery::remove(&dir, id);
}
let errors = s.model.results.iter().filter(|r| r.error.is_some()).count();
Ok(json!({"id": id, "path": entry.path, "name": entry.name, "features": s.doc.features.len(), "errors": errors}))
}
fn recovery_discard(_s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "doc.recovery_discard";
let dir = recovery_dir(p, cmd)?;
let ids: Vec<String> = if bool_(p, "all").unwrap_or(false) {
crate::recovery::orphans(&dir).into_iter().map(|e| e.id).collect()
} else {
vec![str_(p, "id").ok_or_else(|| bad(cmd, "`id` or `all` is required"))?.to_string()]
};
for id in &ids {
crate::recovery::remove(&dir, id);
}
Ok(json!({"discarded": ids}))
}
+5
View File
@@ -10,6 +10,7 @@
pub mod cmd;
pub mod params;
pub mod recovery;
pub mod sample;
pub mod view;
@@ -224,6 +225,10 @@ impl Session {
pub fn mark_saved(&mut self) {
self.saved = self.doc.clone();
}
/// The design has changes not in its file (a recovered design).
pub fn mark_unsaved(&mut self) {
self.saved = Arc::new(Document::new(""));
}
pub fn echo(&mut self, s: impl Into<String>) {
self.log.push(s.into());
+248
View File
@@ -0,0 +1,248 @@
//! Autosave and crash recovery.
//!
//! A running app keeps one recovery entry in the recovery folder: `<id>.solvecraft` (the design
//! as of the last autosave), `<id>.json` (its file path, name and time) and `<id>.lock`, which
//! the app holds locked while it runs. Autosaves are written atomically, only when the design
//! has unsaved changes it hasn't written yet; a design with nothing unsaved has no entry. An
//! entry whose lock nobody holds belongs to an app that crashed (or closed with unsaved changes):
//! the next launch offers to recover it.
use std::fs::{self, File, OpenOptions};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use solvecraft_doc::Document;
use crate::{EngineError, Result, Session};
/// The recovery folder: `SOLVECRAFT_RECOVERY_DIR`, else the platform's per-user data folder.
pub fn default_dir() -> Option<PathBuf> {
// Tests never touch the user's real recovery folder.
if cfg!(test) {
return Some(std::env::temp_dir().join(format!("solvecraft-test-recovery-{}", std::process::id())));
}
if let Some(d) = std::env::var_os("SOLVECRAFT_RECOVERY_DIR").filter(|d| !d.is_empty()) {
return Some(PathBuf::from(d));
}
let env = |k: &str| std::env::var_os(k).filter(|d| !d.is_empty()).map(PathBuf::from);
let base = if cfg!(windows) {
env("LOCALAPPDATA")?.join("SolveCraft")
} else if cfg!(target_os = "macos") {
env("HOME")?.join("Library/Application Support/SolveCraft")
} else {
env("XDG_DATA_HOME").or_else(|| env("HOME").map(|h| h.join(".local/share")))?.join("solvecraft")
};
Some(base.join("recovery"))
}
/// What an entry's `<id>.json` holds.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Entry {
pub id: String,
/// The design's file, if it had one.
pub path: Option<String>,
pub name: String,
/// When it was autosaved (seconds since 1970).
pub saved_at: u64,
pub features: usize,
}
fn unix_now() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
fn valid_id(id: &str) -> bool {
!id.is_empty() && id.len() <= 64 && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
}
fn file(dir: &Path, id: &str, ext: &str) -> PathBuf {
dir.join(format!("{id}.{ext}"))
}
/// Entries left by apps that are no longer running, newest first.
pub fn orphans(dir: &Path) -> Vec<Entry> {
let mut out: Vec<Entry> = fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.filter_map(|e| {
let p = e.path();
if p.extension().and_then(|x| x.to_str()) != Some("json") {
return None;
}
let entry: Entry = serde_json::from_slice(&fs::read(&p).ok()?).ok()?;
if !valid_id(&entry.id) || !file(dir, &entry.id, "solvecraft").is_file() {
return None;
}
// Held by a running app: not an orphan.
let lock = File::open(file(dir, &entry.id, "lock"));
if let Ok(l) = &lock
&& l.try_lock().is_err()
{
return None;
}
Some(entry)
})
.take(1000)
.collect();
out.sort_by_key(|e| std::cmp::Reverse(e.saved_at));
out
}
/// Read an entry's design.
pub fn load(dir: &Path, id: &str) -> Result<(Document, Entry)> {
if !valid_id(id) {
return Err(EngineError::Other(format!("no recovered design `{id}`")));
}
let entry: Entry = fs::read(file(dir, id, "json"))
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.ok_or_else(|| EngineError::Other(format!("no recovered design `{id}`")))?;
let bytes = fs::read(file(dir, id, "solvecraft")).map_err(|e| EngineError::Other(format!("recovered design `{id}`: {e}")))?;
Ok((solvecraft_io::read_design(&bytes)?, entry))
}
/// Delete an entry.
pub fn remove(dir: &Path, id: &str) {
if valid_id(id) {
for ext in ["json", "solvecraft", "lock"] {
let _ = fs::remove_file(file(dir, id, ext));
}
}
}
/// One app's autosaver.
pub struct Autosaver {
pub dir: PathBuf,
pub id: String,
/// Autosave this often while there are unsaved changes.
pub interval: Duration,
lock: Option<File>,
last: Option<Arc<Document>>,
last_at: Instant,
has_entry: bool,
}
impl Autosaver {
/// Start autosaving into `dir` (created if needed), holding this app's lock.
pub fn new(dir: &Path, interval: Duration) -> Result<Autosaver> {
let io = |e: std::io::Error| EngineError::Other(format!("{}: {e}", dir.display()));
fs::create_dir_all(dir).map_err(io)?;
// Locks of apps that ended with nothing to recover.
for e in fs::read_dir(dir).into_iter().flatten().flatten() {
let p = e.path();
if p.extension().and_then(|x| x.to_str()) == Some("lock")
&& !p.with_extension("json").exists()
&& File::open(&p).is_ok_and(|l| l.try_lock().is_ok())
{
let _ = fs::remove_file(&p);
}
}
let id = format!("{}-{}", unix_now(), std::process::id());
let lock = OpenOptions::new().create(true).truncate(false).write(true).open(file(dir, &id, "lock")).map_err(io)?;
lock.lock().map_err(io)?;
Ok(Autosaver { dir: dir.to_path_buf(), id, interval, lock: Some(lock), last: None, last_at: Instant::now(), has_entry: false })
}
/// Autosave now if the design has unsaved changes not written yet (and drop the entry when
/// nothing is unsaved). Returns whether it wrote.
pub fn save(&mut self, s: &Session) -> Result<bool> {
self.last_at = Instant::now();
if !s.is_dirty() {
if self.has_entry {
for ext in ["json", "solvecraft"] {
let _ = fs::remove_file(file(&self.dir, &self.id, ext));
}
self.has_entry = false;
}
self.last = None;
return Ok(false);
}
if self.last.as_ref().is_some_and(|l| Arc::ptr_eq(l, &s.doc) || **l == *s.doc) {
return Ok(false);
}
let io = |e: std::io::Error| EngineError::Other(format!("autosave: {e}"));
solvecraft_io::write_atomic(&file(&self.dir, &self.id, "solvecraft"), &solvecraft_io::write_design(&s.doc), false).map_err(io)?;
let entry =
Entry { id: self.id.clone(), path: s.path.clone(), name: s.doc.name.clone(), saved_at: unix_now(), features: s.doc.features.len() };
let meta = serde_json::to_vec_pretty(&entry).map_err(|e| EngineError::Other(e.to_string()))?;
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;
Ok(true)
}
/// Autosave when the interval has passed, or right away after a big operation.
pub fn tick(&mut self, s: &Session, big: bool) -> Result<bool> {
if big || self.last_at.elapsed() >= self.interval { self.save(s) } else { Ok(false) }
}
/// Closing the app: with nothing unsaved the entry goes; otherwise it stays for the next
/// launch to offer. The lock is released either way.
pub fn close(mut self, s: &Session) {
let _ = self.save(s);
if !self.has_entry {
let _ = fs::remove_file(file(&self.dir, &self.id, "lock"));
}
self.lock = None;
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("solvecraft-recovery-{tag}-{}", std::process::id()));
let _ = fs::remove_dir_all(&d);
d
}
#[test]
fn autosave_only_unsaved_changes_and_recover_after_a_crash() {
let d = dir("crash");
let mut s = Session::default();
let mut a = Autosaver::new(&d, Duration::from_secs(3600)).unwrap();
assert!(!a.save(&s).unwrap(), "nothing unsaved");
s.execute("PrimitiveBox", &json!({"length": 10, "width": 10, "height": 10})).unwrap();
assert!(!a.tick(&s, false).unwrap(), "not due yet");
assert!(a.tick(&s, true).unwrap(), "after a big operation");
assert!(!a.save(&s).unwrap(), "unchanged since the last autosave");
// Our own entry is locked: not offered.
assert!(orphans(&d).is_empty());
// The app dies without closing (the lock goes with the process).
drop(a);
let found = orphans(&d);
assert_eq!(found.len(), 1);
let (doc, entry) = load(&d, &found[0].id).unwrap();
assert_eq!(doc, *s.doc);
assert_eq!(entry.features, 1);
remove(&d, &entry.id);
assert!(orphans(&d).is_empty());
let _ = fs::remove_dir_all(&d);
}
#[test]
fn saving_drops_the_entry_and_a_clean_close_leaves_nothing() {
let d = dir("clean");
let mut s = Session::default();
let mut a = Autosaver::new(&d, Duration::ZERO).unwrap();
s.execute("PrimitiveBox", &json!({"length": 10, "width": 10, "height": 10})).unwrap();
assert!(a.tick(&s, false).unwrap());
s.mark_saved();
a.tick(&s, false).unwrap();
assert!(!file(&d, &a.id, "solvecraft").exists());
a.close(&s);
assert_eq!(fs::read_dir(&d).unwrap().count(), 0);
// Closing with unsaved changes keeps them for the next launch.
let b = Autosaver::new(&d, Duration::ZERO).unwrap();
s.execute("PrimitiveBox", &json!({"length": 5, "width": 5, "height": 5})).unwrap();
b.close(&s);
assert_eq!(orphans(&d).len(), 1);
assert!(load(&d, "../etc").is_err());
let _ = fs::remove_dir_all(&d);
}
}