Skip to content

inotify bridge: deliver Windows-side file changes to Linux watchers - #119

Merged
zcsizmadia merged 5 commits into
mainfrom
feat/inotify-bridge
Sep 13, 2026
Merged

zcsizmadia merged 5 commits into
mainfrom
feat/inotify-bridge

Conversation

@zcsizmadia

Copy link
Copy Markdown
Collaborator

Closes #118.

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
what 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 without
ever touching fsnotify — fuse_reverse_inval_entry has 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:

far side replayed as kernel raises
file written utimensat, mtime only IN_MODIFY
file created mknodat IN_CREATE
directory created mkdirat IN_CREATE with IN_ISDIR
deleted unlinkat / unlinkat(AT_REMOVEDIR) IN_DELETE
renamed renameat IN_MOVED_FROM + IN_MOVED_TO, one cookie

Touching only mtime is deliberate: fsnotify_change() maps a change to both timestamps to
FS_ATTRIB and to mtime alone to FS_MODIFY, so this is what yields IN_MODIFY rather than the
weaker IN_ATTRIB. Letting the kernel perform the rename is what produces a genuine move cookie, so
a 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 rm silently not deleting. The bridge also
refuses to start unless its root reports FUSE_SUPER_MAGIC, and every poke re-checks that its
target'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_PASSTHROUGH and no newer
libfuse, so it neither depends on that mount design nor conflicts with it.

Also

mknod on a regular file now works on the mount; it was unimplemented and failing with ENOSYS.

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.md records it alongside the other limits.

Testing

scripts/inotify-conformance.sh is 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 -Werror against stub headers for the
FUSE 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

zcsizmadia and others added 5 commits September 13, 2026 11:43
)

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>
@zcsizmadia

Copy link
Copy Markdown
Collaborator Author

What CI caught that local verification could not

Three defects, all in the delivery hop, all found on the runners rather than by reading:

  1. A dangling else. EXPECT_ expands to an if/else, so an unbraced if (cond) EXPECT_...
    trips -Wdangling-else. Braces around the enclosing loop do not help; they have to go around the
    assertion.
  2. The creation poke was forwarded, not claimed — the one that mattered. libfuse's FUSE_MKNOD
    handler offers a regular file to the create handler first and only falls back to mknod if
    that answers ENOSYS, so a mount with a create handler never sees the poke where it was being
    claimed. Forwarded, it became a genuine create, which replaced the file whose arrival prompted
    the notification with an empty one. That was the two failing external ... change is seen on re-open checks.
  3. An unrecognised poke was fail-dangerous. The guard fell through to forwarding, which is how
    (2) reached the agent at all. It now refuses instead: the bridge performs no genuine mutations,
    so a mutation from its thread is one of its own replays and nothing else. A refused poke costs a
    notification; a forwarded one costs data.

A fourth was found by inspection while those were in flight: a failed removal or move poke left the
ghost entry it had handed out cached, so a path the far side had removed would read as still present
until the entry cache let go. It is dropped explicitly now.

Worth noting that (2) and (3) were only diagnosable because the change-notification battery runs
with always() and the mount now names the pokes it could not deliver. Before that, every event
type failing and one event type failing looked identical from the outside.

Acceptance criteria from #118

All measured on the runner, in the Filesystem conformance (mounted) job.

criterion result
editing from the far side fires inotify on the mount write -> MODIFY
create, modify, delete, rename map to the right event types ✅ all six, including CREATE,ISDIR and DELETE,ISDIR
a rename arrives as a pair MOVED_FROM + MOVED_TO
recursive watch on a 10k+ tree without overflow ✅ 10,000 files, plus a deep write still notifying
added latency vs. a native Linux-side write, documented ✅ 4 ms either way
read/write throughput regression, documented ✅ none — the bridge is on the invalidation path, not the data path
vite, nodemon, cargo-watch hot-reload ⚠️ not automated; the event types those watchers key on are covered above, but no runner drives the tools themselves

Full battery: 31/31 filesystem checks and 12/12 change-notification checks, with the WinFsp mount
job green as well.

🤖 Generated with Claude Code

@zcsizmadia
zcsizmadia merged commit f6c82f7 into main Sep 13, 2026
6 checks passed
@zcsizmadia
zcsizmadia deleted the feat/inotify-bridge branch September 13, 2026 17:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

inotify bridge: deliver Windows-side file changes to Linux watchers

1 participant