Keep startup responsive while recovering autosaved documents (#2115)

This commit is contained in:
Harrison O'Connor-Hoover authored and GitHub committed 2026-10-09 19:50:53 -04:00
1 parent 03bb64a681
commit 269d8fedc7
7 files changed
+414 -38

No files matched your search

+26 -5
View File
@@ -5,7 +5,7 @@ use photocraft_color::{ColorMode, SampleType};
use photocraft_doc::{Document, Layer, Size};
use photocraft_format::RecoveryStore;
use photocraft_geom::Rect;
use photocraft_ui_egui::{FileDialogAnswer, FileDialogReply, FileDialogRequest, Recovered, Services};
use photocraft_ui_egui::{FileDialogAnswer, FileDialogReply, FileDialogRequest, Recoverable, Services};
use std::cell::RefCell;
use std::future::Future;
use std::path::{Path, PathBuf};
@@ -13,6 +13,9 @@ use std::pin::Pin;
use std::rc::Rc;
use std::sync::Arc;
#[cfg(test)]
mod recovery_tests;
/// Everything File › Open reads: PhotoCraft, Photoshop and Affinity documents, flat images, and
/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries.
const OPEN_EXTS: &[&str] = &[
@@ -211,8 +214,9 @@ fn with_store<R>(store: &SharedRecovery, f: impl FnOnce(&mut RecoveryStore) -> R
/// directory, so autosaves fail and nothing is recovered). Recovered documents keep their entries
/// until a newer autosave replaces them or they're saved or closed (see [`RecoveryStore`]).
fn recovery_services(dir: Option<PathBuf>) -> Services {
let recover_dir = dir.clone();
let store: SharedRecovery = Rc::new(RefCell::new(dir.map(RecoveryStore::new)));
let (s1, s2, s3, s4) = (store.clone(), store.clone(), store.clone(), store.clone());
let (s1, s2, s4) = (store.clone(), store.clone(), store.clone());
Services {
autosave: Some(Box::new(move |doc: &Arc<Document>, revision: u64, path: Option<&str>| {
with_store(&s1, |s| s.autosave_checked(doc, revision, path.map(str::to_string)))?
@@ -222,8 +226,16 @@ fn recovery_services(dir: Option<PathBuf>) -> Services {
let _ = with_store(&s2, |s| s.discard(id));
})),
recover: Some(Box::new(move || {
let found = with_store(&s3, |s| s.recover()).unwrap_or_default();
found.into_iter().map(|(e, doc)| Recovered { key: e.info.key, path: e.info.original_path, doc }).collect()
let Some(dir) = recover_dir.as_ref() else { return Vec::new() };
photocraft_format::list_recovery(dir)
.into_iter()
.map(|e| Recoverable {
key: e.info.key.clone(),
name: e.info.document_name.clone(),
path: e.info.original_path.clone(),
load: Box::new(move || photocraft_format::recover(&e).map_err(|e| e.to_string())),
})
.collect()
})),
adopt_autosave: Some(Box::new(move |id: u64, key: &str| {
let _ = with_store(&store, |s| s.adopt(id, key));
@@ -585,7 +597,16 @@ mod tests {
/// One app launch with crash recovery in `dir` (dropping it is the crash: background writes
/// already queued finish, nothing else runs).
fn launch(dir: &Path) -> PhotocraftApp {
PhotocraftApp::new(Session::new(), recovery_services(Some(dir.to_path_buf())))
let mut app = PhotocraftApp::new(Session::new(), recovery_services(Some(dir.to_path_buf())));
let ctx = egui::Context::default();
prefs_ui::tick(&mut app, &ctx);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while app.session.has_jobs() && std::time::Instant::now() < deadline {
photocraft_ui_egui::jobs_ui::tick(&mut app, &ctx);
std::thread::sleep(std::time::Duration::from_millis(1));
}
assert!(!app.session.has_jobs(), "recovery finished");
app
}
/// The first pixel of each open document, and whether it's unsaved.
@@ -0,0 +1,68 @@
use super::*;
use photocraft_color::Color;
use photocraft_format::{Autosaver, RecoveryEntry, SaveOptions, list_recovery};
use std::sync::atomic::{AtomicUsize, Ordering};
fn snapshot(tag: &str) -> (PathBuf, Document, RecoveryEntry) {
static NEXT: AtomicUsize = AtomicUsize::new(0);
let dir = std::env::temp_dir().join(format!("photocraft-deferred-recovery-{tag}-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&dir);
let mut doc = Document::with_background("Recovered drawing", Size::new(4, 3), ColorMode::Rgb, SampleType::U16, Color::WHITE);
doc.layers[0].surface_mut().unwrap().write_pixel(1, 2, &[1.0, 0.0, 0.0, 1.0]);
let saver = Autosaver::new(&dir, "previous-session");
saver.request_checked(Arc::new(doc.clone()), 7, Some(dir.join("original.pcraft").to_string_lossy().into_owned()), SaveOptions::default()).unwrap();
assert!(saver.flush().unwrap().unwrap().tiles_written > 0);
let mut entries = list_recovery(&dir);
assert_eq!(entries.len(), 1);
(dir, doc, entries.pop().unwrap())
}
#[test]
fn recovery_discovers_metadata_before_decoding_and_keeps_a_failed_snapshot() {
let (dir, doc, entry) = snapshot("corrupt");
let tile = std::fs::read_dir(entry.bundle.join("tiles")).unwrap().next().unwrap().unwrap().path();
std::fs::write(&tile, b"corrupt compressed tile").unwrap();
let sidecar = dir.join(format!("{}.json", entry.info.key));
let metadata = std::fs::read(&sidecar).unwrap();
let mut services = recovery_services(Some(dir.clone()));
let mut found = services.recover.as_mut().unwrap()();
assert_eq!(found.len(), 1, "discovery lists metadata without decoding the corrupt pixels");
let pending = found.pop().unwrap();
assert_eq!(pending.key, entry.info.key);
assert_eq!(pending.name, doc.name);
assert_eq!(pending.path, entry.info.original_path);
assert!((pending.load)().is_err(), "decoding is deferred until the loader runs");
assert_eq!(std::fs::read(&tile).unwrap(), b"corrupt compressed tile");
assert_eq!(std::fs::read(&sidecar).unwrap(), metadata);
assert_eq!(list_recovery(&dir), vec![entry], "a failed recovery must leave the snapshot available");
drop(services);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn deferred_recovery_preserves_pixels_and_metadata_until_the_adopted_entry_is_discarded() {
let (dir, doc, entry) = snapshot("valid");
let mut services = recovery_services(Some(dir.clone()));
let mut found = services.recover.as_mut().unwrap()();
assert_eq!(found.len(), 1);
let pending = found.pop().unwrap();
assert_eq!(pending.key, entry.info.key);
assert_eq!(pending.name, doc.name);
assert_eq!(pending.path, entry.info.original_path);
// The loader can move to a worker without taking the main thread's recovery store with it.
let restored = std::thread::spawn(pending.load).join().unwrap().unwrap();
assert_eq!(restored, doc);
assert_eq!(restored.layers[0].surface().unwrap().pixel(1, 2), vec![1.0, 0.0, 0.0, 1.0]);
services.adopt_autosave.as_mut().unwrap()(restored.id.0, &pending.key);
assert_eq!(list_recovery(&dir), vec![entry.clone()], "loading and adopting retain the recovery files");
services.discard_autosave.as_mut().unwrap()(restored.id.0);
assert!(list_recovery(&dir).is_empty());
assert!(!entry.bundle.exists());
assert!(!dir.join(format!("{}.json", pending.key)).exists());
drop(services);
std::fs::remove_dir_all(dir).unwrap();
}
+3
View File
@@ -344,6 +344,9 @@ fn on_event(app: &mut PhotocraftApp, ctx: &egui::Context, e: JobEvent) {
true
}
});
if crate::prefs_ui::on_recovery_event(app, &e) {
return;
}
// Camera Raw's open-time re-develop and its final step.
if crate::camera_raw_ui::on_redevelop_event(app, &e) {
return;
+9 -8
View File
@@ -232,20 +232,21 @@ pub type AutosaveFn = Box<dyn FnMut(&std::sync::Arc<Document>, u64, Option<&str>
pub type AutosaveResultsFn = Box<dyn FnMut() -> Vec<(u64, u64, Result<(), String>)>>;
/// Drop the recovery data of a document (by `DocId` value) once it is saved or closed.
pub type DiscardAutosaveFn = Box<dyn FnMut(u64)>;
/// Load recoverable documents left by a previous session. Their recovery data stays until the
/// documents are saved or closed.
pub type RecoverFn = Box<dyn FnMut() -> Vec<Recovered>>;
/// List recoverable documents without decoding them. Their data stays until the documents are
/// saved or closed; the shell runs each entry's loader on a background worker.
pub type RecoverFn = Box<dyn FnMut() -> Vec<Recoverable>>;
/// A recovered document (by `DocId` value, once open) takes over its recovery entry (by key):
/// its autosaves replace the entry, and saving or closing it drops the entry.
pub type AdoptAutosaveFn = Box<dyn FnMut(u64, &str)>;
/// A document [`RecoverFn`] found.
pub struct Recovered {
/// The recovery entry it was loaded from (see [`AdoptAutosaveFn`]).
/// A recovery entry [`RecoverFn`] found; its loader owns only the data it needs to read.
pub struct Recoverable {
/// The recovery entry to adopt once loading succeeds (see [`AdoptAutosaveFn`]).
pub key: String,
pub name: String,
/// Where the user last saved it, if anywhere.
pub path: Option<String>,
pub doc: Document,
pub load: Box<dyn FnOnce() -> Result<Document, String> + Send + 'static>,
}
/// Append text to a file (History Log).
pub type AppendTextFn = Box<dyn FnMut(&str, &str) -> Result<(), String>>;
@@ -632,7 +633,7 @@ impl PhotocraftApp {
#[cfg(all(debug_assertions, not(target_arch = "wasm32")))]
live_tokens: theme::live::LiveTokens::from_env(),
};
// Saved preferences (and recovered documents) are in place before the first frame.
// Saved preferences are in place before the first frame; recovery starts in upkeep.
prefs_ui::load(&mut app);
notices::wayland_file_drop_guidance(&mut app);
// File › Scripts › Script Events Manager: "Start Application".
+114 -25
View File
@@ -7,10 +7,11 @@
//! change them with `prefs.get` / `prefs.set`); this module only edits a working copy in a dialog
//! and commits it with those commands on Apply or OK.
use std::collections::{BTreeMap, HashMap};
use std::collections::{BTreeMap, HashMap, VecDeque};
use egui::{Color32, RichText, Sense, vec2};
use photocraft_doc::DocId;
use photocraft_engine::jobs::{JobEvent, JobId, JobOutcome, Started};
use photocraft_engine::prefs::{self, SECTIONS, Theme};
use photocraft_engine::snap::{SnapLine, SnapTargets};
use serde_json::{Map, Value, json};
@@ -19,6 +20,9 @@ use crate::PhotocraftApp;
use crate::state::DialogKind;
use crate::theme::{ThemeKind, Tokens};
#[cfg(test)]
mod recovery_tests;
/// Shell runtime state for preferences, autosave and snapping (not serialised).
#[derive(Default)]
pub struct Runtime {
@@ -34,6 +38,9 @@ pub struct Runtime {
autosaved: HashMap<DocId, u64>,
/// Queued writes must not masquerade as durable snapshots.
autosave_pending: HashMap<DocId, u64>,
recovery_started: bool,
recovery_queue: VecDeque<crate::Recoverable>,
recovery_pending: Option<(JobId, String)>,
log_len: usize,
/// Snapping state of the drag in progress (see `snap_ui`).
pub(crate) snap: Option<crate::snap_ui::ActiveSnap>,
@@ -96,8 +103,7 @@ fn theme_pref(k: ThemeKind) -> Theme {
// ------------------------------------------------------------------ lifecycle
/// Load saved preferences (once) and recover autosaved documents. Called when the app is
/// created.
/// Load saved preferences once, when the app is created. Recovery starts during frame upkeep.
pub fn load(app: &mut PhotocraftApp) {
if app.prefs_rt.loaded {
return;
@@ -112,29 +118,102 @@ pub fn load(app: &mut PhotocraftApp) {
app.sync_recent();
app.prefs_rt.saved_rev = app.session.prefs.rev();
app.prefs_rt.saved_value = Some(app.session.prefs_value());
if app.session.prefs().file_handling.recover_on_launch
&& let Some(recover) = app.services.recover.as_mut()
{
let docs = recover();
let n = docs.len();
for r in docs {
app.session.add_document(r.doc, r.path);
// Recovered documents are unsaved.
let Some(st) = app.session.active_mut() else { continue };
st.saved_revision = 0;
// Their entry already holds this revision: it stays until a newer autosave replaces
// it or the document is saved or closed (see `autosave`).
let (id, revision) = (st.doc.id, st.revision);
app.prefs_rt.autosaved.insert(id, revision);
if let Some(adopt) = app.services.adopt_autosave.as_mut() {
adopt(id.0, &r.key);
}
}
if n > 0 {
app.sync_views();
app.ui.status = format!("Recovered {n} document{}", if n == 1 { "" } else { "s" });
}
/// Discover recovery entries after construction, then decode one at a time without blocking
/// the window. Only the completed job's apply step touches the session.
fn recovery(app: &mut PhotocraftApp) {
if !app.prefs_rt.recovery_started {
app.prefs_rt.recovery_started = true;
if app.session.prefs().file_handling.recover_on_launch
&& let Some(recover) = app.services.recover.as_mut()
{
app.prefs_rt.recovery_queue.extend(recover());
}
}
start_next_recovery(app);
}
fn start_next_recovery(app: &mut PhotocraftApp) {
while app.prefs_rt.recovery_pending.is_none() {
let Some(r) = app.prefs_rt.recovery_queue.pop_front() else { return };
let label = format!("Recovering {}", r.name);
let started = app.session.start_job(
"file.recover",
json!({}),
&label,
false,
move |ctx| {
ctx.check()?;
ctx.progress(0.0, "Loading recovery data");
let doc = (r.load)().map_err(photocraft_engine::EngineError::Other)?;
ctx.check()?;
Ok(doc)
},
move |s, doc| {
let active = s.active().map(|st| st.doc.id);
let first = s.documents().is_empty();
s.add_document(doc, r.path);
let st = s.active_mut().ok_or(photocraft_engine::EngineError::NoDocument)?;
st.saved_revision = 0;
let (id, revision) = (st.doc.id, st.revision);
if let Some(index) = active.and_then(|id| s.documents().iter().position(|st| st.doc.id == id)) {
s.set_active(index);
}
Ok(json!({"documentId": id.0, "revision": revision, "firstDocument": first}))
},
);
match started {
Ok(Started::Job(job)) => app.prefs_rt.recovery_pending = Some((job, r.key)),
Ok(Started::Done(v)) => finish_recovery(app, &r.key, &v),
Err(e) => crate::notices::error(app, format!("{label}: {e}")),
}
}
}
fn finish_recovery(app: &mut PhotocraftApp, key: &str, v: &Value) {
let recovered = v
.get("documentId")
.and_then(Value::as_u64)
.zip(v.get("revision").and_then(Value::as_u64))
.and_then(|(id, revision)| app.session.documents().iter().find(|st| st.doc.id.0 == id).map(|st| (st.doc.id, revision, st.doc.name.clone())));
let Some((id, revision, name)) = recovered else {
crate::notices::error(app, "The recovered document is no longer open".into());
return;
};
// Track the admitted snapshot's revision, even if another job has already edited it.
app.prefs_rt.autosaved.insert(id, revision);
if let Some(adopt) = app.services.adopt_autosave.as_mut() {
adopt(id.0, key);
}
if v.get("firstDocument").and_then(Value::as_bool) == Some(true) {
app.ui.chrome.home = None;
}
app.sync_views();
app.ui.status = format!("Recovered {name}");
app.ui.status_error = false;
}
/// Recovery uses the existing job polling/progress/cancel path. Its successful result adopts
/// the original entry on the UI thread, using identity rather than a mutable tab position.
pub(crate) fn on_recovery_event(app: &mut PhotocraftApp, e: &JobEvent) -> bool {
if !app.prefs_rt.recovery_pending.as_ref().is_some_and(|(job, _)| *job == e.id) {
return false;
}
let Some((_, key)) = app.prefs_rt.recovery_pending.take() else { return false };
match &e.outcome {
JobOutcome::Done(v) => finish_recovery(app, &key, v),
JobOutcome::Failed(err) => crate::notices::error(app, format!("{}: {err}", e.label)),
JobOutcome::Cancelled => {
// Native bundle decoding cannot stop midway. Cancel admission and the rest of the
// batch so another large decoder isn't started while this worker finishes.
app.prefs_rt.recovery_queue.clear();
app.ui.status = format!("Cancelled {}", e.label);
app.ui.status_error = false;
}
}
start_next_recovery(app);
true
}
/// Attach the brush preset store once its background load finishes, and surface write
@@ -203,6 +282,7 @@ pub fn tick(app: &mut PhotocraftApp, ctx: &egui::Context) {
if !app.prefs_rt.loaded {
load(app);
}
recovery(app);
sync_display_scale(app, ctx);
// Interface theme: a preference change applies to the UI; a theme picked from the Window
// menu is stored as the preference.
@@ -2250,12 +2330,21 @@ mod tests {
Ok(())
})),
discard_autosave: Some(Box::new(move |id: u64| l2.lock().unwrap().push(format!("discard {id}")))),
recover: Some(Box::new(move || ["a", "b"].map(|key| crate::Recovered { key: key.into(), path: None, doc: doc() }).into())),
recover: Some(Box::new(move || {
["a", "b"].map(|key| crate::Recoverable { key: key.into(), name: "R".into(), path: None, load: Box::new(move || Ok(doc())) }).into()
})),
adopt_autosave: Some(Box::new(move |id: u64, key: &str| l3.lock().unwrap().push(format!("adopt {id} {key}")))),
..Default::default()
};
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services);
let ctx = egui::Context::default();
tick(&mut app, &ctx);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while app.session.has_jobs() && std::time::Instant::now() < deadline {
crate::jobs_ui::tick(&mut app, &ctx);
std::thread::sleep(std::time::Duration::from_millis(1));
}
assert!(!app.session.has_jobs(), "recovery finished");
let ids: Vec<u64> = app.session.documents().iter().map(|d| d.doc.id.0).collect();
assert!(app.session.documents().iter().all(|d| d.is_dirty()), "recovered documents are unsaved");
let take = || std::mem::take(&mut *log.lock().unwrap());
@@ -0,0 +1,192 @@
use super::*;
use crate::{Recoverable, Services};
use photocraft_doc::{Color, ColorMode, Document, SampleType, Size};
use photocraft_engine::Session;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, mpsc};
use std::time::{Duration, Instant};
fn document(name: &str) -> Document {
Document::with_background(name, Size::new(4, 3), ColorMode::Rgb, SampleType::U16, Color::WHITE)
}
fn entry(key: &str, doc: Document) -> Recoverable {
Recoverable { key: key.into(), name: doc.name.clone(), path: Some("original.psb".into()), load: Box::new(move || Ok(doc)) }
}
type RecoveryLog = Arc<Mutex<Vec<String>>>;
fn services(entries: Vec<Recoverable>) -> (Services, RecoveryLog) {
let mut entries = Some(entries);
let log: RecoveryLog = Arc::default();
let (adopted, discarded) = (log.clone(), log.clone());
(
Services {
recover: Some(Box::new(move || entries.take().unwrap_or_default())),
adopt_autosave: Some(Box::new(move |id, key| adopted.lock().unwrap().push(format!("adopt {id} {key}")))),
discard_autosave: Some(Box::new(move |id| discarded.lock().unwrap().push(format!("discard {id}")))),
..Default::default()
},
log,
)
}
// Dropping `release` on an assertion failure also unblocks the worker.
fn gated(doc: Document) -> (Recoverable, mpsc::Receiver<()>, mpsc::Sender<()>, mpsc::Receiver<()>) {
let (started, start) = mpsc::channel();
let (release, wait) = mpsc::channel();
let (finished, finish) = mpsc::channel();
let mut item = entry("pending", doc.clone());
item.load = Box::new(move || {
let _ = started.send(());
wait.recv_timeout(Duration::from_secs(10)).map_err(|e| e.to_string())?;
let _ = finished.send(());
Ok(doc)
});
(item, start, release, finish)
}
fn frame(app: &mut PhotocraftApp, ctx: &egui::Context) {
ctx.run_ui(egui::RawInput::default(), |ui| {
tick(app, ui.ctx());
crate::jobs_ui::tick(app, ui.ctx());
})
.textures_delta
.clear();
}
fn settle(app: &mut PhotocraftApp, ctx: &egui::Context) {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
frame(app, ctx);
if !app.session.has_jobs() && app.prefs_rt.recovery_queue.is_empty() {
return;
}
assert!(Instant::now() < deadline, "recovery did not finish");
std::thread::yield_now();
}
}
#[test]
fn recovery_is_deferred_until_after_app_construction() {
let calls = Arc::new(AtomicUsize::new(0));
let called = calls.clone();
let services = Services {
recover: Some(Box::new(move || {
called.fetch_add(1, Ordering::SeqCst);
Vec::new()
})),
..Default::default()
};
let app = PhotocraftApp::new(Session::new(), services);
assert!(app.session.prefs().file_handling.recover_on_launch);
assert_eq!(calls.load(Ordering::SeqCst), 0, "recovery must not hold up creation of the application window");
}
#[test]
fn recovery_keeps_frames_and_editing_live_and_adopts_the_admitted_document() {
let mut doc = document("Recovered");
doc.layers[0].surface_mut().unwrap().write_pixel(1, 2, &[1.0, 0.0, 0.0, 1.0]);
let original = doc.clone();
let (pending, started, release, finished) = gated(doc);
let (services, log) = services(vec![pending]);
let mut app = PhotocraftApp::new(Session::new(), services);
let ctx = egui::Context::default();
assert!(app.session.documents().is_empty());
frame(&mut app, &ctx);
started.recv_timeout(Duration::from_secs(10)).unwrap();
assert!(app.session.has_jobs());
assert!(log.lock().unwrap().is_empty());
// Opening a copy while decoding exercises ID collision handling as well as focus retention.
app.session.add_document(original.clone(), Some("working.psb".into()));
let working_id = app.session.active().unwrap().doc.id;
app.run("edit.fill", json!({"color": "#0000ff"})).unwrap();
for _ in 0..3 {
frame(&mut app, &ctx);
assert_eq!(app.session.documents().len(), 1);
}
assert_eq!(app.session.active().unwrap().doc.layers[0].surface().unwrap().pixel(1, 2), vec![0.0, 0.0, 1.0, 1.0]);
release.send(()).unwrap();
finished.recv_timeout(Duration::from_secs(10)).unwrap();
settle(&mut app, &ctx);
assert_eq!(app.session.active().unwrap().doc.id, working_id, "finishing recovery must not steal focus");
let recovered = app.session.documents().iter().find(|st| st.doc.id != working_id).unwrap();
assert_eq!(recovered.path.as_deref(), Some("original.psb"));
assert!(recovered.is_dirty());
assert_eq!(recovered.doc.layers, original.layers);
assert_eq!(app.prefs_rt.autosaved.get(&recovered.doc.id), Some(&recovered.revision));
assert_eq!(*log.lock().unwrap(), [format!("adopt {} pending", recovered.doc.id.0)]);
}
#[test]
fn cancelling_recovery_skips_the_queue_and_never_adopts_a_late_result() {
let (pending, started, release, finished) = gated(document("First"));
let second_calls = Arc::new(AtomicUsize::new(0));
let calls = second_calls.clone();
let second = Recoverable {
key: "second".into(),
name: "Second".into(),
path: None,
load: Box::new(move || {
calls.fetch_add(1, Ordering::SeqCst);
Ok(document("Second"))
}),
};
let (services, log) = services(vec![pending, second]);
let mut app = PhotocraftApp::new(Session::new(), services);
let ctx = egui::Context::default();
frame(&mut app, &ctx);
started.recv_timeout(Duration::from_secs(10)).unwrap();
let job = app.session.jobs()[0].id;
crate::jobs_ui::cancel(&mut app, job);
frame(&mut app, &ctx);
assert!(!app.session.has_jobs());
assert!(app.prefs_rt.recovery_queue.is_empty());
release.send(()).unwrap();
finished.recv_timeout(Duration::from_secs(10)).unwrap();
for _ in 0..3 {
frame(&mut app, &ctx);
}
assert!(app.session.documents().is_empty());
assert!(log.lock().unwrap().is_empty(), "cancelled recovery must not adopt or discard saved entries");
assert_eq!(second_calls.load(Ordering::SeqCst), 0);
}
#[test]
fn failed_recovery_reports_the_error_and_continues_to_the_next_entry() {
let broken = Recoverable { key: "broken".into(), name: "Broken".into(), path: None, load: Box::new(|| Err("corrupt tile".into())) };
let (services, log) = services(vec![broken, entry("good", document("Good"))]);
let mut app = PhotocraftApp::new(Session::new(), services);
let ctx = egui::Context::default();
settle(&mut app, &ctx);
assert_eq!(app.session.documents().len(), 1);
assert!(app.ui.chrome.home.is_none(), "the first recovered document should be visible");
assert!(app.ui.notices.iter().any(|n| n.error && (n.title.contains("corrupt tile") || n.lines.iter().any(|l| l.contains("corrupt tile")))));
assert_eq!(*log.lock().unwrap(), [format!("adopt {} good", app.session.active().unwrap().doc.id.0)]);
}
#[test]
fn disabled_recovery_never_discovers_or_decodes_entries() {
let calls = Arc::new(AtomicUsize::new(0));
let called = calls.clone();
let mut session = Session::new();
session.execute("prefs.set", json!({"path": "fileHandling.recoverOnLaunch", "value": false})).unwrap();
let mut app = PhotocraftApp::new(
session,
Services {
recover: Some(Box::new(move || {
called.fetch_add(1, Ordering::SeqCst);
vec![entry("disabled", document("Disabled"))]
})),
..Default::default()
},
);
let ctx = egui::Context::default();
for _ in 0..3 {
frame(&mut app, &ctx);
}
assert_eq!(calls.load(Ordering::SeqCst), 0);
assert!(!app.session.has_jobs());
assert!(app.session.documents().is_empty());
}
+2
View File
@@ -152,6 +152,8 @@ cargo run -p photocraft-cli -- commands --filter blur # the c
Keep one `PcraftWriter` per open document: re-saving then only compresses and writes tiles that changed. Directory bundles verify objects on first encounter in a folder; later saves reuse them while their file size and modification time are unchanged, and re-verify changed objects, repair missing or damaged objects, and garbage-collect unreferenced ones. `format::Autosaver` writes snapshots into a recovery directory on a background thread. `list_recovery` / `recover` / `discard_recovery` implement crash recovery, and `format::RecoveryStore` is the lifecycle the desktop app uses: new documents autosave under per-launch keys (document ids restart every launch, so they never overwrite an older entry), and a recovered document adopts the entry it came from. That entry is replaced in place by the next autosave and removed only when the document is saved or closed, never just because it was recovered, so a second crash loses nothing. The web build has no crash recovery (no autosave services).
At startup the desktop lists recovery metadata, then decodes one document at a time through the existing background-job system. The window stays usable, with recovery progress and Cancel in the status bar. Cancel skips the rest of that launch's recovery queue and prevents the current result from opening; the decoder may finish in the background. Failed or cancelled loads leave their recovery files intact for a later launch. Turning off Preferences › File Handling › Recover on launch skips discovery altogether.
`photocraft-io` routes `.pcraft` through this crate in `import`/`export`, detecting it by magic or by extension.
## MCP (agents)