diff --git a/crates/pdfs-fuse/src/drain.rs b/crates/pdfs-fuse/src/drain.rs index a2e912b..d7e8aca 100644 --- a/crates/pdfs-fuse/src/drain.rs +++ b/crates/pdfs-fuse/src/drain.rs @@ -1081,12 +1081,20 @@ impl Core { Err(e) => return Err(e.into()), }; let children = self.block_on_bounded(self.drive.enumerate_nodes_light(&uids))?; + // As for a create's own twin (`Core::adoptable_twin`): a node this + // mount trashed is not one, and a node in the tree was answered for. + // A file of the user's renamed to the name the create was sent under + // was taken for it, and trashed (`docs/BUGS.md` B193). + let hidden = self.hidden.lock().clone(); let Some(twin) = children .into_iter() - .find(|node| node.name == name && !node.trashed) + .find(|node| node.name == name && !node.trashed && !hidden.contains(&node.uid)) else { return Ok(None); }; + if self.state().by_uid.contains_key(&twin.uid) { + return Ok(None); + } let queued = twin.uid.to_string(); for kind in [OP_RENAME, OP_REVISION, OP_TRASH] { if self.db.has_pending_op(&queued, kind)? { diff --git a/crates/pdfs-fuse/src/sim/daemon.rs b/crates/pdfs-fuse/src/sim/daemon.rs index e02476e..1cd63d5 100644 --- a/crates/pdfs-fuse/src/sim/daemon.rs +++ b/crates/pdfs-fuse/src/sim/daemon.rs @@ -1736,6 +1736,58 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] + fn a_withdrawn_create_leaves_the_file_renamed_to_the_name_it_was_sent_under() { + // A create was sent as d and refused, the file renamed to q, and an + // empty file of the user's renamed to d. Deleting q left a trash that + // looked for what the create may have made under d, took the user's + // empty file for it and trashed it (B193). + let drive = FakeDrive::new(); + let dir = scratch("withdrawn-create-renamed-over"); + let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); + let mnt = daemon.mountpoint.clone(); + let empty = Some(Entry::File(Arc::new(Vec::new()))); + + std::fs::write(mnt.join("e.md"), b"").unwrap(); + assert!( + wait_until(Duration::from_secs(30), || { + daemon.pending().is_ok_and(|items| items.is_empty()) + && drive.tree().get("e.md").cloned() == empty + }), + "{:?} {:?}", + daemon.pending(), + drive.tree() + ); + drive.set_quota(0); + std::fs::write(mnt.join("d"), b"refused").unwrap(); + assert!(wait_until(Duration::from_secs(30), || { + daemon + .pending() + .is_ok_and(|items| items.iter().any(|item| item.attempts > 0)) + })); + std::fs::rename(mnt.join("d"), mnt.join("q")).unwrap(); + std::fs::rename(mnt.join("e.md"), mnt.join("d")).unwrap(); + assert!(wait_until(Duration::from_secs(30), || { + drive.tree().get("d").cloned() == empty + })); + std::fs::remove_file(mnt.join("q")).unwrap(); + assert!( + wait_until(Duration::from_secs(30), || { + daemon.pending().is_ok_and(|items| items.is_empty()) + }), + "{:?}", + daemon.pending() + ); + let tree = drive.tree(); + assert_eq!(tree.get("d").cloned(), empty, "{tree:?}"); + assert_eq!(tree.len(), 1, "{tree:?}"); + assert_eq!(std::fs::read(mnt.join("d")).unwrap(), b""); + + 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_made_under_the_name_of_a_create_on_the_wire_waits_for_it() { diff --git a/docs/BUGS.md b/docs/BUGS.md index d4ef5ae..3695b46 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -12,6 +12,29 @@ Conventions: --- +## B193 — Deleting a file whose create failed can trash a file of the user's on Drive + +**Status:** Fixed (unverified). +**Found:** 2026-10-05, in the confirmation run on 1436c5f (`one_client_on_a_flaky_link` seed 6; +no replay reproduced it). A file renamed over `y/y/d`, then emptied, was gone from Drive and +from the local database, with nothing queued. The run kept no log. The path below explains it, +and the test reproduces that path every time. + +**Where:** `Core::withdrawn_twin_in` in `crates/pdfs-fuse/src/drain.rs`. + +**Cause.** A delete turns a create that was sent into a trash of what it may have made, under a +name it was sent with (B151, B184). The drain looks there for a node made in the last two +minutes that holds bytes the create sent, or none. It did not leave out a node in the tree, so +an empty file of the user's renamed to that name matched, and was trashed. A create's own search +for its twin (`Core::adoptable_twin`) already left such a node out, and nodes this mount trashed +(B162). + +**Fix.** The trash's search leaves out the same nodes. A file a lost answer made that a listing +has since put in the tree stays on Drive, where the user can see and delete it. + +**Test:** simulation test `a_withdrawn_create_leaves_the_file_renamed_to_the_name_it_was_sent_under` +(failed before the fix: Drive was empty). + ## B192 — A new file closed empty reaches Drive only half a minute later **Status:** Fixed. diff --git a/docs/MILESTONE-3.0.0-STATUS.md b/docs/MILESTONE-3.0.0-STATUS.md index 4e08052..7726aa2 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: B125, B127, B130 and B141 to B192. +- Fixes from the simulation and account runs since then: B125, B127, B130 and B141 to B193. - **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`; Discard button and `pdfs sync discard`; a warning badge on the node in the file browser. @@ -119,7 +119,8 @@ Tracks `docs/MILESTONE-3.0.0.md`. Update it with every commit that moves a phase saved just before a stop whose release the stop dropped (B190, fixed; older too). The run on 1436c5f found a file deleted after its failed create was written again left on Drive (B191, fixed). Its test for B193 found a new empty file waiting out the idle poll (B192, - fixed). + fixed), and seed 6 a deleted file's trash taking a file of the user's renamed to the name + its create was sent under (B193, fixed). - [ ] A clean account run. The one on a6f36d5 (2026-10-04, LAN: 427 passed, 12 failed) failed B113 and B121 on every on-demand mount, two mirror-to-mirror move cases, and the journal check. B121 was a bug (B149, fixed); B113's case still expected creates