mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 11:17:01 -06:00
- Every Preferences > File Handling > Data Recovery interval, modified documents
changed since their last copy are written to the recovery store in the
background (the same snapshot and encoder as Background Save; the status bar
shows it). Complex documents (over 20,000 objects) are skipped while Turn off
Data Recovery for complex documents is on.
- A copy goes when its document is saved, reverted, closed or undone back to its
saved state, and when the app quits with nothing unsaved.
- Each running app keeps its copies in an area of its own: a sub-folder whose
.lock file it keeps locked for its lifetime (File::try_lock), or on the web a
heartbeat refreshed every minute. Only areas nobody holds (lock free, or no
heartbeat for three intervals, at least three minutes) are offered, and an
area is held while it is restored or discarded, so a second running instance
never offers or deletes a live instance's copies. Empty leftover areas are
tidied away.
- The next launch after a crash asks with the Recover Documents dialog (Restore /
Discard / Cancel): restored documents open as "<name> [Recovered]", unsaved, and
Save asks where to save them, suggesting their original file; the copy moves
into the new app's area.
- Store: the recoveryFolder preference, else Data Recovery beside the desktop
app's preferences; browser storage on the web (Services::recovery_store);
tests use an in-memory store (with in-memory locks, or heartbeats).
- Engine: file.recovery.save, file.recovery.list, file.recovery.restore {file?},
file.recovery.discard {file?}; DocState.recovered and DocState.recovery.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
362 lines
18 KiB
Rust
362 lines
18 KiB
Rust
//! Data Recovery: `file.recovery.save/list/restore/discard`, copies removed on save, revert and
|
|
//! close, complex documents skipped, each app's own area (another running app's copies are never
|
|
//! offered: locks, heartbeats), and the folder store with real file locks.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use serde_json::json;
|
|
|
|
use super::*;
|
|
use crate::cmd::recovery::{FolderStore, MemoryStore, RecoveryStore};
|
|
|
|
/// A session keeping its copies in `store`, with one modified document.
|
|
fn session_in(store: &Arc<MemoryStore>) -> Session {
|
|
let mut s = Session::new();
|
|
s.recovery.set_store(store.clone());
|
|
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
|
|
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap();
|
|
s
|
|
}
|
|
|
|
/// A session keeping its copies in memory (the store is returned to look inside), with one
|
|
/// modified document.
|
|
fn session() -> (Session, Arc<MemoryStore>) {
|
|
let store = Arc::new(MemoryStore::default());
|
|
(session_in(&store), store)
|
|
}
|
|
|
|
/// The copies in the store, without their areas.
|
|
fn copies(store: &dyn RecoveryStore) -> Vec<String> {
|
|
let mut v: Vec<String> =
|
|
store.list().unwrap().iter().filter_map(|n| n.strip_suffix(".vectorcraft")).map(|n| n.split_once('/').unwrap().1.to_string()).collect();
|
|
v.sort();
|
|
v
|
|
}
|
|
|
|
/// The titles of the copies `file.recovery.list` offers after a crash (neither open here nor
|
|
/// kept by a running app).
|
|
fn offered(s: &mut Session) -> Vec<String> {
|
|
let l = s.execute("file.recovery.list", &json!({})).unwrap();
|
|
l["copies"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.filter(|c| c["open"] == false && c["running"] == false)
|
|
.map(|c| c["title"].as_str().unwrap().to_string())
|
|
.collect()
|
|
}
|
|
|
|
/// A crash: a new session over the same store (the copies stay behind; dropping the old session
|
|
/// releases its lock, as the system does for a process that ends).
|
|
fn relaunch(store: &Arc<MemoryStore>) -> Session {
|
|
let mut s = Session::new();
|
|
s.recovery.set_store(store.clone());
|
|
s
|
|
}
|
|
|
|
fn restored(r: serde_json::Value) -> usize {
|
|
r["restored"].as_array().unwrap().len()
|
|
}
|
|
|
|
#[test]
|
|
fn recovery_save_writes_modified_documents_once() {
|
|
let (mut s, store) = session();
|
|
// A clean second document gets no copy.
|
|
s.execute("file.new", &json!({"width": 50, "height": 50})).unwrap();
|
|
let r = s.execute("file.recovery.save", &json!({})).unwrap();
|
|
assert_eq!(r["saved"].as_array().unwrap().len(), 1, "{r}");
|
|
let area = s.recovery.own_area().unwrap().to_string();
|
|
assert_eq!(r["saved"][0]["file"], format!("{area}/Untitled-1-1"));
|
|
assert_eq!(store.list().unwrap(), [format!("{area}/Untitled-1-1.json"), format!("{area}/Untitled-1-1.vectorcraft")]);
|
|
let doc = store.read(&format!("{area}/Untitled-1-1.vectorcraft")).unwrap();
|
|
assert_eq!(vectorcraft_format::load(&doc).unwrap().node_count(), s.documents()[0].doc.node_count());
|
|
// Unchanged since: nothing written again.
|
|
let again = s.execute("file.recovery.save", &json!({})).unwrap();
|
|
assert!(again["saved"].as_array().unwrap().is_empty());
|
|
assert_eq!(again["skipped"][0]["reason"], "unchanged since its last recovery copy");
|
|
// Changed: the same copy is rewritten.
|
|
s.execute("document.activate", &json!({"index": 0})).unwrap();
|
|
s.execute("shape.ellipse", &json!({"x": 100, "y": 10, "width": 20, "height": 20})).unwrap();
|
|
let r = s.execute("file.recovery.save", &json!({})).unwrap();
|
|
assert_eq!(r["saved"][0]["file"], format!("{area}/Untitled-1-1"));
|
|
assert_eq!(copies(store.as_ref()), ["Untitled-1-1"]);
|
|
// The list shows it as the copy of an open document, not one to offer.
|
|
let l = s.execute("file.recovery.list", &json!({})).unwrap();
|
|
assert_eq!((l["copies"][0]["title"].as_str(), l["copies"][0]["open"].as_bool()), (Some("Untitled-1"), Some(true)));
|
|
assert_eq!(l["location"], "memory");
|
|
assert!(offered(&mut s).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn a_crash_leaves_copies_that_restore_modified_and_titled() {
|
|
let (mut s, store) = session();
|
|
s.doc_mut().unwrap().path = Some("/art/Poster.svg".into());
|
|
s.doc_mut().unwrap().format = "svg";
|
|
s.execute("file.recovery.save", &json!({})).unwrap();
|
|
let nodes = s.doc().unwrap().doc.node_count();
|
|
let crashed = s.recovery.own_area().unwrap().to_string();
|
|
drop(s);
|
|
|
|
let mut s = relaunch(&store);
|
|
let l = s.execute("file.recovery.list", &json!({})).unwrap();
|
|
let copy = &l["copies"][0];
|
|
assert_eq!(copy["file"], format!("{crashed}/Poster-1"));
|
|
assert_eq!((copy["path"].as_str(), copy["open"].as_bool(), copy["running"].as_bool()), (Some("/art/Poster.svg"), Some(false), Some(false)));
|
|
let r = s.execute("file.recovery.restore", &json!({})).unwrap();
|
|
assert_eq!(r["restored"][0]["title"], "Poster [Recovered]");
|
|
let st = s.doc().unwrap();
|
|
assert_eq!(st.title(), "Poster [Recovered]");
|
|
assert!(st.is_dirty() && st.recovered, "restored documents are unsaved");
|
|
assert_eq!((st.path.as_deref(), st.format, st.doc.node_count()), (Some("/art/Poster.svg"), "svg", nodes));
|
|
// The copy moved into this session's area; the crashed one's area is gone.
|
|
let mine = s.recovery.own_area().unwrap().to_string();
|
|
assert_eq!(st.recovery.as_ref().unwrap().file, format!("{mine}/Poster-1"));
|
|
assert_eq!(store.areas().unwrap(), std::slice::from_ref(&mine));
|
|
// Save asks for a path (suggesting the original file) rather than overwriting it.
|
|
let saved = s.execute("document.save", &json!({})).unwrap();
|
|
assert!(saved.get("dataBase64").is_some(), "no path: {saved}");
|
|
assert_eq!(saved["name"], "Poster.svg");
|
|
// Restored once: nothing is left to restore.
|
|
assert_eq!(restored(s.execute("file.recovery.restore", &json!({})).unwrap()), 0);
|
|
assert!(s.execute("file.recovery.restore", &json!({"file": format!("{mine}/Poster-1")})).is_err(), "an open document's copy");
|
|
}
|
|
|
|
#[test]
|
|
fn another_running_apps_copies_are_never_offered() {
|
|
let store = Arc::new(MemoryStore::default());
|
|
let mut a = session_in(&store);
|
|
a.execute("file.recovery.save", &json!({})).unwrap();
|
|
let a_file = format!("{}/Untitled-1-1", a.recovery.own_area().unwrap());
|
|
// B runs at the same time: A's copy is listed as running, never offered or taken.
|
|
let mut b = session_in(&store);
|
|
b.execute("file.recovery.save", &json!({})).unwrap();
|
|
let l = b.execute("file.recovery.list", &json!({})).unwrap();
|
|
let rows = l["copies"].as_array().unwrap();
|
|
assert_eq!(rows.len(), 2);
|
|
assert!(rows.iter().any(|c| c["file"] == a_file.as_str() && c["running"] == true));
|
|
assert!(offered(&mut b).is_empty());
|
|
assert_eq!(restored(b.execute("file.recovery.restore", &json!({})).unwrap()), 0);
|
|
assert!(b.execute("file.recovery.discard", &json!({})).unwrap()["discarded"].as_array().unwrap().is_empty());
|
|
let e = b.execute("file.recovery.discard", &json!({"file": a_file})).unwrap_err().to_string();
|
|
assert!(e.contains("running"), "{e}");
|
|
assert_eq!(copies(store.as_ref()).len(), 2, "nothing deleted");
|
|
// A quits normally: its area goes, and B still has nothing to offer.
|
|
a.execute("file.close", &json!({})).unwrap();
|
|
cmd::recovery::forget_all(&mut a);
|
|
assert!(offered(&mut b).is_empty());
|
|
assert_eq!(copies(store.as_ref()), ["Untitled-1-1"], "B's own copy");
|
|
}
|
|
|
|
#[test]
|
|
fn a_crashed_apps_copies_are_offered_once_its_lock_is_released() {
|
|
let store = Arc::new(MemoryStore::default());
|
|
let mut a = session_in(&store);
|
|
a.execute("file.recovery.save", &json!({})).unwrap();
|
|
let mut b = relaunch(&store);
|
|
assert!(offered(&mut b).is_empty(), "A still runs");
|
|
drop(a);
|
|
assert_eq!(offered(&mut b), ["Untitled-1"]);
|
|
// Restored by B, the copy is B's: C, launched at the same time, can't take it.
|
|
let mut c = relaunch(&store);
|
|
assert_eq!(restored(b.execute("file.recovery.restore", &json!({})).unwrap()), 1);
|
|
assert!(offered(&mut c).is_empty(), "B owns the copy now");
|
|
assert_eq!(restored(c.execute("file.recovery.restore", &json!({})).unwrap()), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn heartbeats_hold_areas_where_the_store_has_no_locks() {
|
|
let store = Arc::new(MemoryStore::without_locks());
|
|
store.set_now(10_000);
|
|
let mut a = session_in(&store);
|
|
a.execute("file.recovery.save", &json!({})).unwrap();
|
|
let mut b = relaunch(&store);
|
|
b.prefs.autosave_interval = 2;
|
|
// Three intervals (6 minutes) without a heartbeat: until then A counts as running.
|
|
store.set_now(10_000 + 359);
|
|
assert!(offered(&mut b).is_empty());
|
|
// A beats (as the app does every minute): fresh again.
|
|
cmd::recovery::heartbeat(&a);
|
|
store.set_now(10_000 + 359 + 300);
|
|
assert!(offered(&mut b).is_empty());
|
|
assert_eq!(restored(b.execute("file.recovery.restore", &json!({})).unwrap()), 0);
|
|
// A stops beating (it crashed): its copies are offered once three intervals have passed.
|
|
drop(a);
|
|
store.set_now(10_000 + 359 + 361);
|
|
assert_eq!(offered(&mut b), ["Untitled-1"]);
|
|
let mut c = relaunch(&store);
|
|
// B restores the copies one by one (as the dialog does): its own heartbeat on A's area doesn't
|
|
// stop it, and C sees the area taken.
|
|
let file = b.execute("file.recovery.list", &json!({})).unwrap()["copies"][0]["file"].as_str().unwrap().to_string();
|
|
b.execute("file.recovery.restore", &json!({"file": file})).unwrap();
|
|
assert!(offered(&mut c).is_empty());
|
|
assert_eq!(b.doc().unwrap().title(), "Untitled-1 [Recovered]");
|
|
// Never under three minutes, whatever the interval.
|
|
let store = Arc::new(MemoryStore::without_locks());
|
|
let mut a = session_in(&store);
|
|
a.execute("file.recovery.save", &json!({})).unwrap();
|
|
drop(a);
|
|
let mut b = relaunch(&store);
|
|
b.prefs.autosave_interval = 1;
|
|
store.set_now(1_000_000 + 179);
|
|
assert!(offered(&mut b).is_empty());
|
|
store.set_now(1_000_000 + 181);
|
|
assert_eq!(offered(&mut b), ["Untitled-1"]);
|
|
}
|
|
|
|
#[test]
|
|
fn folder_areas_are_locked_by_running_apps() {
|
|
let dir = std::env::temp_dir().join(format!("vc-recovery-locks-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
let folder = dir.to_string_lossy().to_string();
|
|
let app = |dirty: bool| {
|
|
let mut s = Session::new();
|
|
s.prefs.recovery_folder = folder.clone();
|
|
s.execute("file.new", &json!({"width": 50, "height": 50})).unwrap();
|
|
if dirty {
|
|
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap();
|
|
}
|
|
s
|
|
};
|
|
let mut a = app(true);
|
|
a.execute("file.recovery.save", &json!({})).unwrap();
|
|
let area = a.recovery.own_area().unwrap().to_string();
|
|
assert!(dir.join(&area).join(".lock").is_file() && dir.join(&area).join("Untitled-1-1.vectorcraft").is_file());
|
|
// The lock file is held: another app (another handle) can't take it.
|
|
let store = FolderStore::new(&dir);
|
|
assert!(matches!(store.lock(&area), Ok(cmd::recovery::Lock::Busy)));
|
|
let mut b = app(false);
|
|
assert!(offered(&mut b).is_empty());
|
|
assert!(b.execute("file.recovery.discard", &json!({})).unwrap()["discarded"].as_array().unwrap().is_empty());
|
|
assert!(dir.join(&area).join("Untitled-1-1.vectorcraft").is_file());
|
|
// A crashes (its handle closes): B offers its copy, and restoring it tidies A's folder away.
|
|
drop(a);
|
|
assert_eq!(offered(&mut b), ["Untitled-1"]);
|
|
b.execute("file.recovery.restore", &json!({})).unwrap();
|
|
assert!(!dir.join(&area).exists(), "the crashed app's folder is removed");
|
|
let mine = b.recovery.own_area().unwrap().to_string();
|
|
assert!(dir.join(&mine).join("Untitled-1-1.vectorcraft").is_file());
|
|
// An empty folder a crash left (no copies) is tidied by the next list.
|
|
std::fs::create_dir_all(dir.join("123-1")).unwrap();
|
|
std::fs::write(dir.join("123-1").join(".lock"), b"").unwrap();
|
|
b.execute("file.recovery.list", &json!({})).unwrap();
|
|
assert!(!dir.join("123-1").exists());
|
|
// B quits with nothing unsaved: its folder goes too.
|
|
b.execute("file.close", &json!({})).unwrap();
|
|
b.execute("file.close", &json!({})).unwrap();
|
|
cmd::recovery::forget_all(&mut b);
|
|
assert!(!dir.join(&mine).exists());
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn saving_reverting_or_closing_removes_the_copy() {
|
|
let dir = std::env::temp_dir().join(format!("vc-recovery-save-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let path = dir.join("doc.vectorcraft").to_string_lossy().to_string();
|
|
// Save to a file.
|
|
let (mut s, store) = session();
|
|
s.execute("file.recovery.save", &json!({})).unwrap();
|
|
assert_eq!(copies(store.as_ref()).len(), 1);
|
|
s.execute("document.save", &json!({"path": path})).unwrap();
|
|
assert!(copies(store.as_ref()).is_empty(), "saved: the copy goes");
|
|
assert!(s.doc().unwrap().recovery.is_none());
|
|
// Revert.
|
|
s.execute("shape.ellipse", &json!({"x": 100, "y": 10, "width": 20, "height": 20})).unwrap();
|
|
s.execute("file.recovery.save", &json!({})).unwrap();
|
|
assert_eq!(copies(store.as_ref()).len(), 1);
|
|
s.execute("file.revert", &json!({})).unwrap();
|
|
assert!(copies(store.as_ref()).is_empty(), "reverted: the copy goes");
|
|
// Close (discarding the changes).
|
|
s.execute("shape.ellipse", &json!({"x": 100, "y": 10, "width": 20, "height": 20})).unwrap();
|
|
s.execute("file.recovery.save", &json!({})).unwrap();
|
|
s.execute("file.close", &json!({})).unwrap();
|
|
assert!(copies(store.as_ref()).is_empty(), "closed: the copy goes");
|
|
// A restored document's copy goes once it is saved.
|
|
let (mut s, store) = session();
|
|
s.execute("file.recovery.save", &json!({})).unwrap();
|
|
drop(s);
|
|
let mut s2 = relaunch(&store);
|
|
assert_eq!(restored(s2.execute("file.recovery.restore", &json!({})).unwrap()), 1);
|
|
s2.execute("document.save", &json!({"path": path})).unwrap();
|
|
assert!(copies(store.as_ref()).is_empty());
|
|
let st = s2.doc().unwrap();
|
|
assert!(!st.recovered && !st.is_dirty());
|
|
assert_eq!(st.title(), "doc.vectorcraft");
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn undoing_back_to_the_saved_state_drops_the_copy() {
|
|
let (mut s, store) = session();
|
|
s.execute("file.recovery.save", &json!({})).unwrap();
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
assert!(!s.doc().unwrap().is_dirty());
|
|
let r = s.execute("file.recovery.save", &json!({})).unwrap();
|
|
assert!(r["saved"].as_array().unwrap().is_empty() && r["skipped"].as_array().unwrap().is_empty(), "{r}");
|
|
assert!(copies(store.as_ref()).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn complex_documents_are_skipped_when_the_preference_says() {
|
|
let store = Arc::new(MemoryStore::default());
|
|
let mut s = Session::new();
|
|
s.recovery.set_store(store.clone());
|
|
let mut d = vectorcraft_doc::Document::new(1000.0, 1000.0);
|
|
let layer = d.layers[0].id;
|
|
for i in 0..cmd::recovery::COMPLEX_OBJECTS {
|
|
let id = d.alloc_id();
|
|
let r = vectorcraft_geom::Rect::new((i % 200) as f64, (i / 200) as f64, (i % 200) as f64 + 1.0, (i / 200) as f64 + 1.0);
|
|
let n = vectorcraft_doc::Node::path(id, vectorcraft_geom::shapes::rectangle(r), vectorcraft_doc::Appearance::default_art());
|
|
d.insert(Some(layer), i, n).unwrap();
|
|
}
|
|
s.add_document(d, None);
|
|
// One more object makes it complex (and modified).
|
|
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap();
|
|
s.prefs.recovery_off_for_complex = true;
|
|
let r = s.execute("file.recovery.save", &json!({})).unwrap();
|
|
assert!(r["saved"].as_array().unwrap().is_empty());
|
|
assert!(r["skipped"][0]["reason"].as_str().unwrap().starts_with("complex document"), "{r}");
|
|
assert!(store.list().unwrap().is_empty(), "no copy, no area");
|
|
s.prefs.recovery_off_for_complex = false;
|
|
assert_eq!(s.execute("file.recovery.save", &json!({})).unwrap()["saved"].as_array().unwrap().len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn discard_deletes_copies_left_behind_only() {
|
|
let (mut s, store) = session();
|
|
s.execute("file.recovery.save", &json!({})).unwrap();
|
|
let crashed = s.recovery.own_area().unwrap().to_string();
|
|
drop(s);
|
|
let mut s = relaunch(&store);
|
|
s.execute("file.new", &json!({"width": 50, "height": 50})).unwrap();
|
|
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap();
|
|
s.execute("file.recovery.save", &json!({})).unwrap();
|
|
assert_eq!(copies(store.as_ref()).len(), 2);
|
|
assert!(s.execute("file.recovery.discard", &json!({"file": "nope"})).is_err());
|
|
// The open document's copy stays.
|
|
let r = s.execute("file.recovery.discard", &json!({})).unwrap();
|
|
assert_eq!(r["discarded"], json!([format!("{crashed}/Untitled-1-1")]));
|
|
let mine = s.recovery.own_area().unwrap();
|
|
assert_eq!(store.list().unwrap(), [format!("{mine}/Untitled-1-1.json"), format!("{mine}/Untitled-1-1.vectorcraft")]);
|
|
}
|
|
|
|
#[test]
|
|
fn without_a_store_the_commands_say_where_to_set_one() {
|
|
let mut s = Session::new();
|
|
let e = s.execute("file.recovery.list", &json!({})).unwrap_err().to_string();
|
|
assert!(e.contains("recoveryFolder"), "{e}");
|
|
// The preference names a folder: copies go there.
|
|
let dir = std::env::temp_dir().join(format!("vc-recovery-folder-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
s.prefs.recovery_folder = dir.to_string_lossy().to_string();
|
|
assert!(s.execute("file.recovery.list", &json!({})).unwrap()["copies"].as_array().unwrap().is_empty(), "no folder yet: no copies");
|
|
s.execute("file.new", &json!({"width": 50, "height": 50})).unwrap();
|
|
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap();
|
|
s.execute("file.recovery.save", &json!({})).unwrap();
|
|
let area = dir.join(s.recovery.own_area().unwrap());
|
|
assert!(area.join("Untitled-1-1.vectorcraft").is_file() && area.join("Untitled-1-1.json").is_file());
|
|
s.execute("file.close", &json!({})).unwrap();
|
|
assert!(!area.join("Untitled-1-1.vectorcraft").exists());
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|