diff --git a/crates/pdfs-fuse/src/drain.rs b/crates/pdfs-fuse/src/drain.rs index 92cf839..d1433cf 100644 --- a/crates/pdfs-fuse/src/drain.rs +++ b/crates/pdfs-fuse/src/drain.rs @@ -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"); } diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index 2d9e44e..78200e6 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -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(()) diff --git a/crates/pdfs-fuse/src/sim/daemon.rs b/crates/pdfs-fuse/src/sim/daemon.rs index 275a93d..e087885 100644 --- a/crates/pdfs-fuse/src/sim/daemon.rs +++ b/crates/pdfs-fuse/src/sim/daemon.rs @@ -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() { diff --git a/docs/BUGS.md b/docs/BUGS.md index 565113d..b650af6 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -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). diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f73c071..a1aa55a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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). diff --git a/docs/MILESTONE-3.0.0-STATUS.md b/docs/MILESTONE-3.0.0-STATUS.md index 629c452..68edda5 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 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