From 378667fb37ba4567adf698af827f770aa1852eac Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sun, 4 Oct 2026 21:51:52 +0200 Subject: [PATCH] fix(fuse): a file deleted just as its create lands is trashed on Drive (B173) --- crates/pdfs-fuse/src/drain.rs | 2 ++ crates/pdfs-fuse/src/drive.rs | 5 +++-- crates/pdfs-fuse/src/filesystem.rs | 19 ++++++++++++---- crates/pdfs-fuse/src/sim/daemon.rs | 35 ++++++++++++++++++++++++++++++ docs/BUGS.md | 22 +++++++++++++++++++ docs/CHANGELOG.md | 2 ++ docs/MILESTONE-3.0.0-STATUS.md | 6 +++-- 7 files changed, 83 insertions(+), 8 deletions(-) diff --git a/crates/pdfs-fuse/src/drain.rs b/crates/pdfs-fuse/src/drain.rs index b8d1700..46ec7b5 100644 --- a/crates/pdfs-fuse/src/drain.rs +++ b/crates/pdfs-fuse/src/drain.rs @@ -1539,6 +1539,8 @@ impl Core { } retired }; + #[cfg(test)] + self.drive.landing(real); if retired == CreateRetired::Landed && let Some(blob) = uploaded { diff --git a/crates/pdfs-fuse/src/drive.rs b/crates/pdfs-fuse/src/drive.rs index 2fd3bf5..0816347 100644 --- a/crates/pdfs-fuse/src/drive.rs +++ b/crates/pdfs-fuse/src/drive.rs @@ -155,8 +155,9 @@ pub(crate) trait DriveApi: Send + Sync { async fn quota(&self) -> Result; /// Called by the drain once it has let go of the blob of an upload to - /// `uid` that landed, before the tree has the node, so a simulation can - /// hold it there. Blocks the drain worker it is called on. + /// `uid` that landed, or retired the create that made `uid`, before the + /// tree has the node, so a simulation can hold it there. Blocks the drain + /// worker it is called on. #[cfg(test)] fn landing(&self, _uid: &NodeUid) {} } diff --git a/crates/pdfs-fuse/src/filesystem.rs b/crates/pdfs-fuse/src/filesystem.rs index 4ba8b89..81f6eeb 100644 --- a/crates/pdfs-fuse/src/filesystem.rs +++ b/crates/pdfs-fuse/src/filesystem.rs @@ -2140,7 +2140,7 @@ impl ProtonFs { reply.error(error); return; } - let (ino, uid) = match self.core.lookup_child(parent, name) { + let (ino, mut uid) = match self.core.lookup_child(parent, name) { Ok(x) => x, Err(e) => { reply.error(e); @@ -2168,9 +2168,20 @@ impl ProtonFs { return; } self.core.state().forget_or_unlink(&uid); - debug!(%uid, name, "deleted a node that had not been created remotely yet"); - reply.ok(); - return; + // A create that landed before the ops were dropped left none: Drive + // has the node, and the tree had not taken its uid yet. It is + // trashed there like any other (docs/BUGS.md B173). + match self.core.landed_row_uid(ino) { + Some(real) => { + debug!(local = %uid, %real, name, "create landed under a delete"); + uid = real; + } + None => { + debug!(%uid, name, "deleted a node that had not been created remotely yet"); + reply.ok(); + return; + } + } } // Offline: queue it. Trashing is the one mutation a user expects to work // regardless โ€” the file is gone from their point of view the moment the diff --git a/crates/pdfs-fuse/src/sim/daemon.rs b/crates/pdfs-fuse/src/sim/daemon.rs index 4ce626b..5eb686a 100644 --- a/crates/pdfs-fuse/src/sim/daemon.rs +++ b/crates/pdfs-fuse/src/sim/daemon.rs @@ -525,6 +525,41 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] + fn a_file_deleted_as_its_create_lands_is_trashed_on_drive() { + // The drain had retired the create and the tree still had the file + // under its stand-in. The delete found no op left to drop and took the + // file for one Drive had never seen, so Drive kept it (B173). + let drive = FakeDrive::new(); + let dir = scratch("deleted-as-create-lands"); + let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); + let path = daemon.mountpoint.join("c.bin"); + + let create = daemon.client.hold_reply_to_next_create(); + std::fs::write(&path, b"abc").unwrap(); + assert!(wait_until(Duration::from_secs(30), || create.reached())); + let uid = drive.lookup("c.bin").unwrap(); + let landing = daemon.client.hold_next_landing(&uid); + drop(create); + assert!(wait_until(Duration::from_secs(30), || landing.reached())); + std::fs::remove_file(&path).unwrap(); + drop(landing); + assert!( + wait_until(Duration::from_secs(30), || { + daemon.pending().is_ok_and(|items| items.is_empty()) + && drive.lookup("c.bin").is_none() + }), + "{:?} {:?}", + daemon.pending(), + drive.tree() + ); + assert!(!path.exists()); + + assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_file_moved_while_its_create_was_on_the_wire_keeps_its_new_name() { diff --git a/docs/BUGS.md b/docs/BUGS.md index 16dcaf2..9c8781e 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -12,6 +12,28 @@ Conventions: --- +## B173 โ€” A file deleted just as its create lands stays on Drive + +**Status:** Fixed (unverified). +**Found:** 2026-10-04, by `one_client_on_a_good_link` (seed 12) on d850265, run three at a time +in a loop: one of nine runs failed. A deleted `c.bin` was still on Drive with its old bytes, +and the next `c.bin` made on the mount landed as a conflict copy beside it. + +**Where:** `trash_child` in `crates/pdfs-fuse/src/filesystem.rs`. + +**Cause.** When a create lands, the drain retires its op and moves the node's row to the uid +Drive gave it, then gives the tree that uid. In between, it caches the uploaded bytes. A delete +in that gap still found the file under its stand-in, found no op left to drop, and took it for +a file Drive had never seen. So nothing trashed it on Drive. + +**Fix.** After dropping a stand-in's ops, the delete checks whether the node's row has a real +uid. If it has, the create landed first, and the file is trashed on Drive like any other. + +**Test:** `a_file_deleted_as_its_create_lands_is_trashed_on_drive` in +`crates/pdfs-fuse/src/sim/daemon.rs`, which holds the drain between the two steps. + +--- + ## B172 โ€” A file renamed while its upload is read back shows its old name **Status:** Fixed (unverified). diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 91eb949..60d4aab 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -72,6 +72,8 @@ by its local id, in the queue, the stored tree and pins alike. it failed with "Input/output error" (B170). - **A file deleted while its upload was retrying is removed from Drive right away.** It no longer waits for the upload's next retry (B171). +- **A file deleted just as it reached Drive is removed from Drive too.** It no longer stayed + there, and the next file with that name no longer became a conflict copy (B173). - **A file renamed just as its upload finished keeps its new name.** The mount no longer showed the old name, and a second rename no longer failed with "No such file or directory" (B152, B172). diff --git a/docs/MILESTONE-3.0.0-STATUS.md b/docs/MILESTONE-3.0.0-STATUS.md index 437d6f9..68adadb 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: B127 and B141 to B172. +- Fixes from the simulation and account runs since then: B127 and B141 to B173. - **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`. @@ -115,7 +115,9 @@ Tracks `docs/MILESTONE-3.0.0.md`. Update it with every commit that moves a phase it replaced as its base, and the next write failed with `EIO` (B170, fixed). The suite's seed 9 found a deleted file's trash waiting out its create's backoff (B171, fixed). Run three at a time on 2502af6, seed 8 failed two of nine runs: a file renamed - while its upload was read back showed its old name again (B172, fixed). + while its upload was read back showed its old name again (B172, fixed). The loop + on d850265 failed seed 12 once in nine runs: a file deleted just as its create + landed stayed on Drive (B173, fixed). 5. **Release**: 2.10.0 is tagged; the 2.9.0 tag still waits for the user's go-ahead. ## After 3.0.0