diff --git a/crates/format/src/autosave.rs b/crates/format/src/autosave.rs index 476741361..06150e60e 100644 --- a/crates/format/src/autosave.rs +++ b/crates/format/src/autosave.rs @@ -199,12 +199,33 @@ pub fn list_recovery(recovery_dir: &Path) -> Vec { 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 `.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 { + 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 { 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: -/// - `.pcraft` bundle folders with no `.json` sidecar (a crash during a document's -/// first autosave: the sidecar is written after the bundle, and `list_recovery` skips them); +/// - `.pcraft` bundle folders with no `.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); + } }