mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-17 12:49:31 +02:00
fix(format): recover autosaves with missing sidecars
Some checks are pending
Documentation / mdBook build (push) Waiting to run
Some checks are pending
Documentation / mdBook build (push) Waiting to run
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:
parent
706638b284
commit
ed7df56a24
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user