Merge pull request #2666 from dotCooCoo/fix/recovery-off-interface-thread

Recovery: the timer writes recovery data on a background thread
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-12 16:01:55 -05:00
commit 9fe0dd5197
15 files changed
+1890 -78

No files matched your search

+6
View File
@@ -14,6 +14,12 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- **2026-11-06** · Automation roots refuse `COM¹` to `LPT³` and a device name before a colon (`COM1:`, `NUL:x`). A batch-export layout of that name is written with a leading `_` on every platform. No readiness percentage change.
- **2026-10-30** · Document Fonts lookup treats a bare document filename's empty parent as the current directory (#2014), preserving case-insensitive discovery and automation read-root checks. No readiness percentage change.
- **2026-10-27** · Automatic recovery writes off the interface thread, straight to disk: on a
190-page catalogue with 4.2 GB of placed files an autosave was 3.6 GB deflated in RAM on the
interface thread, which stopped the window answering for 30-100 s after an edit. It is now a
background write (`file.recovery.save {background}`, `file.recovery.progress`), the caller
blocked for 109 ms instead, and Save, Close and Revert cancel a snapshot still being written.
No readiness percentage change.
- **2026-10-28** · Inline table rows leave room for column and spanning footnotes, continuing in
the next column or frame when needed (#1901). Cell-local footnotes contribute to row height
and sit inside the cell rather than outside its measuring frame. Vertical justification keeps
+4
View File
@@ -8,6 +8,10 @@ description = "DesignCraft engine: session, documents, history, selection, comma
[lints]
workspace = true
[features]
# Recovery-writer handshakes for other crates' tests (`designcraft_engine::recovery::testing`).
testing = []
[dependencies]
craft-print = { workspace = true }
craft-docpath = { workspace = true }
+15 -1
View File
@@ -67,10 +67,24 @@ pub fn specs() -> Vec<CommandSpec> {
let dir = recovery_dir(s, p, FolderAccess::Read)?;
Ok(Value::Array(crate::recovery::list(&dir).into_iter().map(|(uid, mut m)| { m["uid"] = json!(uid); m }).collect()))
}),
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| {
cmd!(noundo "file.recovery.save", "Save Recovery Data", [], None,
"{dir?, background?} — write every unsaved document to the recovery folder (the app does this on a timer; `background` queues the writes and returns at once) → {saved} or {queued}",
always, |s, p| {
let background = match p.get("background") {
None | Some(Value::Null) => false,
Some(v) => v.as_bool().ok_or_else(|| bad("file.recovery.save", "`background` must be true or false"))?,
};
let dir = recovery_dir(s, p, FolderAccess::Write)?;
if background {
return Ok(json!({"queued": crate::recovery::start_tracked(s, &dir)?}));
}
Ok(json!({"saved": crate::recovery::save_tracked(s, &dir)?}))
}),
cmd!(query "file.recovery.progress", "Recovery Progress", [], None,
"{} → {written, busy, errors} recovery data queued in the background has been written", always, |s, _| {
let done = crate::recovery::finished(s);
Ok(json!({"written": done.written, "busy": done.busy, "errors": done.errors}))
}),
cmd!(noundo "file.recovery.open", "Recover Documents", [], None, "{dir?} — reopen documents left in the recovery folder (unsaved)", always, |s, p| {
// Reopening removes the entries it read.
let dir = recovery_dir(s, p, FolderAccess::ReadWrite)?;
+5
View File
@@ -414,6 +414,10 @@ pub struct Session {
pub clipboard_origin: f64,
/// Crash-recovery folder (the app sets it; saving or closing a document removes its entry).
pub recovery_dir: Option<std::path::PathBuf>,
/// This session's number for the background recovery writer, which returns each result to
/// the session that queued it.
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
pub(crate) recovery_owner: recovery::Owner,
/// Copied text with its formatting (a frameless story slice), its plain text, and what pasting
/// it needs from its document: a copy holding only the stories its anchored text frames show
/// (and theirs) and the graphics the text and those stories place.
@@ -479,6 +483,7 @@ impl Session {
clipboard: None,
clipboard_origin: 0.0,
recovery_dir: None,
recovery_owner: recovery::Owner::new(),
text_clipboard: None,
link_checks: Default::default(),
ui_requests: vec![],
File diff suppressed because it is too large. Load diff
+749 -6
View File
@@ -1,5 +1,12 @@
use super::*;
/// [`super::discard`] as the session that wrote or recovered the entry, if one did.
fn discard(dir: &Path, uid: u64) {
let key = directory_key(dir).ok().map(|key| (key, uid));
let owner = key.and_then(|key| WRITTEN.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key).copied());
super::discard(dir, uid, owner.unwrap_or(0));
}
fn dirty_session() -> Session {
let mut session = Session::new();
session.execute("file.new", &json!({})).unwrap();
@@ -126,6 +133,727 @@ fn recovery_data_is_private_to_the_user() {
}
}
/// The recovery writer is one thread for the whole process, and its results go to whichever
/// session polls it: tests that submit to it take turns, so one test's write or error is never
/// drained by another.
static BACKGROUND: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn background() -> std::sync::MutexGuard<'static, ()> {
BACKGROUND.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// Wait for the background writer to have nothing left, then read `file.recovery.progress` as the
/// app does at the end of a frame.
fn finished_writes(s: &mut Session) -> usize {
testing::wait_until_idle();
let v = s.execute("file.recovery.progress", &json!({})).unwrap();
assert_eq!(v["errors"], json!([]), "{v}");
assert_eq!(v["busy"], false, "{v}");
v["written"].as_u64().unwrap_or(0) as usize
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_background_save_returns_while_its_write_is_held() {
let _turn = background();
let dir = tempfile::tempdir().unwrap();
let mut s = dirty_session();
s.recovery_dir = Some(dir.path().to_path_buf());
let uid = s.docs[0].uid;
let (file, _) = files(dir.path(), uid);
let held = testing::hold_next_write(dir.path());
let queued = s.execute("file.recovery.save", &json!({"background": true})).unwrap();
assert!(!held.ran_here(), "the write ran on the calling thread");
assert_eq!(queued["queued"], 1, "{queued}");
// The writer thread is stopped at the start of the write, and the session goes on.
held.wait_until_held();
let v = s.execute("file.recovery.progress", &json!({})).unwrap();
assert_eq!(v["busy"], true, "{v}");
s.execute("frame.create", &json!({"rect": [20, 20, 60, 60]})).unwrap();
assert!(!file.exists(), "nothing is published while the write is held");
held.release();
assert_eq!(finished_writes(&mut s), 1);
assert!(file.exists());
discard(dir.path(), uid);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_document_saved_while_its_write_is_held_leaves_no_snapshot() {
let _turn = background();
let dir = tempfile::tempdir().unwrap();
let mut s = dirty_session();
s.recovery_dir = Some(dir.path().to_path_buf());
let uid = s.docs[0].uid;
let held = testing::hold_next_write(dir.path());
s.execute("file.recovery.save", &json!({"background": true})).unwrap();
held.wait_until_held();
let saved = dir.path().join("saved.designcraft");
s.execute("file.saveAs", &json!({"path": saved.to_string_lossy()})).unwrap();
held.release();
// Before the session takes the result: a crash now must not find the stale snapshot.
testing::wait_until_idle();
assert!(!files(dir.path(), uid).0.exists(), "the stale snapshot was not published");
assert!(!files(dir.path(), uid).1.exists(), "nor its metadata");
assert_eq!(finished_writes(&mut s), 0);
assert!(list(dir.path()).is_empty());
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_document_saved_while_its_write_to_a_named_folder_is_held_leaves_no_snapshot() {
// A folder given to `file.recovery.save` is not the session's, and is not in the document's
// entries until the write's result is taken.
let _turn = background();
let named = tempfile::tempdir().unwrap();
let mut s = dirty_session();
let uid = s.docs[0].uid;
let held = testing::hold_next_write(named.path());
let queued = s.execute("file.recovery.save", &json!({"background": true, "dir": named.path().to_string_lossy()})).unwrap();
assert_eq!(queued["queued"], 1, "{queued}");
held.wait_until_held();
let saved = named.path().join("saved.designcraft");
s.execute("file.saveAs", &json!({"path": saved.to_string_lossy()})).unwrap();
held.release();
// Before the session takes the result: a crash now must not find the stale snapshot.
testing::wait_until_idle();
assert!(!files(named.path(), uid).0.exists(), "the stale snapshot was not published");
assert_eq!(finished_writes(&mut s), 0);
assert!(list(named.path()).is_empty());
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn saving_removes_a_finished_write_to_a_named_folder_whose_result_was_not_taken() {
let _turn = background();
let named = tempfile::tempdir().unwrap();
let mut s = dirty_session();
let uid = s.docs[0].uid;
s.execute("file.recovery.save", &json!({"background": true, "dir": named.path().to_string_lossy()})).unwrap();
testing::wait_until_idle();
assert!(files(named.path(), uid).0.exists(), "the write finished");
let saved = named.path().join("saved.designcraft");
s.execute("file.saveAs", &json!({"path": saved.to_string_lossy()})).unwrap();
assert!(!files(named.path(), uid).0.exists(), "Save removed the snapshot before its result was taken");
// Edited again before the result is taken: the removed snapshot is not its checkpoint.
s.execute("frame.create", &json!({"rect": [20, 20, 60, 60]})).unwrap();
assert_eq!(finished_writes(&mut s), 0);
assert!(!s.docs[0].recovery_entries.iter().any(|(d, _)| same_directory(d, named.path())), "no checkpoint for a removed snapshot");
assert!(list(named.path()).is_empty());
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn opening_recovery_leaves_a_finished_background_write_whose_result_was_not_taken() {
let _turn = background();
let dir = tempfile::tempdir().unwrap();
let mut s = dirty_session();
s.recovery_dir = Some(dir.path().to_path_buf());
let uid = s.docs[0].uid;
s.execute("file.recovery.save", &json!({"background": true})).unwrap();
testing::wait_until_idle();
assert!(files(dir.path(), uid).0.exists(), "the write finished");
assert!(open(&mut s, dir.path()).unwrap().is_empty(), "the live document's own snapshot is not recovered");
assert_eq!(s.docs.len(), 1);
assert_eq!(finished_writes(&mut s), 1);
assert_eq!(list(dir.path()).len(), 1);
discard(dir.path(), uid);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn an_edit_after_a_save_is_written_after_the_cancelled_write_stops() {
let _turn = background();
let dir = tempfile::tempdir().unwrap();
let mut s = dirty_session();
s.recovery_dir = Some(dir.path().to_path_buf());
let uid = s.docs[0].uid;
let held = testing::hold_next_write(dir.path());
s.execute("file.recovery.save", &json!({"background": true})).unwrap();
held.wait_until_held();
let saved = tempfile::tempdir().unwrap();
s.execute("file.saveAs", &json!({"path": saved.path().join("saved.designcraft").to_string_lossy()})).unwrap();
s.execute("frame.create", &json!({"rect": [20, 20, 60, 60]})).unwrap();
// The held write was cancelled: the new edit is queued behind it instead of waiting for the
// next timer tick.
let queued = s.execute("file.recovery.save", &json!({"background": true})).unwrap();
assert_eq!(queued["queued"], 1, "{queued}");
held.release();
assert_eq!(finished_writes(&mut s), 1);
let mut reopened = Session::new();
assert_eq!(open(&mut reopened, dir.path()).unwrap().len(), 1);
assert_eq!(reopened.doc().unwrap().doc.spreads[0].items.len(), 2, "the snapshot after the edit was written");
discard(dir.path(), reopened.doc().unwrap().uid);
discard(dir.path(), uid);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_background_result_goes_to_the_session_that_queued_it() {
let _turn = background();
let dir = tempfile::tempdir().unwrap();
let mut queued_by = dirty_session();
queued_by.recovery_dir = Some(dir.path().to_path_buf());
let uid = queued_by.docs[0].uid;
let mut other = dirty_session();
queued_by.execute("file.recovery.save", &json!({"background": true})).unwrap();
testing::wait_until_idle();
// Another session has none of these documents: it must neither take the result nor remove
// the snapshot as if its document had been closed.
let v = other.execute("file.recovery.progress", &json!({})).unwrap();
assert_eq!(v["written"], 0, "{v}");
assert!(files(dir.path(), uid).0.exists(), "the snapshot is still there");
assert_eq!(finished_writes(&mut queued_by), 1);
assert!(queued_by.docs[0].recovery_entries.iter().any(|(d, u)| *u == uid && same_directory(d, dir.path())), "the checkpoint is recorded");
discard(dir.path(), uid);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn saving_in_one_session_leaves_another_sessions_write_alone() {
let _turn = background();
let dir = tempfile::tempdir().unwrap();
let mut queued_by = dirty_session();
queued_by.recovery_dir = Some(dir.path().to_path_buf());
let uid = queued_by.docs[0].uid;
// The same uid in another session (recovered documents keep the uids of their entries).
let mut other = dirty_session();
other.docs[0].uid = uid;
let held = testing::hold_next_write(dir.path());
queued_by.execute("file.recovery.save", &json!({"background": true})).unwrap();
held.wait_until_held();
let elsewhere = tempfile::tempdir().unwrap();
other.execute("file.saveAs", &json!({"path": elsewhere.path().join("other.designcraft").to_string_lossy()})).unwrap();
held.release();
assert_eq!(finished_writes(&mut queued_by), 1);
assert!(files(dir.path(), uid).0.exists(), "the other session's save did not cancel this write");
discard(dir.path(), uid);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn saving_in_one_session_leaves_another_sessions_write_to_the_same_folder_alone() {
let _turn = background();
let dir = tempfile::tempdir().unwrap();
let mut queued_by = dirty_session();
queued_by.recovery_dir = Some(dir.path().to_path_buf());
let uid = queued_by.docs[0].uid;
// The same uid and recovery folder in another session.
let mut other = dirty_session();
other.recovery_dir = Some(dir.path().to_path_buf());
other.docs[0].uid = uid;
let held = testing::hold_next_write(dir.path());
queued_by.execute("file.recovery.save", &json!({"background": true})).unwrap();
held.wait_until_held();
let elsewhere = tempfile::tempdir().unwrap();
other.execute("file.saveAs", &json!({"path": elsewhere.path().join("other.designcraft").to_string_lossy()})).unwrap();
assert!(!worker::cancelled(dir.path(), uid), "the other session's save cancelled this write");
held.release();
assert_eq!(finished_writes(&mut queued_by), 1);
assert!(files(dir.path(), uid).0.exists(), "this session's snapshot was kept");
discard(dir.path(), uid);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_write_now_in_one_session_leaves_another_sessions_write_of_the_same_uid_alone() {
let _turn = background();
let dir = tempfile::tempdir().unwrap();
let mut queued_by = dirty_session();
queued_by.recovery_dir = Some(dir.path().to_path_buf());
let uid = queued_by.docs[0].uid;
// The same uid in another session, with two objects instead of one.
let mut other = dirty_session();
other.docs[0].uid = uid;
other.execute("frame.create", &json!({"rect": [20, 20, 60, 60]})).unwrap();
let held = testing::hold_next_write(dir.path());
queued_by.execute("file.recovery.save", &json!({"background": true})).unwrap();
held.wait_until_held();
let err = save(&other, dir.path()).unwrap_err();
assert!(err.to_string().contains("still being written"), "{err}");
let err = other.execute("file.recovery.save", &json!({"dir": dir.path().to_string_lossy()})).unwrap_err();
assert!(err.to_string().contains("still being written"), "{err}");
assert!(!worker::cancelled(dir.path(), uid), "the other session's write now cancelled this write");
held.release();
assert_eq!(finished_writes(&mut queued_by), 1);
let mut reopened = Session::new();
assert_eq!(open(&mut reopened, dir.path()).unwrap().len(), 1);
assert_eq!(reopened.doc().unwrap().doc.spreads[0].items.len(), 1, "the queued session's snapshot was kept");
discard(dir.path(), reopened.doc().unwrap().uid);
discard(dir.path(), uid);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn no_other_sessions_write_of_the_uid_is_queued_during_a_write_now() {
let _turn = background();
let dir = tempfile::tempdir().unwrap();
let writing = dirty_session();
let uid = writing.docs[0].uid;
let mut other = dirty_session();
other.recovery_dir = Some(dir.path().to_path_buf());
other.docs[0].uid = uid;
let now = worker::write_now(dir.path(), uid, writing.recovery_owner.0).expect("nothing else is in flight");
let queued = other.execute("file.recovery.save", &json!({"background": true})).unwrap();
assert_eq!(queued["queued"], 0, "another session's write was queued during the write now: {queued}");
assert!(worker::write_now(dir.path(), uid, other.recovery_owner.0).is_none(), "a second write now of the uid started");
drop(now);
let queued = other.execute("file.recovery.save", &json!({"background": true})).unwrap();
assert_eq!(queued["queued"], 1, "{queued}");
assert_eq!(finished_writes(&mut other), 1);
discard(dir.path(), uid);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_write_now_cancels_a_background_write_to_the_same_folder_spelled_another_way() {
let _turn = background();
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("sub")).unwrap();
let spelled = dir.path().join("sub").join("..");
let mut s = dirty_session();
s.recovery_dir = Some(spelled.clone());
let uid = s.docs[0].uid;
let held = testing::hold_next_write(&spelled);
s.execute("file.recovery.save", &json!({"background": true})).unwrap();
held.wait_until_held();
// The newer state, written now under the folder's plain name.
s.execute("frame.create", &json!({"rect": [20, 20, 60, 60]})).unwrap();
s.execute("file.recovery.save", &json!({"dir": dir.path().to_string_lossy()})).unwrap();
assert!(worker::cancelled(&spelled, uid), "the older write is cancelled");
held.release();
testing::wait_until_idle();
let _ = s.execute("file.recovery.progress", &json!({})).unwrap();
let mut reopened = Session::new();
assert_eq!(open(&mut reopened, dir.path()).unwrap().len(), 1);
assert_eq!(reopened.doc().unwrap().doc.spreads[0].items.len(), 2, "the newer snapshot was kept");
discard(dir.path(), reopened.doc().unwrap().uid);
discard(dir.path(), uid);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn queued_background_writes_count_toward_the_checkpoint_limit() {
let _turn = background();
let mut s = dirty_session();
let folders: Vec<tempfile::TempDir> = (0..=MAX_RECOVERY_ENTRIES).map(|_| tempfile::tempdir().unwrap()).collect();
// The first write is held, so every later one is still queued when the next is asked for.
let held = testing::hold_next_write(folders[0].path());
for folder in &folders[..MAX_RECOVERY_ENTRIES] {
let queued = s.execute("file.recovery.save", &json!({"background": true, "dir": folder.path().to_string_lossy()})).unwrap();
assert_eq!(queued["queued"], 1, "{queued}");
}
let over = folders[MAX_RECOVERY_ENTRIES].path();
let err = s.execute("file.recovery.save", &json!({"background": true, "dir": over.to_string_lossy()})).unwrap_err();
assert!(err.to_string().contains("too many recovery checkpoints"), "{err}");
// Saving cancels the queued writes: they no longer count once the document changes again.
let saved = folders[0].path().join("saved.designcraft");
s.execute("file.saveAs", &json!({"path": saved.to_string_lossy()})).unwrap();
s.execute("frame.create", &json!({"rect": [20, 20, 60, 60]})).unwrap();
let queued = s.execute("file.recovery.save", &json!({"background": true, "dir": over.to_string_lossy()})).unwrap();
assert_eq!(queued["queued"], 1, "{queued}");
held.release();
assert_eq!(finished_writes(&mut s), 1, "only the write queued after the save is a checkpoint");
assert_eq!(list(over).len(), 1);
let uid = s.docs[0].uid;
for folder in &folders {
discard(folder.path(), uid);
}
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn results_for_a_session_that_is_gone_are_not_kept() {
let _turn = background();
let dir = tempfile::tempdir().unwrap();
let mut gone = dirty_session();
gone.recovery_dir = Some(dir.path().to_path_buf());
let owner = gone.recovery_owner.0;
gone.execute("file.recovery.save", &json!({"background": true})).unwrap();
testing::wait_until_idle();
drop(gone);
let mut other = dirty_session();
other.execute("file.recovery.progress", &json!({})).unwrap();
let writer = worker::started().unwrap();
let kept = writer.undelivered.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
assert!(!kept.iter().any(|d| d.owner == owner), "a result nobody can take was kept");
}
#[test]
fn a_background_save_rejects_a_background_that_is_not_true_or_false() {
let dir = tempfile::tempdir().unwrap();
let mut s = dirty_session();
s.recovery_dir = Some(dir.path().to_path_buf());
let err = s.execute("file.recovery.save", &json!({"background": "yes"})).unwrap_err();
assert!(err.to_string().contains("`background` must be true or false"), "{err}");
assert!(list(dir.path()).is_empty(), "nothing was written");
}
#[test]
fn a_background_save_writes_recovery_data_the_app_can_recover() {
let _turn = background();
let dir = tempfile::tempdir().unwrap();
let mut s = dirty_session();
s.recovery_dir = Some(dir.path().to_path_buf());
// The timer's call: the interface thread does not wait for the deflate or the file writes.
let queued = s.execute("file.recovery.save", &json!({"background": true})).unwrap();
assert_eq!(queued["queued"], 1, "{queued}");
assert_eq!(finished_writes(&mut s), 1);
assert_eq!(list(dir.path()).len(), 1);
assert!(s.docs[0].recovery_entries.iter().any(|(d, _)| same_directory(d, dir.path())), "the checkpoint is recorded");
// Recovering it gives the unsaved edits back.
let mut reopened = Session::new();
assert_eq!(open(&mut reopened, dir.path()).unwrap().len(), 1);
assert_eq!(reopened.doc().unwrap().doc.spreads[0].items.len(), 1);
assert!(reopened.doc().unwrap().is_dirty());
discard(dir.path(), reopened.doc().unwrap().uid);
}
#[test]
fn a_snapshot_that_finishes_after_the_save_is_dropped() {
// A write of a large document runs for seconds: the user can save while it does. The saved
// document's checkpoint is consumed then, and the write must not put a stale snapshot back.
let dir = tempfile::tempdir().unwrap();
let mut s = dirty_session();
s.recovery_dir = Some(dir.path().to_path_buf());
let uid = s.docs[0].uid;
// The snapshot the writer took before the save.
let entry = Snapshot::of(&prepare_save(&s, dir.path()).unwrap(), dir.path(), &s.docs[0]);
let saved = dir.path().join("saved.designcraft");
s.execute("file.saveAs", &json!({"path": saved.to_string_lossy()})).unwrap();
assert!(!s.docs[0].is_dirty());
// The write lands after the save.
let written = write_snapshot(&entry, || false);
assert!(written.published && written.error.is_none(), "the snapshot landed");
let (file, _) = files(dir.path(), uid);
assert!(file.exists());
assert!(!record_checkpoint(&mut s, dir.path().to_path_buf(), uid), "a saved document records no checkpoint");
assert!(!file.exists(), "its snapshot is not left to be recovered");
assert!(list(dir.path()).is_empty());
// A document still unsaved does record one.
s.execute("frame.create", &json!({"rect": [10, 10, 50, 50]})).unwrap();
let entry = Snapshot::of(&prepare_save(&s, dir.path()).unwrap(), dir.path(), &s.docs[0]);
let written = write_snapshot(&entry, || false);
assert!(written.published && written.error.is_none(), "the second snapshot landed");
assert!(record_checkpoint(&mut s, dir.path().to_path_buf(), uid));
assert!(s.docs[0].recovery_entries.iter().any(|(d, u)| *u == uid && d == dir.path()));
}
#[test]
fn a_shared_folders_entry_is_only_its_writing_sessions() {
// A recovered entry keeps the uid it was written under, which a document of another session
// in the same process can have.
let dir = tempfile::tempdir().unwrap();
let writer = dirty_session();
let uid = writer.docs[0].uid;
assert_eq!(save(&writer, dir.path()).unwrap(), 1);
let (file, _) = files(dir.path(), uid);
let snapshot = std::fs::read(&file).unwrap();
// Another session's clean document with that uid: its discard leaves the entry alone.
let mut clean = Session::new();
clean.execute("file.new", &json!({})).unwrap();
clean.docs[0].uid = uid;
assert_eq!(save(&clean, dir.path()).unwrap(), 0);
assert!(file.exists(), "another session's discard removed the entry");
// Another session's edited document with that uid: it does not write over the entry.
let mut edited = dirty_session();
edited.docs[0].uid = uid;
assert!(save(&edited, dir.path()).is_err());
assert_eq!(std::fs::read(&file).unwrap(), snapshot, "another session wrote over the entry");
super::discard(dir.path(), uid, writer.recovery_owner.0);
assert!(!file.exists(), "the writing session removes its entry");
}
#[test]
fn a_snapshot_queued_as_the_sessions_own_is_recorded_when_it_lands() {
// A write taken while the shared folder's entry was this session's, published after a cleanup
// dropped that record: Save and Close must still remove what it put there.
let dir = tempfile::tempdir().unwrap();
let s = dirty_session();
let uid = s.docs[0].uid;
let owner = s.recovery_owner.0;
assert_eq!(save(&s, dir.path()).unwrap(), 1);
let entry = Snapshot::of(&prepare_save(&s, dir.path()).unwrap(), dir.path(), &s.docs[0]);
assert!(entry.own);
super::discard(dir.path(), uid, owner);
let (file, _) = files(dir.path(), uid);
assert!(!file.exists());
let written = write_snapshot(&entry, || false);
assert!(written.published && written.error.is_none(), "the snapshot landed");
super::discard(dir.path(), uid, owner);
assert!(!file.exists(), "the snapshot it wrote is the session's to remove");
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_snapshot_whose_metadata_failed_is_still_the_documents_to_remove() {
// The primary document is the recovery data; its metadata is optional. A metadata failure
// must not leave the published snapshot untracked, or Save and Close would not remove it and
// it would stay in the folder for ever.
let root = tempfile::tempdir().unwrap();
let dir = run_dir(root.path()).unwrap();
let mut s = dirty_session();
s.recovery_dir = Some(dir.clone());
let uid = s.docs[0].uid;
let (document, metadata) = files(&dir, uid);
// The folder is this run's own, so the entry is its own; a folder takes the metadata's name,
// which makes the optional write fail after the primary landed.
std::fs::create_dir(&metadata).unwrap();
let err = save_tracked(&mut s, &dir).unwrap_err();
assert!(err.to_string().contains(&metadata.display().to_string()), "{err}");
assert!(document.exists(), "the primary landed");
assert!(s.docs[0].recovery_entries.iter().any(|(d, u)| *u == uid && same_directory(d, &dir)), "and is tracked");
// Saving the document removes the snapshot this run wrote.
let saved = root.path().join("saved.designcraft");
s.execute("file.saveAs", &json!({"path": saved.to_string_lossy()})).unwrap();
assert!(!document.exists(), "Save removed the snapshot");
assert!(s.docs[0].recovery_entries.is_empty());
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_snapshot_whose_folder_sync_failed_is_still_the_documents_to_remove() {
// The rename put the snapshot in place before the folder sync failed: it is tracked, so Save
// removes it.
let root = tempfile::tempdir().unwrap();
let dir = run_dir(root.path()).unwrap();
let mut s = dirty_session();
s.recovery_dir = Some(dir.clone());
let uid = s.docs[0].uid;
let (document, _) = files(&dir, uid);
testing::fail_next_sync(&dir);
let err = save_tracked(&mut s, &dir).unwrap_err();
assert!(err.to_string().contains("sync failed"), "{err}");
assert!(document.exists(), "the snapshot landed");
assert!(s.docs[0].recovery_entries.iter().any(|(d, u)| *u == uid && same_directory(d, &dir)), "and is tracked");
let saved = root.path().join("saved.designcraft");
s.execute("file.saveAs", &json!({"path": saved.to_string_lossy()})).unwrap();
assert!(!document.exists(), "Save removed the snapshot");
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_document_undone_to_its_saved_state_while_its_write_is_held_leaves_no_snapshot() {
let _turn = background();
let dir = tempfile::tempdir().unwrap();
let mut s = dirty_session();
s.recovery_dir = Some(dir.path().to_path_buf());
let uid = s.docs[0].uid;
let held = testing::hold_next_write(dir.path());
s.execute("file.recovery.save", &json!({"background": true})).unwrap();
held.wait_until_held();
s.execute("edit.undo", &json!({})).unwrap();
assert!(!s.docs[0].is_dirty(), "back to the state it had");
// A save of recovery data now drops the clean document's checkpoint, and the held write of
// the older state must not publish after it.
save(&s, dir.path()).unwrap();
held.release();
testing::wait_until_idle();
assert!(!files(dir.path(), uid).0.exists(), "the older snapshot was not published");
let _ = s.execute("file.recovery.progress", &json!({}));
assert!(list(dir.path()).is_empty());
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_document_undone_to_its_saved_state_loses_the_snapshot_a_finished_write_left_in_another_folder() {
let _turn = background();
let dir = tempfile::tempdir().unwrap();
let other = tempfile::tempdir().unwrap();
let mut s = dirty_session();
s.recovery_dir = Some(other.path().to_path_buf());
let uid = s.docs[0].uid;
s.execute("file.recovery.save", &json!({"background": true})).unwrap();
// Written, but its result not yet taken by the session.
testing::wait_until_idle();
assert!(files(other.path(), uid).0.exists());
s.execute("edit.undo", &json!({})).unwrap();
save(&s, dir.path()).unwrap();
assert!(!files(other.path(), uid).0.exists(), "the snapshot of the older state is removed");
let _ = s.execute("file.recovery.progress", &json!({}));
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_clean_document_leaves_another_sessions_write_queued_behind_its_own() {
let _turn = background();
let dir = tempfile::tempdir().unwrap();
let mut a = dirty_session();
a.recovery_dir = Some(dir.path().to_path_buf());
let uid = a.docs[0].uid;
let mut b = dirty_session();
b.recovery_dir = Some(dir.path().to_path_buf());
b.docs[0].uid = uid;
b.execute("frame.create", &json!({"rect": [20, 20, 60, 60]})).unwrap();
let held = testing::hold_next_write(dir.path());
a.execute("file.recovery.save", &json!({"background": true})).unwrap();
held.wait_until_held();
// A write now cancels the held one. The other session does not write over the entry it left.
a.execute("file.recovery.save", &json!({"dir": dir.path().to_string_lossy()})).unwrap();
let err = b.execute("file.recovery.save", &json!({"background": true})).unwrap_err();
assert!(err.to_string().contains("did not write"), "{err}");
a.execute("edit.undo", &json!({})).unwrap();
assert!(!a.docs[0].is_dirty());
save(&a, dir.path()).unwrap();
// Once that entry is gone, the other session's write is queued to follow the held one.
b.execute("file.recovery.save", &json!({"background": true})).unwrap();
held.release();
assert_eq!(finished_writes(&mut b), 1, "the other session's write ran");
let mut reopened = Session::new();
assert_eq!(open(&mut reopened, dir.path()).unwrap().len(), 1);
assert_eq!(reopened.doc().unwrap().doc.spreads[0].items.len(), 2, "its snapshot was kept");
discard(dir.path(), uid);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_background_recovery_write_reports_another_sessions_held_write() {
let _turn = background();
let dir = tempfile::tempdir().unwrap();
let mut first = dirty_session();
first.recovery_dir = Some(dir.path().to_path_buf());
let uid = first.docs[0].uid;
let mut second = dirty_session();
second.recovery_dir = Some(dir.path().to_path_buf());
second.docs[0].uid = uid;
second.execute("frame.create", &json!({"rect": [20, 20, 60, 60]})).unwrap();
let held = testing::hold_next_write(dir.path());
first.execute("file.recovery.save", &json!({"background": true})).unwrap();
held.wait_until_held();
let err = second.execute("file.recovery.save", &json!({"background": true})).unwrap_err();
assert!(err.to_string().contains("another session"), "{err}");
held.release();
assert_eq!(finished_writes(&mut first), 1);
assert!(files(dir.path(), uid).0.exists(), "the first session's recovery file remains intact");
discard(dir.path(), uid);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_clean_document_in_another_session_leaves_this_sessions_write_alone() {
let _turn = background();
let dir = tempfile::tempdir().unwrap();
let mut queued_by = dirty_session();
queued_by.recovery_dir = Some(dir.path().to_path_buf());
let uid = queued_by.docs[0].uid;
// The same uid, clean, in another session.
let mut other = Session::new();
other.execute("file.new", &json!({})).unwrap();
other.docs[0].uid = uid;
assert!(!other.docs[0].is_dirty());
let held = testing::hold_next_write(dir.path());
queued_by.execute("file.recovery.save", &json!({"background": true})).unwrap();
held.wait_until_held();
save(&other, dir.path()).unwrap();
held.release();
assert_eq!(finished_writes(&mut queued_by), 1);
assert!(files(dir.path(), uid).0.exists(), "the other session's save did not cancel this write");
discard(dir.path(), uid);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_background_snapshot_whose_metadata_failed_is_still_the_documents_to_remove() {
// The background writer's result must be applied the same way: a published primary is the
// document's snapshot even when its optional metadata fails.
let _turn = background();
let root = tempfile::tempdir().unwrap();
let dir = run_dir(root.path()).unwrap();
let mut s = dirty_session();
s.recovery_dir = Some(dir.clone());
let uid = s.docs[0].uid;
let (document, metadata) = files(&dir, uid);
std::fs::create_dir(&metadata).unwrap();
let queued = s.execute("file.recovery.save", &json!({"background": true})).unwrap();
assert_eq!(queued["queued"], 1, "{queued}");
testing::wait_until_idle();
let v = s.execute("file.recovery.progress", &json!({})).unwrap();
assert_eq!(v["busy"], false, "{v}");
let reported = v["errors"].as_array().and_then(|errors| errors.first()).and_then(Value::as_str).map(str::to_string);
let reported = reported.expect("the metadata failure is reported");
assert!(reported.contains(&metadata.display().to_string()), "{reported}");
assert!(document.exists(), "the primary landed");
assert!(s.docs[0].recovery_entries.iter().any(|(d, u)| *u == uid && same_directory(d, &dir)), "and is tracked");
// Saving the document removes the snapshot this run wrote.
let saved = root.path().join("saved.designcraft");
s.execute("file.saveAs", &json!({"path": saved.to_string_lossy()})).unwrap();
assert!(!document.exists(), "Save removed the snapshot");
}
#[test]
fn a_write_cancelled_midway_publishes_nothing() {
// Save/Revert/Close during a write marks it cancelled; the snapshot must not land after it,
// even if the app crashes before the next interface frame discards it.
let dir = tempfile::tempdir().unwrap();
let s = dirty_session();
let entry = Snapshot::of(&prepare_save(&s, dir.path()).unwrap(), dir.path(), &s.docs[0]);
let written = write_snapshot(&entry, || true);
assert!(!written.published && written.error.is_none(), "a cancelled write is not an error");
assert!(list(dir.path()).is_empty(), "a cancelled write leaves no snapshot");
assert!(!files(dir.path(), entry.uid).0.exists(), "nor its document");
assert!(!files(dir.path(), entry.uid).1.exists(), "nor its metadata");
// The temporary file it was written to is dropped, not left behind.
assert!(
std::fs::read_dir(dir.path()).unwrap().all(|e| e.is_ok_and(|e| !e.file_name().to_string_lossy().starts_with(".designcraft-recovery-"))),
"no temporary file is left"
);
}
#[test]
fn a_cancel_stops_a_write_before_the_package_is_finished() {
let dir = tempfile::tempdir().unwrap();
let s = dirty_session();
let entry = Snapshot::of(&prepare_save(&s, dir.path()).unwrap(), dir.path(), &s.docs[0]);
// Cancelled once the write has started: the first check passes, every later one fails.
let checks = std::cell::Cell::new(0);
let written = write_snapshot(&entry, || {
checks.set(checks.get() + 1);
checks.get() > 1
});
assert!(!written.published && written.error.is_none(), "a stopped write is not an error");
assert!(checks.get() >= 2, "the write checked again before it finished");
assert!(!files(dir.path(), entry.uid).0.exists(), "nothing was published");
assert!(
std::fs::read_dir(dir.path()).unwrap().all(|e| e.is_ok_and(|e| !e.file_name().to_string_lossy().starts_with(".designcraft-recovery-"))),
"no temporary file is left"
);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn only_a_queued_write_is_cancelled() {
// A mark for a document with no write in flight would stay in the writer's set for ever.
let dir = tempfile::tempdir().unwrap();
worker::cancel_owned(dir.path(), 7, 1);
assert!(!worker::cancelled(dir.path(), 7), "nothing was queued for it");
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_panicking_recovery_write_is_an_error_and_the_writer_keeps_going() {
let err = worker::guarded(|| -> Result<()> { panic!("boom") }).unwrap_err();
assert!(err.to_string().contains("panicked") && err.to_string().contains("boom"), "{err}");
assert_eq!(worker::guarded(|| Ok(7)).unwrap(), 7, "the next write still runs");
}
#[test]
fn a_saved_document_is_not_written_in_the_background() {
// The autosave writes unsaved documents only.
let _turn = background();
let dir = tempfile::tempdir().unwrap();
let mut s = dirty_session();
s.recovery_dir = Some(dir.path().to_path_buf());
let saved = dir.path().join("saved.designcraft");
s.execute("file.saveAs", &json!({"path": saved.to_string_lossy()})).unwrap();
let queued = s.execute("file.recovery.save", &json!({"background": true})).unwrap();
assert_eq!(queued["queued"], 0, "{queued}");
assert_eq!(finished_writes(&mut s), 0);
assert!(list(dir.path()).is_empty());
}
#[test]
fn existing_recovery_directory_is_used() {
let root = tempfile::tempdir().unwrap();
@@ -440,6 +1168,11 @@ fn restart_recovery_child() {
}
}
/// A child process starts with a copy of every open file of this one until it runs its program, so
/// a lock file closed meanwhile stays locked: a test that closes a lock and expects it free does
/// not run while a child is started.
static SPAWN: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn run_restart_case(mode: &str) {
let source = restart_fixture();
if mode == "max_uid" {
@@ -448,12 +1181,20 @@ fn run_restart_case(mode: &str) {
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();
// `spawn` returns once the child runs its program, and its copies of this process's files
// are closed by then.
let child = {
let _spawning = SPAWN.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
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)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap()
};
let output = child.wait_with_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}");
@@ -684,6 +1425,8 @@ fn documents_of_two_apps_that_ended_are_both_recovered() {
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);
// No child starts while the lock is open, so closing it frees it (see `SPAWN`).
let _no_spawn = SPAWN.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let lock = std::fs::File::open(running.join(LOCK)).unwrap();
lock.try_lock().unwrap();
let own = run_dir(root.path()).unwrap();
+20 -3
View File
@@ -91,6 +91,24 @@ fn save_with_large_entries_from(doc: &Document, preview: Option<&[u8]>, large_en
/// [`save_with_large_entries_from`], deflating only what a reader with `limits` inflates.
fn save_within(doc: &Document, preview: Option<&[u8]>, large_entry: usize, limits: Limits) -> Result<Vec<u8>, FormatError> {
let mut buf = Cursor::new(Vec::new());
write_with_large_entries_from(&mut buf, doc, preview, large_entry, limits)?;
Ok(buf.into_inner())
}
/// Write the package to `out` instead of returning it in memory: a document with gigabytes of
/// placed files needs no second copy in RAM (recovery snapshots, large saves).
pub fn write<W: Write + std::io::Seek>(out: &mut W, doc: &Document, preview: Option<&[u8]>) -> Result<(), FormatError> {
write_with_large_entries_from(out, doc, preview, LARGE_ENTRY, Limits::DEFAULT)
}
fn write_with_large_entries_from<W: Write + std::io::Seek>(
out: &mut W,
doc: &Document,
preview: Option<&[u8]>,
large_entry: usize,
limits: Limits,
) -> Result<(), FormatError> {
// JSON writes infinity and NaN as null, which no load accepts: the file would not open.
if let Some(id) = doc.non_finite_item() {
return Err(FormatError::Unsaveable(format!("object {} has a position or size that is not a finite number", id.0)));
@@ -99,9 +117,8 @@ fn save_within(doc: &Document, preview: Option<&[u8]>, large_entry: usize, limit
compact.compact_assets();
let doc = &compact;
let io = |e: zip::result::ZipError| FormatError::Io(e.to_string());
let mut buf = Cursor::new(Vec::new());
{
let mut z = zip::ZipWriter::new(&mut buf);
let mut z = zip::ZipWriter::new(out);
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
let deflate = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
z.start_file("mimetype", stored).map_err(io)?;
@@ -140,7 +157,7 @@ fn save_within(doc: &Document, preview: Option<&[u8]>, large_entry: usize, limit
}
z.finish().map_err(io)?;
}
Ok(buf.into_inner())
Ok(())
}
/// The file name extension of a DesignCraft document.
+18
View File
@@ -651,6 +651,24 @@ fn saved_tab_lists_load_sanitized() {
assert_eq!(tabs.last().map(|t| t.position), Some(3960.0), "the first stops by position");
}
#[test]
fn writing_to_a_stream_makes_the_same_file_as_saving_in_memory() {
// A save that streams needs no copy of the archive in RAM; it must write the same package.
let mut d = Document::new(&NewDocument::default());
let a = png_asset(&mut d, b"streamed bytes");
place(&mut d, a, 0.0);
let png = b"\x89PNG streamed".to_vec();
let saved = save_with_preview(&d, Some(&png)).unwrap();
let mut out = Cursor::new(Vec::new());
write(&mut out, &d, Some(&png)).unwrap();
let streamed = out.into_inner();
assert_eq!(saved.len(), streamed.len());
assert_eq!(asset_entries(&streamed), 1);
let back = load(&streamed).unwrap();
assert_eq!(back.assets.len(), 1);
assert_eq!(read_preview(Cursor::new(&streamed)), Some(png));
}
#[test]
fn the_preview_is_read_back_without_the_document() {
let d = Document::new(&NewDocument::default());
+1
View File
@@ -44,6 +44,7 @@ zip = { workspace = true }
# Headless (offscreen wgpu) rendering of the whole window for UI screenshots: `examples/ui_shot.rs`.
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
designcraft-fonts = { workspace = true, features = ["testing"] }
designcraft-engine = { workspace = true, features = ["testing"] }
[build-dependencies]
serde_json = { workspace = true }
+1
View File
@@ -111,6 +111,7 @@ impl DesignApp {
shortcut_answers: Default::default(),
last_time: now,
last_recovery: now,
recovery_writing: false,
egui_ctx: None,
in_control: false,
object_clip_text: None,
+33 -7
View File
@@ -83,14 +83,28 @@ impl DesignApp {
/// uses the monotonic clock, so a wall-clock/NTP jump cannot postpone or advance it.
pub(super) fn ui_frame_end(&mut self, ctx: &egui::Context) {
let now = now_ms() / 1000.0;
// Crash recovery: unsaved documents are written to the recovery folder every 30 s.
// Crash recovery: unsaved documents are written to the recovery folder every 30 s, on a
// background thread — writing a catalogue with gigabytes of placed files took minutes, and
// on the interface thread the window stopped answering for all of it.
#[cfg(not(target_arch = "wasm32"))]
if self.session.recovery_dir.is_some() && now - self.last_recovery > (self.session.prefs.recovery_minutes * 60.0).max(5.0) {
self.last_recovery = now;
if self.session.documents().iter().any(|d| d.is_dirty())
&& let Err(e) = self.session.execute("file.recovery.save", &json!({}))
{
self.status(crate::i18n::tr(&self.ui.language, "Couldn't write recovery data: {error}").replace("{error}", &e.to_string()));
{
if self.session.recovery_dir.is_some() && now - self.last_recovery > (self.session.prefs.recovery_minutes * 60.0).max(5.0) {
self.last_recovery = now;
if self.session.documents().iter().any(|d| d.is_dirty()) {
match self.session.execute("file.recovery.save", &json!({"background": true})) {
Ok(v) if v["queued"].as_u64().unwrap_or(0) > 0 => self.recovery_writing = true,
Ok(_) => {}
Err(e) => self
.status(crate::i18n::tr(&self.ui.language, "Couldn't write recovery data: {error}").replace("{error}", &e.to_string())),
}
}
}
if self.recovery_writing {
self.poll_recovery();
// Keep the frames coming while the writer works; it has no interface to ask.
if self.recovery_writing {
ctx.request_repaint_after(std::time::Duration::from_millis(150));
}
}
}
let dt = now - self.last_time;
@@ -101,6 +115,18 @@ impl DesignApp {
self.issue_screenshots(ctx);
}
/// Take what the background recovery writer finished: its checkpoints, and what failed.
#[cfg(not(target_arch = "wasm32"))]
fn poll_recovery(&mut self) {
let Ok(v) = self.session.execute("file.recovery.progress", &json!({})) else { return };
self.recovery_writing = v["busy"].as_bool().unwrap_or(false);
let errors: Vec<String> = v["errors"].as_array().into_iter().flatten().filter_map(|e| e.as_str().map(str::to_string)).collect();
for e in errors {
let message = crate::i18n::tr(&self.ui.language, "Couldn't write recovery data: {error}").replace("{error}", &e);
self.status(message);
}
}
/// The document area: the start screen, or the document tabs over one or two canvas panes.
pub(crate) fn canvas_area(&mut self, ui: &mut egui::Ui, presenting: bool) {
let t = theme::Tokens::get(ui.ctx());
+3
View File
@@ -255,6 +255,9 @@ pub struct DesignApp {
last_time: f64,
/// When recovery data was last written (seconds on the monotonic clock; see [`now_ms`]).
pub last_recovery: f64,
/// Recovery data is being written in the background: poll for it, and keep repainting.
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
pub(crate) recovery_writing: bool,
/// The egui context (set by the first UI pass): copied text goes to the system clipboard through it.
egui_ctx: Option<egui::Context>,
/// A control-channel request is being handled: commands it runs never read or write the
+41
View File
@@ -140,6 +140,47 @@ fn app_with_unsaved_document() -> DesignApp {
app
}
/// The recovery timer wrote the whole document on the interface thread: on a catalogue with
/// gigabytes of linked files the window stopped answering for minutes after every edit.
#[test]
fn the_recovery_timer_writes_off_the_interface_thread() {
let ctx = egui::Context::default();
let mut app = app_with_unsaved_document();
let dir = tempfile::tempdir().unwrap();
app.session.recovery_dir = Some(dir.path().to_path_buf());
app.last_recovery = -1.0e9;
let uid = app.session.documents()[0].uid;
let entry = dir.path().join(format!("{uid}.designcraft"));
// The writer stops at the start of the write until the test lets it go on, so a frame that
// waited for the write would never return.
let held = designcraft_engine::recovery::testing::hold_next_write(dir.path());
// egui discards a pass that needs another layout; the timer runs in the first pass it keeps.
for _ in 0..10 {
shot_frame(&mut app, &ctx, egui::vec2(1400.0, 900.0));
if app.last_recovery > 0.0 {
break;
}
}
assert!(app.last_recovery > 0.0, "the timer fired and restarted its interval");
assert!(!held.ran_here(), "the timer wrote the recovery data on the interface thread");
assert!(app.recovery_writing, "the timer queued the write");
held.wait_until_held();
// While the write is held, the interface keeps drawing frames and taking edits.
app.run("frame.create", json!({"rect": [20, 20, 80, 80]})).unwrap();
shot_frame(&mut app, &ctx, egui::vec2(1400.0, 900.0));
assert!(app.recovery_writing, "the write is still held");
assert!(!entry.exists(), "nothing is published while the write is held");
held.release();
designcraft_engine::recovery::testing::wait_until_idle();
shot_frame(&mut app, &ctx, egui::vec2(1400.0, 900.0));
assert!(!app.recovery_writing, "the frame after the write took its result");
assert!(entry.exists(), "the timer's background write published the document");
// The checkpoint is the document's: saving it removes the recovery entry.
let saved = dir.path().join("saved.designcraft");
app.run("file.saveAs", json!({"path": saved.to_string_lossy()})).unwrap();
assert!(!dir.path().join(format!("{uid}.designcraft")).exists());
}
/// File ▸ Close (and its shortcut) closed a document with unsaved changes without asking, and
/// closing discards the document's recovery data too.
#[test]
+5 -2
View File
@@ -1,6 +1,6 @@
# DesignCraft architecture
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (shared PDF writing) · **Target:** Adobe InDesign 2026
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (recovery writes on a background thread, streamed to disk) · **Target:** Adobe InDesign 2026
How DesignCraft is built today. The design notes behind it are in `plan/architecture.md`
(local, gitignored); this file describes what the code on `main` does. Agents: AGENTS.md has the
@@ -58,7 +58,9 @@ panels, zoom, dialogs) are `UI_COMMANDS` in `crates/ui-egui/src/menus/ui_command
Tool gestures emit Begin/Preview/Commit commands so a drag is one undo step. Every journaled
command is replayable as a script. Escaped panics during a command or an import/export become an
error (`engine/src/guard.rs`), never a lost document; native recovery is written periodically
into each running app's locked folder. Orderly native shutdown finalizes complete, current quit
into each running app's locked folder, on a background thread and straight to disk, so an
autosave of a catalogue with gigabytes of placed files no longer stops the window answering.
Orderly native shutdown finalizes complete, current quit
approvals and writes the remaining documents' latest recovery state, including clean-document
cleanup after Undo. Unavailable or failed recovery is reported. The desktop opts into a pinned
winit 0.30 patch that cancels AppKit termination and queues ordinary window close requests,
@@ -119,6 +121,7 @@ builds (on manifest changes), packaging lint and the release workflow; `xtask ci
| Date | Change | Summary |
|---|---|---|
| 2026-10-27 | minor | Recovery writes off the interface thread, streamed to disk; a snapshot still being written when its document is saved, reverted or closed is cancelled |
| 2026-10-12 | minor | Documented cancelable Dock Quit through the native event-loop boundary |
| 2026-10-12 | minor | Documented orderly native exit recovery and the remaining cancelable Dock Quit gap |
| 2026-10-10 | major | Created from the code on main (92649ff) and `plan/architecture.md` |
+6 -2
View File
@@ -1,6 +1,6 @@
# Where DesignCraft falls short of InDesign
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (gap 21 shrunk: pt-BR translates the whole interface; placed graphic layer-option preservation and explicit rendering warnings; gap 16 shrunk: File › File Info; gap 16: Convert Variable to Text; gap 28 added: placed-PDF font repair remains after visible unembedded font detection, #909; gap 26 shrunk: autoflow fills the following pages before it adds pages at the end; gap 6 shrunk: Type › Reload Fonts reads fonts installed, edited or removed while running (#328); gap 3: Package links are relative, #340; gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (gap 20: automatic recovery moved off the interface thread; gap 21 shrunk: pt-BR translates the whole interface; placed graphic layer-option preservation and explicit rendering warnings; gap 16 shrunk: File › File Info; gap 16: Convert Variable to Text; gap 28 added: placed-PDF font repair remains after visible unembedded font detection, #909; gap 26 shrunk: autoflow fills the following pages before it adds pages at the end; gap 6 shrunk: Type › Reload Fonts reads fonts installed, edited or removed while running (#328); gap 3: Package links are relative, #340; gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6)
Every known shortfall against InDesign 2026, one entry each, ranked by how much it stops an
InDesign professional from doing real work. This is the work list: agents pick from the top.
@@ -240,7 +240,11 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format ·
against InDesign on long books and image-heavy catalogues.
- **Have:** compose 300k characters ~38 ms warm, damage-region repaint (< 1 ms while typing),
parallel story composition, and an edit composes only the paragraphs it can change in a plain
story (240 paragraphs: 3 composed for a typed character).
story (240 paragraphs: 3 composed for a typed character). Automatic recovery writes on a
background thread and streams to disk, so an autosave of that catalogue no longer blocks the
interface thread for 30-100 s (`file.recovery.save {background}`, `file.recovery.progress`). A
snapshot still being written when its document is saved, reverted or closed is cancelled before
it is published, so a crash cannot offer back a copy older than the saved file.
- **Estimate:** 40–80 h.
### 26. Autoflow flows into the primary text frame of the pages that follow (F)