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 <[email protected]> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
01b349842c
commit
f40356a74f
11 files changed
+679
-1
No files matched your search
@@ -52,6 +52,7 @@ headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done (oracle 67-76). PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, no Fusion oracle (licence), tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table, DXF export), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Bend (Fold) has no engine command yet | in progress | 36 |
|
||||
| M11 | 2D drawings | deferred | — |
|
||||
| M12 | Release: installers, signing, docs, performance | planned | 16 |
|
||||
| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `<file>.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). Next: file format version and migrations, old-design corpus, fuzzed design files | in progress | 6 |
|
||||
|
||||
## Known limitations
|
||||
|
||||
|
||||
@@ -55,6 +55,8 @@ fn main() -> ExitCode {
|
||||
Some("oracle") => oracle::run(&args[1..]),
|
||||
Some("step-corpus") => oracle::step_corpus(&args[1..]),
|
||||
Some("mcp") => cmd_mcp(&args[1..]),
|
||||
// Hidden, for the kill-mid-save test: save a large design over and over.
|
||||
Some("save-stress") => cmd_save_stress(&args[1..]),
|
||||
Some("--version" | "-V") => {
|
||||
println!("solvecraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
Ok(())
|
||||
@@ -217,3 +219,25 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
|
||||
let stdout = std::io::stdout();
|
||||
solvecraft_mcp::Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// `save-stress <path> <rounds>`: save a design of a few hundred kilobytes `rounds` times, its
|
||||
/// parameter `save_round` set to the round number each time.
|
||||
fn cmd_save_stress(args: &[String]) -> Result<(), String> {
|
||||
let (Some(path), Some(rounds)) = (args.first(), args.get(1).and_then(|n| n.parse::<u64>().ok())) else {
|
||||
return Err("usage: solvecraft-cli save-stress <path> <rounds>".into());
|
||||
};
|
||||
let mut s = Session::default();
|
||||
let filler = (0..3000).map(|i| solvecraft_engine::doc::Param {
|
||||
name: format!("p{i}"),
|
||||
expr: format!("{i} mm + 0.5 mm"),
|
||||
unit: "mm".into(),
|
||||
comment: "filler to make the file large".into(),
|
||||
model: false,
|
||||
});
|
||||
s.doc_mut().params.extend(filler);
|
||||
for r in 0..rounds {
|
||||
s.doc_mut().set_param("save_round", &r.to_string(), None, None).map_err(|e| e.to_string())?;
|
||||
s.execute("SaveDocumentCommand", &json!({ "path": path })).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//! Kill the process while it saves: the design file is always the old version or the new one,
|
||||
//! whole, and opens.
|
||||
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
fn round_of(path: &std::path::Path) -> Option<String> {
|
||||
let bytes = std::fs::read(path).ok()?;
|
||||
let doc = solvecraft_engine::io::read_design(&bytes).ok()?;
|
||||
doc.params.iter().find(|p| p.name == "save_round").map(|p| p.expr.clone())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn killed_mid_save_the_old_file_survives() {
|
||||
let exe = env!("CARGO_BIN_EXE_solvecraft-cli");
|
||||
let dir = std::env::temp_dir().join(format!("solvecraft-kill-mid-save-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("design.solvecraft");
|
||||
let ok = Command::new(exe).args(["save-stress", &path.to_string_lossy(), "1"]).status().unwrap();
|
||||
assert!(ok.success());
|
||||
assert_eq!(round_of(&path).as_deref(), Some("0"));
|
||||
let mut kills = 0;
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for i in 0..25u64 {
|
||||
let mut child = Command::new(exe).args(["save-stress", &path.to_string_lossy(), "100000"]).spawn().unwrap();
|
||||
// Let it get into its save loop (building the design takes a moment), then kill it.
|
||||
std::thread::sleep(Duration::from_millis(150 + (i * 37) % 200));
|
||||
if child.try_wait().unwrap().is_none() {
|
||||
child.kill().unwrap();
|
||||
kills += 1;
|
||||
}
|
||||
let _ = child.wait();
|
||||
let round = round_of(&path);
|
||||
assert!(round.is_some(), "after kill {i} the design file is torn or missing");
|
||||
seen.insert(round);
|
||||
let bak = solvecraft_engine::io::backup_path(&path);
|
||||
if bak.exists() {
|
||||
assert!(round_of(&bak).is_some(), "after kill {i} the backup is torn");
|
||||
}
|
||||
}
|
||||
assert!(kills > 20, "the saver was killed while running ({kills})");
|
||||
assert!(seen.len() > 5, "the saver got to saving before it was killed ({seen:?})");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -35,6 +35,9 @@ impl eframe::App for App {
|
||||
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
||||
storage.set_string(PREFS_KEY, self.0.prefs());
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
solvecraft_ui_egui::recovery_ui::close(&mut self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the preferences (theme) are kept in eframe's storage.
|
||||
@@ -134,6 +137,11 @@ fn main() -> eframe::Result {
|
||||
let rev = app.session.revision;
|
||||
app.home.show(rev);
|
||||
}
|
||||
// Autosave, and offer what crashed sessions left behind.
|
||||
if let Some(dir) = solvecraft_engine::recovery::default_dir() {
|
||||
let every = if app.autosave_minutes > 0.0 { app.autosave_minutes * 60.0 } else { f64::MAX / 4.0 };
|
||||
solvecraft_ui_egui::recovery_ui::start(&mut app, &dir, std::time::Duration::from_secs_f64(every.min(1e9)));
|
||||
}
|
||||
Ok(Box::new(App(app)))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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}))
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,13 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod dxf;
|
||||
pub mod safe_write;
|
||||
mod sketch2d;
|
||||
mod svg;
|
||||
mod threemf;
|
||||
|
||||
pub use dxf::{read_dxf, write_dxf};
|
||||
pub use safe_write::{backup_path, write_atomic};
|
||||
pub use sketch2d::{Geom2, MAX_DRAWING_BYTES};
|
||||
pub use svg::read_svg;
|
||||
pub use threemf::{MAX_3MF_BYTES, MeshObject, model_xml, read_3mf, weld, write_3mf};
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
//! Crash-safe file writes: the new content goes to a temporary file next to the target, is
|
||||
//! flushed to disk, and replaces the target in one rename. A crash or power cut at any moment
|
||||
//! leaves the old file or the new one, never a torn mix. The previous version can be kept as
|
||||
//! `<file>.bak` (itself written the same way).
|
||||
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The backup of a file written with `backup: true`.
|
||||
pub fn backup_path(path: &Path) -> PathBuf {
|
||||
let mut s = path.as_os_str().to_owned();
|
||||
s.push(".bak");
|
||||
PathBuf::from(s)
|
||||
}
|
||||
|
||||
fn temp_beside(path: &Path, tag: &str) -> PathBuf {
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "file".into());
|
||||
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos()).unwrap_or(0);
|
||||
path.with_file_name(format!(".{name}.{tag}.{}.{nanos}.tmp", std::process::id()))
|
||||
}
|
||||
|
||||
fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
let mut f = OpenOptions::new().write(true).create_new(true).open(path)?;
|
||||
f.write_all(bytes)?;
|
||||
f.sync_all()
|
||||
}
|
||||
|
||||
/// Flush the directory entry (the rename) to disk where the platform allows it.
|
||||
fn sync_dir(path: &Path) {
|
||||
#[cfg(unix)]
|
||||
if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty())
|
||||
&& let Ok(d) = File::open(dir)
|
||||
{
|
||||
let _ = d.sync_all();
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = path;
|
||||
}
|
||||
|
||||
/// Steps of a save, so tests can stop one part way (as a crash would).
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
|
||||
enum Step {
|
||||
TempWritten,
|
||||
BackupWritten,
|
||||
#[allow(dead_code)] // stopping after the last step is a whole save (tests name it)
|
||||
Replaced,
|
||||
}
|
||||
|
||||
fn write_steps(path: &Path, bytes: &[u8], backup: bool, stop: Option<Step>) -> std::io::Result<()> {
|
||||
let tmp = temp_beside(path, "new");
|
||||
if let Err(e) = write_synced(&tmp, bytes) {
|
||||
let _ = fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
if stop == Some(Step::TempWritten) {
|
||||
return Ok(());
|
||||
}
|
||||
if backup && path.is_file() {
|
||||
// Copy (not move) the old file, so the target exists at every moment.
|
||||
let old = fs::read(path);
|
||||
let bak_tmp = temp_beside(path, "bak");
|
||||
let r = old.and_then(|old| write_synced(&bak_tmp, &old)).and_then(|_| fs::rename(&bak_tmp, backup_path(path)));
|
||||
if let Err(e) = r {
|
||||
let _ = fs::remove_file(&bak_tmp);
|
||||
let _ = fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
if stop == Some(Step::BackupWritten) {
|
||||
return Ok(());
|
||||
}
|
||||
if let Err(e) = fs::rename(&tmp, path) {
|
||||
let _ = fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
sync_dir(path);
|
||||
// Temporary files of saves that crashed part way (a minute old or more) go.
|
||||
for t in stale_temps(path) {
|
||||
if fs::metadata(&t).and_then(|m| m.modified()).ok().and_then(|m| m.elapsed().ok()).is_some_and(|age| age.as_secs() >= 60) {
|
||||
let _ = fs::remove_file(t);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write `bytes` to `path` atomically; with `backup`, the file being replaced is kept as
|
||||
/// `<path>.bak` first.
|
||||
pub fn write_atomic(path: &Path, bytes: &[u8], backup: bool) -> std::io::Result<()> {
|
||||
write_steps(path, bytes, backup, None)
|
||||
}
|
||||
|
||||
/// Leftover temporary files of saves that never finished (a crash part way), next to `path`.
|
||||
pub fn stale_temps(path: &Path) -> Vec<PathBuf> {
|
||||
let Some(name) = path.file_name().map(|n| n.to_string_lossy().to_string()) else { return Vec::new() };
|
||||
let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new("."));
|
||||
let prefix = format!(".{name}.");
|
||||
fs::read_dir(dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.file_name().map(|n| n.to_string_lossy().to_string()).is_some_and(|n| n.starts_with(&prefix) && n.ends_with(".tmp")))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn dir(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("solvecraft-safe-write-{tag}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaces_and_keeps_a_backup() {
|
||||
let d = dir("bak");
|
||||
let p = d.join("a.solvecraft");
|
||||
write_atomic(&p, b"one", true).unwrap();
|
||||
assert!(!backup_path(&p).exists(), "nothing to back up the first time");
|
||||
write_atomic(&p, b"two", true).unwrap();
|
||||
assert_eq!(fs::read(&p).unwrap(), b"two");
|
||||
assert_eq!(fs::read(backup_path(&p)).unwrap(), b"one");
|
||||
write_atomic(&p, b"three", false).unwrap();
|
||||
assert_eq!(fs::read(backup_path(&p)).unwrap(), b"one");
|
||||
assert!(stale_temps(&p).is_empty());
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// A save stopped after any step leaves the old file whole (or the new one).
|
||||
#[test]
|
||||
fn a_save_stopped_part_way_keeps_the_old_file() {
|
||||
let d = dir("stop");
|
||||
let p = d.join("a.solvecraft");
|
||||
write_atomic(&p, b"old", true).unwrap();
|
||||
for stop in [Step::TempWritten, Step::BackupWritten, Step::Replaced] {
|
||||
write_steps(&p, b"new content", true, Some(stop)).unwrap();
|
||||
let now = fs::read(&p).unwrap();
|
||||
assert!(now == b"old" || now == b"new content", "{stop:?}: {now:?}");
|
||||
if stop < Step::Replaced {
|
||||
assert_eq!(now, b"old");
|
||||
assert!(!stale_temps(&p).is_empty(), "the unfinished save's temporary file is left over");
|
||||
for t in stale_temps(&p) {
|
||||
fs::remove_file(t).unwrap();
|
||||
}
|
||||
}
|
||||
write_atomic(&p, b"old", false).unwrap();
|
||||
}
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_write_leaves_the_target() {
|
||||
let d = dir("fail");
|
||||
let p = d.join("missing-dir").join("a.solvecraft");
|
||||
assert!(write_atomic(&p, b"x", true).is_err());
|
||||
assert!(!p.exists());
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ pub mod params_dialog;
|
||||
#[cfg(test)]
|
||||
mod preselect_tests;
|
||||
pub mod preview;
|
||||
pub mod recovery_ui;
|
||||
pub mod ref_images;
|
||||
pub mod scenario;
|
||||
pub mod selection;
|
||||
@@ -163,6 +164,14 @@ pub struct SolveApp {
|
||||
pub esc_handled: bool,
|
||||
pub status: Option<(String, f64, bool)>,
|
||||
pub quit_requested: bool,
|
||||
/// Autosave into the recovery folder (the desktop app turns it on).
|
||||
pub autosave: Option<solvecraft_engine::recovery::Autosaver>,
|
||||
/// Designs left by crashed apps, offered for recovery.
|
||||
pub recoverable: Vec<solvecraft_engine::recovery::Entry>,
|
||||
/// The last command was a big operation: autosave after it.
|
||||
pub big_operation: bool,
|
||||
/// Autosave interval (minutes; 0 = only after big operations). A preference.
|
||||
pub autosave_minutes: f64,
|
||||
/// The application bar is the window's title bar (Windows, Linux: no OS decorations).
|
||||
pub custom_titlebar: bool,
|
||||
/// macOS: the traffic lights sit over the application bar's left end.
|
||||
@@ -206,6 +215,10 @@ impl SolveApp {
|
||||
esc_handled: false,
|
||||
status: None,
|
||||
quit_requested: false,
|
||||
autosave: None,
|
||||
recoverable: Vec::new(),
|
||||
big_operation: false,
|
||||
autosave_minutes: 5.0,
|
||||
custom_titlebar: false,
|
||||
integrated_titlebar: false,
|
||||
frame_ms: 0.0,
|
||||
@@ -241,6 +254,7 @@ impl SolveApp {
|
||||
"recent": self.home.recent,
|
||||
"shortcut_box": self.sbox.prefs(),
|
||||
"shortcuts": self.keymap.prefs(),
|
||||
"autosave_minutes": self.autosave_minutes,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
@@ -264,6 +278,9 @@ impl SolveApp {
|
||||
if let Some(a) = flag("auto_project") {
|
||||
self.session.auto_project = a;
|
||||
}
|
||||
if let Some(m) = v.get("autosave_minutes").and_then(Value::as_f64).filter(|m| m.is_finite()) {
|
||||
self.autosave_minutes = m.clamp(0.0, 600.0);
|
||||
}
|
||||
if let Some(s) = v.get("visual_style").and_then(Value::as_u64) {
|
||||
self.ui.visual_style = u8::try_from(s.min(2)).unwrap_or(0);
|
||||
}
|
||||
@@ -296,7 +313,9 @@ impl SolveApp {
|
||||
|
||||
/// Run a command programmatically (JSON parameters, no dialogs).
|
||||
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
|
||||
let t0 = now_ms();
|
||||
let r = self.session.execute(id, ¶ms).map_err(|e| e.to_string());
|
||||
self.big_operation |= now_ms() - t0 >= recovery_ui::BIG_OPERATION_MS;
|
||||
match &r {
|
||||
Ok(_) => {
|
||||
self.session.echo(format!("{id}: done"));
|
||||
@@ -604,6 +623,7 @@ impl SolveApp {
|
||||
self.collect_screenshots(ctx);
|
||||
self.issue_screenshots(ctx);
|
||||
toolbar::shortcuts(self, ctx);
|
||||
recovery_ui::tick(self);
|
||||
for f in ctx.input(|i| i.raw.dropped_files.clone()) {
|
||||
let p = f.path().to_string_lossy().to_string();
|
||||
if !p.is_empty() {
|
||||
@@ -663,6 +683,7 @@ impl SolveApp {
|
||||
dialogs::show(self, ui.ctx());
|
||||
sketch_palette::show(self, ui.ctx());
|
||||
params_dialog::show(self, ui.ctx());
|
||||
recovery_ui::show(self, ui.ctx());
|
||||
context_menu::show(self, ui.ctx());
|
||||
delete::show(self, ui.ctx());
|
||||
if self.custom_titlebar {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
//! Autosave ticks and the "Recover unsaved design?" window shown at launch.
|
||||
|
||||
use egui::RichText;
|
||||
use serde_json::json;
|
||||
use solvecraft_engine::recovery::{self, Autosaver, Entry};
|
||||
|
||||
use crate::SolveApp;
|
||||
|
||||
/// A command that ran at least this long counts as a big operation: autosave right after it.
|
||||
pub const BIG_OPERATION_MS: f64 = 1500.0;
|
||||
|
||||
/// Autosave (when due, or after a big operation).
|
||||
pub fn tick(app: &mut SolveApp) {
|
||||
let big = std::mem::take(&mut app.big_operation);
|
||||
let Some(a) = app.autosave.as_mut() else { return };
|
||||
if let Err(e) = a.tick(&app.session, big) {
|
||||
let msg = format!("autosave failed: {e}");
|
||||
if app.status.as_ref().is_none_or(|(s, _, _)| *s != msg) {
|
||||
app.set_status(msg, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start autosaving into `dir` and queue the designs that crashed apps left there.
|
||||
pub fn start(app: &mut SolveApp, dir: &std::path::Path, interval: std::time::Duration) {
|
||||
match Autosaver::new(dir, interval) {
|
||||
Ok(a) => {
|
||||
app.recoverable = recovery::orphans(dir);
|
||||
app.autosave = Some(a);
|
||||
}
|
||||
Err(e) => app.set_status(format!("autosave is off: {e}"), true),
|
||||
}
|
||||
}
|
||||
|
||||
/// The app is closing: keep unsaved work for the next launch, drop everything else.
|
||||
pub fn close(app: &mut SolveApp) {
|
||||
if let Some(a) = app.autosave.take() {
|
||||
a.close(&app.session);
|
||||
}
|
||||
}
|
||||
|
||||
fn ago(secs: u64) -> String {
|
||||
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(secs);
|
||||
let d = now.saturating_sub(secs);
|
||||
match d {
|
||||
0..=59 => "just now".into(),
|
||||
60..=3599 => format!("{} min ago", d / 60),
|
||||
3600..=86_399 => format!("{} h ago", d / 3600),
|
||||
_ => format!("{} days ago", d / 86_400),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
if app.recoverable.is_empty() {
|
||||
return;
|
||||
}
|
||||
let dir = app.autosave.as_ref().map(|a| a.dir.clone()).or_else(recovery::default_dir);
|
||||
let mut act: Option<(Entry, bool)> = None;
|
||||
let mut later = false;
|
||||
egui::Window::new(RichText::new("Recover unsaved design?").strong().size(13.0))
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.min_width(420.0)
|
||||
.anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0))
|
||||
.show(ctx, |ui| {
|
||||
ui.label("SolveCraft closed before these designs were saved. Their last autosave can be opened.");
|
||||
ui.add_space(6.0);
|
||||
for e in app.recoverable.iter().take(20) {
|
||||
ui.label(RichText::new(&e.name).strong());
|
||||
let file = e.path.clone().unwrap_or_else(|| "never saved".into());
|
||||
ui.label(RichText::new(format!("{file} · {} features · autosaved {}", e.features, ago(e.saved_at))).small().weak());
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Recover").clicked() {
|
||||
act = Some((e.clone(), true));
|
||||
}
|
||||
if ui.button("Discard").clicked() {
|
||||
act = Some((e.clone(), false));
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Later").on_hover_text("Keep them; ask again next launch").clicked() {
|
||||
later = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
let dir_s = dir.map(|d| d.to_string_lossy().to_string());
|
||||
if let Some((e, recover)) = act {
|
||||
app.recoverable.retain(|x| x.id != e.id);
|
||||
if recover {
|
||||
if app.session.is_dirty() {
|
||||
app.set_status("save or discard the open design's changes before recovering another", true);
|
||||
app.recoverable.insert(0, e);
|
||||
return;
|
||||
}
|
||||
if app.run("doc.recover", json!({"id": e.id, "dir": dir_s})).is_ok() {
|
||||
// Ours now: autosave it under this app's entry before the old entry goes.
|
||||
let kept = app.autosave.as_mut().map(|a| a.save(&app.session));
|
||||
if let Some(Ok(_)) = kept {
|
||||
let _ = app.run("doc.recovery_discard", json!({"id": e.id, "dir": dir_s}));
|
||||
}
|
||||
app.set_status(format!("recovered {}: save it to keep it", e.name), false);
|
||||
}
|
||||
} else {
|
||||
let _ = app.run("doc.recovery_discard", json!({"id": e.id, "dir": dir_s}));
|
||||
}
|
||||
}
|
||||
if later {
|
||||
app.recoverable.clear();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user