Compare commits

...
3 Commits
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 c699e3cda6 Release 0.9.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 10:16:05 -04:00
Brandon ThomasandClaude Opus 5.5 7250e6e29a Tests: autosave retry waits for the finished write, not a mid-write listing
Since ed7df56a, list_recovery also lists a committed bundle whose sidecar
is not yet published (revision 0). The worker commits the manifest before
it writes the sidecar, so the retry test could catch the bundle between
the two and read revision 0 (seen on windows-latest; 215/300 local runs
on Linux under load). Wait until the entry carries the expected revision.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 09:21:47 -04:00
MarekSpodBiedry ed7df56a24 fix(format): recover autosaves with missing sidecars
Discover committed autosave bundles whose JSON sidecar was never published. Rebuild recovery metadata from the readable manifest and retain these bundles during orphan cleanup.

Fixes #4967
2026-10-14 02:02:34 -06:00
4 changed files with 99 additions and 34 deletions

No files matched your search

Generated
+29 -29
View File
@@ -4613,7 +4613,7 @@ dependencies = [
[[package]]
name = "photocraft"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"arboard",
"async-io",
@@ -4657,7 +4657,7 @@ dependencies = [
[[package]]
name = "photocraft-affinity"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"crc32fast",
"flate2",
@@ -4667,7 +4667,7 @@ dependencies = [
[[package]]
name = "photocraft-algo"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4680,7 +4680,7 @@ dependencies = [
[[package]]
name = "photocraft-automation"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"base64 0.22.1",
"cap-std",
@@ -4702,7 +4702,7 @@ dependencies = [
[[package]]
name = "photocraft-cli"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"photocraft-automation",
"photocraft-codecs",
@@ -4716,7 +4716,7 @@ dependencies = [
[[package]]
name = "photocraft-cms"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"moxcms",
"rayon",
@@ -4724,7 +4724,7 @@ dependencies = [
[[package]]
name = "photocraft-codecs"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"exr",
"flate2",
@@ -4745,7 +4745,7 @@ dependencies = [
[[package]]
name = "photocraft-color"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"photocraft-cms",
"serde",
@@ -4753,7 +4753,7 @@ dependencies = [
[[package]]
name = "photocraft-compose"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"criterion",
"photocraft-color",
@@ -4768,7 +4768,7 @@ dependencies = [
[[package]]
name = "photocraft-doc"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4779,7 +4779,7 @@ dependencies = [
[[package]]
name = "photocraft-engine"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"blake3",
"craft-print",
@@ -4814,7 +4814,7 @@ dependencies = [
[[package]]
name = "photocraft-format"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"blake3",
"crc32fast",
@@ -4835,14 +4835,14 @@ dependencies = [
[[package]]
name = "photocraft-geom"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"serde",
]
[[package]]
name = "photocraft-gpu"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"half",
"log",
@@ -4859,14 +4859,14 @@ dependencies = [
[[package]]
name = "photocraft-heif"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"heic-rs",
]
[[package]]
name = "photocraft-io"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"blake3",
"crc32fast",
@@ -4895,14 +4895,14 @@ dependencies = [
[[package]]
name = "photocraft-ops"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"photocraft-doc",
]
[[package]]
name = "photocraft-paint"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4913,7 +4913,7 @@ dependencies = [
[[package]]
name = "photocraft-plugins"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4928,7 +4928,7 @@ dependencies = [
[[package]]
name = "photocraft-psd"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"flate2",
"photocraft-psd",
@@ -4939,7 +4939,7 @@ dependencies = [
[[package]]
name = "photocraft-raster"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4949,7 +4949,7 @@ dependencies = [
[[package]]
name = "photocraft-raw"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"flate2",
"image",
@@ -4964,7 +4964,7 @@ dependencies = [
[[package]]
name = "photocraft-tablet"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"block2 0.6.2",
"objc2 0.6.4",
@@ -4976,7 +4976,7 @@ dependencies = [
[[package]]
name = "photocraft-testkit"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"memory-stats",
"photocraft-color",
@@ -4987,7 +4987,7 @@ dependencies = [
[[package]]
name = "photocraft-text"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"flate2",
"parley",
@@ -5001,7 +5001,7 @@ dependencies = [
[[package]]
name = "photocraft-ui-egui"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"base64 0.22.1",
"craft-print",
@@ -5042,7 +5042,7 @@ dependencies = [
[[package]]
name = "photocraft-vector"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"photocraft-color",
"photocraft-doc",
@@ -5053,7 +5053,7 @@ dependencies = [
[[package]]
name = "photocraft-web"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"eframe",
"egui",
@@ -8651,7 +8651,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]]
name = "xtask"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"serde",
"serde_json",
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
[workspace.package]
version = "0.8.2"
version = "0.9.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+6 -1
View File
@@ -907,6 +907,7 @@ mod tests {
let mut app = launch(&recovery);
new_doc(&mut app, "#ff0000");
let revision = app.session.active().unwrap().revision;
assert_ne!(revision, 0, "a sidecar-less bundle lists as revision 0");
autosave(&mut app, &ctx);
let mut failed = false;
@@ -927,12 +928,16 @@ mod tests {
std::fs::remove_file(&recovery).unwrap();
std::fs::create_dir(&recovery).unwrap();
autosave(&mut app, &ctx);
// The worker commits the bundle's manifest before it publishes the sidecar, and
// `list_recovery` lists a committed sidecar-less bundle (revision 0) on its own, so
// the first non-empty listing can come from the middle of the write. Wait for the write
// to finish: the entry then carries the sidecar's revision.
let mut recovered = Vec::new();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while std::time::Instant::now() < deadline {
prefs_ui::tick(&mut app, &ctx);
recovered = list_recovery(&recovery);
if !recovered.is_empty() {
if recovered.iter().any(|e| e.info.revision == revision) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
+63 -3
View File
@@ -199,12 +199,33 @@ pub fn list_recovery(recovery_dir: &Path) -> Vec<RecoveryEntry> {
if bundle.join(crate::store::MANIFEST).is_file() {
out.push(RecoveryEntry { info, bundle });
}
} else if e.file_type().is_ok_and(|ft| ft.is_dir())
&& let Some(entry) = sidecarless_entry(recovery_dir, &p)
{
out.push(entry);
}
}
out.sort_by(|a, b| b.info.saved_at.cmp(&a.info.saved_at).then(a.info.key.cmp(&b.info.key)));
out
}
/// A committed `<key>.pcraft` bundle (its manifest is written last) whose sidecar was never
/// published, e.g. a crash during a document's first autosave. The metadata is rebuilt from the
/// manifest (read like a load reads it) and its modification time. Incomplete bundles (no
/// readable manifest) and bundles with anything at the sidecar's path are not entries.
fn sidecarless_entry(recovery_dir: &Path, bundle: &Path) -> Option<RecoveryEntry> {
let key = bundle.file_name()?.to_str()?.strip_suffix(".pcraft")?;
if key.is_empty() || sanitize(key) != key || std::fs::symlink_metadata(recovery_dir.join(format!("{key}.json"))).is_ok() {
return None;
}
let meta = std::fs::symlink_metadata(bundle.join(crate::store::MANIFEST)).ok().filter(|m| m.is_file())?;
let source = crate::store::DirSource { root: bundle.to_path_buf() };
let manifest = crate::store::read_manifest(&source, &crate::LoadOptions::default()).ok()?;
let saved_at = meta.modified().ok().and_then(|t| t.duration_since(UNIX_EPOCH).ok()).map_or(0, |d| d.as_secs());
let info = RecoveryInfo { key: key.to_string(), document_name: manifest.document.name, original_path: None, saved_at, revision: 0 };
Some(RecoveryEntry { info, bundle: bundle.to_path_buf() })
}
/// Load a recovered document.
pub fn recover(entry: &RecoveryEntry) -> Result<Document> {
crate::load_path(&entry.bundle)
@@ -292,8 +313,9 @@ impl RecoveryStore {
const ORPHAN_AGE: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60);
/// Best-effort removal of recovery data nothing can ever load or clean up:
/// - `<key>.pcraft` bundle folders with no `<key>.json` sidecar (a crash during a document's
/// first autosave: the sidecar is written after the bundle, and `list_recovery` skips them);
/// - `<key>.pcraft` bundle folders with no `<key>.json` sidecar and no readable manifest (a
/// crash in the middle of a document's first autosave). A sidecar-less bundle whose manifest
/// was committed is complete and listed by `list_recovery`, so it is kept;
/// - `*.pcsave.tmp` files left by an interrupted atomic write (see [`crate::atomic::temp_name`])
/// that another process created.
///
@@ -321,7 +343,8 @@ fn sweep_orphans(recovery_dir: &Path, min_age: std::time::Duration) {
// Anything at the sidecar's path (even a broken one) keeps the bundle.
let has_sidecar = std::fs::symlink_metadata(recovery_dir.join(format!("{key}.json"))).is_ok();
let parts = [path.clone(), path.join("tiles"), path.join("blobs")];
if !has_sidecar && parts.iter().all(|p| untouched_for(p, min_age, true)) {
// A committed bundle is recoverable work: keep exactly what `list_recovery` lists.
if !has_sidecar && sidecarless_entry(recovery_dir, &path).is_none() && parts.iter().all(|p| untouched_for(p, min_age, true)) {
let _ = std::fs::remove_dir_all(&path);
} else {
bundles.extend(parts);
@@ -437,4 +460,41 @@ mod tests {
assert_eq!(recover(&entries[0]).unwrap(), *doc);
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_committed_bundle_without_a_sidecar_is_listed_and_survives_the_sweep() {
let dir = std::env::temp_dir().join(format!(
"pcraft-nosidecar-{}-{}",
std::process::id(),
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0)
));
let doc =
Arc::new(Document::new("Lost", photocraft_doc::Size { width: 4, height: 4 }, photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8));
let saver = Autosaver::new(&dir, "first");
saver.request(doc.clone(), 1, None, SaveOptions::default());
assert!(saver.flush().is_some_and(|r| r.is_ok()));
// The crash: the bundle was committed, the sidecar never published.
std::fs::remove_file(dir.join("first.json")).unwrap();
let entries = list_recovery(&dir);
assert_eq!(entries.len(), 1, "the committed bundle is discoverable");
assert_eq!((entries[0].info.key.as_str(), entries[0].info.document_name.as_str()), ("first", "Lost"));
assert_eq!(recover(&entries[0]).unwrap(), *doc);
sweep_orphans(&dir, std::time::Duration::ZERO);
assert!(dir.join("first.pcraft").is_dir(), "aged cleanup keeps a committed bundle");
// Incomplete bundles (no manifest, or one that does not read) are neither listed nor kept.
let partial = dir.join("partial.pcraft");
std::fs::create_dir_all(partial.join("tiles")).unwrap();
let broken = dir.join("broken.pcraft");
std::fs::create_dir_all(&broken).unwrap();
std::fs::write(broken.join("manifest.json"), b"{").unwrap();
assert_eq!(list_recovery(&dir).len(), 1);
sweep_orphans(&dir, std::time::Duration::ZERO);
assert!(!partial.exists() && !broken.exists());
assert!(dir.join("first.pcraft").is_dir());
let _ = std::fs::remove_dir_all(&dir);
}
}