From 257fb08dbdc1140223e8db1f897845bff6913d9c Mon Sep 17 00:00:00 2001 From: noah1400 <48360809+noah1400@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:28:26 +0200 Subject: [PATCH] Recovery: each running app keeps its own recovery data (#496) Document uids start at 1 in every process, and every running app wrote its recovery entries into the one recovery folder. Two apps wrote over each other's `1.designcraft`, and an app that started reopened and removed the entries of one still running. A run now has a folder of its own in the recovery folder and holds a lock in it for as long as it runs. An app that starts moves the entries of runs that have ended, and those an earlier version wrote, into its folder and reopens them. A locked folder is left alone. --- apps/designcraft/src/profile.rs | 24 +- crates/engine/src/recovery.rs | 391 ++++++++++++++++++++++++++++++++ docs/target-app-parity.md | 2 +- 3 files changed, 415 insertions(+), 2 deletions(-) diff --git a/apps/designcraft/src/profile.rs b/apps/designcraft/src/profile.rs index c3da12b08..4b9bd4921 100644 --- a/apps/designcraft/src/profile.rs +++ b/apps/designcraft/src/profile.rs @@ -22,7 +22,16 @@ fn config_dir_with(os: &str, env: impl Fn(&str) -> Option) -> Option

Option { - recovery_dir_with(std::env::var_os("DESIGNCRAFT_CONFIG_DIR"), designcraft_engine::recovery::default_dir) + recovery_dir_with(std::env::var_os("DESIGNCRAFT_CONFIG_DIR"), designcraft_engine::recovery::default_dir).map(own_run) +} + +/// This run's folder in the recovery folder `root`. A second running DesignCraft has its own, so +/// neither writes over the other's documents nor takes them for a crash's. +fn own_run(root: PathBuf) -> PathBuf { + designcraft_engine::recovery::run_dir(&root).unwrap_or_else(|e| { + log::warn!("recovery data is shared with other running apps: {e}"); + root + }) } fn recovery_dir_with(root: Option, default: impl FnOnce() -> Option) -> Option { @@ -68,4 +77,17 @@ mod tests { assert_eq!(config_dir_with("linux", |key| if key == "HOME" { environment(key) } else { None }), Some("/user/.config/designcraft".into())); assert_eq!(recovery_dir_with(None, || Some("/existing/recovery".into())), Some("/existing/recovery".into())); } + + #[test] + fn a_run_has_its_own_recovery_folder_when_one_can_be_made() { + let dir = std::env::temp_dir().join(format!("designcraft-profile-run-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let root = dir.join("Recovery"); + assert_eq!(own_run(root.clone()).parent(), Some(root.as_path())); + // Nothing can be made under a file: the recovery folder itself, as before. + let blocked = dir.join("blocked"); + std::fs::write(&blocked, "a file where the folder should be").unwrap(); + assert_eq!(own_run(blocked.join("Recovery")), blocked.join("Recovery")); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/engine/src/recovery.rs b/crates/engine/src/recovery.rs index a038c2d27..058495791 100644 --- a/crates/engine/src/recovery.rs +++ b/crates/engine/src/recovery.rs @@ -6,6 +6,11 @@ //! A recovery entry is `.designcraft` (the document) plus `.json` (`{"path", "title", //! "saved"}`: the original file, the title, and when it was written). //! +//! Document uids start at 1 in every process, so each running app keeps its entries in a folder +//! of its own in the recovery folder (`run_dir`) and holds the lock in it for as long as it runs. +//! An app that starts moves into its own folder what apps that have ended left in theirs, and +//! leaves the folder of one still running alone. +//! //! Each file is replaced atomically, but the pair is not a transaction. The document is published //! and made durable before its metadata so a crash can leave metadata missing or stale, never a //! listed metadata record without a complete primary document. Listing and opening recovery data @@ -31,6 +36,170 @@ pub fn default_dir() -> Option { std::env::var_os("XDG_DATA_HOME").map(PathBuf::from).or_else(|| home.map(|h| h.join(".local/share"))).map(|d| d.join("designcraft/recovery")) } +/// This process's folder in a recovery folder, with the lock that says it is still running. +#[cfg(not(target_arch = "wasm32"))] +struct Run { + root: PathBuf, + dir: PathBuf, + lock: std::fs::File, +} + +#[cfg(not(target_arch = "wasm32"))] +static RUNS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + +/// The lock in a run's folder. +#[cfg(not(target_arch = "wasm32"))] +const LOCK: &str = "lock"; + +/// This run's own folder in the recovery folder `root`, made on the first call. Document uids +/// start at 1 in every process, so two running apps writing to one folder wrote over each +/// other's entries. The folder's lock is held until the process ends: an app that starts leaves +/// the folder of one that is still running alone. +#[cfg(not(target_arch = "wasm32"))] +pub fn run_dir(root: &Path) -> Result { + let mut runs = RUNS.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(run) = runs.iter().find(|r| r.root == root) { + return Ok(run.dir.clone()); + } + secure_recovery_dir(root)?; + for n in 0..64 { + let dir = root.join(format!("run-{}-{n}", std::process::id())); + // Only a folder this run makes is its own. One that is there is another app's, or was + // left by a run that ended under this process id. + let mut new = std::fs::DirBuilder::new(); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + new.mode(0o700); + } + match new.create(&dir) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(EngineError::Other(format!("{}: {e}", dir.display()))), + } + let locked = validate_recovery_dir(&dir).and_then(|()| { + let lock = new_lock(&dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + match lock.try_lock() { + Ok(()) => Ok(Some(lock)), + Err(std::fs::TryLockError::WouldBlock) => Ok(None), + Err(std::fs::TryLockError::Error(e)) => Err(EngineError::Other(format!("{}: {e}", dir.display()))), + } + }); + match locked { + // An app that is starting can take a folder this new for one a run left, and remove + // it before the lock is held: then the lock is gone, or that app holds it. + Ok(Some(lock)) if std::fs::symlink_metadata(dir.join(LOCK)).is_ok() => { + runs.push(Run { root: root.to_path_buf(), dir: dir.clone(), lock }); + return Ok(dir); + } + Ok(_) => {} + Err(e) => { + let _ = std::fs::remove_file(dir.join(LOCK)); + let _ = std::fs::remove_dir(&dir); + return Err(e); + } + } + } + Err(EngineError::Other(format!("{}: no free folder for this run", root.display()))) +} + +#[cfg(not(target_arch = "wasm32"))] +fn new_lock(dir: &Path) -> std::io::Result { + let mut new = std::fs::OpenOptions::new(); + new.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + new.mode(0o600); + } + new.open(dir.join(LOCK)) +} + +/// A run's folder that was removed while it runs is made again by the next save: so is its lock. +#[cfg(not(target_arch = "wasm32"))] +fn relock(dir: &Path) { + let mut runs = RUNS.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let Some(run) = runs.iter_mut().find(|r| r.dir == dir) else { return }; + if std::fs::symlink_metadata(dir.join(LOCK)).is_err() + && let Ok(lock) = new_lock(dir) + && lock.try_lock().is_ok() + { + run.lock = lock; + } +} + +/// Move into `own`, this run's folder, what is left to recover beside it: the entries of runs +/// that have ended, and those written straight into the recovery folder before runs had folders. +#[cfg(not(target_arch = "wasm32"))] +fn take_over(own: &Path) { + let root = { + let runs = RUNS.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let Some(run) = runs.iter().find(|r| r.dir == own) else { return }; + run.root.clone() + }; + move_entries(&root, own); + let Ok(rd) = std::fs::read_dir(&root) else { return }; + for e in rd.flatten() { + let dir = e.path(); + if dir == own || !e.file_name().to_string_lossy().starts_with("run-") || validate_recovery_dir(&dir).is_err() { + continue; + } + // Opened for writing: over NFS an exclusive lock on a file opened for reading is refused. + let Ok(lock) = std::fs::OpenOptions::new().write(true).open(dir.join(LOCK)) else { continue }; + match lock.try_lock() { + // Free: the run has ended. Holding it keeps another starting app out. + Ok(()) => {} + Err(std::fs::TryLockError::WouldBlock) => continue, + Err(std::fs::TryLockError::Error(e)) => { + log::warn!("recovery data in {} is left there: {e}", dir.display()); + continue; + } + } + move_entries(&dir, own); + remove_emptied(&dir); + } +} + +/// Remove the folder of a run that ended, with what the app left in it beside documents. A +/// document still in it, or a file the app did not write, keeps the folder as it is. +#[cfg(not(target_arch = "wasm32"))] +fn remove_emptied(dir: &Path) { + let Ok(rd) = std::fs::read_dir(dir) else { return }; + let mut left = Vec::new(); + for e in rd { + let Ok(e) = e else { return }; + let path = e.path(); + let name = e.file_name(); + let name = name.to_string_lossy(); + let numbered = path.file_stem().and_then(|s| s.to_str()).is_some_and(|s| s.parse::().is_ok()); + // Metadata whose document has gone, and a file a crash left half written. + if (numbered && name.ends_with(".json")) || (name.starts_with(".designcraft-recovery-") && name.ends_with(".tmp")) { + left.push(path); + } else if name != LOCK { + return; + } + } + for path in left { + let _ = std::fs::remove_file(path); + } + // The lock last: until then the folder is still that of a run that ended. + let _ = std::fs::remove_file(dir.join(LOCK)); + let _ = std::fs::remove_dir(dir); +} + +#[cfg(not(target_arch = "wasm32"))] +fn move_entries(from: &Path, own: &Path) { + for (uid, _) in list(from) { + let (doc, meta) = files(from, uid); + // Under a uid no document of this run has or gets. + let (own_doc, own_meta) = files(own, crate::NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed)); + // The document first: its metadata is optional. + if std::fs::rename(&doc, &own_doc).is_ok() { + let _ = std::fs::rename(&meta, &own_meta); + } + } +} + fn files(dir: &Path, uid: u64) -> (PathBuf, PathBuf) { (dir.join(format!("{uid}.designcraft")), dir.join(format!("{uid}.json"))) } @@ -50,6 +219,8 @@ pub fn discard(dir: &Path, uid: u64) { /// many were written. pub fn save(s: &Session, dir: &Path) -> Result { secure_recovery_dir(dir)?; + #[cfg(not(target_arch = "wasm32"))] + relock(dir); let mut n = 0; for d in &s.docs { if !d.is_dirty() { @@ -213,6 +384,9 @@ pub fn open(s: &mut Session, dir: &Path) -> Result> { #[cfg(not(target_arch = "wasm32"))] validate_recovery_dir(dir)?; let mut opened = Vec::new(); + // For this run's own folder, first bring in what runs that ended left in theirs. + #[cfg(not(target_arch = "wasm32"))] + take_over(dir); for (uid, meta) in list(dir) { #[cfg(not(target_arch = "wasm32"))] validate_recovery_dir(dir)?; @@ -350,6 +524,223 @@ mod tests { assert!(open(&mut Session::new(), &link).is_err()); assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink()); } + + #[test] + fn a_run_has_a_locked_folder_of_its_own() { + let root = tempfile::tempdir().unwrap(); + let dir = root.path().join("recovery"); + let own = run_dir(&dir).unwrap(); + assert_eq!(own.parent(), Some(dir.as_path())); + assert_eq!(run_dir(&dir).unwrap(), own, "one for the whole run"); + let lock = std::fs::File::open(own.join(LOCK)).unwrap(); + assert!(matches!(lock.try_lock(), Err(std::fs::TryLockError::WouldBlock)), "held while the run lives"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!(std::fs::metadata(&own).unwrap().permissions().mode() & 0o777, 0o700); + assert_eq!(std::fs::metadata(own.join(LOCK)).unwrap().permissions().mode() & 0o777, 0o600); + } + } + + /// The recovery folder was cleared while the app runs: the next save makes the run's folder + /// again, and its lock with it. + #[cfg(unix)] + #[test] + fn a_removed_run_folder_is_made_again_with_its_lock() { + let root = tempfile::tempdir().unwrap(); + let own = run_dir(root.path()).unwrap(); + std::fs::remove_dir_all(&own).unwrap(); + assert_eq!(save(&dirty_session(), &own).unwrap(), 1); + let lock = std::fs::File::open(own.join(LOCK)).unwrap(); + assert!(matches!(lock.try_lock(), Err(std::fs::TryLockError::WouldBlock))); + } + + #[cfg(unix)] + #[test] + fn a_run_gets_no_folder_in_a_symlinked_recovery_folder() { + use std::os::unix::fs::symlink; + + let root = tempfile::tempdir().unwrap(); + let destination = root.path().join("destination"); + let link = root.path().join("recovery"); + std::fs::create_dir(&destination).unwrap(); + symlink(&destination, &link).unwrap(); + + assert!(run_dir(&link).is_err()); + assert!(std::fs::read_dir(&destination).unwrap().next().is_none()); + } + + fn unsaved(text: &str) -> Session { + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + session.execute("frame.create", &json!({"rect": [10, 10, 100, 100], "content": "text", "text": text})).unwrap(); + session + } + + fn texts(s: &Session) -> Vec { + let mut texts: Vec = s.documents().iter().flat_map(|d| d.doc.stories.values().map(|st| st.text.clone())).collect(); + texts.sort(); + texts + } + + /// What a run that has ended leaves: its folder with its lock, free now, and the entry of + /// one unsaved document, numbered `uid` as that process numbered it. + fn run_left(root: &Path, name: &str, text: &str, uid: u64) -> PathBuf { + let dir = root.join(name); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(LOCK), b"").unwrap(); + let mut s = unsaved(text); + s.docs[0].uid = uid; + assert_eq!(save(&s, &dir).unwrap(), 1); + dir + } + + /// Every process numbers its first document 1: two running apps both wrote `1.designcraft` + /// into the one recovery folder, and only the document written last was recovered. + #[test] + fn documents_of_two_apps_that_ended_are_both_recovered() { + let root = tempfile::tempdir().unwrap(); + let first = run_left(root.path(), "run-7-0", "first app", 1); + let second = run_left(root.path(), "run-8-0", "second app", 1); + let own = run_dir(root.path()).unwrap(); + let mut s = Session::new(); + assert_eq!(open(&mut s, &own).unwrap().len(), 2); + assert_eq!(texts(&s), ["first app", "second app"]); + assert!(s.documents().iter().all(DocState::is_dirty)); + assert!(!first.exists() && !second.exists(), "nothing is left of the runs that ended"); + } + + /// An app that started reopened the unsaved documents of one still running, and removed + /// its entries. + #[test] + fn a_running_apps_recovery_data_is_left_alone() { + let root = tempfile::tempdir().unwrap(); + let running = run_left(root.path(), "run-7-0", "still open", 1); + let lock = std::fs::File::open(running.join(LOCK)).unwrap(); + lock.try_lock().unwrap(); + let own = run_dir(root.path()).unwrap(); + let mut s = Session::new(); + assert!(open(&mut s, &own).unwrap().is_empty()); + assert_eq!(list(&running).len(), 1); + // Once it has ended, its document is recovered. + drop(lock); + assert_eq!(open(&mut s, &own).unwrap().len(), 1); + assert_eq!(texts(&s), ["still open"]); + assert!(!running.exists()); + } + + /// A process id comes round again: the folder a run left under it is not this run's. + #[test] + fn a_folder_left_under_this_process_id_is_not_reused() { + let root = tempfile::tempdir().unwrap(); + let left = run_left(root.path(), &format!("run-{}-0", std::process::id()), "left behind", 1); + let own = run_dir(root.path()).unwrap(); + assert_ne!(own, left); + let mut s = Session::new(); + assert_eq!(open(&mut s, &own).unwrap().len(), 1); + assert_eq!(texts(&s), ["left behind"]); + } + + /// Entries written straight into the recovery folder, as before runs had folders. + #[test] + fn recovery_data_of_an_earlier_version_is_recovered() { + let root = tempfile::tempdir().unwrap(); + assert_eq!(save(&unsaved("earlier version"), root.path()).unwrap(), 1); + let own = run_dir(root.path()).unwrap(); + let mut s = Session::new(); + assert_eq!(open(&mut s, &own).unwrap().len(), 1); + assert_eq!(texts(&s), ["earlier version"]); + assert!(list(root.path()).is_empty()); + } + + /// An entry that can't be opened stays on disk, in this run's folder now. + #[test] + fn an_unreadable_entry_of_a_run_that_ended_is_kept() { + let root = tempfile::tempdir().unwrap(); + let left = run_left(root.path(), "run-7-0", "readable", 1); + std::fs::write(left.join("2.designcraft"), b"not a document").unwrap(); + let own = run_dir(root.path()).unwrap(); + let mut s = Session::new(); + assert_eq!(open(&mut s, &own).unwrap().len(), 1); + assert!(list(&own).iter().any(|e| std::fs::read(files(&own, e.0).0).unwrap() == b"not a document")); + assert!(!left.exists()); + } + + /// Only a folder named as a run's, with a lock that is free, is a run that ended. + #[test] + fn what_is_no_run_that_ended_is_passed_over() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("run-1-0"), b"a file").unwrap(); + // An app that is starting has made its folder and not yet its lock. + let starting = root.path().join("run-2-0"); + std::fs::create_dir(&starting).unwrap(); + let other = run_left(root.path(), "backup", "not a run", 1); + #[cfg(unix)] + let (_elsewhere, linked) = { + let elsewhere = tempfile::tempdir().unwrap(); + let linked = run_left(elsewhere.path(), "run-3-0", "behind a link", 1); + std::os::unix::fs::symlink(&linked, root.path().join("run-3-0")).unwrap(); + (elsewhere, linked) + }; + let own = run_dir(root.path()).unwrap(); + let mut s = Session::new(); + assert!(open(&mut s, &own).unwrap().is_empty()); + assert!(starting.exists()); + assert_eq!(list(&other).len(), 1); + #[cfg(unix)] + assert!(list(&linked).len() == 1 && root.path().join("run-3-0").is_symlink()); + } + + /// The metadata goes with its document: a document taken over remembers its file. + #[test] + fn a_document_taken_over_remembers_its_file() { + let root = tempfile::tempdir().unwrap(); + let file = root.path().join("layout.designcraft"); + let recovery = root.path().join("recovery"); + let left = recovery.join("run-7-0"); + std::fs::create_dir_all(&left).unwrap(); + std::fs::write(left.join(LOCK), b"").unwrap(); + let mut a = unsaved("saved once"); + a.execute("file.saveAs", &json!({"path": file.to_string_lossy()})).unwrap(); + a.execute("frame.create", &json!({"rect": [10, 10, 100, 100]})).unwrap(); + assert_eq!(save(&a, &left).unwrap(), 1); + let own = run_dir(&recovery).unwrap(); + let mut s = Session::new(); + assert_eq!(open(&mut s, &own).unwrap().len(), 1); + assert_eq!(s.doc().unwrap().path.as_deref(), Some(file.to_string_lossy().as_ref())); + } + + /// The folder of a run that ended goes with what the app left in it. A file the app did not + /// write keeps the folder, and is not deleted. + #[test] + fn only_what_the_app_left_goes_with_the_folder_of_a_run_that_ended() { + let root = tempfile::tempdir().unwrap(); + let emptied = run_left(root.path(), "run-7-0", "moved", 1); + std::fs::write(emptied.join("9.json"), b"{}").unwrap(); + std::fs::write(emptied.join(".designcraft-recovery-abc.tmp"), b"half written").unwrap(); + let kept = run_left(root.path(), "run-8-0", "moved too", 1); + std::fs::write(kept.join("copy.designcraft"), b"a copy someone put here").unwrap(); + let own = run_dir(root.path()).unwrap(); + let mut s = Session::new(); + assert_eq!(open(&mut s, &own).unwrap().len(), 2); + assert!(!emptied.exists()); + assert_eq!(std::fs::read(kept.join("copy.designcraft")).unwrap(), b"a copy someone put here"); + assert!(kept.join(LOCK).exists(), "still the folder of a run that ended"); + } + + /// A folder that is there is another app's or was left behind, lock or no lock: a run's own + /// is one it made itself. + #[test] + fn a_folder_that_is_there_is_not_made_this_runs() { + let root = tempfile::tempdir().unwrap(); + let left = root.path().join(format!("run-{}-0", std::process::id())); + std::fs::create_dir(&left).unwrap(); + std::fs::write(left.join("1.designcraft"), b"left with no lock").unwrap(); + let own = run_dir(root.path()).unwrap(); + assert_ne!(own, left); + assert!(!left.join(LOCK).exists()); + assert_eq!(std::fs::read(left.join("1.designcraft")).unwrap(), b"left with no lock"); + } } #[cfg(test)] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8044ed51b..3127c43a1 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -288,7 +288,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Missing fonts: highlighted pink on screen (never in output), listed by Preflight; Type › Find/Replace Font (fonts in the document, missing first; replace in text, cells, footnotes and styles); `font.list` / `font.replace`. - Missing glyphs, as in InDesign: a character the applied font lacks is drawn as that font's missing-glyph box (its .notdef; a drawn box when the font's is empty), on screen and in PDF (as a path, so PDF/A and PDF/UA exports take it), and Preflight lists the characters by font. Preferences › Composition › Draw Missing Glyphs from Fallback Fonts (`document.preferences {glyphFallback}`, stored with the document) draws them from other fonts instead; it is off in new documents and IDML imports, on in documents saved before it existed. A missing font's substitute always uses fallback fonts. - Damage-region repaint: after an edit the canvas re-renders only the frames whose composed text (or the items that) changed and patches its texture — pixel-identical to a full render; typing in a frame repaints in under a millisecond instead of a full-viewport render (22 ms measured on the sample). -- Crash recovery: unsaved documents are written to the recovery folder every 30 s and reopened (unsaved, remembering their files) after a crash; File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`. +- Crash recovery: unsaved documents are written to the recovery folder every 30 s and reopened (unsaved, remembering their files) after a crash; each running app writes to a folder of its own there, and an app that starts takes over only what apps that have ended left; File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`. - Graphics backend chosen before the window exists (DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, OpenGL as the fallback; `WGPU_BACKEND` overrides) with a start-up fallback: a backend whose start never showed a frame is skipped at the next start and the status bar says so (`gpu.json`). Found on an AMD hybrid-graphics laptop whose Vulkan driver faulted at the first present. - Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool). - Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`.