diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index 761bc8e..14406bc 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -3281,6 +3281,42 @@ impl Core { .filter(|uid| !is_local_uid(uid)) } + /// Queue what the write handles the kernel never released hold. + /// + /// `close(2)` returns before the kernel sends `release`, and the stop + /// aborts the FUSE connection with the releases still queued: a file a + /// program had just saved was never queued, and Drive kept it as it was + /// (`docs/BUGS.md` B190). Runs once every session has ended, so no release + /// can race it. A handle still open in a program goes the same way, with + /// what it wrote so far; one on an unlinked file only leaves its scratch. + pub(crate) fn queue_unreleased_writes(&self) { + let handles: Vec<(WriteHandle, bool)> = { + let mut st = self.state(); + st.handles.clear(); + let inos: Vec = st.active_writes.keys().copied().collect(); + inos.into_iter() + .filter_map(|ino| { + let unlinked = st.entries.get(&ino).is_some_and(|e| e.unlinked); + st.active_writes.remove(&ino).map(|h| (h, unlinked)) + }) + .collect() + }; + for (mut h, unlinked) in handles { + if unlinked { + self.cache.clear_scratch_durable(&h.path); + let _ = std::fs::remove_file(&h.path); + continue; + } + self.follow_landed_create(&mut h); + match self.queue_revision(&mut h) { + Ok(()) => info!(uid = %h.uid, "queued a write the kernel never released"), + Err(error) => { + warn!(uid = %h.uid, ?error, "cannot queue a write the kernel never released") + } + } + } + } + /// Accept a released write handle's bytes and queue their upload /// (offline.md Phase 3). /// diff --git a/crates/pdfs-fuse/src/mount.rs b/crates/pdfs-fuse/src/mount.rs index 5ecd195..d11875b 100644 --- a/crates/pdfs-fuse/src/mount.rs +++ b/crates/pdfs-fuse/src/mount.rs @@ -750,6 +750,7 @@ pub(crate) fn mount_with( warn!(id, error = %e, "unmount on-demand folder failed"); } } + core.queue_unreleased_writes(); // Last, because the workers may legitimately be finishing an upload, and // nothing they can still do is harmful once the mounts are down. diff --git a/crates/pdfs-fuse/src/sim/daemon.rs b/crates/pdfs-fuse/src/sim/daemon.rs index b290303..2be180b 100644 --- a/crates/pdfs-fuse/src/sim/daemon.rs +++ b/crates/pdfs-fuse/src/sim/daemon.rs @@ -476,6 +476,37 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] + fn a_stop_keeps_what_a_handle_wrote_before_its_release() { + // close(2) returns before the kernel sends the release, and the stop + // aborted the connection with the release still queued: the bytes of a + // file just saved were never queued, and Drive kept it empty (B190). + // A handle still open at the stop is the same case, held open. + use std::io::Write; + let drive = FakeDrive::new(); + let dir = scratch("stop-before-release"); + let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); + let mut file = std::fs::File::create(daemon.mountpoint.join("f.txt")).unwrap(); + file.write_all(b"0123456789").unwrap(); + + let daemon = daemon.restart().unwrap(); + drop(file); + 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.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_that_failed_on_a_short_outage_goes_out_soon_after_the_link_returns() { diff --git a/docs/BUGS.md b/docs/BUGS.md index a043a9d..35f7806 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -12,6 +12,27 @@ Conventions: --- +## B190 — A file saved just before the daemon stops can keep its old content on Drive + +**Status:** Fixed (unverified). +**Found:** 2026-10-05, in the confirmation run on 2baad67 (`one_client_on_a_flaky_link` seed 9, +about one run in three). A client wrote a 32-byte file and closed it, and the daemon was +restarted at once. Drive held the file empty, and nothing was queued. + +**Where:** the stop in `crates/pdfs-fuse/src/mount.rs`, and `Core::queue_unreleased_writes` in +`crates/pdfs-fuse/src/lib.rs`. + +**Cause.** `close(2)` returns before the kernel sends `release`, and the release is what queues +a handle's bytes. The stop aborts the FUSE connection so that the unmount cannot hang, and the +abort drops the requests the kernel still has queued. A release among them never reached the +daemon. The file's create went out with what it held before the write, and the bytes went with +the handle's scratch file. A handle a program still held open at the stop lost what it had +written in the same way. + +**Fix.** Once every session has ended, the stop queues each write handle the kernel never +released, as its release would have. Test `a_stop_keeps_what_a_handle_wrote_before_its_release` +in `crates/pdfs-fuse/src/sim/daemon.rs`. + ## B189 — A change that failed on a short outage waits out its retry long after the link is back **Status:** Fixed (unverified). diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 30260c4..dbac999 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -113,6 +113,8 @@ by its local id, in the queue, the stored tree and pins alike. (B187). - **Changes that failed during a short outage go out soon after the connection is back**, not after a retry delay of up to five minutes (B189). +- **A file saved just before the daemon stops reaches Drive** with what was written. It could + keep its old content, or stay empty (B190). - **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 c9446c2..febb803 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 B189. +- Fixes from the simulation and account runs since then: B125, B127, B130 and B141 to B190. - **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. @@ -115,7 +115,8 @@ Tracks `docs/MILESTONE-3.0.0.md`. Update it with every commit that moves a phase 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), and changes that sat out their retry - after an outage only the drain had met (B189, fixed; older than 3.0.0). + after an outage only the drain had met (B189, fixed; older than 3.0.0), and a file + saved just before a stop whose release the stop dropped (B190, fixed; older too). - [ ] 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