Lost window device: close only once every unsaved document is saved for recovery, retrying every 2 s; also check the device on the screen-picker frame (#6464 review)

This commit is contained in:
00xJ0
2026-10-18 17:15:13 -07:00
parent 4615a8bc26
commit 7bb7819b00
5 changed files with 155 additions and 30 deletions
+67 -23
View File
@@ -43,7 +43,9 @@ pub fn check(app: &mut PhotocraftApp, ctx: &egui::Context) {
/// with shapes panics creating its staging buffers (#1215), and one without leaves a frozen window
/// whose close button opens an unsaved-changes prompt nobody can see. So this frame and every later
/// one draw nothing; the first writes every unsaved document to crash recovery, waits for the
/// writes, and closes the app without asking. The next launch reopens the documents.
/// writes, and closes the app without asking once all of them are saved. The next launch reopens
/// the documents. If a write fails (a full disk, say) the app stays open with the documents in
/// memory and tries again every [`RECOVERY_RETRY_SECS`]: closing would lose them.
pub fn check_window(app: &mut PhotocraftApp, ctx: &egui::Context) {
let Some((device, health)) = app.window_device.as_ref() else { return };
// A loss while the app sat idle or the PC slept is noticed here, before the next frame.
@@ -52,36 +54,78 @@ pub fn check_window(app: &mut PhotocraftApp, ctx: &egui::Context) {
return;
}
ctx.graphics_mut(|g| *g = Default::default());
if app.window_lost {
return;
let now = ctx.input(|i| i.time);
if !app.window_lost {
app.window_lost = true;
let reason = health.fault().map_or_else(|| "GPU device lost".to_string(), |f| f.to_string());
log::error!("{reason}: the window can't be drawn any more; saving unsaved documents for recovery and closing");
} else {
match app.window_recovery_retry {
// Saved, and the close is on its way.
None => return,
Some(at) if now < at => {
ctx.request_repaint_after(std::time::Duration::from_secs_f64((at - now).max(0.0)));
return;
}
Some(_) => {}
}
}
match save_for_recovery(app) {
Ok(()) => {
app.window_recovery_retry = None;
app.allow_close = true;
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
Err(e) => {
log::error!("{e}; keeping the documents open and trying again in {RECOVERY_RETRY_SECS} s");
app.window_recovery_retry = Some(now + RECOVERY_RETRY_SECS);
ctx.request_repaint_after(std::time::Duration::from_secs_f64(RECOVERY_RETRY_SECS));
}
}
app.window_lost = true;
let reason = health.fault().map_or_else(|| "GPU device lost".to_string(), |f| f.to_string());
log::error!("{reason}: the window can't be drawn any more; saving unsaved documents for recovery and closing");
save_for_recovery(app);
app.allow_close = true;
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
/// Queue a crash-recovery autosave of every document with unsaved changes, whatever the autosave
/// timer and preference, and wait until the writes finished (see `Services::flush_autosaves`).
fn save_for_recovery(app: &mut PhotocraftApp) {
/// Seconds between attempts to save unsaved work for recovery after the window's device was lost.
pub const RECOVERY_RETRY_SECS: f64 = 2.0;
/// Write every document with unsaved changes to crash recovery, whatever the autosave timer and
/// preference, and wait until the writes finished (see `Services::flush_autosaves`). `Ok` only when
/// each of them is known to be on disk.
fn save_for_recovery(app: &mut PhotocraftApp) -> Result<(), String> {
let jobs: Vec<_> = app.session.documents().iter().filter(|d| d.is_dirty()).map(|d| (d.doc.clone(), d.revision, d.path.clone())).collect();
if let Some(save) = app.services.autosave.as_mut() {
for (doc, revision, path) in &jobs {
if let Err(e) = save(doc, *revision, path.as_deref()) {
log::error!("couldn't save {} for recovery: {e}", doc.name);
}
if jobs.is_empty() {
return Ok(());
}
// (document id, why) for each document not known to be saved.
let mut failed: Vec<(u64, String)> = Vec::new();
let Some(save) = app.services.autosave.as_mut() else { return Err("no crash-recovery autosave".into()) };
for (doc, revision, path) in &jobs {
if let Err(e) = save(doc, *revision, path.as_deref()) {
failed.push((doc.id.0, format!("{}: {e}", doc.name)));
}
}
if let Some(flush) = app.services.flush_autosaves.as_mut() {
for (id, revision, outcome) in flush() {
match outcome {
Ok(()) => log::info!("document {id} (revision {revision}) saved for recovery"),
Err(e) => log::error!("couldn't save document {id} for recovery: {e}"),
}
// Without a flush, only a service whose `autosave` reports the finished write can confirm it.
let outcomes = match app.services.flush_autosaves.as_mut() {
Some(flush) => flush(),
None if app.services.autosave_results.is_none() => Vec::new(),
None => return Err("crash-recovery autosaves can't be waited for".into()),
};
let confirm = app.services.flush_autosaves.is_some();
for (doc, revision, _) in &jobs {
if failed.iter().any(|(id, _)| *id == doc.id.0) {
continue;
}
// The writer skips to the newest queued snapshot, so a later revision confirms this one.
let mine: Vec<_> = outcomes.iter().filter(|(id, written, _)| *id == doc.id.0 && written >= revision).collect();
if let Some((.., Err(e))) = mine.iter().find(|(.., outcome)| outcome.is_err()) {
failed.push((doc.id.0, format!("{}: {e}", doc.name)));
} else if confirm && mine.is_empty() {
failed.push((doc.id.0, format!("{}: the write wasn't confirmed", doc.name)));
} else {
log::info!("{} (revision {revision}) saved for recovery", doc.name);
}
}
let failed: Vec<_> = failed.into_iter().map(|(_, why)| why).collect();
if failed.is_empty() { Ok(()) } else { Err(format!("couldn't save for recovery: {}", failed.join("; "))) }
}
/// Drop the GPU canvas after `fault`: free its resources, send every view through the CPU