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 91135c7cb..6a5a6d327 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() { @@ -220,6 +391,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)?; @@ -373,6 +547,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 e45b59433..2e2f65c45 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. - Graphics adapter chosen by the app: the GPU that drives the primary display (Windows `EnumDisplayDevices`, Linux sysfs), then the power preference (`WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` override). An adapter that fails or hangs while the window starts is left out and the app starts again on the next one, then on the next backend, with software renderers last; wgpu's indirect-call check is off (it lost the Metal device on an Intel Mac, #334); why the app stopped is logged, also when the window never opened (#273). Ported from VectorCraft. - 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).