From a5ef6b76e8ee8dba14361cdddeda03e1ffc39ebc Mon Sep 17 00:00:00 2001 From: FredApps Date: Fri, 16 Oct 2026 02:17:12 +0200 Subject: [PATCH] Wake idle windows for pending recovery snapshots (#2663) --- crates/engine/src/lib.rs | 5 ++ crates/engine/src/tests.rs | 4 ++ crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/recovery.rs | 93 +++++++++++++++++++++++++++++++++- docs/roadmap.md | 5 ++ 5 files changed, 107 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 01f162ae7d..9ded73c3e8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -4666,6 +4666,11 @@ impl Session { write_full(doc, &opts).map(Arc::new).map_err(|e| EditError::Write(e.to_string())) } + /// Whether a changed document still needs a recovery snapshot. This does not consume it. + pub fn has_pending_autosave(&self) -> bool { + self.docs.iter().any(|d| d.dirty && d.generation != d.snapshot_generation) + } + /// Documents with unsaved changes made since the last call: their current working file, /// for crash recovery. Encrypted documents stay encrypted in the snapshot. pub fn autosave_snapshots(&mut self) -> Vec { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ed90eea411..7a3fcf7262 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1233,9 +1233,13 @@ fn owner_password_of_older_revisions_opens_the_viewer_too() { #[test] fn autosave_snapshots_only_changed_documents() { let (mut s, id) = session_with(2); + assert!(!s.has_pending_autosave()); assert!(s.autosave_snapshots().is_empty(), "clean documents are not snapshotted"); s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + assert!(s.has_pending_autosave()); + assert!(s.has_pending_autosave(), "checking a deadline must not consume a snapshot"); let snaps = s.autosave_snapshots(); + assert!(!s.has_pending_autosave()); assert_eq!(snaps.len(), 1); assert_eq!(snaps[0].bytes, s.get(id).unwrap().bytes, "the working file"); assert!(s.autosave_snapshots().is_empty(), "nothing new since the last snapshot"); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d101d812e0..00a163a600 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -3028,7 +3028,7 @@ impl eframe::App for PdfCraftApp { // macOS: quit through `terminate:` instead of dropping the window (Touch Bar crash, #1136). craft_quit::on_close_request(ctx); let now = ctx.input(|i| i.time); - self.autosave_tick(now); + self.autosave_tick(ctx, now); // Reload documents whose files a build rewrote (#431). #[cfg(not(target_arch = "wasm32"))] self.watch_files(); diff --git a/crates/ui-egui/src/recovery.rs b/crates/ui-egui/src/recovery.rs index 4f1f1d8e8e..a691bcdfea 100644 --- a/crates/ui-egui/src/recovery.rs +++ b/crates/ui-egui/src/recovery.rs @@ -290,7 +290,7 @@ impl PdfCraftApp { } /// Autosave when the interval has passed (called every frame). - pub(crate) fn autosave_tick(&mut self, now: f64) { + pub(crate) fn autosave_tick(&mut self, ctx: &egui::Context, now: f64) { if self.recovery.is_none() { return; } @@ -298,6 +298,14 @@ impl PdfCraftApp { self.last_autosave = now; self.autosave_now(); } + // An idle native window has no input-driven frames. Wake it at the existing + // autosave deadline, without keeping clean or already-snapshotted documents awake. + if self.session.has_pending_autosave() { + let remaining = (self.last_autosave + AUTOSAVE_SECS - now).clamp(0.0, AUTOSAVE_SECS); + if let Ok(delay) = std::time::Duration::try_from_secs_f64(remaining) { + ctx.request_repaint_after(delay); + } + } } /// Reopen recovered documents (`keys`), as unsaved changes at their original paths. @@ -385,3 +393,86 @@ impl PdfCraftApp { } } } + +#[cfg(test)] +mod idle_tests { + use super::*; + use std::time::Duration; + + struct TestDir(PathBuf); + + impl TestDir { + fn new() -> Self { + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let path = std::env::temp_dir().join(format!("pdfcraft-idle-recovery-{}-{nonce}", std::process::id())); + std::fs::create_dir(&path).unwrap(); + Self(path) + } + fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn frame(app: &mut PdfCraftApp, ctx: &egui::Context, now: f64) -> Duration { + let mut out = ctx.run_ui(egui::RawInput { time: Some(now), ..Default::default() }, |ui| { + app.autosave_tick(ui.ctx(), now); + }); + // Headless timer test: acknowledge texture uploads without a GPU backend. + out.textures_delta.clear(); + out.viewport_output[&egui::ViewportId::ROOT].repaint_delay + } + + #[test] + fn idle_dirty_document_requests_its_deadline_and_writes_without_input() { + let dir = TestDir::new(); + let store = RecoveryStore::new(dir.path()); + let mut app = PdfCraftApp::new(); + app.enable_recovery(store.clone()); + let bytes = app.session.create_blank(200.0, 300.0, 1).unwrap(); + let id = app.session.open_new("Untitled.pdf", bytes).unwrap(); + let ctx = egui::Context::default(); + let mut delay = Duration::ZERO; + for _ in 0..4 { + delay = frame(&mut app, &ctx, 15.0); + } + assert!(delay > Duration::ZERO && delay <= Duration::from_secs(45), "idle deadline: {delay:?}"); + assert!(store.list().is_empty()); + frame(&mut app, &ctx, 60.0); + assert_eq!(store.list().len(), 1, "timer frame writes the snapshot without input"); + assert!(app.session.get(id).unwrap().dirty, "autosave does not mark the document saved"); + assert!(!app.session.has_pending_autosave()); + for _ in 0..4 { + delay = frame(&mut app, &ctx, 61.0); + } + assert_eq!(delay, Duration::MAX, "unchanged snapshot needs no periodic wake"); + } + + #[test] + fn clean_documents_and_disabled_recovery_do_not_schedule_autosave() { + for recovery_enabled in [false, true] { + let dir = TestDir::new(); + let mut app = PdfCraftApp::new(); + if recovery_enabled { + app.enable_recovery(RecoveryStore::new(dir.path())); + } + let bytes = app.session.create_blank(200.0, 300.0, 1).unwrap(); + if recovery_enabled { + app.session.open("Clean.pdf", None, bytes, None).unwrap(); + } else { + app.session.open_new("Unsaved.pdf", bytes).unwrap(); + } + let ctx = egui::Context::default(); + let mut delay = Duration::ZERO; + for _ in 0..4 { + delay = frame(&mut app, &ctx, 15.0); + } + assert_eq!(delay, Duration::MAX); + } + } +} diff --git a/docs/roadmap.md b/docs/roadmap.md index 3148823ea5..85b476444c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -41,6 +41,11 @@ live in `plan/execution-plan.md` (local-only, gitignored). Keep it current: update the milestone row and the [ROADMAP.md](../ROADMAP.md) progress log at the end of every session; re-estimate when a milestone lands. +Recovery scheduling (#2663): a native window with a pending dirty snapshot requests a +repaint at the existing 60-second autosave deadline, even with no mouse or keyboard input. +Clean and already-snapshotted documents do not schedule recovery wakeups. Snapshot contents, +encryption and storage-write retry behavior are unchanged; this does not resolve #1803. + ## Alpha gate The stage standard's gate: Acrobat Pro's everyday core workflows must each work end to end on the