fix(fuse): keep what an open file reads when its delete is queued (B186)

This commit is contained in:
nvrl committed 2026-10-05 02:51:55 +02:00
1 parent d26dc8ada4
commit c271a1897c
6 files changed
+109 -5

No files matched your search

+11
View File
@@ -994,6 +994,17 @@ impl Core {
Err(e) => return Err(e.into()),
}
self.retire_trash_op(op, &uid)?;
// A file trashed while open kept the bytes its handle reads, and a
// final close while the trash was queued left them to this
// (`docs/BUGS.md` B186).
if !self.is_open_anywhere(&uid) {
if let Some(blob) = self.pending_blob(&uid) {
self.release_pending(&uid, &blob);
self.cache.discard_staged(&blob);
}
self.cache.evict(&uid);
self.evict_reader(&uid);
}
if let Err(e) = self.db.clear_own_sealed_rev(&uid.to_string()) {
debug!(%uid, error = %e, "clearing the sealed-revision record failed");
}
+21 -3
View File
@@ -3902,6 +3902,11 @@ impl Core {
/// staged bytes that never landed — which is precisely what deleting an
/// un-uploaded file means, and the alternative (upload it, then trash it) is
/// worse in every way.
///
/// A file still open keeps the bytes its handle reads, the queued ones and
/// the cached ones, as when the trash is sent at once: a partial write
/// fills its gaps from them, offline too (`docs/BUGS.md` B186). The final
/// close or the trash landing, whichever comes last, lets go of them.
fn queue_trash(&self, uid: &NodeUid, name: &str) -> Result<(), Errno> {
self.require_uid_writable(uid)?;
// The bytes below are about to be discarded, and a drain worker may be
@@ -3917,7 +3922,18 @@ impl Core {
error!(%uid, error = %e, "queueing trash failed");
Errno::EIO
})?;
self.release_dropped_ops(uid, blobs);
let open_now = self.is_open_anywhere(uid);
if open_now {
let read_from = self.pending_blob(uid);
self.release_dropped_below(
blobs
.into_iter()
.filter(|blob| read_from.as_deref() != Some(Path::new(blob)))
.collect(),
);
} else {
self.release_dropped_ops(uid, blobs);
}
self.hidden.lock().insert(uid.clone());
// Withdrawn from the tree, which every mount serves from, so no other
// mount goes on serving a file the user just trashed (`docs/BUGS.md`
@@ -3925,8 +3941,10 @@ impl Core {
self.for_each_state(|st| {
st.unlink_mem(uid);
});
self.cache.evict(uid);
self.evict_reader(uid);
if !open_now {
self.cache.evict(uid);
self.evict_reader(uid);
}
self.wake_drain();
debug!(%uid, name, "trashed offline; queued");
Ok(())
+53
View File
@@ -423,6 +423,59 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_file_deleted_offline_while_open_still_reads_and_takes_writes() {
// Queuing the trash dropped the cached bytes the open handle reads,
// and a partial write offline failed with EIO (B186).
use std::io::{Read, Seek, SeekFrom, Write};
let drive = FakeDrive::new();
let dir = scratch("unlink-open-offline");
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);
let mut file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(&path)
.unwrap();
std::fs::remove_file(&path).unwrap();
file.seek(SeekFrom::End(0)).unwrap();
file.write_all(b"ab").unwrap();
let mut read = Vec::new();
file.seek(SeekFrom::Start(0)).unwrap();
file.read_to_end(&mut read).unwrap();
assert_eq!(read, b"0123456789ab");
drop(file);
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()
);
// The bytes kept for the handle went once the trash landed with it closed.
let cached: Vec<_> = std::fs::read_dir(dir.join("cache"))
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_ok_and(|t| t.is_file()))
.map(|e| e.file_name())
.collect();
assert!(cached.is_empty(), "{cached:?}");
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() {
+19
View File
@@ -12,6 +12,25 @@ Conventions:
---
## B186 — A file deleted offline while open fails a partial write with "Input/output error"
**Status:** Fixed (unverified).
**Found:** 2026-10-05, in the confirmation run on 3073052 (`three_clients_one_writer_each`
seed 4; it fails on da013c2 too). With its link down, a client opened a file it had uploaded,
deleted it, and appended to it. The append took 30 s and failed with `EIO`.
**Where:** `Core::queue_trash` in `crates/pdfs-fuse/src/lib.rs` and `Core::drain_trash` in
`crates/pdfs-fuse/src/drain.rs`.
**Cause.** A delete sent to Drive at once keeps what an open handle reads, its queued and cached
bytes, until the final close (B106). A delete queued, as local-first and offline do, dropped
them at once. A partial write fills its gaps from those bytes, so it had to download the file,
and offline it could not.
**Fix.** A queued delete of an open file keeps them as well. The final close lets go of them
once the delete has landed, and the delete landing lets go of them once the file is closed.
Sim test `a_file_deleted_offline_while_open_still_reads_and_takes_writes`.
## B185 — A new file written again just as it starts uploading waits out a retry
**Status:** Fixed (unverified).
+2
View File
@@ -107,6 +107,8 @@ by its local id, in the queue, the stored tree and pins alike.
(B184).
- **A new file written again just as it starts uploading no longer waits out a retry** before
it reaches Drive (B185).
- **A file deleted while open can still be read and written until it is closed**, offline too.
A write into it no longer failed with "Input/output error" (B186).
- **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).
+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 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 B185.
- Fixes from the simulation and account runs since then: B125, B127, B130 and B141 to B186.
- **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.
@@ -111,7 +111,8 @@ Tracks `docs/MILESTONE-3.0.0.md`. Update it with every commit that moves a phase
what its create sent (B183). Replays with those fixed found a third: a create renamed
after it lost its answer (B184). All three fixed; no known-bug matcher fired. It was
stopped before `three_clients`. The rerun on 3073052 found a create that waited out
a retry after a write replaced its blob (B185, fixed).
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).
- [ ] 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