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
This commit is contained in:
MarekSpodBiedry authored and GitHub committed 2026-10-14 02:02:34 -06:00
1 parent 706638b284
commit ed7df56a24
1 file changed
+63 -3
+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);
}
}