fix(format): keep stale previews until the manifest commits; errno 45 only where it is ENOTSUP

A directory save removed a thumbnail or composite the new document no
longer has before writing the new manifest, so a failed manifest flush
left the previous manifest naming a preview that was gone. They are now
removed after the manifest commits, best-effort like unreferenced
objects; the failed-flush test checks the thumbnail survives.

errno 45 counted as "can't flush directories" on every Unix, but it is
ENOTSUP only on Apple platforms and the BSDs (illumos: EDEADLK). Other
Unix targets now accept only EINVAL and ErrorKind::Unsupported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Finn Newick
2026-10-18 22:15:41 +01:00
co-authored by Claude Opus 5.5
parent 747f7a148b
commit f77ffa3e3d
2 changed files with 20 additions and 15 deletions
+5 -2
View File
@@ -251,14 +251,17 @@ fn dir_sync_unsupported(e: &io::Error) -> bool {
return true;
}
// errno values: EINVAL is 22 everywhere; ENOTSUP and EOPNOTSUPP are 95 on Linux and
// Android, 45 and 102 on Apple platforms, 45 on the BSDs; ENOTTY is 25.
// Android, 45 and 102 on Apple platforms, 45 on the BSDs; ENOTTY is 25. Elsewhere 45 means
// something else (illumos: EDEADLK), so only EINVAL and `Unsupported` count there.
const EINVAL: i32 = 22;
let unsupported: &[i32] = if cfg!(any(target_os = "linux", target_os = "android")) {
&[EINVAL, 95]
} else if cfg!(target_vendor = "apple") {
&[EINVAL, 45, 102, 25]
} else if cfg!(unix) {
} else if cfg!(any(target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", target_os = "dragonfly")) {
&[EINVAL, 45]
} else if cfg!(unix) {
&[EINVAL]
} else {
&[]
};
+15 -13
View File
@@ -909,20 +909,16 @@ impl PcraftWriter {
// Pin all GC directories before removing stale previews or objects.
// Unix cleanup then stays within these directories even if a pathname is swapped.
let gc = BundleGc::new(dir)?;
write_bundle_file(dir, &dir.join(MANIFEST), &p.manifest)?;
// The manifest is committed: the save has succeeded. Removing previews and objects it no
// longer references is best-effort housekeeping (a vanished or locked file must not turn a
// complete save into a reported failure); a leftover is retried on the next save. Previews
// go only now too: if the manifest can't be committed, the previous one still names them.
for stale in [THUMB, COMPOSITE] {
if !p.previews.iter().any(|(n, _)| *n == stale) {
let path = dir.join(stale);
validate_bundle_file(dir, &path)?;
match remove_bundle_file(dir, &path, &gc) {
Err(FormatError::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => {}
other => other?,
}
let _ = remove_bundle_file(dir, &dir.join(stale), &gc);
}
}
write_bundle_file(dir, &dir.join(MANIFEST), &p.manifest)?;
// The manifest is committed: the save has succeeded. Removing objects it no longer
// references is best-effort housekeeping (a vanished or locked file must not turn a
// complete save into a reported failure); a leftover is retried on the next save.
for path in existing {
if !p.objects.contains_key(&path) {
if remove_bundle_file(dir, &dir.join(&path), &gc).is_ok() {
@@ -1003,8 +999,9 @@ fn gc_pinned_directory_handles_resist_subdirectory_swap() {
std::fs::remove_dir_all(root).unwrap();
}
/// #4968: a manifest whose folder can't be flushed fails the save, and the objects the previous
/// manifest references stay in place; the next save that flushes removes what is unreferenced.
/// #4968: a manifest whose folder can't be flushed fails the save, and the objects and previews the
/// previous manifest references stay in place; the next save that flushes removes what is
/// unreferenced.
#[cfg(test)]
#[test]
fn a_failed_manifest_flush_keeps_the_previous_objects() {
@@ -1021,7 +1018,10 @@ fn a_failed_manifest_flush_keeps_the_previous_objects() {
};
paint(&mut doc, 1.0);
let mut w = PcraftWriter::new();
w.save_dir(&doc, &dir, &SaveOptions::default()).unwrap();
// The first save has a thumbnail; the next ones drop it.
let thumb = SaveOptions { thumbnail: Some(Rgba8Image::new(4, 3)), composite: None };
w.save_dir(&doc, &dir, &thumb).unwrap();
assert!(dir.join(THUMB).exists());
let old = list_objects(&dir).unwrap();
paint(&mut doc, 0.0);
FAIL_DIR_FLUSH.with(|f| f.set(Some(MANIFEST)));
@@ -1030,8 +1030,10 @@ fn a_failed_manifest_flush_keeps_the_previous_objects() {
let now = list_objects(&dir).unwrap();
assert!(old.is_subset(&now), "the previous manifest's objects are kept");
assert!(now.len() > old.len(), "the new objects were written");
assert!(dir.join(THUMB).exists(), "the previous manifest's thumbnail is kept");
let stats = w.save_dir(&doc, &dir, &SaveOptions::default()).unwrap();
assert!(stats.objects_removed > 0, "the next save removes the unreferenced objects");
assert!(!dir.join(THUMB).exists(), "and the thumbnail it no longer names");
assert_eq!(crate::load_path(&dir).unwrap(), doc);
std::fs::remove_dir_all(dir).unwrap();
}