Wake idle windows for pending recovery snapshots (#2663)

This commit is contained in:
FredApps
2026-10-16 02:17:12 +02:00
parent 99941daa5a
commit a5ef6b76e8
5 changed files with 107 additions and 2 deletions
+5
View File
@@ -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<RecoverySnapshot> {
+4
View File
@@ -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");
+1 -1
View File
@@ -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();
+92 -1
View File
@@ -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);
}
}
}
+5
View File
@@ -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