inotify bridge: deliver Windows-side file changes to Linux watchers - #119
Conversation
) A file changed by a Windows application raises no inotify event inside WSL2, so vite, nodemon, tsc --watch, jest --watch, air and cargo-watch silently never fire. Nothing errors, which is why people lose an afternoon to it before finding microsoft/WSL#4739. wsldrive already carries every far-side change across as an invalidation, so the mount knows what happened within milliseconds. What was missing was the last hop, and it cannot be taken directly: the kernel raises fsnotify events from the VFS, at the point an operation is performed. A filesystem cannot raise one itself, and the fuse_lowlevel_notify_* calls invalidate dentries and pages without ever touching fsnotify. So ask the kernel to raise it, by performing on the mount the operation the far side already performed. A thread in the mount process replays each change as an ordinary syscall against the mount's own path: a write as an mtime-only utimensat (fsnotify_change reports a change to both timestamps as FS_ATTRIB and to mtime alone as FS_MODIFY, so this is what yields IN_MODIFY rather than the weaker IN_ATTRIB), a creation as mknodat, a deletion as unlinkat, a rename as renameat - which is what produces a genuine move cookie, so a watcher sees one move instead of an unrelated delete and create. The events are the kernel's own, so any watcher sees them with no preload, plugin or cooperation of any kind. Those operations must not cross the boundary a second time, so the FUSE handlers answer the bridge's own requests locally. That test is deliberately over-determined - own thread, matching poke in flight, exact path, claimed once - because a false positive would be a user's rm silently not deleting. Two further guards keep the replay inside the mount: the bridge refuses to start unless its root reports FUSE_SUPER_MAGIC, and every poke re-checks its target's device. Getting the right event type out also meant carrying what happened, not just what the mirror must do about it. Invalidation ops now hold a change kind and a rename-pairing cookie (protocol version 4; a peer that ignores both stays correct). The kind survives coalescing - a create followed by three writes is still a creation - and every half-move is degraded to what it amounts to alone rather than reaching a consumer unpaired. Created versus modified is settled by the client against its own mirror, because the agent re-stats after the fact and cannot tell the two apart. Also adds op_mknod, which was unimplemented and failing with ENOSYS. On by default; wsldrive mount --no-inotify turns it off. Direction B only - a WinFsp volume raises Windows change notifications through its own mechanism. Known gap: an overflow on the far side sends a rescan, which names no path, so the bridge can only touch the mount root. Naming what changed means diffing the old tree against the new snapshot and is not implemented; docs/inotify.md records it alongside the rest. scripts/inotify-conformance.sh mounts a tree, changes it from the serving side, and asserts each change raises the right event type. It also checks the thing that would be worst to get wrong - that the served tree is untouched by the bridge's own operations - exercises a recursive watch over 10k files, and reports far-side against local event latency. CI runs it in the Linux conformance job. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fixes the Linux build (a dangling else in the new agent test, which GCC rejects with -Werror where the local harness did not), and hardens the one place in the bridge whose failure mode is unacceptable. If claim() did not recognise a request from the bridge's own poke thread, the handler fell through and forwarded it across the boundary. For a creation that means asking the agent to create the file that already exists there - which replaces the very file whose arrival prompted the notification with an empty one. The guard was fail- dangerous, which is exactly backwards for a guard whose whole purpose is to protect the served tree. The bridge issues no genuine mutations, so a mutation request from its thread is one of its own replays and nothing else. Handlers now refuse an unrecognised one instead of forwarding it. A refused poke costs a missed notification; a forwarded one costs data. Also adds the diagnostics this needed and did not have. Change delivery is invisible when it works and equally invisible when it does not, so a failed poke left no trace anywhere: the mount now names the first few and reports delivered/failed/dropped counts on exit. The CI change-notification battery runs with always(), so when the filesystem battery fails its verdict is available to say whether the two share a cause. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The change-notification battery isolated this precisely: every event type worked except file creation, and the mount logged a refused poke for each file that appeared on the served side. libfuse's FUSE_MKNOD handler offers a regular file to the `create` handler first and falls back to `mknod` only if that answers ENOSYS. A mount with a `create` handler therefore never sees the poke at `mknod` at all, which is where it was being claimed. Before the previous commit made an unrecognised poke refuse rather than forward, that meant the poke was applied as a genuine create and replaced the file whose arrival prompted the notification with an empty one -- which is what the two external-change conformance checks were failing on. Claim it in op_create too. On a claim the handler returns without allocating a write handle, so the release that libfuse pairs with it has nothing to free. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GTest's EXPECT_ expands to an if/else, so an unbraced `if (cond) EXPECT_...` leaves the macro's else looking like it belongs to the outer if. Bracing the enclosing for-loop, as the previous attempt did, does not change that -- the braces have to go around the assertion. Also drops the ghost entry when a removal or move poke fails. On success the operation itself retires the dentry the ghost handed out; on failure it stays, and a path the far side has removed or moved away from then reads as still present to anyone who looks, for as long as the entry cache holds it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The issue asked for the added latency against a write made locally on the mount, measured rather than asserted. The conformance battery now reports both, and on a CI runner they are the same number: 4 ms either way, per event type, including a write deep inside a 10,000-file tree. That follows from where the work is. The invalidation had already crossed the boundary and updated the mirror before the bridge did anything, and the poke never leaves the local kernel. Read and write throughput are untouched for the same reason -- the bridge sits on the invalidation path, not the data path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
What CI caught that local verification could notThree defects, all in the delivery hop, all found on the runners rather than by reading:
A fourth was found by inspection while those were in flight: a failed removal or move poke left the Worth noting that (2) and (3) were only diagnosable because the change-notification battery runs Acceptance criteria from #118All measured on the runner, in the
Full battery: 31/31 filesystem checks and 12/12 change-notification checks, with the 🤖 Generated with Claude Code |
Closes #118.
A file changed by a Windows application raises no
inotifyevent inside WSL2, sovite,nodemon,tsc --watch,jest --watch,airandcargo-watchsilently never fire. Nothing errors, which iswhat makes microsoft/WSL#4739 so expensive to
diagnose. This makes those events arrive on a Direction B mount, for unmodified watchers.
Why the last hop needed a mechanism
wsldrive already carries every far-side change across as an invalidation, so the mirror and the page
cache are correct within milliseconds. The event is the part that cannot be delivered directly: the
kernel raises fsnotify events from the VFS, at the point an operation is performed. A filesystem
cannot raise one itself, and the
fuse_lowlevel_notify_*calls invalidate dentries and pages withoutever touching fsnotify —
fuse_reverse_inval_entryhas no fsnotify hook.What it does instead
It asks the kernel to raise the event, by performing on the mount the operation the far side already
performed. A thread in the mount process replays each change as an ordinary syscall:
utimensat, mtime onlyIN_MODIFYmknodatIN_CREATEmkdiratIN_CREATEwithIN_ISDIRunlinkat/unlinkat(AT_REMOVEDIR)IN_DELETErenameatIN_MOVED_FROM+IN_MOVED_TO, one cookieTouching only mtime is deliberate:
fsnotify_change()maps a change to both timestamps toFS_ATTRIBand to mtime alone toFS_MODIFY, so this is what yieldsIN_MODIFYrather than theweaker
IN_ATTRIB. Letting the kernel perform the rename is what produces a genuine move cookie, soa watcher sees one move instead of an unrelated delete and create.
Safety of the replay
The pokes are real filesystem calls, so three things guard them.
The FUSE handlers answer the bridge's own requests locally instead of forwarding them, and that test
is deliberately over-determined — own thread, a matching poke in flight, the exact path, claimed at
most once — because a false positive would be a user's
rmsilently not deleting. The bridge alsorefuses to start unless its root reports
FUSE_SUPER_MAGIC, and every poke re-checks that itstarget's parent is still on that mount's device.
The conformance script asserts the outcome that matters here: that the served tree is byte-for-byte
untouched by the bridge's own operations.
Protocol version 4
Getting the right event type out meant carrying what happened, not just what the mirror must do
about it. Invalidation ops now hold a change kind and a rename-pairing cookie. A peer that ignores
both stays correct; agent and client must match versions, as before.
The kind survives coalescing, so a create followed by three writes is still a creation, and every
half-move is degraded to what it amounts to alone rather than reaching a consumer unpaired. Created
versus modified is settled by the client against its own mirror — the agent re-stats after the fact
and cannot tell the two apart.
On the sequencing suggested in #118
I did not sequence this behind #93. The bridge needs no
CONFIG_FUSE_PASSTHROUGHand no newerlibfuse, so it neither depends on that mount design nor conflicts with it.
Also
mknodon a regular file now works on the mount; it was unimplemented and failing withENOSYS.Known gap
An overflow on the far side sends a rescan, which names no path, so the bridge can only touch the
mount root. A watcher that re-walks on any event below its root catches up; one waiting on a specific
path is not told. Naming what changed means diffing the old tree against the new snapshot and is not
implemented.
docs/inotify.mdrecords it alongside the other limits.Testing
scripts/inotify-conformance.shis new and runs in the Linux conformance job. It mounts a tree,changes it from the serving side, asserts each change raises the right event type, checks the served
tree is untouched, exercises a recursive watch over 10k files, and reports far-side against local
event latency.
Unit tests cover the change-kind semantics, rename pairing and its degradation, the wire round-trip,
and the whole agent-to-client path end to end via
RootServer::notify.Local verification was limited: this machine has no MSVC, WinFsp or libfuse3, so I compiled every
changed translation unit under the project's warning set with
-Werroragainst stub headers for theFUSE and Linux APIs, and ran the suites with a GoogleTest shim (core 87/87; agent 49 with 5
environment failures identical to an unmodified baseline built in a worktree). CI is the first real
build.
🤖 Generated with Claude Code