diff --git a/crates/pdfs-fuse/src/drain.rs b/crates/pdfs-fuse/src/drain.rs index 2b6a3bd..d6369ba 100644 --- a/crates/pdfs-fuse/src/drain.rs +++ b/crates/pdfs-fuse/src/drain.rs @@ -1160,6 +1160,19 @@ impl Core { parent: &NodeUid, name: &str, ) -> Result, Box> { + // A second file queued under the same name, after the first was + // renamed away, finds the first one's node fresh and empty while its + // create is on the wire. Taking it put both files on one node, and the + // first one's rename took the second one's bytes along + // (docs/BUGS.md B147). + let racing = self + .creating + .lock() + .iter() + .any(|(id, (p, n))| *id != op.id && p == parent && n == name); + if racing { + return Ok(None); + } let uids = self .rt .block_on(self.drive.enumerate_folder_children_node_uids(parent))?; @@ -1170,6 +1183,12 @@ impl Core { else { return Ok(None); }; + // A node in the tree was answered for, by this mount or by a listing: + // the same race after the first create landed. Without the adoption, + // an unanswered twin forks a conflict copy, which loses nothing. + if self.state().by_uid.contains_key(&twin.uid) { + return Ok(None); + } // The light listing carries no file size, and an adoptable file is // exactly an empty one. let twin = match twin.kind { diff --git a/docs/BUGS.md b/docs/BUGS.md index 34f1a29..b8fa298 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -12,6 +12,25 @@ Conventions: --- +## B147 — A new file made under a name just renamed away can share the first file's node + +**Status:** Fixed (unverified). +**Found:** 2026-10-04, by `sim::run::tests::three_clients_one_writer_each`, seed 2, in about one +run of eight. Client 1 made `a.txt`, renamed it to `b.txt`, and made `a.txt` again. Drive ended +up without `b.txt`, and `e.md`, a later rename of the new `a.txt`, held `b.txt`'s bytes. + +**Where:** `Core::adoptable_twin` in `crates/pdfs-fuse/src/drain.rs`. + +**Cause.** The first file's create went out as `a.txt` and the rename was queued behind it. While +that create was on the wire, the second file's create found `a.txt` taken by a fresh, empty file, +which is how the node of an unanswered create of its own looks, and adopted it. Both local files +landed on the one node, and the first file's rename took it to `b.txt`. + +**Fix.** A create no longer adopts a node another create is making under the same name and folder: +it waits for that one to land, as for any name a queued change holds. Nor does it adopt a node +already in the tree, which was answered for. An unanswered twin the tree has already listed +lands as a conflict copy, which loses nothing. + ## B146 — A file renamed just as its upload lands can go back to its old name **Status:** Fixed (unverified). diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index cdce71a..3c6aa97 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -44,6 +44,8 @@ are kept. A file made a moment before was taken for deleted and dropped from the queue (B145). - **A file renamed just as its upload lands keeps its new name.** The tree could take back the name Drive still had (B146). +- **A new file made under a name another file just left no longer takes that file's place on + Drive.** Both could land on one node, and one file's content was lost (B147). ## [2.10.0] — 2026-10-03