From 23dc5c3723beb3d2316e91ac3f6373d9c160fc46 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sun, 4 Oct 2026 04:41:57 +0200 Subject: [PATCH] fix(fuse): land a conflict copy in the root when its folder is gone (B150) --- crates/pdfs-fuse/src/drain.rs | 10 +++++-- crates/pdfs-fuse/src/sim/daemon.rs | 47 ++++++++++++++++++++++++++++++ docs/BUGS.md | 20 +++++++++++++ docs/CHANGELOG.md | 2 ++ docs/MILESTONE-3.0.0-STATUS.md | 2 +- 5 files changed, 78 insertions(+), 3 deletions(-) diff --git a/crates/pdfs-fuse/src/drain.rs b/crates/pdfs-fuse/src/drain.rs index afdd76c..b31d4ee 100644 --- a/crates/pdfs-fuse/src/drain.rs +++ b/crates/pdfs-fuse/src/drain.rs @@ -1585,8 +1585,14 @@ impl Core { .map(|(_, n)| n.clone()) .unwrap_or_else(|| self.node_name(uid)); // A node the tree has forgotten still has bytes worth keeping, so the - // copy falls back to the root rather than being abandoned. - let Some(parent) = place.map(|(p, _)| p).or_else(|| self.root_uid()) else { + // copy falls back to the root rather than being abandoned. So does one + // whose folder went with it: an upload into a folder Drive no longer + // has fails on every retry (B150). + let folder = match place.map(|(p, _)| p) { + Some(p) if self.fetch_node_remote(&p)?.is_some_and(|n| !n.trashed) => Some(p), + _ => None, + }; + let Some(parent) = folder.or_else(|| self.root_uid()) else { return self.abandon_to_staging(op, blob, uid, &name, reason); }; warn!(%uid, name, reason, "queued write conflicts; keeping a conflict copy"); diff --git a/crates/pdfs-fuse/src/sim/daemon.rs b/crates/pdfs-fuse/src/sim/daemon.rs index 42065ca..d33b90b 100644 --- a/crates/pdfs-fuse/src/sim/daemon.rs +++ b/crates/pdfs-fuse/src/sim/daemon.rs @@ -278,6 +278,7 @@ pub(crate) fn wait_until(limit: Duration, mut done: impl FnMut() -> bool) -> boo mod tests { use super::*; use crate::sim::fake_drive::{Entry, FakeDrive, Faults}; + use std::collections::BTreeMap; #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] @@ -534,6 +535,52 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] + fn a_queued_write_whose_folder_was_trashed_lands_in_the_root() { + // The conflict copy of a write to a file trashed elsewhere went into + // the file's folder. With that folder gone too, the upload failed on + // every retry, every two minutes (B150). + let drive = FakeDrive::new(); + let dir = scratch("conflict-copy-folder-gone"); + let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); + + let mount = &daemon.mountpoint; + std::fs::create_dir(mount.join("d")).unwrap(); + std::fs::write(mount.join("d/f.txt"), b"first").unwrap(); + assert!(wait_until(Duration::from_secs(30), || { + drive.tree().contains_key("d/f.txt") && daemon.pending().is_ok_and(|ops| ops.is_empty()) + })); + + let paused = |paused| Request::SetSyncPaused { + paused, + until: None, + }; + daemon.request(&paused(true)).unwrap(); + std::fs::write(mount.join("d/f.txt"), b"second").unwrap(); + drive.device().trash("d/f.txt"); + drive.device().trash("d"); + daemon.request(&paused(false)).unwrap(); + + let copy = |tree: &BTreeMap| { + tree.iter() + .find(|(path, _)| path.starts_with("f (sync-conflict ")) + .map(|(_, entry)| entry.clone()) + }; + assert!( + wait_until(Duration::from_secs(30), || { + copy(&drive.tree()) == Some(Entry::File(Arc::new(b"second".to_vec()))) + && daemon.pending().is_ok_and(|ops| ops.is_empty()) + }), + "{:?} {:?}", + drive.tree().keys().collect::>(), + daemon.pending() + ); + + assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); + let _ = std::fs::remove_dir_all(&dir); + } + /// Make 100 folders of 10 empty files each through a mount on `faults`, and /// time the syscalls and then the drain until Drive holds all of it. fn time_a_thousand_files(faults: Faults) -> (Duration, Duration) { diff --git a/docs/BUGS.md b/docs/BUGS.md index 1a8f5d8..4fa4a45 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -12,6 +12,26 @@ Conventions: --- +## B150 — A conflict copy whose folder is gone retries forever + +**Status:** Fixed (unverified). +**Found:** 2026-10-04, by the journal check of the account run on a6f36d5: 33 warnings, one +every two minutes for the whole run, all "queued write conflicts; keeping a conflict copy" for a +`write.bin` from an earlier run's B79 case, each followed by `DoesNotExist` from Drive. + +**Where:** `Core::keep_as_conflict_copy` in `crates/pdfs-fuse/src/drain.rs`. + +**Cause.** A queued write to a file that was trashed or deleted on Drive is kept as a conflict +copy beside the file. When the file's folder was deleted too, the upload into it failed, the op +backed off and tried again, with no end. The copy only fell back to the root when the tree had +forgotten the file, not when Drive had lost its folder. + +**Fix.** The copy checks that the folder is still on Drive and not trashed, and lands in the +location's root otherwise. Sim test `a_queued_write_whose_folder_was_trashed_lands_in_the_root`. + +**Left open.** How the B79 case's bytes stayed in `staging/` after the file was deleted, so that +the next start queued them again ("reconciled orphaned staged writes queued=1"), is not known yet. + ## B149 — `pdfs rename` or `pdfs move` right after a rename through the mount is undone **Status:** Fixed (unverified). diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 81c9a59..4363f9e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -33,6 +33,8 @@ folder by local id too. for this release. ### Fixed +- **A change to a file deleted elsewhere, together with its folder, no longer retries forever.** + The conflict copy that keeps it now lands in the location's root (B150). - **`pdfs rename` and `pdfs move` right after a change through the mount are no longer undone.** They are queued behind it, and they work on a file that has not reached Drive yet (B149). - **A folder kept offline before it reached Drive stays kept offline.** Its pin was lost when the diff --git a/docs/MILESTONE-3.0.0-STATUS.md b/docs/MILESTONE-3.0.0-STATUS.md index c382167..5bf8c60 100644 --- a/docs/MILESTONE-3.0.0-STATUS.md +++ b/docs/MILESTONE-3.0.0-STATUS.md @@ -9,7 +9,7 @@ Tracks `docs/MILESTONE-3.0.0.md`. Update it with every commit that moves a phase - **Phase 2**: local ids, inode = local id, one tree for every mount (2.10.0). - **Phase 3, first part**: mkdir, create, rename, unlink and rmdir on the mount are recorded locally and sent from the queue; `"local_first": false` brings back the old path (5e0f30c). -- Fixes from the simulation and account runs since then: B141 to B149. +- Fixes from the simulation and account runs since then: B141 to B150. - **Sync issues, first cut** (§6.2): refusals sorted into issues, shown at once in the app, tray and CLI; Export button and `pdfs sync export`; `pdfs sync issues`.