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.
This commit is contained in:
parent
9509b2bcea
commit
8e151a2fd4
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
}),
|
||||
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<Value> {
|
|||
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<Value> {
|
|||
// 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")]
|
||||
|
|
|
|||
|
|
@ -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<Arc<Document>>,
|
||||
/// 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);
|
||||
|
|
|
|||
|
|
@ -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<usize> {
|
||||
/// 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<usize> {
|
||||
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<usize> {
|
||||
// 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<bool> {
|
||||
match std::fs::symlink_metadata(dir) {
|
||||
|
|
@ -410,6 +501,11 @@ fn secure_recovery_dir(_dir: &Path) -> Result<bool> {
|
|||
|
||||
#[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<PathBuf> {
|
||||
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<std::collections::HashSet<u64>> {
|
||||
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<u64>) -> 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<Vec<usize>> {
|
||||
#[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<Vec<usize>> {
|
|||
// 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::<Vec<_>>(), [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<String> {
|
||||
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)]
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Reference in New Issue