filesystem: fix the view ledger deadlock and keep FUSE mounts alive after a failed invalidation

Merged
VA
3d ago
+372-89

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's RwLock for 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 writev on inval_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} and Mount::sync built or borrowed a meter other than the source's original one. While that meter was active, the invalidator's callback_meter refused with ControlPending, and retain_handler_failure turned that into a permanent mount failure. Every revalidation now runs on source.native_original_work() (customer::lifecycle::with_session_work, and LiveLazyMount::revalidate for the other lazy-mount paths). This is the meter the callbacks and the invalidator share.
  • One failed pass killed the mount. run_invalidator no longer calls retain_handler_failure. A failed pass is logged with tracing::warn!. While the last pass failed, ProjectionState::admissible returns 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 failed instead 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 hung live_mount_keeps_a_reused_identity_apart this way.

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 main it fails 4/4 with a 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_statuses now also pins the explicit (MountSourceError, HRESULT) pairs for NotDirectory, IsDirectory and DirectoryNotEmpty, 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.

buildrunsokhung (killed)other failures
main ccab904f3 (before)303-
main 25218c52c (stopped early)1208 (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 d06627f11969600
this PR on 25218c52c, run 1969411 x FUSE shutdown previously failed
this PR on 25218c52c, run 2 (gdb on any run past 60 s)968907 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):

testmain 25218c52cthis PR
lazy::tests::live_mount::live_mount_follows_concurrent_changes_to_its_sourcehangs (killed at 600 s)ok
live_mount_keeps_a_reused_identity_apartfails (FUSE invalidation previously failed); hung 600 s on ccab904f3ok
live_mount_shows_changes_made_to_its_sourceokok
live_mount_shows_writes_through_a_name_outside_its_sourceokok
live_mount_keeps_hard_links_one_filefails, NotFoundfails, NotFound, same
linux_ set (11)9 ok; linux_mount_behaviour_cases (NotFound) and linux_installed_callbacks_observe_original_finite_mount_cancellation (ESTALE, expected ECANCELED) failthe 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: ok
  • cargo 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 record took 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: record plans 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 (admissible returns 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, and DirectoryNotEmpty, and the reused-identity test drops its O_PATH handle before an early unmount.