fix(fuse): a stop queues the writes whose release the kernel never sent (B190)

This commit is contained in:
nvrl committed 2026-10-05 03:55:52 +02:00
1 parent f0a43fb923
commit 1436c5f37e
6 files changed
+94 -2

No files matched your search

+36
View File
@@ -3281,6 +3281,42 @@ impl Core {
.filter(|uid| !is_local_uid(uid)) .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<u64> = 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 /// Accept a released write handle's bytes and queue their upload
/// (offline.md Phase 3). /// (offline.md Phase 3).
/// ///
+1
View File
@@ -750,6 +750,7 @@ pub(crate) fn mount_with(
warn!(id, error = %e, "unmount on-demand folder failed"); warn!(id, error = %e, "unmount on-demand folder failed");
} }
} }
core.queue_unreleased_writes();
// Last, because the workers may legitimately be finishing an upload, and // Last, because the workers may legitimately be finishing an upload, and
// nothing they can still do is harmful once the mounts are down. // nothing they can still do is harmful once the mounts are down.
+31
View File
@@ -476,6 +476,37 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir); 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] #[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] #[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() { fn a_write_that_failed_on_a_short_outage_goes_out_soon_after_the_link_returns() {
+21
View File
@@ -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 ## B189 — A change that failed on a short outage waits out its retry long after the link is back
**Status:** Fixed (unverified). **Status:** Fixed (unverified).
+2
View File
@@ -113,6 +113,8 @@ by its local id, in the queue, the stored tree and pins alike.
(B187). (B187).
- **Changes that failed during a short outage go out soon after the connection is back**, not - **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). 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 - **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, the old name, and a second rename no longer failed with "No such file or directory" (B152,
B172). B172).
+3 -2
View File
@@ -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 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 - **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). 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, - **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 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. `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). 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 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 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) - [ ] 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 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 the journal check. B121 was a bug (B149, fixed); B113's case still expected creates