mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 13:33:19 +00:00
Merge pull request #6766 from dotCooCoo/fix/recovery-retake-lock
Data Recovery: a running app locks its area again when the lock file is removed
This commit is contained in:
@@ -17,7 +17,9 @@
|
||||
//! store can, an announcement that lasts as long as the app ([`RecoveryStore::announce`]: the
|
||||
//! browser's Web Locks). Only areas nobody holds any more (their app is gone) are offered, and
|
||||
//! while their copies are restored or discarded the area is held, so two apps launched together
|
||||
//! never both take it.
|
||||
//! never both take it. An area's lock file or folder can be removed while its app runs, so each
|
||||
//! recovery save and heartbeat checks that the lock still holds the area, and locks it again or
|
||||
//! takes a new area.
|
||||
//!
|
||||
//! A heartbeat alone can't tell a gone app from one whose timers stopped (a background tab), so
|
||||
//! when unsure an area counts as held, and its app repairs what another app did meanwhile: each
|
||||
@@ -100,6 +102,12 @@ pub trait RecoveryStore: Send + Sync {
|
||||
fn lock(&self, _area: &str) -> std::result::Result<Lock, String> {
|
||||
Ok(Lock::Unsupported)
|
||||
}
|
||||
/// Does `hold`, which [`RecoveryStore::lock`] gave for area `area`, still hold it? A lock on a
|
||||
/// file or folder that was removed or replaced since holds nothing; when that can't be told now,
|
||||
/// it does (default: it does).
|
||||
fn still_held(&self, _area: &str, _hold: &(dyn std::any::Any + Send + Sync)) -> bool {
|
||||
true
|
||||
}
|
||||
/// Stores without locks: also hold area `area` in a way other apps can see that lasts exactly as
|
||||
/// long as this app runs, if the store has one (the browser's Web Locks) → it, until dropped.
|
||||
fn announce(&self, _area: &str) -> Option<Hold> {
|
||||
@@ -416,6 +424,16 @@ impl RecoveryStore for FolderStore {
|
||||
Self::create_area(&dir)?;
|
||||
Self::lock_area(&dir)
|
||||
}
|
||||
fn still_held(&self, area: &str, hold: &(dyn std::any::Any + Send + Sync)) -> bool {
|
||||
let (Some(file), Ok(dir)) = (hold.downcast_ref::<std::fs::File>(), self.area(area)) else { return true };
|
||||
let path = dir.join(LOCK_FILE);
|
||||
// Only a lock file or folder that is gone, or another one in its place, ends the hold. One
|
||||
// that can't be read now (a network folder that doesn't answer) still counts as held.
|
||||
match std::fs::symlink_metadata(&path) {
|
||||
Ok(_) => Self::own_area(&dir).is_ok() && Self::still_named(file, &path),
|
||||
Err(e) => e.kind() != std::io::ErrorKind::NotFound,
|
||||
}
|
||||
}
|
||||
fn remove_area(&self, area: &str) {
|
||||
// Best effort: whatever is left is tidied at a later launch. A folder that isn't empty
|
||||
// (copies came back meanwhile) stays. The caller still holds the lock here.
|
||||
@@ -457,6 +475,10 @@ pub struct Recovery {
|
||||
/// heartbeat it wrote there: that one doesn't make them look running, a newer one does (their
|
||||
/// app was only paused and came back).
|
||||
adopted: BTreeMap<String, i64>,
|
||||
/// Copies in an area this session had to leave (`keep_hold`), by document uid, with their
|
||||
/// store: each stays until its document has a copy in the new area, or needs none. They are
|
||||
/// still this session's: never offered or discarded as a crash's here.
|
||||
left: BTreeMap<u64, (Arc<dyn RecoveryStore>, String)>,
|
||||
}
|
||||
|
||||
impl Recovery {
|
||||
@@ -526,14 +548,81 @@ fn beating(store: &dyn RecoveryStore, area: &str, stale: i64, mine: Option<i64>)
|
||||
|
||||
/// Refresh this session's hold on its area (the app does this every [`HEARTBEAT_EVERY`] seconds,
|
||||
/// so also on its first frame after its timers were paused): its heartbeat in a store without
|
||||
/// locks, and its copies ([`repair`]) → the documents whose copies went missing (write them now).
|
||||
/// Best effort: a missed beat only lets another app offer the copies once three intervals have
|
||||
/// passed.
|
||||
/// locks, and its copies ([`repair`]) → the documents whose copies went missing (write them now),
|
||||
/// and those whose only copy is in an area the session left (`keep_hold`). Best effort: a missed
|
||||
/// beat only lets another app offer the copies once three intervals have passed.
|
||||
pub fn heartbeat(s: &mut Session) -> Vec<u64> {
|
||||
let mut lost = keep_hold(s);
|
||||
if let Some(own) = s.recovery.own.as_ref().filter(|o| o.beats) {
|
||||
let _ = beat(own.store.as_ref(), &own.area);
|
||||
}
|
||||
repair(s)
|
||||
// A copy that was being written when the session left its area finished there. A document
|
||||
// that gets no copy now (a complex one) keeps the copy it has.
|
||||
lost.extend(
|
||||
s.recovery
|
||||
.left
|
||||
.keys()
|
||||
.filter(|&&uid| s.documents().iter().any(|d| d.uid == uid && d.recovery.is_none() && skip_reason(s, d).is_none()))
|
||||
.copied(),
|
||||
);
|
||||
lost.extend(repair(s));
|
||||
lost.sort_unstable();
|
||||
lost.dedup();
|
||||
lost
|
||||
}
|
||||
|
||||
/// Check that this session's lock still holds its area (stores with locks). The area's lock file,
|
||||
/// or the whole area, can be removed while the app runs (by the user, a cleaner, another program),
|
||||
/// and then another app can lock the area and offer or delete this session's copies as a crash's.
|
||||
/// The area is locked again, made again first if it went (its copies are then written again).
|
||||
/// When another app holds it now, the session takes a new area, and each copy left in the old one
|
||||
/// stays until its document has a copy in the new one. → the documents whose copies must be
|
||||
/// written again.
|
||||
fn keep_hold(s: &mut Session) -> Vec<u64> {
|
||||
drop_left_done(s);
|
||||
let Some(own) = &mut s.recovery.own else { return vec![] };
|
||||
if own.beats || own.hold.as_deref().is_none_or(|h| own.store.still_held(&own.area, h)) {
|
||||
return vec![];
|
||||
}
|
||||
let (store, area) = (own.store.clone(), own.area.clone());
|
||||
// The lock held is on a file the area no longer has, so it holds nothing: it goes first (a
|
||||
// removed file this app still has open can keep a new lock file from being made).
|
||||
own.hold = None;
|
||||
if let Ok(Lock::Held(h)) = store.lock(&area) {
|
||||
own.hold = Some(h);
|
||||
return repair(s);
|
||||
}
|
||||
let mut lost = vec![];
|
||||
for st in &mut s.docs {
|
||||
if let Some(c) = st.recovery.take() {
|
||||
s.recovery.left.insert(st.uid, (store.clone(), c.file));
|
||||
lost.push(st.uid);
|
||||
}
|
||||
}
|
||||
s.recovery.own = None;
|
||||
// Best effort: when no area can be taken now, the next recovery save says why.
|
||||
let _ = claim(s, &store);
|
||||
lost
|
||||
}
|
||||
|
||||
/// The copies left in an area this session left whose documents were saved, reverted or closed
|
||||
/// since go.
|
||||
fn drop_left_done(s: &mut Session) {
|
||||
let done: Vec<u64> = s.recovery.left.keys().filter(|&&uid| !s.documents().iter().any(|d| d.uid == uid && d.is_dirty())).copied().collect();
|
||||
for uid in done {
|
||||
drop_left(s, uid);
|
||||
}
|
||||
}
|
||||
|
||||
/// Document `uid`'s copy in an area this session left goes. A copy that can't be removed now stays
|
||||
/// this session's, and a later heartbeat removes it.
|
||||
fn drop_left(s: &mut Session, uid: u64) {
|
||||
let Some((store, file)) = s.recovery.left.get(&uid) else { return };
|
||||
let doc = store.remove(&format!("{file}{DOC_EXT}"));
|
||||
let meta = store.remove(&format!("{file}{META_EXT}"));
|
||||
if doc.is_ok() && meta.is_ok() {
|
||||
s.recovery.left.remove(&uid);
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget this session's copies that are missing from its area, so the next recovery save writes
|
||||
@@ -555,10 +644,13 @@ pub fn repair(s: &mut Session) -> Vec<u64> {
|
||||
/// This session's area in `store`, taken now if it has none there yet: a fresh area, held for the
|
||||
/// session's lifetime (locked, or with a heartbeat and an announcement where the store has them).
|
||||
pub fn claim(s: &mut Session, store: &Arc<dyn RecoveryStore>) -> Result<String> {
|
||||
if let Some(own) = &s.recovery.own {
|
||||
if own.store.location() == store.location() {
|
||||
if s.recovery.own.as_ref().is_some_and(|o| o.store.location() == store.location()) {
|
||||
// Documents whose copies went are left without one, so the caller writes them again.
|
||||
keep_hold(s);
|
||||
if let Some(own) = &s.recovery.own {
|
||||
return Ok(own.area.clone());
|
||||
}
|
||||
} else if s.recovery.own.is_some() {
|
||||
// The store moved (the preference names another folder): copies start again there.
|
||||
release(s);
|
||||
}
|
||||
@@ -588,6 +680,11 @@ pub fn release(s: &mut Session) {
|
||||
for st in &mut s.docs {
|
||||
st.recovery = None;
|
||||
}
|
||||
// Copies left in an area this session had to leave go too.
|
||||
let left: Vec<u64> = s.recovery.left.keys().copied().collect();
|
||||
for uid in left {
|
||||
drop_left(s, uid);
|
||||
}
|
||||
let Some(Own { store, area, hold, .. }) = s.recovery.own.take() else { return };
|
||||
let mine = |n: &String| n.split_once('/').is_some_and(|(a, _)| a == area);
|
||||
for name in store.list().unwrap_or_default().iter().filter(|n| mine(n)) {
|
||||
@@ -750,12 +847,16 @@ pub struct RecoveryJob {
|
||||
pub title: String,
|
||||
meta: Meta,
|
||||
save: SaveJob,
|
||||
/// The store the copy goes to (the preference can name another folder before it is finished).
|
||||
store: Arc<dyn RecoveryStore>,
|
||||
}
|
||||
|
||||
impl RecoveryJob {
|
||||
/// Encode the snapshot and store it with its details and the images the area doesn't have
|
||||
/// yet → `{file, title, bytes (the document's), imageBytes (images written now)}`.
|
||||
pub fn write(&self, store: &dyn RecoveryStore) -> std::result::Result<Value, String> {
|
||||
/// yet, in the store the job was made for → `{file, title, bytes (the document's), imageBytes
|
||||
/// (images written now)}`.
|
||||
pub fn write(&self) -> std::result::Result<Value, String> {
|
||||
let store = self.store.as_ref();
|
||||
let doc = self.save.doc();
|
||||
// A linked image keeps its preview, as a save writes it.
|
||||
let linked = doc.linked_only_images();
|
||||
@@ -808,13 +909,35 @@ impl RecoveryJob {
|
||||
|
||||
/// Once stored: the document (if still open) remembers the copy, so it isn't written again
|
||||
/// while unchanged and goes when the document is saved or closed. A document saved or closed
|
||||
/// meanwhile has nothing left to recover: its copy goes at once.
|
||||
/// meanwhile has nothing left to recover: its copy goes at once. So does a copy written in an
|
||||
/// area the session left meanwhile (`keep_hold`), and its document gets a new one.
|
||||
pub fn finish(self, s: &mut Session) {
|
||||
let snapshot = self.save.snapshot().clone();
|
||||
let copy = RecoveryCopy { file: self.file.clone(), doc: snapshot };
|
||||
let area = self.file.split_once('/').map(|(a, _)| a);
|
||||
let current = s.recovery.own.as_ref().is_some_and(|o| o.store.location() == self.store.location() && Some(o.area.as_str()) == area);
|
||||
match s.document_mut(self.uid).filter(|st| st.is_dirty()) {
|
||||
Some(st) => st.recovery = Some(copy),
|
||||
None => remove_copy(s, &self.file),
|
||||
Some(st) if current => {
|
||||
st.recovery = Some(copy);
|
||||
drop_left(s, self.uid);
|
||||
}
|
||||
// Written in an area left meanwhile: kept, in place of the copy left there before,
|
||||
// until the document has a copy in the new area.
|
||||
Some(st) if st.recovery.is_none() => {
|
||||
let before = s.recovery.left.insert(self.uid, (self.store.clone(), self.file.clone()));
|
||||
// The copy left there before, unless this one was written over it. Best effort,
|
||||
// as in `remove_copy`.
|
||||
if let Some((store, file)) = before.filter(|(old, f)| old.location() != self.store.location() || *f != self.file) {
|
||||
let _ = store.remove(&format!("{file}{DOC_EXT}"));
|
||||
let _ = store.remove(&format!("{file}{META_EXT}"));
|
||||
}
|
||||
}
|
||||
// Best effort, as in `remove_copy`.
|
||||
_ => {
|
||||
let _ = self.store.remove(&format!("{}{DOC_EXT}", self.file));
|
||||
let _ = self.store.remove(&format!("{}{META_EXT}", self.file));
|
||||
drop_left(s, self.uid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -867,6 +990,11 @@ fn fresh_name(area: &str, stem: &str, taken: &[String]) -> String {
|
||||
/// when it has none and a copy is needed) → (jobs, skipped: `[{title, reason}]` for modified
|
||||
/// documents that get none).
|
||||
pub fn jobs(s: &mut Session, store: &Arc<dyn RecoveryStore>) -> Result<(Vec<RecoveryJob>, Vec<Value>)> {
|
||||
// A lost lock is taken again first: copies that go with it are written again, even of
|
||||
// documents unchanged since.
|
||||
if s.recovery.own.as_ref().is_some_and(|o| o.store.location() == store.location()) {
|
||||
keep_hold(s);
|
||||
}
|
||||
// Copies another app removed are written again.
|
||||
repair(s);
|
||||
let skipped: Vec<Value> = s
|
||||
@@ -923,7 +1051,7 @@ pub fn jobs(s: &mut Session, store: &Arc<dyn RecoveryStore>) -> Result<(Vec<Reco
|
||||
// Filled in as the copy is written.
|
||||
images: BTreeMap::new(),
|
||||
};
|
||||
out.push(RecoveryJob { uid: st.uid, file, title, meta, save });
|
||||
out.push(RecoveryJob { uid: st.uid, file, title, meta, save, store: store.clone() });
|
||||
}
|
||||
Ok((out, skipped))
|
||||
}
|
||||
@@ -954,7 +1082,7 @@ pub(crate) fn save_copies(s: &mut Session) -> Result<()> {
|
||||
return Err(EngineError::Other("a document with unsaved changes has no recovery copy".into()));
|
||||
}
|
||||
for j in jobs {
|
||||
j.write(store.as_ref()).map_err(EngineError::Other)?;
|
||||
j.write().map_err(EngineError::Other)?;
|
||||
j.finish(s);
|
||||
}
|
||||
Ok(())
|
||||
@@ -966,6 +1094,8 @@ pub fn forget(s: &mut Session, uid: u64) {
|
||||
if s.script_in_run {
|
||||
return;
|
||||
}
|
||||
// So does a copy of it left in an area this session left.
|
||||
drop_left(s, uid);
|
||||
let Some(copy) = s.document_mut(uid).and_then(|st| st.recovery.take()) else { return };
|
||||
remove_copy(s, ©.file);
|
||||
}
|
||||
@@ -975,12 +1105,16 @@ pub fn forget_all(s: &mut Session) {
|
||||
release(s);
|
||||
}
|
||||
|
||||
/// The copies of documents with no unsaved changes left (undone back to the saved state) go.
|
||||
/// The copies of documents with no unsaved changes left (undone back to the saved state) go, and
|
||||
/// the copies left in an area this session left of documents closed since.
|
||||
pub fn forget_clean(s: &mut Session) {
|
||||
let uids: Vec<u64> = s.documents().iter().filter(|d| !d.is_dirty() && d.recovery.is_some()).map(|d| d.uid).collect();
|
||||
for uid in uids {
|
||||
forget(s, uid);
|
||||
}
|
||||
if !s.script_in_run {
|
||||
drop_left_done(s);
|
||||
}
|
||||
}
|
||||
|
||||
fn save(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
@@ -988,7 +1122,7 @@ fn save(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let (jobs, skipped) = jobs(s, &store)?;
|
||||
let mut saved = vec![];
|
||||
for j in jobs {
|
||||
saved.push(j.write(store.as_ref()).map_err(EngineError::Other)?);
|
||||
saved.push(j.write().map_err(EngineError::Other)?);
|
||||
j.finish(s);
|
||||
}
|
||||
forget_clean(s);
|
||||
@@ -1057,9 +1191,15 @@ fn open_copies(s: &Session) -> Vec<String> {
|
||||
s.documents().iter().filter_map(|d| d.recovery.as_ref()).map(|c| c.file.clone()).collect()
|
||||
}
|
||||
|
||||
/// The copies of documents open now in this session that are in an area it left, in `store`.
|
||||
fn left_copies(s: &Session, store: &dyn RecoveryStore) -> Vec<String> {
|
||||
s.recovery.left.values().filter(|(st, _)| st.location() == store.location()).map(|(_, f)| f.clone()).collect()
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let store = need_store(s, "file.recovery.list")?;
|
||||
let open = open_copies(s);
|
||||
let left = left_copies(s, store.as_ref());
|
||||
let copies = copies_in(&store.list().map_err(EngineError::Other)?);
|
||||
let mut rows = vec![];
|
||||
for area in store.areas().map_err(EngineError::Other)? {
|
||||
@@ -1071,7 +1211,7 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let title = if meta.title.is_empty() { f.rsplit('/').next().unwrap_or(f).to_string() } else { meta.title };
|
||||
rows.push(json!({
|
||||
"file": f, "title": title, "path": meta.path, "format": meta.format, "saved": meta.saved,
|
||||
"open": who == Owner::Mine && open.contains(f), "running": who == Owner::Running,
|
||||
"open": (who == Owner::Mine && open.contains(f)) || left.contains(f), "running": who == Owner::Running,
|
||||
}));
|
||||
}
|
||||
// A gone app's empty area is tidied away.
|
||||
@@ -1095,7 +1235,14 @@ fn targets(s: &mut Session, store: &dyn RecoveryStore, cmd: &str, p: &Value) ->
|
||||
{
|
||||
return Err(bad(cmd, format!("no recovery copy `{f}` (see file.recovery.list)")));
|
||||
}
|
||||
let picked = |c: &str| file.is_none_or(|f| f == c);
|
||||
// A copy this session left in an area it had to leave is still its own.
|
||||
let left = left_copies(s, store);
|
||||
if let Some(f) = file
|
||||
&& left.iter().any(|c| c == f)
|
||||
{
|
||||
return Err(bad(cmd, format!("`{f}` is the copy of a document open now")));
|
||||
}
|
||||
let picked = |c: &str| file.is_none_or(|f| f == c) && !left.iter().any(|l| l == c);
|
||||
let areas: BTreeSet<&str> = copies.iter().filter(|(_, c)| picked(c)).map(|(a, _)| a.as_str()).collect();
|
||||
let mut out = vec![];
|
||||
for area in areas {
|
||||
|
||||
@@ -682,6 +682,343 @@ fn a_recovery_save_takes_the_next_area_when_the_first_is_tidied_away() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A session keeping its copies in folder `dir`, with one modified document whose copy is written.
|
||||
fn saved_in(dir: &std::path::Path) -> (Session, String) {
|
||||
let mut s = Session::new();
|
||||
s.prefs.recovery_folder = dir.to_string_lossy().to_string();
|
||||
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 = s.recovery.own_area().unwrap().to_string();
|
||||
(s, area)
|
||||
}
|
||||
|
||||
/// A new app over folder `dir`, with nothing open.
|
||||
#[cfg(unix)]
|
||||
fn other_app(dir: &std::path::Path) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.prefs.recovery_folder = dir.to_string_lossy().to_string();
|
||||
s
|
||||
}
|
||||
|
||||
/// The area's lock file was removed while its app runs: the next recovery save locks the area
|
||||
/// again, so another app neither offers nor discards its copy.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_recovery_save_locks_the_area_again_when_its_lock_file_was_removed() {
|
||||
use cmd::recovery::Lock;
|
||||
let dir = std::env::temp_dir().join(format!("vc-recovery-relock-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let (mut a, area) = saved_in(&dir);
|
||||
std::fs::remove_file(dir.join(&area).join(".lock")).unwrap();
|
||||
a.execute("file.recovery.save", &json!({})).unwrap();
|
||||
assert_eq!(a.recovery.own_area(), Some(area.as_str()));
|
||||
let mut b = other_app(&dir);
|
||||
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());
|
||||
assert!(matches!(FolderStore::new(&dir).lock(&area), Ok(Lock::Busy)));
|
||||
drop(a);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The whole area was removed while its app runs: the next recovery save makes it again, locked,
|
||||
/// with the copy written again.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_recovery_save_makes_the_area_again_when_it_was_removed() {
|
||||
use cmd::recovery::Lock;
|
||||
let dir = std::env::temp_dir().join(format!("vc-recovery-remade-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let (mut a, area) = saved_in(&dir);
|
||||
std::fs::remove_dir_all(dir.join(&area)).unwrap();
|
||||
let r = a.execute("file.recovery.save", &json!({})).unwrap();
|
||||
assert_eq!(r["saved"][0]["file"], format!("{area}/Untitled-1-1").as_str(), "{r}");
|
||||
assert!(dir.join(&area).join("Untitled-1-1.vectorcraft").is_file());
|
||||
assert!(matches!(FolderStore::new(&dir).lock(&area), Ok(Lock::Busy)));
|
||||
assert!(offered(&mut other_app(&dir)).is_empty());
|
||||
drop(a);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Another app locked the area after its lock file was removed: the next heartbeat takes a new
|
||||
/// area, and the document is to be copied again there. Its copy in the old area stays until then.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn an_area_another_app_locked_meanwhile_is_left_for_a_new_one() {
|
||||
use cmd::recovery::Lock;
|
||||
let dir = std::env::temp_dir().join(format!("vc-recovery-moved-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let (mut a, area) = saved_in(&dir);
|
||||
std::fs::remove_file(dir.join(&area).join(".lock")).unwrap();
|
||||
let store = FolderStore::new(&dir);
|
||||
let Ok(Lock::Held(other)) = store.lock(&area) else { panic!("the other app locks the area") };
|
||||
let uid = a.doc().unwrap().uid;
|
||||
assert_eq!(cmd::recovery::heartbeat(&mut a), [uid]);
|
||||
let mine = a.recovery.own_area().unwrap().to_string();
|
||||
assert_ne!(mine, area);
|
||||
assert!(a.doc().unwrap().recovery.is_none());
|
||||
assert!(dir.join(&area).join("Untitled-1-1.vectorcraft").is_file(), "kept until the new copy is written");
|
||||
let r = a.execute("file.recovery.save", &json!({})).unwrap();
|
||||
assert_eq!(r["saved"][0]["file"], format!("{mine}/Untitled-1-1").as_str(), "{r}");
|
||||
assert!(!dir.join(&area).join("Untitled-1-1.vectorcraft").exists(), "the copy left there goes");
|
||||
assert!(matches!(store.lock(&mine), Ok(Lock::Busy)));
|
||||
drop(other);
|
||||
assert!(offered(&mut other_app(&dir)).is_empty());
|
||||
drop(a);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A copy written in the background into the area the session left meanwhile is not the
|
||||
/// document's copy: it stays only until the document is copied again in the new area.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_copy_written_into_an_area_left_meanwhile_goes_once_copied_again() {
|
||||
use cmd::recovery::Lock;
|
||||
let dir = std::env::temp_dir().join(format!("vc-recovery-inflight-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let (mut a, area) = saved_in(&dir);
|
||||
a.execute("shape.ellipse", &json!({"x": 10, "y": 10, "width": 5, "height": 5})).unwrap();
|
||||
let store: Arc<dyn RecoveryStore> = Arc::new(FolderStore::new(&dir));
|
||||
let (jobs, _) = cmd::recovery::jobs(&mut a, &store).unwrap();
|
||||
assert_eq!(jobs.len(), 1);
|
||||
std::fs::remove_file(dir.join(&area).join(".lock")).unwrap();
|
||||
let Ok(Lock::Held(other)) = store.lock(&area) else { panic!("the other app locks the area") };
|
||||
cmd::recovery::heartbeat(&mut a);
|
||||
for j in jobs {
|
||||
j.write().unwrap();
|
||||
j.finish(&mut a);
|
||||
}
|
||||
assert!(a.doc().unwrap().recovery.is_none());
|
||||
assert!(dir.join(&area).join("Untitled-1-1.vectorcraft").is_file());
|
||||
let uid = a.doc().unwrap().uid;
|
||||
assert_eq!(cmd::recovery::heartbeat(&mut a), [uid], "the next heartbeat has it copied again");
|
||||
let r = a.execute("file.recovery.save", &json!({})).unwrap();
|
||||
assert_eq!(r["saved"].as_array().unwrap().len(), 1, "{r}");
|
||||
assert!(!dir.join(&area).join("Untitled-1-1.vectorcraft").exists());
|
||||
assert!(cmd::recovery::heartbeat(&mut a).is_empty());
|
||||
drop(other);
|
||||
assert!(offered(&mut other_app(&dir)).is_empty());
|
||||
drop(a);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Another app locked the area meanwhile and the document is unchanged since its copy: the next
|
||||
/// recovery save still writes the copy, in the new area.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_recovery_save_copies_unchanged_documents_into_the_new_area() {
|
||||
use cmd::recovery::Lock;
|
||||
let dir = std::env::temp_dir().join(format!("vc-recovery-unchanged-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let (mut a, area) = saved_in(&dir);
|
||||
std::fs::remove_file(dir.join(&area).join(".lock")).unwrap();
|
||||
let Ok(Lock::Held(other)) = FolderStore::new(&dir).lock(&area) else { panic!("the other app locks the area") };
|
||||
let r = a.execute("file.recovery.save", &json!({})).unwrap();
|
||||
let mine = a.recovery.own_area().unwrap().to_string();
|
||||
assert_ne!(mine, area);
|
||||
assert_eq!(r["saved"][0]["file"], format!("{mine}/Untitled-1-1").as_str(), "{r}");
|
||||
assert!(dir.join(&mine).join("Untitled-1-1.vectorcraft").is_file());
|
||||
assert!(!dir.join(&area).join("Untitled-1-1.vectorcraft").exists());
|
||||
drop(other);
|
||||
drop(a);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A copy left in an area the session had to leave goes at once when its document is saved,
|
||||
/// reverted or closed, and every one goes when the app quits with nothing unsaved.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn copies_left_behind_go_when_their_document_is_saved_reverted_or_closed() {
|
||||
use cmd::recovery::Lock;
|
||||
let base = std::env::temp_dir().join(format!("vc-recovery-leftgo-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
let dir = base.join("recovery");
|
||||
let path = base.join("doc.vectorcraft").to_string_lossy().to_string();
|
||||
let left = |area: &str| std::fs::read_dir(dir.join(area)).unwrap().flatten().any(|e| e.path().extension().is_some_and(|x| x == "vectorcraft"));
|
||||
for how in ["save", "revert", "close", "quit"] {
|
||||
let (mut a, _) = saved_in(&dir);
|
||||
a.execute("document.save", &json!({"path": path})).unwrap();
|
||||
a.execute("shape.ellipse", &json!({"x": 10, "y": 10, "width": 5, "height": 5})).unwrap();
|
||||
a.execute("file.recovery.save", &json!({})).unwrap();
|
||||
let area = a.recovery.own_area().unwrap().to_string();
|
||||
std::fs::remove_file(dir.join(&area).join(".lock")).unwrap();
|
||||
let Ok(Lock::Held(other)) = FolderStore::new(&dir).lock(&area) else { panic!("the other app locks the area") };
|
||||
cmd::recovery::heartbeat(&mut a);
|
||||
assert!(left(&area), "{how}");
|
||||
match how {
|
||||
"save" => a.execute("document.save", &json!({"path": path})).map(drop).unwrap(),
|
||||
"revert" => a.execute("file.revert", &json!({})).map(drop).unwrap(),
|
||||
"close" => a.execute("file.close", &json!({})).map(drop).unwrap(),
|
||||
_ => cmd::recovery::forget_all(&mut a),
|
||||
}
|
||||
assert!(!left(&area), "{how}");
|
||||
drop(other);
|
||||
drop(a);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
/// The lock file was removed: the next heartbeat locks the area again, before any recovery save.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_heartbeat_locks_the_area_again_when_its_lock_file_was_removed() {
|
||||
use cmd::recovery::Lock;
|
||||
let dir = std::env::temp_dir().join(format!("vc-recovery-beatlock-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let (mut a, area) = saved_in(&dir);
|
||||
std::fs::remove_file(dir.join(&area).join(".lock")).unwrap();
|
||||
assert!(cmd::recovery::heartbeat(&mut a).is_empty(), "the copy is still there");
|
||||
assert_eq!(a.recovery.own_area(), Some(area.as_str()));
|
||||
assert!(matches!(FolderStore::new(&dir).lock(&area), Ok(Lock::Busy)));
|
||||
assert!(offered(&mut other_app(&dir)).is_empty());
|
||||
drop(a);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The lock file was removed and can't be made again (the area folder can't be written to now):
|
||||
/// the session takes a new area and writes its copy there.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn an_area_whose_lock_file_cannot_be_made_again_is_left_for_a_new_one() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
use cmd::recovery::Lock;
|
||||
// Permissions don't stop the superuser.
|
||||
if rustix::process::geteuid().is_root() {
|
||||
return;
|
||||
}
|
||||
let dir = std::env::temp_dir().join(format!("vc-recovery-readonly-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let (mut a, area) = saved_in(&dir);
|
||||
std::fs::remove_file(dir.join(&area).join(".lock")).unwrap();
|
||||
std::fs::set_permissions(dir.join(&area), std::fs::Permissions::from_mode(0o500)).unwrap();
|
||||
let r = a.execute("file.recovery.save", &json!({})).unwrap();
|
||||
let mine = a.recovery.own_area().unwrap().to_string();
|
||||
assert_ne!(mine, area);
|
||||
assert_eq!(r["saved"][0]["file"], format!("{mine}/Untitled-1-1").as_str(), "{r}");
|
||||
assert!(matches!(FolderStore::new(&dir).lock(&mine), Ok(Lock::Busy)));
|
||||
std::fs::set_permissions(dir.join(&area), std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
drop(a);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A copy left behind that can't be removed when its document is saved (its area can't be written
|
||||
/// to now) stays the session's own: it isn't offered here, and a later heartbeat removes it.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_copy_left_behind_that_cannot_be_removed_yet_stays_the_sessions_own() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
use cmd::recovery::Lock;
|
||||
// Permissions don't stop the superuser.
|
||||
if rustix::process::geteuid().is_root() {
|
||||
return;
|
||||
}
|
||||
let base = std::env::temp_dir().join(format!("vc-recovery-leftstuck-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
let dir = base.join("recovery");
|
||||
let path = base.join("doc.vectorcraft").to_string_lossy().to_string();
|
||||
let (mut a, area) = saved_in(&dir);
|
||||
std::fs::remove_file(dir.join(&area).join(".lock")).unwrap();
|
||||
let Ok(Lock::Held(other)) = FolderStore::new(&dir).lock(&area) else { panic!("the other app locks the area") };
|
||||
cmd::recovery::heartbeat(&mut a);
|
||||
std::fs::set_permissions(dir.join(&area), std::fs::Permissions::from_mode(0o500)).unwrap();
|
||||
a.execute("document.save", &json!({"path": path})).unwrap();
|
||||
std::fs::set_permissions(dir.join(&area), std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let copy = dir.join(&area).join("Untitled-1-1.vectorcraft");
|
||||
assert!(copy.is_file());
|
||||
drop(other);
|
||||
assert!(offered(&mut a).is_empty());
|
||||
cmd::recovery::heartbeat(&mut a);
|
||||
assert!(!copy.exists());
|
||||
drop(a);
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
/// A copy left in an area the session had to leave is still the session's own after the app that
|
||||
/// locked that area is gone: the session neither offers nor discards it.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_copy_left_behind_is_not_offered_by_its_own_app() {
|
||||
use cmd::recovery::Lock;
|
||||
let dir = std::env::temp_dir().join(format!("vc-recovery-leftmine-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let (mut a, area) = saved_in(&dir);
|
||||
std::fs::remove_file(dir.join(&area).join(".lock")).unwrap();
|
||||
let Ok(Lock::Held(other)) = FolderStore::new(&dir).lock(&area) else { panic!("the other app locks the area") };
|
||||
cmd::recovery::heartbeat(&mut a);
|
||||
drop(other);
|
||||
assert!(offered(&mut a).is_empty());
|
||||
let r = a.execute("file.recovery.discard", &json!({})).unwrap();
|
||||
assert!(r["discarded"].as_array().unwrap().is_empty(), "{r}");
|
||||
assert!(a.execute("file.recovery.discard", &json!({"file": format!("{area}/Untitled-1-1")})).is_err());
|
||||
assert!(dir.join(&area).join("Untitled-1-1.vectorcraft").is_file());
|
||||
drop(a);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A complex document (Data Recovery off for those) gets no new copy after the session left its
|
||||
/// area: it keeps the copy it left there, and the heartbeat asks for it only once.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_complex_documents_copy_left_behind_is_asked_for_once() {
|
||||
use cmd::recovery::Lock;
|
||||
let dir = std::env::temp_dir().join(format!("vc-recovery-leftcomplex-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut a = other_app(&dir);
|
||||
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();
|
||||
}
|
||||
a.add_document(d, None);
|
||||
a.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap();
|
||||
let r = a.execute("file.recovery.save", &json!({})).unwrap();
|
||||
let copy = r["saved"][0]["file"].as_str().unwrap().to_string();
|
||||
let area = a.recovery.own_area().unwrap().to_string();
|
||||
a.prefs.recovery_off_for_complex = true;
|
||||
std::fs::remove_file(dir.join(&area).join(".lock")).unwrap();
|
||||
let Ok(Lock::Held(other)) = FolderStore::new(&dir).lock(&area) else { panic!("the other app locks the area") };
|
||||
let uid = a.doc().unwrap().uid;
|
||||
assert_eq!(cmd::recovery::heartbeat(&mut a), [uid]);
|
||||
let r = a.execute("file.recovery.save", &json!({})).unwrap();
|
||||
assert!(r["saved"].as_array().unwrap().is_empty(), "{r}");
|
||||
assert!(cmd::recovery::heartbeat(&mut a).is_empty(), "asked for once");
|
||||
assert!(dir.join(format!("{copy}.vectorcraft")).is_file(), "its copy stays");
|
||||
drop(other);
|
||||
drop(a);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The preference named another folder while a copy was written in the background: the copy goes
|
||||
/// from the folder it was written to, and the document keeps its copy in the new folder.
|
||||
#[test]
|
||||
fn a_copy_finished_after_the_folder_changed_goes_from_its_own_folder() {
|
||||
let base = std::env::temp_dir().join(format!("vc-recovery-refolder-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
let (old, new) = (base.join("old"), base.join("new"));
|
||||
let (mut a, area) = saved_in(&old);
|
||||
a.execute("shape.ellipse", &json!({"x": 10, "y": 10, "width": 5, "height": 5})).unwrap();
|
||||
let store: Arc<dyn RecoveryStore> = Arc::new(FolderStore::new(&old));
|
||||
let (jobs, _) = cmd::recovery::jobs(&mut a, &store).unwrap();
|
||||
a.prefs.recovery_folder = new.to_string_lossy().to_string();
|
||||
a.execute("file.recovery.save", &json!({})).unwrap();
|
||||
let mine = a.doc().unwrap().recovery.as_ref().unwrap().file.clone();
|
||||
for j in jobs {
|
||||
j.write().unwrap();
|
||||
j.finish(&mut a);
|
||||
}
|
||||
assert!(!old.join(&area).join("Untitled-1-1.vectorcraft").exists());
|
||||
assert_eq!(a.doc().unwrap().recovery.as_ref().unwrap().file, mine);
|
||||
assert!(new.join(format!("{mine}.vectorcraft")).is_file());
|
||||
drop(a);
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_reverting_or_closing_removes_the_copy() {
|
||||
let dir = std::env::temp_dir().join(format!("vc-recovery-save-{}", std::process::id()));
|
||||
|
||||
@@ -105,8 +105,8 @@ fn write(app: &mut VectorcraftApp, store: &Arc<dyn RecoveryStore>, pick: impl Fn
|
||||
let mut started = 0;
|
||||
for job in jobs {
|
||||
let (uid, label, title) = (job.uid, format!("Saving recovery data for {}", job.title), job.title.clone());
|
||||
let (done, store) = (job.clone(), store.clone());
|
||||
let work = move |_: Writer| job.write(store.as_ref());
|
||||
let done = job.clone();
|
||||
let work = move |_: Writer| job.write();
|
||||
let then = move |app: &mut VectorcraftApp, r: Result<Value, String>| {
|
||||
let r = r?;
|
||||
done.finish(&mut app.session);
|
||||
|
||||
Reference in New Issue
Block a user