mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 11:16:53 -04:00
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:
1 parent
706638b284
commit
ed7df56a24
1 file changed
+63
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user