diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index d7a3298..79a4711 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -3225,8 +3225,19 @@ impl Core { /// `None` while it is still queued. Read from the row, which has it before /// the tree does. pub(crate) fn landed_row_uid(&self, ino: u64) -> Option { - state::ino_lid(ino) - .and_then(|lid| self.db.uid_of_lid(lid).ok().flatten()) + state::ino_lid(ino).and_then(|lid| self.landed_lid_uid(lid)) + } + + /// [`Core::landed_row_uid`] for a node known by its stand-in `uid`. + pub(crate) fn landed_stand_in_uid(&self, uid: &NodeUid) -> Option { + pdfs_core::db::local_lid(&uid.to_string()).and_then(|lid| self.landed_lid_uid(lid)) + } + + fn landed_lid_uid(&self, lid: i64) -> Option { + self.db + .uid_of_lid(lid) + .ok() + .flatten() .and_then(|uid| parse_node_uid(&uid)) .filter(|uid| !is_local_uid(uid)) } diff --git a/crates/pdfs-fuse/src/reads.rs b/crates/pdfs-fuse/src/reads.rs index 5172de4..c20b6a5 100644 --- a/crates/pdfs-fuse/src/reads.rs +++ b/crates/pdfs-fuse/src/reads.rs @@ -499,9 +499,14 @@ impl Core { } // A node created offline and never written has no blob and no remote: it // is an empty file, and asking the API about a `local~` uid would only - // earn a 404 (offline.md Phase 3b). + // earn a 404 (offline.md Phase 3b). Unless its create landed since the + // read looked: the landing moved the queued write to the uid Drive + // gave it, and the row says which (`docs/BUGS.md` B179). if is_local_uid(uid) { - return Ok(Vec::new()); + return match self.landed_stand_in_uid(uid) { + Some(real) => self.read_range(&real, mtime, fsize, offset, len, cache_blocks), + None => Ok(Vec::new()), + }; } self.read_range_remote(uid, mtime, fsize, offset, len, cache_blocks) } diff --git a/crates/pdfs-fuse/src/sim/daemon.rs b/crates/pdfs-fuse/src/sim/daemon.rs index 99efa6e..329b738 100644 --- a/crates/pdfs-fuse/src/sim/daemon.rs +++ b/crates/pdfs-fuse/src/sim/daemon.rs @@ -609,6 +609,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_read_as_its_create_lands_has_its_bytes() { + // The landing moved the queued write to the uid Drive gave the file. + // A read that still knew the file by its stand-in found no write + // queued under it, took the file for one never written and read it + // empty (B179). + let drive = FakeDrive::new(); + let dir = scratch("read-as-create-lands"); + let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); + let mnt = daemon.mountpoint.clone(); + let create = daemon.client.hold_reply_to_next_create(); + std::fs::write(mnt.join("a.txt"), b"old").unwrap(); + assert!(wait_until(Duration::from_secs(30), || create.reached())); + std::fs::write(mnt.join("a.txt"), b"newer").unwrap(); + let uid = drive.lookup("a.txt").unwrap(); + let landing = daemon.client.hold_next_landing(&uid); + drop(create); + assert!(wait_until(Duration::from_secs(30), || landing.reached())); + assert_eq!(std::fs::read(mnt.join("a.txt")).unwrap(), b"newer"); + drop(landing); + assert!( + wait_until(Duration::from_secs(30), || { + daemon.pending().is_ok_and(|items| items.is_empty()) + && drive.tree().get("a.txt") == Some(&Entry::File(Arc::new(b"newer".to_vec()))) + }), + "{:?} {:?}", + daemon.pending(), + drive.tree() + ); + + 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 b0742a5..331fc0e 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -12,6 +12,29 @@ Conventions: --- +## B179 — A file read just as its create lands reads empty + +**Status:** Fixed (unverified). +**Found:** 2026-10-04, by the account run on 378667f, on on-demand sync folders: the unusual +names case read `|pipe` back as 0 bytes instead of 12, and B70's completed download as 0 +bytes instead of 147456. Both files had a write that landed during their create, which the +daemon queued as a revision. + +**Where:** `Core::read_range` in `crates/pdfs-fuse/src/reads.rs`. + +**Cause.** A read takes the file's uid from the tree and reads on a transfer worker. A file made +on the mount has its stand-in uid there until its create lands. When it lands, the queued write +moves to the uid Drive gave the file. A read that still asked by the stand-in found no write +queued under it, took the file for one created and never written, and returned nothing. The +read waits on the transfer lane, which a run keeps busy, so the window is not small. + +**Fix.** A stand-in with no queued write is looked up by its local id. Once its create has +landed, the read goes on under the uid Drive gave it. + +**Test:** `a_file_read_as_its_create_lands_has_its_bytes` in `crates/pdfs-fuse/src/sim/daemon.rs`. + +--- + ## B178 — Trashing a folder while sync is paused keeps a create in it as a trash **Status:** Fixed (unverified). diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ec18e3c..c213f39 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -83,6 +83,7 @@ by its local id, in the queue, the stored tree and pins alike. (B177). - **Deleting a folder while sync is paused no longer leaves a queued change behind** for a file in it that was never uploaded (B178). +- **A file read just as it reaches Drive no longer reads empty** (B179). - **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 4cecd2b..bf00a57 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 B178. +- Fixes from the simulation and account runs since then: B127 and B141 to B179. - **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`. @@ -124,7 +124,7 @@ Tracks `docs/MILESTONE-3.0.0.md`. Update it with every commit that moves a phase The account run on 378667f: a folder listed as its create landed asked Drive by its stand-in and answered `EIO` (B177, fixed). A folder trashed while sync was paused kept a create below it as a trash: a paused drain worker had claimed it - (B178, fixed). + (B178, fixed). A file read as its create landed read empty (B179, 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