Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install dependencies
run: sudo apt-get update && sudo apt-get install -y ninja-build libfuse3-dev fuse3
run: sudo apt-get update && sudo apt-get install -y ninja-build libfuse3-dev fuse3 inotify-tools
- name: Build
run: |
cmake --preset linux-release
Expand All @@ -59,6 +59,16 @@ jobs:
git config --global user.email ci@example.com
git config --global user.name CI
bash scripts/fs-conformance.sh
# The other half of "the mount behaves like a filesystem": a change made on
# the far side has to raise a real inotify event here, or every watch-mode
# tool silently does nothing. Which kernel hook each operation runs is not
# observable from a unit test, so it can only be checked on a live mount.
# always(): the two batteries cover different things, and when the
# filesystem one fails the change-notification verdict is exactly what
# says whether the two failures share a cause.
- name: Run the change-notification battery
if: always()
run: bash scripts/inotify-conformance.sh

# The same idea for Direction A - the feature the product is named for. Mounts
# a real drive letter through WinFsp on the Windows runner and runs the Windows
Expand Down
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,28 @@

## Unreleased

### Added

- **File changes made on the Windows side now fire `inotify` inside WSL.** A Direction B mount
delivers far-side changes to ordinary Linux watchers, so `vite`, `nodemon`, `tsc --watch`,
`jest --watch`, `air` and `cargo-watch` react to an edit made from a Windows editor. Watchers need
no cooperation of any kind. Create, write, delete and rename each raise the matching event type,
and a rename arrives as a paired `IN_MOVED_FROM`/`IN_MOVED_TO` with one cookie rather than as an
unrelated delete and create. This is [microsoft/WSL#4739](https://github.com/microsoft/WSL/issues/4739),
which nothing has solved. Measured against a write made locally on the mount, it adds nothing:
**4 ms either way**, including a write deep inside a 10,000-file tree, and read/write throughput is
untouched because the bridge sits on the invalidation path rather than the data path. On by
default; `wsldrive mount --no-inotify` turns it off. How it works, and what it does not cover, is
in [`docs/inotify.md`](docs/inotify.md).
- `mknod` on a regular file now works on the mount, instead of failing with `ENOSYS`.

### Changed

- The wire protocol is at version 4. Every invalidation op carries what the watcher actually saw
(created, modified, removed, or one half of a move) alongside what the mirror should do about it,
plus a cookie pairing the two halves of a rename. A peer that ignores both fields stays correct.
The agent and the client must be the same version, as before.

### Performance

- **The mount lets the kernel cache file pages.** `auto_cache` replaces
Expand Down
28 changes: 25 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ driver of its own (WinFsp on Windows, libfuse3 in WSL). Full numbers in [`bench/
Desktop, Explorer/Search, Unity, Office) reach a WSL source tree without paying the `\\wsl.localhost`
Plan 9 tax.
- **Direction B — a Windows drive mounted inside WSL2.** Linux tools read/write an NTFS tree without the
`/mnt/c` 9P/virtiofs tax.
`/mnt/c` 9P/virtiofs tax — and a Windows-side edit fires `inotify`, so
[watch mode works](#watch-mode-works-inotify-across-the-boundary).

WSL2 only. WSL1 has no VM boundary (DrvFs runs in the NT kernel, `\\wsl$` is served in-process), so it
has neither problem and is unsupported by design.
Expand Down Expand Up @@ -120,7 +121,8 @@ Two small user-space binaries, no kernel drivers of wsldrive's own:
it for changes (`ReadDirectoryChangesW`+IOCP on Windows, inotify on Linux), pushing coalesced
invalidations.
- **`wsldrive`** — the *client*: mounts the served tree as a filesystem (WinFsp on Windows, libfuse3 in
WSL, one FUSE3 implementation) backed by an in-RAM metadata mirror and a content cache.
WSL, one FUSE3 implementation) backed by an in-RAM metadata mirror and a content cache. In WSL it
also turns each invalidation into a real `inotify` event, so watch-mode tools see far-side changes.

Either binary can host either role, so the same code serves both directions — the direction is just
which side runs the agent and which runs the mount.
Expand Down Expand Up @@ -188,6 +190,25 @@ explicit flag always wins over the probe.
A `.wsldriveignore` at the served root (gitignore-style: `node_modules/`, `*.log`, `/build`, …) excludes
paths from the mount and from sync.

### Watch mode works (`inotify` across the boundary)

Edit a file from a Windows editor and `vite`, `nodemon`, `tsc --watch`, `jest --watch`, `air` and
`cargo-watch` reload — on a Direction B mount, with no plugin, no preload and no polling.

They do not, on `/mnt/c` or anywhere else. A change made by a Windows application raises no `inotify`
event inside WSL2, so a Linux watcher never fires. Nothing errors; the tooling just quietly stops
reacting, which is why people lose an afternoon to it before finding
[microsoft/WSL#4739](https://github.com/microsoft/WSL/issues/4739).

wsldrive already carries every far-side change across as an invalidation, so the mount knows what
happened within milliseconds. It turns each one into a real kernel event by replaying the operation
on the mount itself — a write becomes an mtime-only `utimensat` (`IN_MODIFY`), a creation a `mknodat`
(`IN_CREATE`), a rename a `renameat`, so the two halves arrive paired under one cookie rather than as
an unrelated delete and create. The events are the kernel's own, so any watcher sees them.

On by default. `wsldrive mount --no-inotify` turns it off. Mechanism, guarantees and the two things
it does not cover: [`docs/inotify.md`](docs/inotify.md).

### What gets served (and what doesn't)

The default root is your **home directory** (`~`) — that is where the work is, and it keeps the tree
Expand Down Expand Up @@ -323,7 +344,8 @@ detected (Linux) — so CI and minimal builds are unaffected.
`src/core` — platform-independent library (metadata tree, string pool, framed protocol, coalescer,
auth token, ignore rules, name escaping, path utils), all unit-tested. `src/net` — sockets
(TCP/vsock/hvsocket) and the framed channel. `src/platform` — watchers and process launchers per OS.
`src/agent` — the scanner, `RootServer`, and the `RemoteRoot` client. `src/mount` — the FUSE3 mount.
`src/agent` — the scanner, `RootServer`, and the `RemoteRoot` client. `src/mount` — the FUSE3 mount
and the `inotify` bridge.
`src/tools` — the `wsldrive` and `wsldrived` binaries. `tests`, `bench`, `scripts` as named.

## Security
Expand Down
127 changes: 127 additions & 0 deletions docs/inotify.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# Change notification across the boundary

A file changed by a Windows application does not fire `inotify` inside WSL2. Nothing errors.
`vite`, `webpack --watch`, `nodemon`, `jest --watch`, `tsc --watch`, `air` and `cargo-watch` all
just sit there. It is [microsoft/WSL#4739](https://github.com/microsoft/WSL/issues/4739), and it is
the reason people who keep their source on the Windows side end up polling.

A wsldrive Direction B mount delivers those events. Editing a file from a Windows editor raises a
real `IN_MODIFY` on the mount, a new file raises `IN_CREATE` in its directory, a rename raises a
paired `IN_MOVED_FROM`/`IN_MOVED_TO`, and a deletion raises `IN_DELETE`. Watchers need no
cooperation: no preload, no plugin, no knowledge that wsldrive is involved.

It is on by default. `wsldrive mount --no-inotify` turns it off.

## Why it needs a mechanism at all

The kernel raises fsnotify events from the VFS, at the point an operation is performed.
`vfs_create` calls `fsnotify_create`, `vfs_unlink` calls `fsnotify_unlink`, `notify_change` calls
`fsnotify_change`. A filesystem cannot raise one itself, and FUSE offers nothing that does: the
`fuse_lowlevel_notify_*` calls invalidate dentries and pages, which is cache coherence, not
notification. `fuse_reverse_inval_entry` never touches fsnotify. So a userspace daemon has no way to
hand an inotify watcher an event, and this is why the problem has outlived so many attempts at it.

## What wsldrive does

It asks the kernel to raise the event, by performing on the mount the operation the far side already
performed. A thread inside the mount process replays each change as an ordinary syscall against the
mount's own path:

| far side | wsldrive replays | kernel raises |
|---|---|---|
| file written | `utimensat`, mtime only | `IN_MODIFY` |
| file created | `mknodat` (regular file) | `IN_CREATE` |
| directory created | `mkdirat` | `IN_CREATE` with `IN_ISDIR` |
| file deleted | `unlinkat` | `IN_DELETE` |
| directory deleted | `unlinkat(AT_REMOVEDIR)` | `IN_DELETE` with `IN_ISDIR` |
| renamed | `renameat` | `IN_MOVED_FROM` + `IN_MOVED_TO`, one cookie |

The `utimensat` detail matters. `fsnotify_change()` reports a change to *both* timestamps as
`FS_ATTRIB` and a change to mtime alone as `FS_MODIFY`, so touching only mtime is what turns a
far-side write into `IN_MODIFY` rather than the weaker `IN_ATTRIB`. And 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.

These operations must not cross the boundary a second time — the far side already has this state,
and re-applying it would at best waste a round trip and at worst destroy the file that prompted the
event. So the FUSE handlers recognise the bridge's own requests and answer them locally. That test
is deliberately over-determined, because the cost of getting it wrong is a user's `rm` silently not
removing anything: the request must come from the bridge's own thread, a poke of exactly that kind
must be in flight, and it must name exactly that path. A poke is claimed once and then retired.

The recognition has to cover every handler the poke can reach, which is not always the obvious one.
A `mknodat` of a regular file arrives as `FUSE_MKNOD`, but libfuse offers it 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 at `mknod` at all. Getting that wrong is not a missed event; it forwards the
poke as a genuine creation and replaces the file that prompted it with an empty one.

Which is why an unrecognised request from the bridge's thread is refused rather than forwarded. 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.

Two further guards keep the replay inside the mount. The bridge refuses to start unless its root is
a FUSE mount (`statfs` reports `FUSE_SUPER_MAGIC`), and every poke re-checks that its target's
parent is still on that mount's device. Both exist because the pokes are real filesystem calls: aimed
at the wrong tree they would create and delete real files there.

## Two small fictions

Both are visible only to the bridge's own thread; every other caller gets the mirror's real answer.

The invalidation is applied to the metadata mirror before the poke runs, so the mirror is already
telling the truth by the time the kernel looks — which is exactly wrong for making the kernel run
the operation. A creation would find the path already there and never reach `->mknod`; a deletion
would find nothing and never reach `->unlink`. So for the length of one syscall the mount reports a
newly created path as absent, and a newly deleted one as still present. Neither fiction outlives
its call: the create or unlink that follows settles the dentry, and the record is retired the moment
the handler claims it.

## What it costs

One syscall per changed path, answered by the FUSE loop out of the in-RAM mirror. No boundary
crossing and no I/O, so the added latency over a write made locally on the mount is the mount's own
round trip. `scripts/inotify-conformance.sh` measures both and prints them side by side; on a GitHub
Actions runner, with a Linux agent on loopback:

| change to event | |
|---|--:|
| write made locally on the mount | 4 ms |
| write made on the served side | 4 ms |

Each individual event type lands in the same 4 ms, including a write deep inside a 10,000-file tree.
Nothing measurable is added, which follows from where the work happens: 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. The bridge sits on the invalidation path, not the data
path, and adds no work to `read`, `write` or any other operation a tool performs on the mount.

The queue is bounded at 65536 pending changes. Past that the oldest are dropped, and `wsldrive mount`
says so on exit. A dropped notification is a late one, not a wrong one: the mirror and the page cache
are already correct, so the file reads right the moment anything looks at it.

## Limits

- **Direction B only.** Direction A does not have this problem: a WinFsp volume raises Windows change
notifications through its own mechanism.
- **An overflow cannot name paths.** When the far side's watcher loses events it sends a rescan, the
mirror is rebuilt from a fresh snapshot, and the bridge touches the mount root. A watcher that
re-walks on any event below its root will catch up; one waiting for a specific path will not be
told about it. Making an overflow name what changed means diffing the old tree against the new
snapshot, which is not implemented. In practice the Windows watcher's 1 MiB buffer keeps overflows
rare outside of bursts on the scale of an `npm install`.
- **`inotify` only.** `fanotify` marks are not delivered, and neither are the `IN_OPEN`, `IN_ACCESS`
or `IN_CLOSE` classes of event — nothing on the far side reports those, so there is nothing to
replay.
- **The mount's own watch limits apply.** `max_user_watches` bounds a recursive watcher on the mount
exactly as it would on any local filesystem; wsldrive neither raises nor consumes that budget.

## Checking it

`scripts/inotify-conformance.sh` mounts a tree, changes it from the serving side, and asserts that
each change raises the right event type on the mount within a timeout. It also checks the thing that
would be worst to get wrong — that the served tree is untouched by the bridge's own operations — and
exercises a recursive watch over a 10,000-file tree. CI runs it on every change.

To watch the event stream by hand without a mount, `wsldrive fetch --connect <endpoint> --watch`
prints each invalidation with its change kind and rename cookie.
Loading
Loading