filesystem: fix the view ledger deadlock and keep FUSE mounts alive after a failed invalidation
The SDK stress run of native_mount::lazy::tests::live_mount::live_mount_follows_concurrent_changes_to_its_source hung, and other runs failed in under a second with native mount lifecycle failed: FUSE invalidation previously failed. Both are SDK defects. This PR fixes both and adds a regression test that deadlocks on main.
1. Lock-order deadlock in the view ledger
gdb on a hung main run (ccab904f3) showed three threads:
- the source watcher in
view_ledger::storage::record, holding the ledger map'sRwLockfor writing while it waited for the meter (NativeMaterializationWork::with_original_leaf) to charge the scan; - a FUSE handler in
storage::unchanged_since, holding that meter while it waited to read the same map; - the invalidator in
writevoninval_entry. The kernel holds the parent directory's inode lock for that call, so the invalidator was stuck behind the hung handler. It was a victim, not a cause.
Readers everywhere (handlers, the invalidator's stale_kernel_items, query_history, node_unchanged_since_with_meter) take the meter and then the map. record took the map and then the meter. That is an ABBA cycle.
Fix: the lock order is now meter, then map. record reads a plan under a read lock and pays for it with no map lock held. It then takes the write lock only to apply the plan, and only if the map has not changed since (same length, capacity, key presence and custody). Otherwise it plans again. Growing the map, including reserving and claiming custody, also happens outside the lock. The swap happens under the lock, and the old map and custody are dropped after it is released.
No separate notifier thread
The proposed fix was to send invalidations from a dedicated notifier thread that holds no handler lock. run_invalidator already is that thread. It is a dedicated thread fed from a queue (InvalidationQueue) that coalesces work, and it calls inval_inode/inval_entry with no handler or state lock held (drop_kernel_caches runs on the meter alone). The writev stack was a consequence of the ledger deadlock above. With that cycle removed, the invalidator never blocks on a handler, so a second thread would add nothing.
2. A failed invalidation was fatal to the mount
Two causes, both fixed:
- Session controls ran on a foreign meter.
LiveLazyMount::{revalidate, changed_outside, advance_to_head, sync, sync_with_permit, import_selected, selected_import_snapshot, snapshot}andMount::syncbuilt or borrowed a meter other than the source's original one. While that meter was active, the invalidator'scallback_meterrefused withControlPending, andretain_handler_failureturned that into a permanent mount failure. Every revalidation now runs onsource.native_original_work()(customer::lifecycle::with_session_work, andLiveLazyMount::revalidatefor the other lazy-mount paths). This is the meter the callbacks and the invalidator share. - One failed pass killed the mount.
run_invalidatorno longer callsretain_handler_failure. A failed pass is logged withtracing::warn!. While the last pass failed,ProjectionState::admissiblereturns false: TTL zero and no negative caching for every reply, which drops the cache rather than serving stale entries. A later pass retries once its inputs change.- A waiter whose changes may not be visible gets
FUSE invalidation failedinstead of waiting forever. - A waiter that arrives after a failed pass sets
InvalidationQueue::retry, so the invalidator runs one more pass even when its inputs have not changed. Without this, a waiter could miss the denial and wait forever for a pass that never comes. The first revision of this PR hunglive_mount_keeps_a_reused_identity_apartthis way.
- A waiter whose changes may not be visible gets
Tests
- New
native_mount::view_ledger::tests::a_reader_holding_its_meter_never_waits_on_a_recording_writer:- A reader holds the meter while a writer records into the node map, and the reader then reads that map.
- The bounded wait is 30 s.
- On
mainit fails 4/4 witha ledger reader and writer deadlocked on the meter. With this PR it passes 5/5 in under 0.15 s.
- ProjFS:
source_failures_keep_distinct_statusesnow also pins the explicit (MountSourceError, HRESULT) pairs forNotDirectory,IsDirectoryandDirectoryNotEmpty, as requested on #534. (Windows-only, not built in this run.)
Results (Azure 80-core build VM, fuse3)
Two builds were tested: base, which is origin/main (25218c52c), and this PR rebased on it. Before the rebase, the same change was also run on its old base d06627f11. Both trees were checked file by file against git: 2297 files under rust/, blob ids equal.
Stress: live_mount_follows_concurrent_changes_to_its_source, --ignored --exact --test-threads=1, 8 parallel copies x 12 runs.
| build | runs | ok | hung (killed) | other failures |
|---|---|---|---|---|
main ccab904f3 (before) | 3 | 0 | 3 | - |
main 25218c52c (stopped early) | 12 | 0 | 8 (all 8 copies hung on their 1st or 2nd run; gdb could not attach, threads stuck in FUSE waits) | 4 x FUSE invalidation previously failed |
this PR on d06627f11 | 96 | 96 | 0 | 0 |
this PR on 25218c52c, run 1 | 96 | 94 | 1 | 1 x FUSE shutdown previously failed |
this PR on 25218c52c, run 2 (gdb on any run past 60 s) | 96 | 89 | 0 | 7 x FUSE shutdown previously failed |
FUSE shutdown previously failed is a separate, pre-existing race that this PR now reaches. main fails or hangs before it gets to unmount. The test has closed every handle and joined every thread before unmount(). As non-root, fuser detaches lazily (fusermount -u -z). FuseSession::stop then polls /dev/fuse once with a zero timeout and requires POLLERR, which fails if the kernel has not yet torn the connection down. I can follow up separately, by waiting for POLLERR on a bounded deadline instead of polling once. The one hung run in run 1 could not be captured; run 2 re-ran with gdb capture and had none.
Live FUSE tests (--ignored --test-threads=1):
| test | main 25218c52c | this PR |
|---|---|---|
lazy::tests::live_mount::live_mount_follows_concurrent_changes_to_its_source | hangs (killed at 600 s) | ok |
live_mount_keeps_a_reused_identity_apart | fails (FUSE invalidation previously failed); hung 600 s on ccab904f3 | ok |
live_mount_shows_changes_made_to_its_source | ok | ok |
live_mount_shows_writes_through_a_name_outside_its_source | ok | ok |
live_mount_keeps_hard_links_one_file | fails, NotFound | fails, NotFound, same |
linux_ set (11) | 9 ok; linux_mount_behaviour_cases (NotFound) and linux_installed_callbacks_observe_original_finite_mount_cancellation (ESTALE, expected ECANCELED) fail | the same 9 ok, the same 2 fail with the same errors |
live_mount_keeps_a_reused_identity_apart also had a test bug, fixed here. When the host did not reuse the inode, it unmounted while still holding an O_PATH handle into the mount, so the unmount could only detach. It now drops the handle first.
Lib tests: 1221 passed, 5 failed, the same 5 as on main: root_capture_publishes_metadata_and_children, filtered_callback_error_replies_without_overwriting_parent_errno, an_open_handle_reports_its_file_as_it_is_now, accepted_destination_fence_cleanup_survives_original_cancellation, partial_watch_cleanup_can_be_retried.
Checks
cargo fmt --all --check: okcargo clippy -p acyclic-fs --all-targets --all-features --locked: no findings in changed files (pre-existing findings elsewhere unchanged)- Windows ProjFS test file not compiled here.
š¤ Generated with Claude Code
Summary by cubic
Fixes a lock-order deadlock that hung the source watcher, and stops a failed FUSE invalidation pass from permanently failing the mount.
View ledger deadlock
- Readers hold the meter and then read the node map, while
recordtook the map write lock and then charged the meter; a source watcher and a FUSE handler could wait on each other and hang the mount. - Lock order is now meter then map:
recordplans under a read lock, pays with no map lock held, then takes the write lock only to apply the plan if the map is unchanged. - Adds a regression test with a bounded wait that deadlocks on
main.
Failed invalidations no longer fail the mount
- Live mount controls (
revalidate,sync, snapshots, imports) ran on a foreign meter, which made the invalidator refuse and fail the whole mount; they now run on the source's original meter. - A failed pass is logged and stops new caching (
admissiblereturns false) but keeps the mount alive; waiters are told their changes may not be visible, and a waiter that arrives after a failure requests one retry so a pass still runs. - A notification the kernel refuses stays queued, so the pass settles nothing past it and the next pass delivers it; previously the pass settled and left the kernel's cached entries stale.
- The ProjFS test pins the HRESULTs for
NotDirectory,IsDirectory, andDirectoryNotEmpty, and the reused-identity test drops itsO_PATHhandle before an early unmount.