diff --git a/crates/pdfs-fuse/src/filesystem.rs b/crates/pdfs-fuse/src/filesystem.rs index d9e3ceb..81116c9 100644 --- a/crates/pdfs-fuse/src/filesystem.rs +++ b/crates/pdfs-fuse/src/filesystem.rs @@ -1806,7 +1806,7 @@ impl ProtonFs { } (Some(mut h), false) => { self.core.follow_landed_create(&mut h); - match self.core.queue_revision(&h) { + match self.core.queue_revision(&mut h) { Ok(()) => reply.ok(), Err(e) => reply.error(e), } diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index 005d647..bfc35db 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -3242,7 +3242,7 @@ impl Core { /// /// The scratch file is *moved* into staging, never copied: it is the only /// copy of what the user wrote. - fn queue_revision(&self, h: &WriteHandle) -> Result<(), Errno> { + fn queue_revision(&self, h: &mut WriteHandle) -> Result<(), Errno> { // Closing an untouched writable handle is not a mutation. It remains // valid after a downgrade and only owes cleanup of its empty scratch. if !h.dirty { @@ -3266,9 +3266,15 @@ impl Core { // recovery a second, stale copy of the same write. self.cache.clear_scratch_durable(&h.path); if is_local_uid(&h.uid) && !self.db.has_create_op(&h.uid.to_string()).unwrap_or(true) { - debug!(uid = %h.uid, "local node was unlinked before creation; dropping revision"); - let _ = std::fs::remove_file(&h.path); - return Ok(()); + // A create that landed since the handle last looked left no op + // either. It moved the row to its uid in the same transaction, so + // the row says which it was (`docs/BUGS.md` B176). + self.follow_landed_create(h); + if is_local_uid(&h.uid) { + debug!(uid = %h.uid, "local node was unlinked before creation; dropping revision"); + let _ = std::fs::remove_file(&h.path); + return Ok(()); + } } // Materialize as much of the full content as is free to materialize. A // complete blob is uploadable without the network and lets a later write @@ -8780,6 +8786,26 @@ mod tests { assert_eq!(replaced.matches("self.withdraw_queued_ops(uid)").count(), 2); } + #[test] + fn a_write_closed_as_its_create_lands_is_queued() { + // The release followed a landing create, found none, and the create + // landed before it asked for the create op. Finding none, it took the + // file for one unlinked before it was created and dropped the write + // (B176). The landing removes the op and moves the row at once, so a + // missing op is checked against the row. + let revision = function_source(include_str!("lib.rs"), "fn queue_revision("); + assert_before( + revision, + "self.db.has_create_op(", + "self.follow_landed_create(h)", + ); + assert_before( + revision, + "self.follow_landed_create(h)", + "dropping revision", + ); + } + #[test] fn a_replaced_node_put_back_shows_in_the_mount_again() { // The restore after a failed rename left the node hidden and out of diff --git a/docs/BUGS.md b/docs/BUGS.md index 1d7ba2a..a2aa149 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -12,6 +12,27 @@ Conventions: --- +## B176 — A write closed just as its file's create lands is dropped + +**Status:** Fixed (unverified). +**Found:** 2026-10-04, by `one_client_on_a_good_link` (seed 12) on 378667f, run three at a time +in a loop. A write at offset 6 to a file whose create was landing never reached Drive; Drive +ended with the file's first bytes and the later truncate only. + +**Where:** `Core::queue_revision` in `crates/pdfs-fuse/src/lib.rs`. + +**Cause.** A write handle closed on a file not yet on Drive first checks whether the create has +landed and, if so, follows it to the uid Drive gave it. It then checks that the create is still +queued; a file with neither was unlinked before it was created, and its write is dropped. A +create that landed between the two checks looked like that, and the write was dropped. + +**Fix.** The landing removes the create op and gives the row its uid in one transaction. So a +missing create op is checked against the row, and a landed file's write is queued for its uid. + +**Test:** `a_write_closed_as_its_create_lands_is_queued` in `crates/pdfs-fuse/src/lib.rs`. + +--- + ## B175 — A file replaced by a rename just as its create lands stays on Drive **Status:** Fixed (unverified). diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 8c0cdde..914ccb1 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -78,6 +78,7 @@ by its local id, in the queue, the stored tree and pins alike. (B174). - **A file replaced by a rename just as it reached Drive is removed from Drive too.** It no longer stayed there, and the next rename of its name no longer moved it instead (B175). +- **A write closed just as its file reached Drive is no longer lost** (B176). - **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 031d26e..4adb644 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 B175. +- Fixes from the simulation and account runs since then: B127 and B141 to B176. - **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`. @@ -119,7 +119,8 @@ Tracks `docs/MILESTONE-3.0.0.md`. Update it with every commit that moves a phase on d850265 failed seed 12 once in nine runs: a file deleted just as its create landed stayed on Drive (B173, fixed). The suite on 378667f failed seed 6: a rename in the same gap was refused with `EACCES` (B174, fixed). The loop on 378667f failed - seed 8: a file replaced by a rename in that gap stayed on Drive (B175, fixed). + seed 8: a file replaced by a rename in that gap stayed on Drive (B175, fixed), and + seed 12: a write closed as its file's create landed was dropped (B176, 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