diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index a3d6b85..ffd159f 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -1364,24 +1364,42 @@ impl KernelChannel { pub(crate) fn inval_inode(&self, ino: u64) -> std::io::Result<()> { let ino = filesystem::kernel_ino(self.root, ino); - self.notifier.inval_inode(ino, 0, 0) + off_the_runtime(|| self.notifier.inval_inode(ino, 0, 0)) } /// Drop the cached attributes of `ino` but not its page cache. fn inval_attrs(&self, ino: u64) -> std::io::Result<()> { let ino = filesystem::kernel_ino(self.root, ino); - self.notifier.inval_inode(ino, -1, 0) + off_the_runtime(|| self.notifier.inval_inode(ino, -1, 0)) } pub(crate) fn inval_entry(&self, parent: u64, name: &OsStr) -> std::io::Result<()> { let parent = filesystem::kernel_ino(self.root, parent); - self.notifier.inval_entry(parent, name) + off_the_runtime(|| self.notifier.inval_entry(parent, name)) } fn delete(&self, parent: u64, child: u64, name: &OsStr) -> std::io::Result<()> { let parent = filesystem::kernel_ino(self.root, parent); let child = filesystem::kernel_ino(self.root, child); - self.notifier.delete(parent, child, name) + off_the_runtime(|| self.notifier.delete(parent, child, name)) + } +} + +/// Send a notice to the kernel without holding up the runtime. +/// +/// The kernel answers an invalidation only once it has the pages' locks, and +/// a read holds those until the daemon answers it. Sent from a runtime worker, +/// the notice blocked that worker in the kernel; when it was the one driving +/// the timers and I/O, nothing drove them any more, so the read's fetch never +/// finished and the daemon hung (`docs/BUGS.md` B130). On a worker the notice +/// hands the worker's tasks to another thread first; anywhere else it is sent +/// as it is. +fn off_the_runtime(notice: impl FnOnce() -> T) -> T { + match tokio::runtime::Handle::try_current() { + Ok(handle) if handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread => { + tokio::task::block_in_place(notice) + } + _ => notice(), } } @@ -7319,12 +7337,12 @@ mod tests { SELF_CHANGE_TTL_MS, ShareId, SharedWithMeItem, StateRegistry, VirtualRootPlan, accepted_share_provenance, conflict_name, copy_pending_for_truncate, expand_restore, first_seen_in_trash, fuse_name, is_stale_mount, node_visible, note_self_change, - orphan_past_retention, parse_node_uid, prepare_shared_roots, preserve_on_access_denied, - publish_virtual_root_in_listing, reconcile_virtual_root_in_listing, - release_can_discard_unlinked, release_must_retain_queued_trash, release_unlinked_entry, - rename_needs_queue, require_node_parent_access, require_rename_access, - resolve_anywhere_with, shared_with_me_uid, take_self_change, uid_write_authority, - virtual_node, + off_the_runtime, orphan_past_retention, parse_node_uid, prepare_shared_roots, + preserve_on_access_denied, publish_virtual_root_in_listing, + reconcile_virtual_root_in_listing, release_can_discard_unlinked, + release_must_retain_queued_trash, release_unlinked_entry, rename_needs_queue, + require_node_parent_access, require_rename_access, resolve_anywhere_with, + shared_with_me_uid, take_self_change, uid_write_authority, virtual_node, }; use super::{Db, WriteAuthority}; use std::time::Duration; @@ -9000,6 +9018,42 @@ mod tests { assert!(remote.contains("async { tokio::time::timeout(budget, fetch).await }")); } + #[test] + fn a_kernel_notice_sent_from_the_runtime_leaves_its_timers_running() { + // A notice waits in the kernel for a read whose fetch waits on a + // timer of the same runtime (B130). Here the runtime's one worker sends + // it, and the timer has to fire for the notice to return. + let rt = tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .enable_all() + .build() + .unwrap(); + let (fetched, fetch) = std::sync::mpsc::channel(); + rt.spawn(async move { + tokio::time::sleep(Duration::from_millis(10)).await; + let _ = fetched.send(()); + }); + let notice = + rt.spawn(async move { off_the_runtime(|| fetch.recv_timeout(Duration::from_secs(5))) }); + assert!(rt.block_on(notice).unwrap().is_ok()); + + // Every notice to a session goes through it. + let lib = include_str!("lib.rs"); + for method in [ + "fn inval_inode(&self", + "fn inval_attrs(&self", + "fn inval_entry(&self", + "fn delete(&self, parent: u64", + ] { + let source = function_source(lib, method); + assert_eq!(source.matches("self.notifier.").count(), 1, "{method}"); + assert!( + source.contains("off_the_runtime(|| self.notifier."), + "{method}" + ); + } + } + #[test] fn a_deadline_built_under_block_on_needs_no_ambient_runtime() { let rt = tokio::runtime::Builder::new_multi_thread() diff --git a/docs/BUGS.md b/docs/BUGS.md index 279c2b7..acd6a69 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -1218,7 +1218,7 @@ timing, and then on the refused write. ## B130 — A read of a file that a remote change invalidates can hang the daemon -**Status:** Open. +**Status:** Fixed. **Found:** 2026-10-03, by the simulation runs (`sim::run`, profile one-client-flaky, seed 1). After the last step, a read of `c0/z/f.txt` never returned. The watchdog's stacks showed the read's worker in `read_range_remote`, waiting in `block_on` for its fetch, and a runtime worker @@ -1239,9 +1239,16 @@ sleep on a condvar. The read's fetch waits on a timer (here the fake's latency, HTTP response), so it never finishes and never lets go of the page lock. The daemon stays stuck until some other work wakes a worker, and on an idle machine that may never happen. -**Test:** the simulation runs recognise it in the watchdog's stacks (a `tokio-rt-worker` in -state `D` inside `NotifyBatch::flush`), count it and end the seed there. With -`PDFS_SIM_KNOWN=fail`, seed 1 of one-client-flaky fails on it. +**Fix.** Every notice to the kernel goes through `off_the_runtime`, which on a runtime worker +sends it from inside `block_in_place`: the worker's tasks, and the driver with them, go to +another thread while it waits. Anywhere else the notice is sent as before. + +**Test:** `a_kernel_notice_sent_from_the_runtime_leaves_its_timers_running` in +`crates/pdfs-fuse/src/lib.rs`: the one worker of a runtime sends a notice that returns only once +a timer of that runtime has fired. The simulation runs still recognise it in the watchdog's +stacks (a `tokio-rt-worker` in state `D` inside `NotifyBatch::flush`); with +`PDFS_SIM_KNOWN=fail`, seed 1 of one-client-flaky fails on it, though it no longer reaches it +since phase 3. ## B129 — A file deleted or replaced while its create is on the wire stays on Drive diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index cb9d180..8bc7802 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -88,6 +88,7 @@ by its local id, in the queue, the stored tree and pins alike. worker missed the stop (B180). - **A folder made while offline or with sync paused no longer fails with "Input/output error"** once it is refreshed or the connection comes back (B125). +- **The daemon no longer hangs when a file being read changes on Drive** (B130). - **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 26b810b..ea92efe 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 and B141 to B180. +- Fixes from the simulation and account runs since then: B125, B127, B130 and B141 to B180. - **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`. @@ -89,8 +89,8 @@ Tracks `docs/MILESTONE-3.0.0.md`. Update it with every commit that moves a phase create was on the wire, which the mount showed under its old name (B161, fixed). Seed 4 failed on a folder removed and made again, whose create adopted the removed folder (B162, fixed). CI on 903452a passed every profile. - - [ ] Known-bug list in `sim/run.rs` is empty: B130 open; B125, B127, B129 and B132 fixed but - unconfirmed. + - [ ] Known-bug list in `sim/run.rs` is empty: none open; B125, B127, B129, B130 and B132 + fixed but not yet confirmed by the runs. - [ ] 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