diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index 78200e6..761bc8e 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -1961,6 +1961,20 @@ impl Core { "staged bytes are superseded by a queued write; kept for recovery"); continue; } + if self + .db + .has_pending_op(&uid.to_string(), OP_TRASH) + .unwrap_or(false) + { + // The node was deleted while a handle still read these bytes, + // and the daemon stopped before its trash went out. Queued again, + // they lost their blob when the trash landed and retried on it + // for good (`docs/BUGS.md` B188). + info!(%uid, blob = %blob.display(), + "staged bytes belong to a node whose trash is queued; discarded"); + self.cache.discard_staged(&blob); + continue; + } let op = PendingOp { id: 0, kind: OP_REVISION.to_string(), diff --git a/crates/pdfs-fuse/src/sim/daemon.rs b/crates/pdfs-fuse/src/sim/daemon.rs index e087885..4dcce2d 100644 --- a/crates/pdfs-fuse/src/sim/daemon.rs +++ b/crates/pdfs-fuse/src/sim/daemon.rs @@ -476,6 +476,49 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] + fn a_restart_does_not_bring_back_the_write_of_a_file_deleted_while_open() { + // The bytes kept for the open handle outlived a restart, which queued + // them again as a revision; the trash then landed and removed them, and + // the revision retried on a missing blob for good (B188). + use std::io::Write; + let drive = FakeDrive::new(); + let dir = scratch("unlink-open-restart"); + let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); + let path = daemon.mountpoint.join("f.txt"); + + std::fs::write(&path, b"0123456789").unwrap(); + assert!(wait_until(Duration::from_secs(30), || { + daemon.pending().is_ok_and(|items| items.is_empty()) + && drive.tree().get("f.txt") == Some(&Entry::File(Arc::new(b"0123456789".to_vec()))) + })); + daemon.client.set_online(false); + std::fs::write(&path, b"abcdef").unwrap(); + let mut file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .unwrap(); + std::fs::remove_file(&path).unwrap(); + file.write_all(b"xy").unwrap(); + drop(file); + + let daemon = daemon.restart().unwrap(); + daemon.client.set_online(true); + assert!( + wait_until(Duration::from_secs(30), || { + daemon.pending().is_ok_and(|items| items.is_empty()) && drive.tree().is_empty() + }), + "{:?} {:?}", + 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_write_opened_as_an_upload_lands_takes_the_landed_revision_as_its_base() { diff --git a/docs/BUGS.md b/docs/BUGS.md index 786eb1c..99baeca 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -12,6 +12,26 @@ Conventions: --- +## B188 — A restart can leave a queued write retrying forever on a file deleted while open + +**Status:** Fixed (unverified). +**Found:** 2026-10-05, in the confirmation run on 2baad67 (`three_clients_one_writer_each` +seed 12). With its link down, a client overwrote a file, deleted it while open, appended to it +and restarted. After the restart a revision of the deleted file retried on "No such file or +directory" until the run gave up. + +**Where:** `Core::reconcile_staging` in `crates/pdfs-fuse/src/lib.rs`. + +**Cause.** Since B186's fix, the queued trash of an open file keeps the staged bytes the handle +reads, and the trash's landing removes them. A restart in between forgets which handle the bytes +were kept for. The scan at mount found them owned by no op and queued them again as a revision of +the node. The trash, queued first, landed and removed the bytes, and the revision kept retrying on +a blob that was gone. B186's fix brought this in, so no release has it. + +**Fix.** The scan at mount discards staged bytes whose node has a queued trash, as the trash would +have. Test `a_restart_does_not_bring_back_the_write_of_a_file_deleted_while_open` in +`crates/pdfs-fuse/src/sim/daemon.rs`. + ## B187 — An uploaded file can leave a copy of itself in the cache until the next start **Status:** Fixed (unverified). diff --git a/docs/MILESTONE-3.0.0-STATUS.md b/docs/MILESTONE-3.0.0-STATUS.md index fa4d91d..ba892b8 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 B187. +- Fixes from the simulation and account runs since then: B125, B127, B130 and B141 to B188. - **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. @@ -113,6 +113,8 @@ Tracks `docs/MILESTONE-3.0.0.md`. Update it with every commit that moves a phase stopped before `three_clients`. The rerun on 3073052 found a create that waited out a retry after a write replaced its blob (B185, fixed), and a file deleted offline while open that lost the bytes its handle read (B186, fixed; older than 3.0.0). + The run on 2baad67 found that a restart before such a file's trash went out queued + its kept bytes again, for good (B188, 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