diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index e076fce..ea63aad 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -6496,11 +6496,17 @@ fn node_visible(node: &Node, folder_uid: &NodeUid, hidden: &HashSet) -> /// Lay what is queued for a folder over Drive's listing of it, from /// `Db::queued_children`: a node a queued rename moved away is dropped, and a /// node a queued create, mkdir or rename put there is listed as the DB has it. +/// +/// That node holds its name here: a create Drive already has but whose answer +/// is still on its way, or which the drain is still reading back, is listed by +/// Drive under its new uid and by the queue under its stand-in. Both were +/// listed, and a 128-file folder listed 129 (`docs/BUGS.md` B164). fn overlay_queued(nodes: &mut Vec, queued: Vec, gone: &HashSet) { let here: HashSet = queued.iter().map(|node| node.uid.to_string()).collect(); + let names: HashSet<&str> = queued.iter().map(|node| node.name.as_str()).collect(); nodes.retain(|node| { let uid = node.uid.to_string(); - !gone.contains(&uid) && !here.contains(&uid) + !gone.contains(&uid) && !here.contains(&uid) && !names.contains(node.name.as_str()) }); nodes.extend(queued); } diff --git a/crates/pdfs-fuse/src/sim/daemon.rs b/crates/pdfs-fuse/src/sim/daemon.rs index 4daa805..d9d372c 100644 --- a/crates/pdfs-fuse/src/sim/daemon.rs +++ b/crates/pdfs-fuse/src/sim/daemon.rs @@ -554,6 +554,47 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] + fn a_create_drive_lists_before_it_is_read_back_is_listed_once() { + // The feed reported a new file while the drain still read it back, so + // the folder was listed from Drive again, and the file came twice: as + // Drive's node and as the queued create's stand-in. A 128-file folder + // listed 129 (B164). + let drive = FakeDrive::new(); + let dir = scratch("listed-before-retired"); + let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); + let mnt = daemon.mountpoint.clone(); + std::fs::create_dir(mnt.join("x")).unwrap(); + assert!(wait_until(Duration::from_secs(30), || { + daemon.pending().is_ok_and(|items| items.is_empty()) && drive.lookup("x").is_some() + })); + + let create = daemon.client.hold_reply_to_next_create(); + std::fs::write(mnt.join("x/c.bin"), b"abc").unwrap(); + assert!(wait_until(Duration::from_secs(30), || create.reached())); + let uid = drive.lookup("x/c.bin").unwrap(); + let read_back = daemon.client.hold_next_read(&uid); + drop(create); + assert!(wait_until(Duration::from_secs(30), || read_back.reached())); + // Long enough for the next event poll to bring the file's event. + std::thread::sleep(crate::POLL_INTERVAL + Duration::from_secs(3)); + + let names: Vec<_> = std::fs::read_dir(mnt.join("x")) + .unwrap() + .map(|e| e.unwrap().file_name()) + .collect(); + drop(read_back); + assert_eq!(names, ["c.bin"]); + assert!(wait_until(Duration::from_secs(30), || { + daemon.pending().is_ok_and(|items| items.is_empty()) + })); + assert_eq!(std::fs::read(mnt.join("x/c.bin")).unwrap(), b"abc"); + + 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_folder_made_again_while_the_old_one_is_trashed_is_a_new_folder() { diff --git a/docs/BUGS.md b/docs/BUGS.md index b84336b..b41d3ed 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -12,6 +12,33 @@ Conventions: --- +## B164 — A folder lists a new file twice while its create lands + +**Status:** Fixed. +**Found:** 2026-10-04, by the account run on 2.10.0 ("a wide directory", on-demand pairing 2). +Eight threads wrote 128 files into a new folder, and the listing that followed held 129 +entries. Each name was created on Drive once, and nothing was logged. + +**Where:** `overlay_queued` in `crates/pdfs-fuse/src/lib.rs`. + +**Cause.** A listing from Drive shows what is still queued for the folder over what Drive has +(B132). A create Drive has made but whose answer is still on its way, or which the drain is +still reading back, is in both: Drive lists it under its new uid, the queue under its stand-in. +That listing only happens when the folder's listing was dropped, and the feed's event for a +new file does that when it comes before the drain has noted the create as its own. Both were +listed under the same name. + +The run logged nothing for the folder, so the event is inferred, not observed. It is the one +path found that lists a name twice. + +**Fix.** A node queued in the folder holds its name there: Drive's node of the same name is +left out of the listing until the queued op lands. + +**Test:** simulation test `a_create_drive_lists_before_it_is_read_back_is_listed_once` (failed +before the fix: the folder listed `c.bin` twice). + +--- + ## B163 — A file read right after its create landed is empty **Status:** Fixed. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 0041cbb..8a40ebb 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -55,6 +55,8 @@ by its local id, in the queue, the stored tree and pins alike. inside the removed folder and was trashed along with it (B162). - **A new file no longer reads as empty just after it is uploaded.** Drive can list it a moment before its contents are committed, and the mount took that answer (B163). +- **A folder no longer lists a new file twice while it is uploaded.** A listing taken as the + upload landed showed it both as Drive had it and as the queue did (B164). - **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). - **A change that waits for a name to be freed no longer waits seconds after it is.** A rename or diff --git a/docs/MILESTONE-3.0.0-STATUS.md b/docs/MILESTONE-3.0.0-STATUS.md index 463448a..4a52530 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 B163. +- Fixes from the simulation and account runs since then: B127 and B141 to B164. - **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`. @@ -99,8 +99,8 @@ Tracks `docs/MILESTONE-3.0.0.md`. Update it with every commit that moves a phase mirror move carries the local copy along. The journal showed a conflict copy retrying into a deleted folder (B150, fixed). Not confirmed yet. The run on 2.10.0 (2026-10-04: 260 passed, 3 failed) failed a new file that read back empty (B163, - fixed), a 128-entry folder that listed 129, and its cleanup, which ran while the - daemon was restarting. + fixed), a 128-entry folder that listed 129 (B164, fixed), and its cleanup, which ran + while the daemon was restarting. 5. **Release**: 2.10.0 is tagged; the 2.9.0 tag still waits for the user's go-ahead. ## After 3.0.0