From 8e151a2fd48ce2b1d0cebed6281af17ca7bd2006 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Mon, 12 Oct 2026 12:15:46 -0600 Subject: [PATCH] fix(recovery): retain recovered checkpoints until save or close Keep recovered snapshots available through a second crash before autosave. Track checkpoint ownership across folders and restarted IDs; Save, Close and Revert clean only checkpoints owned by that document. Follow-up to #506 and the retention concern in #380. PR #616. --- ROADMAP.md | 3 +- crates/engine/src/cmd/file.rs | 20 +- crates/engine/src/lib.rs | 9 +- crates/engine/src/recovery.rs | 552 +++++++++++++++++++++++++++++++++- docs/target-app-parity.md | 4 +- 5 files changed, 557 insertions(+), 31 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ab0749c..171c129 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: pre-alpha** · next: alpha, ~0% and ~15–25 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (book chapter update safety) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (recovery checkpoint retention) · **Target:** Adobe InDesign 2026 DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. - **2026-10-12** · Book numbering and style updates prepare every chapter before saving. Only closed native chapters are changed; imported IDML remains a read-only style/PDF source. If a later disk write fails, the error names the completed chapters; the batch is not a whole-book transaction. No readiness percentage change. - **2026-10-12** · Desktop write callbacks and CLI raster exports use the atomic writer too; open readers retain the original bytes, while private Unix modes and symlink targets survive replacement. Permanent tests cover both actual callers. No readiness percentage change. - **2026-10-12** · Save As, library edits and book collection edits persist prospective values before changing live state; failed writes preserve the document and existing collections. Save a Copy rebases data-source paths for its destination without changing the open document. No readiness percentage change. diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 2537d1e..692c363 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -39,7 +39,7 @@ pub fn specs() -> Vec { }), cmd!(noundo "file.recovery.save", "Save Recovery Data", [], None, "{dir?} — write every unsaved document to the recovery folder (the app does this on a timer)", always, |s, p| { let dir = recovery_dir(s, p)?; - Ok(json!({"saved": crate::recovery::save(s, &dir)?})) + Ok(json!({"saved": crate::recovery::save_tracked(s, &dir)?})) }), cmd!(noundo "file.recovery.open", "Recover Documents", [], None, "{dir?} — reopen documents left in the recovery folder (unsaved)", always, |s, p| { let dir = recovery_dir(s, p)?; @@ -255,9 +255,10 @@ fn file_save(s: &mut Session, p: &Value) -> Result { st.saved_revision = st.revision; st.saved_doc = st.doc.clone(); #[cfg(not(target_arch = "wasm32"))] - if let Some(dir) = &s.recovery_dir { - let uid = s.doc()?.uid; - crate::recovery::discard(dir, uid); + { + let recovery_dir = s.recovery_dir.clone(); + let index = s.active_index().ok_or(EngineError::NoDocument)?; + crate::recovery::discard_document(s, index, recovery_dir.as_deref()); } Ok(json!({"path": path, "bytes": bytes.len()})) } @@ -646,15 +647,14 @@ fn file_revert(s: &mut Session, _: &Value) -> Result { // As opened again: the fonts beside it now (unchanged files are shared, not read again). let (fonts, _, _) = load_document_fonts(&mut d, &path); let uid = s.doc()?.uid; - let st = s.doc_mut()?; + let recovery_dir = s.recovery_dir.clone(); + let index = s.active_index().ok_or(EngineError::NoDocument)?; let mut fresh = DocState::new(d, Some(path.clone())); fresh.fonts = fonts; fresh.uid = uid; - fresh.revision = st.revision + 1; - *st = fresh; - if let Some(dir) = &s.recovery_dir { - crate::recovery::discard(dir, uid); - } + fresh.revision = s.doc()?.revision + 1; + crate::recovery::discard_document(s, index, recovery_dir.as_deref()); + *s.doc_mut()? = fresh; Ok(json!({"path": path})) } #[cfg(target_arch = "wasm32")] diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1647193..2165eeb 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -93,6 +93,9 @@ pub struct DocState { /// Editing parent spreads (Pages panel double-click on a parent). pub editing_parents: bool, pub uid: u64, + /// Recovery entries this state actually opened or published, for repeated-open and cleanup. + /// Bounded, deduplicated checkpoint addresses; never inferred from uid alone. + pub(crate) recovery_entries: Vec<(std::path::PathBuf, u64)>, /// Unfilled document while data-merge preview is showing a record. Not saved. pub preview_stash: Option>, /// 1-based record currently previewed. @@ -131,6 +134,7 @@ impl DocState { interaction: None, editing_parents: false, uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + recovery_entries: Vec::new(), preview_stash: None, preview_record: None, fonts: None, @@ -376,8 +380,9 @@ impl Session { self.stop_data_preview(); } #[cfg(not(target_arch = "wasm32"))] - if let (Some(dir), Some(d)) = (&self.recovery_dir, self.docs.get(i)) { - recovery::discard(dir, d.uid); + { + let recovery_dir = self.recovery_dir.clone(); + recovery::discard_document(self, i, recovery_dir.as_deref()); } if i < self.docs.len() { self.docs.remove(i); diff --git a/crates/engine/src/recovery.rs b/crates/engine/src/recovery.rs index 2ab67ce..85308be 100644 --- a/crates/engine/src/recovery.rs +++ b/crates/engine/src/recovery.rs @@ -340,31 +340,122 @@ pub fn discard(dir: &Path, uid: u64) { let _ = std::fs::remove_file(m); } -/// Write recovery entries for the dirty documents (and drop those of clean ones). Returns how -/// many were written. -pub fn save(s: &Session, dir: &Path) -> Result { +/// Consume this document's checkpoint provenance after Save/Close/Revert. A default-directory +/// uid may also be another live recovered document's retained source after a failed republish. +pub(crate) fn discard_document(s: &mut Session, index: usize, default_dir: Option<&Path>) { + let Some(doc) = s.docs.get_mut(index) else { return }; + let uid = doc.uid; + // A completed Save/Close/Revert consumes the provenance, even when removal fails. A stale + // source uid must never become a future claim on another document's checkpoint. + let mut entries = std::mem::take(&mut doc.recovery_entries); + if let Some(dir) = default_dir + && !entries.iter().any(|(entry_dir, entry_uid)| *entry_uid == uid && same_directory(entry_dir, dir)) + { + entries.push((dir.to_path_buf(), uid)); + } + for (dir, uid) in entries { + // Session ownership governs deletion as well as writes: never consume another live + // document's only source merely because its old uid matches this unrelated document. + if !s.docs.iter().enumerate().any(|(other_index, owner)| { + other_index != index && owner.recovery_entries.iter().any(|(entry_dir, entry_uid)| *entry_uid == uid && same_directory(entry_dir, &dir)) + }) { + discard(&dir, uid); + } + } +} + +const MAX_RECOVERY_ENTRIES: usize = 16; + +fn same_directory(a: &Path, b: &Path) -> bool { + a == b || std::fs::canonicalize(a).ok().zip(std::fs::canonicalize(b).ok()).is_some_and(|(a, b)| a == b) +} + +fn has_checkpoint(doc: &DocState, dir: &Path) -> bool { + doc.recovery_entries.iter().any(|(entry_dir, uid)| *uid == doc.uid && same_directory(entry_dir, dir)) +} + +fn protect_retained_sources(s: &Session, dir: &Path) -> Result<()> { + for document in &s.docs { + if let Some(owner) = s.docs.iter().find(|owner| { + owner.uid != document.uid && owner.recovery_entries.iter().any(|(entry_dir, uid)| *uid == document.uid && same_directory(entry_dir, dir)) + }) { + return Err(EngineError::Other(format!("save or close recovered document {} before reusing its recovery folder", owner.title()))); + } + } + Ok(()) +} + +fn prepare_save(s: &Session, dir: &Path) -> Result<()> { + // Check before any write or deletion: a failed republish can retain a source whose uid + // coincides with an unrelated live document in a manually configured recovery directory. + protect_retained_sources(s, dir)?; let created = secure_recovery_dir(dir)?; #[cfg(not(target_arch = "wasm32"))] relock(dir, created)?; #[cfg(target_arch = "wasm32")] let _ = created; + Ok(()) +} + +/// Write recovery entries for dirty documents and drop clean checkpoints. Returns how many +/// were written. Command callers use the tracked variant without configuring automatic recovery. +pub fn save(s: &Session, dir: &Path) -> Result { + prepare_save(s, dir)?; let mut n = 0; for d in &s.docs { if !d.is_dirty() { discard(dir, d.uid); continue; } - let (doc, meta) = files(dir, d.uid); - let bytes = crate::cmd::to_bytes(d.preview_stash.as_deref().unwrap_or(d.doc.as_ref())); - // Write then rename, so a crash mid-write never leaves a torn file. - write_recovery_file(&doc, &bytes)?; - let m = json!({"path": d.path, "title": d.title(), "saved": designcraft_doc::vars::now()}); - write_recovery_file(&meta, m.to_string().as_bytes())?; + write_entry(dir, d)?; n += 1; } Ok(n) } +/// Command-path checkpointing also records explicitly selected directories, without configuring +/// automatic recovery. The immutable public helper remains available for fixtures and callers. +pub(crate) fn save_tracked(s: &mut Session, dir: &Path) -> Result { + // Reject the complete batch before writing rather than silently losing old ownership records. + for document in &s.docs { + if document.is_dirty() && !has_checkpoint(document, dir) && document.recovery_entries.len() >= MAX_RECOVERY_ENTRIES { + return Err(EngineError::Other( + "too many recovery checkpoints; save or close the document before choosing another recovery folder".into(), + )); + } + } + prepare_save(s, dir)?; + let mut n = 0; + for index in 0..s.docs.len() { + if s.docs.get(index).is_some_and(|document| !document.is_dirty()) { + discard_document(s, index, Some(dir)); + continue; + } + let Some(document) = s.docs.get_mut(index) else { continue }; + let (doc, meta) = files(dir, document.uid); + let bytes = designcraft_format::save(document.preview_stash.as_deref().unwrap_or(document.doc.as_ref())) + .map_err(|e| EngineError::Other(e.to_string()))?; + write_recovery_file(&doc, &bytes)?; + // The primary is useful even if optional metadata fails next. Track it immediately. + if !has_checkpoint(document, dir) { + document.recovery_entries.push((dir.to_path_buf(), document.uid)); + } + let metadata = json!({"path": document.path, "title": document.title(), "saved": designcraft_doc::vars::now()}); + write_recovery_file(&meta, metadata.to_string().as_bytes())?; + n += 1; + } + Ok(n) +} + +/// Publish a document's recovery entry before its optional metadata. +fn write_entry(dir: &Path, d: &DocState) -> Result<()> { + let (doc, meta) = files(dir, d.uid); + let bytes = designcraft_format::save(d.preview_stash.as_deref().unwrap_or(d.doc.as_ref())).map_err(|e| EngineError::Other(e.to_string()))?; + write_recovery_file(&doc, &bytes)?; + let metadata = json!({"path": d.path, "title": d.title(), "saved": designcraft_doc::vars::now()}); + write_recovery_file(&meta, metadata.to_string().as_bytes()) +} + #[cfg(not(target_arch = "wasm32"))] fn secure_recovery_dir(dir: &Path) -> Result { match std::fs::symlink_metadata(dir) { @@ -410,6 +501,11 @@ fn secure_recovery_dir(_dir: &Path) -> Result { #[cfg(not(target_arch = "wasm32"))] fn write_recovery_file(path: &Path, bytes: &[u8]) -> Result<()> { + publish_recovery_file(path, bytes, true) +} + +#[cfg(not(target_arch = "wasm32"))] +fn publish_recovery_file(path: &Path, bytes: &[u8], replace: bool) -> Result<()> { let Some(dir) = path.parent() else { return Err(EngineError::Other(format!("{} has no parent folder", path.display()))); }; @@ -429,7 +525,8 @@ fn write_recovery_file(path: &Path, bytes: &[u8]) -> Result<()> { // Validate the non-followed leaf again immediately before renaming the randomized temporary // file to its fixed sibling destination. validate_recovery_dir(dir)?; - tmp.persist(path).map_err(|e| EngineError::Other(format!("{}: {}", path.display(), e.error)))?; + let published = if replace { tmp.persist(path) } else { tmp.persist_noclobber(path) }; + published.map_err(|e| EngineError::Other(format!("{}: {}", path.display(), e.error)))?; sync_recovery_dir(dir)?; Ok(()) } @@ -489,6 +586,45 @@ fn write_recovery_file(_path: &Path, _bytes: &[u8]) -> Result<()> { Err(EngineError::Other("recovery files are unavailable on the web".into())) } +#[cfg(target_arch = "wasm32")] +fn publish_recovery_file(_path: &Path, _bytes: &[u8], _replace: bool) -> Result<()> { + Err(EngineError::Other("recovery files are unavailable on the web".into())) +} + +/// Canonical keys are only for alias comparison; operational paths stay unchanged so run +/// ownership checks still find the directory registered by the native profile. +fn directory_key(dir: &Path) -> Result { + std::fs::canonicalize(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display()))) +} + +/// Include metadata-only entries as occupied, so publication cannot attach a new document to +/// somebody else's orphan metadata. No filename-derived maximum changes the uid allocator. +fn occupied_uids(dir: &Path) -> Result> { + let entries = std::fs::read_dir(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + let mut ids = std::collections::HashSet::new(); + for entry in entries { + let entry = entry.map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + let path = entry.path(); + if matches!(path.extension().and_then(|s| s.to_str()), Some("json" | "designcraft")) + && let Some(uid) = path.file_stem().and_then(|s| s.to_str()).and_then(|s| s.parse().ok()) + { + ids.insert(uid); + } + } + Ok(ids) +} + +fn reserve_uid(state: &mut DocState, occupied: &mut std::collections::HashSet) -> Result<()> { + // At most occupied.len() consecutive candidates can collide. The extra attempt must be free. + for _ in 0..=occupied.len() { + if occupied.insert(state.uid) { + return Ok(()); + } + state.uid = crate::NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } + Err(EngineError::Other("no free recovery document uid".into())) +} + /// Recovery entries in `dir`: (uid, metadata). pub fn list(dir: &Path) -> Vec<(u64, Value)> { let mut out = Vec::new(); @@ -515,19 +651,42 @@ pub fn list(dir: &Path) -> Vec<(u64, Value)> { out } -/// Reopen every recovery entry as an unsaved document (and remove the entries: the new session -/// writes its own). Returns the opened documents' indices. +/// Reopen recovery entries as unsaved documents. Publish each recovered document under its new +/// uid before removing the original, so another crash before the next timer preserves the edits. +/// Entries belonging to documents already open in this session's recovery folder are left alone. +/// Returns the opened documents' indices. pub fn open(s: &mut Session, dir: &Path) -> Result> { #[cfg(not(target_arch = "wasm32"))] { validate_recovery_dir(dir)?; relock(dir, false)?; } + let destination = s.recovery_dir.clone().unwrap_or_else(|| dir.to_path_buf()); + let created = secure_recovery_dir(&destination)?; + #[cfg(not(target_arch = "wasm32"))] + relock(&destination, created)?; + #[cfg(target_arch = "wasm32")] + let _ = created; + let source_key = directory_key(dir)?; + let destination_key = directory_key(&destination)?; 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) { + let entries = list(dir); + // Reserve every source entry before opening the first one: publication must never overwrite + // a source still waiting in this list, even after a fresh process restarts its allocator at 1. + let mut occupied = occupied_uids(dir)?; + if source_key != destination_key { + occupied.extend(occupied_uids(&destination)?); + } + occupied.extend(s.docs.iter().map(|d| d.uid)); + for (uid, meta) in entries { + if s.docs.iter().any(|d| { + d.recovery_entries.iter().any(|(entry_dir, entry_uid)| *entry_uid == uid && directory_key(entry_dir).is_ok_and(|key| key == source_key)) + }) { + continue; + } #[cfg(not(target_arch = "wasm32"))] validate_recovery_dir(dir)?; let (doc, _) = files(dir, uid); @@ -540,12 +699,37 @@ pub fn open(s: &mut Session, dir: &Path) -> Result> { // Skipped font files are logged; the document opens without them. let fonts = path.as_deref().and_then(|p| crate::cmd::load_document_fonts(&mut d, p).0); let mut st = DocState::new(d, path); + reserve_uid(&mut st, &mut occupied)?; + st.recovery_entries.push((dir.to_path_buf(), uid)); st.fonts = fonts; // Unsaved: the copy on disk (if any) is older than what was recovered. st.saved_doc = std::sync::Arc::new((*st.doc).clone()); st.revision += 1; - opened.push(s.add_document(st)); - discard(dir, uid); + let index = s.add_document(st); + opened.push(index); + // New uids are published without replacement, and the original is consumed only after + // both files succeed. Track a published primary even if metadata fails, so Save/Close can + // clean up every snapshot this document actually owns rather than an unrelated same uid. + if let Some(state) = s.docs.get_mut(index) { + let (document, metadata) = files(&destination, state.uid); + let publish = designcraft_format::save(state.doc.as_ref()) + .map_err(|e| EngineError::Other(e.to_string())) + .and_then(|bytes| publish_recovery_file(&document, &bytes, false)); + match publish { + Ok(()) => { + state.recovery_entries.push((destination.clone(), state.uid)); + let meta = json!({"path": state.path, "title": state.title(), "saved": designcraft_doc::vars::now()}); + match publish_recovery_file(&metadata, meta.to_string().as_bytes(), false) { + Ok(()) => { + discard(dir, uid); + state.recovery_entries = vec![(destination.clone(), state.uid)]; + } + Err(e) => log::warn!("recovery entry {uid} kept: {e}"), + } + } + Err(e) => log::warn!("recovery entry {uid} kept: {e}"), + } + } } Ok(opened) } @@ -579,7 +763,8 @@ mod tests { let st = s2.doc().unwrap(); assert!(st.is_dirty()); assert_eq!(st.doc.spreads[0].items.len(), 1); - assert!(list(&dir).is_empty(), "entries are consumed"); + assert_eq!(list(&dir).iter().map(|e| e.0).collect::>(), [st.uid], "the entry follows its reopened document"); + discard(&dir, st.uid); // Saving a document drops its entry. s.recovery_dir = Some(dir.clone()); assert_eq!(save(&s, &dir).unwrap(), 1); @@ -589,6 +774,57 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn a_recovered_document_survives_a_second_crash_before_autosave() { + let dir = tempfile::tempdir().unwrap(); + let original = unsaved("edits preserved after a second crash"); + assert_eq!(save(&original, dir.path()).unwrap(), 1); + let mut reopened = Session::new(); + assert_eq!(open(&mut reopened, dir.path()).unwrap().len(), 1); + drop(reopened); + let mut recovered_again = Session::new(); + assert_eq!(open(&mut recovered_again, dir.path()).unwrap().len(), 1); + assert_eq!(texts(&recovered_again), ["edits preserved after a second crash"]); + assert!(recovered_again.documents().iter().all(DocState::is_dirty)); + assert_eq!(list(dir.path()).len(), 1); + } + + #[test] + fn opening_recovery_again_does_not_duplicate_live_documents() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(save(&unsaved("one recovered document"), dir.path()).unwrap(), 1); + let mut reopened = Session::new(); + reopened.recovery_dir = Some(dir.path().to_path_buf()); + assert_eq!(open(&mut reopened, dir.path()).unwrap().len(), 1); + assert!(open(&mut reopened, dir.path()).unwrap().is_empty()); + assert_eq!(texts(&reopened), ["one recovered document"]); + assert_eq!(list(dir.path()).len(), 1); + } + + #[cfg(unix)] + #[test] + fn a_failed_recovery_rewrite_preserves_the_original() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + assert_eq!(save(&unsaved("keep the original"), dir.path()).unwrap(), 1); + let entries = list(dir.path()); + let original = files(dir.path(), entries[0].0).0; + let bytes = std::fs::read(&original).unwrap(); + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o500)).unwrap(); + let mut reopened = Session::new(); + let result = open(&mut reopened, dir.path()); + // Restore before assertions so a failing test does not leave an unremovable fixture. + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + assert_eq!(result.unwrap().len(), 1); + assert_eq!(texts(&reopened), ["keep the original"]); + assert_eq!(std::fs::read(original).unwrap(), bytes); + assert_eq!(list(dir.path()).len(), 1); + // Its source uid differs from the reopened uid, but explicit Close still discards it. + assert!(open(&mut reopened, dir.path()).unwrap().is_empty()); + reopened.close_document(0); + assert!(list(dir.path()).is_empty()); + } + #[cfg(unix)] #[test] fn recovery_data_is_private_to_the_user() { @@ -682,6 +918,290 @@ mod tests { } } + #[cfg(unix)] + fn failed_republication_session() -> (Session, tempfile::TempDir, u64) { + use std::os::unix::fs::PermissionsExt; + let root = tempfile::tempdir().unwrap(); + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + let live_uid = session.docs[0].uid; + let mut original = unsaved("retained old work"); + original.docs[0].uid = live_uid; + save(&original, root.path()).unwrap(); + session.recovery_dir = Some(root.path().to_path_buf()); + std::fs::set_permissions(root.path(), std::fs::Permissions::from_mode(0o500)).unwrap(); + let recovered = open(&mut session, root.path()); + std::fs::set_permissions(root.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + assert_eq!(recovered.unwrap().len(), 1); + assert_ne!(session.docs[1].uid, live_uid); + (session, root, live_uid) + } + + #[cfg(unix)] + #[test] + fn retained_source_survives_a_coincident_live_uid_checkpoint_failure() { + let (mut session, root, live_uid) = failed_republication_session(); + let original = std::fs::read(files(root.path(), live_uid).0).unwrap(); + let recovered_uid = session.docs[1].uid; + std::fs::create_dir(files(root.path(), recovered_uid).0).unwrap(); + session.execute("file.activate", &json!({"index": 0})).unwrap(); + session.execute("frame.create", &json!({"rect": [0,0,100,100], "content": "text", "text": "other live work"})).unwrap(); + assert!(session.execute("file.recovery.save", &json!({"dir": root.path().to_string_lossy()})).is_err()); + assert_eq!(std::fs::read(files(root.path(), live_uid).0).unwrap(), original); + } + + #[cfg(unix)] + #[test] + fn clean_autosave_preserves_another_documents_retained_source() { + let (mut session, root, live_uid) = failed_republication_session(); + let checkpoint = files(root.path(), live_uid).0; + let original = std::fs::read(&checkpoint).unwrap(); + assert!(!session.docs[0].is_dirty()); + assert!(session.docs[1].is_dirty()); + assert!(session.execute("file.recovery.save", &json!({})).is_err()); + assert_eq!(std::fs::read(checkpoint).unwrap(), original); + } + + #[cfg(unix)] + #[test] + fn closing_a_coincident_document_preserves_another_documents_retained_source() { + let (mut session, root, live_uid) = failed_republication_session(); + let checkpoint = files(root.path(), live_uid).0; + let original = std::fs::read(&checkpoint).unwrap(); + session.execute("file.close", &json!({"index": 0})).unwrap(); + assert_eq!(std::fs::read(checkpoint).unwrap(), original); + } + + #[cfg(unix)] + #[test] + fn saving_a_coincident_document_preserves_another_documents_retained_source() { + let (mut session, root, live_uid) = failed_republication_session(); + let checkpoint = files(root.path(), live_uid).0; + let original = std::fs::read(&checkpoint).unwrap(); + session.execute("file.activate", &json!({"index": 0})).unwrap(); + session.execute("file.saveAs", &json!({"path": root.path().join("saved-other.designcraft").to_string_lossy()})).unwrap(); + assert_eq!(std::fs::read(checkpoint).unwrap(), original); + } + + #[cfg(unix)] + #[test] + fn reverting_a_coincident_document_preserves_another_documents_retained_source() { + let (mut session, root, live_uid) = failed_republication_session(); + let checkpoint = files(root.path(), live_uid).0; + let original = std::fs::read(&checkpoint).unwrap(); + let saved = root.path().join("saved-other.designcraft"); + std::fs::write(&saved, designcraft_format::save(&session.docs[0].doc).unwrap()).unwrap(); + session.docs[0].path = Some(saved.to_string_lossy().into_owned()); + session.execute("file.activate", &json!({"index": 0})).unwrap(); + session.execute("frame.create", &json!({"rect": [0,0,100,100]})).unwrap(); + session.execute("file.revert", &json!({})).unwrap(); + assert_eq!(std::fs::read(checkpoint).unwrap(), original); + } + + #[cfg(unix)] + #[test] + fn retained_source_provenance_is_consumed_before_its_uid_is_reused() { + let (mut session, root, live_uid) = failed_republication_session(); + session.execute("file.saveAs", &json!({"path": root.path().join("saved.designcraft").to_string_lossy()})).unwrap(); + session.execute("file.activate", &json!({"index": 0})).unwrap(); + session.execute("frame.create", &json!({"rect": [0,0,100,100], "content": "text", "text": "other live work"})).unwrap(); + session.execute("file.recovery.save", &json!({"dir": root.path().to_string_lossy()})).unwrap(); + let checkpoint = files(root.path(), live_uid).0; + let expected = std::fs::read(&checkpoint).unwrap(); + session.execute("file.close", &json!({"index": 1})).unwrap(); + assert_eq!( + std::fs::read(checkpoint).unwrap(), + expected, + "closing the saved recovered document must not delete another document's checkpoint" + ); + } + + #[test] + fn explicit_checkpoint_tracking_is_bounded_and_deduplicated_before_writes() { + let root = tempfile::tempdir().unwrap(); + let mut session = unsaved("bounded checkpoints"); + for n in 0..MAX_RECOVERY_ENTRIES { + session.execute("file.recovery.save", &json!({"dir": root.path().join(n.to_string()).to_string_lossy()})).unwrap(); + } + let excess = root.path().join("excess"); + assert!(session.execute("file.recovery.save", &json!({"dir": excess.to_string_lossy()})).is_err()); + assert!(!excess.exists(), "reject before creating or writing the next directory"); + let first_alias = root.path().join("0").join("."); + session.execute("file.recovery.save", &json!({"dir": first_alias.to_string_lossy()})).unwrap(); + for n in 0..MAX_RECOVERY_ENTRIES { + assert_eq!(persisted_texts(&root.path().join(n.to_string())), ["bounded checkpoints"]); + } + assert!(session.recovery_dir.is_none()); + session.execute("file.close", &json!({})).unwrap(); + for n in 0..MAX_RECOVERY_ENTRIES { + assert!(list(&root.path().join(n.to_string())).is_empty()); + } + } + + fn restart_fixture() -> tempfile::TempDir { + let root = tempfile::tempdir().unwrap(); + let mut first = unsaved("restart first"); + first.docs[0].uid = 1; + save(&first, root.path()).unwrap(); + let mut second = unsaved("restart second"); + second.docs[0].uid = 2; + save(&second, root.path()).unwrap(); + root + } + + fn persisted_texts(dir: &Path) -> Vec { + let mut result = Vec::new(); + for (uid, _) in list(dir) { + let doc = crate::cmd::from_bytes(&std::fs::read(files(dir, uid).0).unwrap()).unwrap(); + result.extend(doc.stories.values().map(|story| story.text.clone())); + } + result.sort(); + result + } + + #[test] + #[ignore = "subprocess helper, executed by the restart recovery regression tests"] + fn restart_recovery_child() { + let source = PathBuf::from(std::env::var("DESIGNCRAFT_TEST_RECOVERY_SOURCE").unwrap()); + let mode = std::env::var("DESIGNCRAFT_TEST_RECOVERY_MODE").unwrap(); + if mode == "occupied_destination" { + let owned_root = tempfile::tempdir().unwrap(); + let destination = run_dir(owned_root.path()).unwrap(); + let mut existing = unsaved("already in destination"); + existing.docs[0].uid = 3; + save(&existing, &destination).unwrap(); + let (existing_doc, existing_meta) = files(&destination, 3); + let doc_bytes = std::fs::read(&existing_doc).unwrap(); + let meta_bytes = std::fs::read(&existing_meta).unwrap(); + let mut session = Session::new(); + session.recovery_dir = Some(destination.clone()); + let opened = open(&mut session, &source).unwrap(); + assert_eq!(opened.len(), 2); + assert_eq!(texts(&session), ["restart first", "restart second"]); + assert!(session.docs.iter().all(|document| document.uid != 3)); + assert_eq!(persisted_texts(&destination), ["already in destination", "restart first", "restart second"]); + assert!(list(&source).is_empty()); + session.execute("file.saveAs", &json!({"path": source.join("saved.designcraft").to_string_lossy()})).unwrap(); + session.execute("file.close", &json!({"index": opened[0]})).unwrap(); + assert_eq!(list(&destination).len(), 1); + assert_eq!(std::fs::read(existing_doc).unwrap(), doc_bytes); + assert_eq!(std::fs::read(existing_meta).unwrap(), meta_bytes); + return; + } + let mut session = Session::new(); + if mode == "advance" || mode == "coincident_live" { + session.execute("file.new", &json!({})).unwrap(); + assert_eq!(session.docs[0].uid, 1); + } + let owned_root; + let destination = match mode.as_str() { + "cross_dir" => { + owned_root = tempfile::tempdir().unwrap(); + let own = run_dir(owned_root.path()).unwrap(); + session.recovery_dir = Some(own.clone()); + own + } + "alias" => { + let alias = source.join("."); + session.recovery_dir = Some(alias.clone()); + alias + } + "coincident_live" => { + session.recovery_dir = Some(source.clone()); + source.clone() + } + _ => source.clone(), + }; + let opened = open(&mut session, &source).unwrap(); + assert_eq!(opened.len(), 2); + assert_eq!(texts(&session), ["restart first", "restart second"]); + assert_eq!(persisted_texts(&destination), ["restart first", "restart second"]); + assert!(session.docs.iter().all(|d| d.uid > 0 && d.uid < u64::MAX)); + if matches!(mode.as_str(), "cross_dir" | "manual" | "alias" | "revert") { + let was_disabled = session.recovery_dir.is_none(); + if mode == "cross_dir" { + assert!(list(&source).is_empty()); + } + // A copy does not consume recovery. A successful Save and a Close do. + session.execute("file.saveACopy", &json!({"path": source.join("copy.designcraft").to_string_lossy()})).unwrap(); + assert_eq!(list(&destination).len(), 2); + session.execute("file.saveAs", &json!({"path": source.join("saved.designcraft").to_string_lossy()})).unwrap(); + assert_eq!(list(&destination).len(), 1); + if mode == "revert" { + session.execute("frame.create", &json!({"rect": [0, 0, 100, 100], "content": "text", "text": "after saving"})).unwrap(); + session.execute("file.recovery.save", &json!({"dir": destination.to_string_lossy()})).unwrap(); + assert_eq!(list(&destination).len(), 2); + session.execute("file.revert", &json!({})).unwrap(); + assert_eq!(list(&destination).len(), 1); + } + session.execute("file.close", &json!({"index": opened[0]})).unwrap(); + assert!(list(&destination).is_empty()); + assert!(list(&source).is_empty()); + assert_eq!(session.recovery_dir.is_none(), was_disabled, "manual recovery must not configure autosave"); + } + } + + fn run_restart_case(mode: &str) { + let source = restart_fixture(); + if mode == "max_uid" { + let (doc, meta) = files(source.path(), 2); + let (max_doc, max_meta) = files(source.path(), u64::MAX); + std::fs::rename(doc, max_doc).unwrap(); + std::fs::rename(meta, max_meta).unwrap(); + } + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "recovery::tests::restart_recovery_child", "--ignored", "--nocapture"]) + .env("DESIGNCRAFT_TEST_RECOVERY_SOURCE", source.path()) + .env("DESIGNCRAFT_TEST_RECOVERY_MODE", mode) + .output() + .unwrap(); + assert!( + output.status.success(), + "fresh child failed: {}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let child_stdout = String::from_utf8_lossy(&output.stdout); + assert!(child_stdout.contains("test result: ok. 1 passed; 0 failed;"), "the fresh-process helper did not run exactly once: {child_stdout}"); + } + + #[test] + fn restart_uid_collision_cannot_delete_the_new_snapshot() { + run_restart_case("fresh"); + } + #[test] + fn restart_uid_collision_cannot_overwrite_an_unopened_entry() { + run_restart_case("advance"); + } + #[test] + fn cross_directory_recovery_uses_the_configured_destination() { + run_restart_case("cross_dir"); + } + #[test] + fn cross_directory_recovery_preserves_occupied_destination_entries() { + run_restart_case("occupied_destination"); + } + #[test] + fn manual_recovery_save_and_close_clean_up_without_enabling_autosave() { + run_restart_case("manual"); + } + #[test] + fn recovery_directory_aliases_do_not_delete_new_snapshots() { + run_restart_case("alias"); + } + #[test] + fn a_maximum_source_uid_does_not_advance_or_overflow_the_allocator() { + run_restart_case("max_uid"); + } + #[test] + fn a_coincident_live_uid_is_not_recovered_entry_provenance() { + run_restart_case("coincident_live"); + } + #[test] + fn manual_recovery_revert_removes_its_previous_checkpoint() { + run_restart_case("revert"); + } + /// 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)] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2ef2588..9095faf 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DesignCraft parity with Adobe InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (book chapter update safety) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (recovery checkpoint retention) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature @@ -290,7 +290,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - 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). - Native engine saves, Save a Copy, snippets, books, libraries, swatches and engine/MCP exports use same-folder atomic replacement, as do desktop Services writes and CLI raster exports. Failed writes before replacement retain the original; existing Unix permissions apply before staging document bytes, and already-open readers retain the old bytes. Parent-folder flushing is best effort, so this is not a power-loss durability guarantee. - Save As prepares destination-relative data-source paths before writing and installs them only after a successful save. Failed Save As retains the document, saved state and history; Save a Copy rebases only the saved copy. Library and book collection edits likewise update live values only after persistence succeeds. -- Crash recovery: unsaved documents are written every 30 s into a locked folder owned by that running app and reopened (unsaved, remembering their files) after a crash. Startup takes entries from ended runs and leaves live runs alone; writes and cleanup validate current folder and lock ownership. Failed isolation disables recovery with a warning. Concurrent older versions still use unlocked root entries; mixed-version isolation remains open. File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`. +- Crash recovery: unsaved documents are written every 30 s into a locked folder owned by that running app and reopened (unsaved, remembering their files) after a crash. Startup takes entries from ended runs and leaves live runs alone; writes and cleanup validate current folder and lock ownership. Failed isolation disables recovery with a warning. Recovered checkpoints remain available through another crash before autosave; restarting IDs and explicit recovery from another folder preserve unopened snapshots. Successful Save, Close or Revert clears the document's checkpoints, including manual recovery while autosave is disabled; failed republishing keeps the original. Concurrent older versions still use unlocked root entries; mixed-version isolation remains open. File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`. - Window close and application/menu Quit ask about every unsaved document. Cancel or a cancelled/failed save keeps all tabs open; Don't Save approvals discard nothing until the complete plan is current. Later edits and new documents are checked before closing. Control-channel quit remains immediate; a cancelable macOS Dock Quit prompt is still missing (#544). - 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).