diff --git a/.gitignore b/.gitignore index 5d8f085..efbb384 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,15 @@ +# build output build/ out/ +.cache/ + +# IDE / editor .vs/ .vscode/ -.cache/ *.user *.swp CMakeUserPresets.json -# transient logs +# transient logs / captured output *.log +*.err diff --git a/README.md b/README.md index 8692d81..f53396d 100644 --- a/README.md +++ b/README.md @@ -2,115 +2,176 @@ [![CI](https://github.com/zcsizmadia/wsldrive/actions/workflows/ci.yml/badge.svg)](https://github.com/zcsizmadia/wsldrive/actions/workflows/ci.yml) -Native-speed file access across the WSL2 boundary, in both directions: +**Cross the WSL2 filesystem boundary at native speed — in both directions.** -- **Direction A (primary):** mount a WSL2 ext4 path as a real Windows drive letter so - Windows-only tools (Visual Studio, GitHub Desktop, Explorer, Unity, ...) stop paying the - `\\wsl.localhost` Plan 9 tax. -- **Direction B (secondary, benchmark-gated):** a cached view of Windows drives inside WSL2. +Anyone doing real work in WSL2 hits the same wall: files on the *other* side of the VM boundary are +slow. Opening a WSL source tree from Visual Studio or Explorer crawls over `\\wsl.localhost`; a Linux +build reading `/mnt/c` stalls on every file. It's the single most common WSL2 complaint, and it taxes +every edit-build-test loop that spans the two worlds. -See [plan.md](plan.md) for the design, semantics table, benchmark targets and roadmap. +`wsldrive` makes that boundary disappear. It keeps the whole directory tree's metadata in RAM on the +side that's reading, caches file contents, and moves bytes over a **Hyper-V socket** instead of the OS's +network filesystem — so cross-boundary access runs **5–15× faster on reads** (more on metadata) than the +paths Windows and WSL ship: -## Status +| | today (OS path) | with wsldrive | speedup | +|---|--:|--:|--:| +| **Windows reading WSL files** (read 3000 files) | 2856 ms (`\\wsl.localhost`) | 366 ms | **~8×** | +| **WSL reading Windows files** (read 3000 files) | 5853 ms (`/mnt/c`) | 383 ms | **~15×** | +| directory walk, either direction | 100–400 ms | ~12–39 ms | **~3–30×** | -Early. The platform-independent core is implemented and tested: +Both directions are read-write and mount *alongside* the built-in paths — nothing to replace, no kernel +driver of its own (WinFsp on Windows, libfuse3 in WSL). Full numbers in [`bench/RESULTS.md`](bench/RESULTS.md). -| module | purpose | +- **Direction A — WSL2 ext4 as a Windows drive letter.** Windows-only tools (Visual Studio, GitHub + 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. + +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. + +## How it works + +Two small user-space binaries, no kernel drivers of wsldrive's own: + +- **`wsldrived`** — the *agent*: serves a directory tree (snapshot, file contents, writes) and watches + 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. + +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. + +**Metadata in RAM.** The whole tree (names, sizes, mtimes, mode→attributes) loads on mount and stays +coherent via pushed invalidations, so `stat`/`readdir`/`open`/negative lookups never cross the boundary. + +**Content cache + read-ahead.** Small files are cached whole in RAM (BLAKE3-addressed, LRU, invalidated +on change); on a read miss the file's directory is bulk-fetched in one `ReadMany` round-trip, so a +sequential reader pays one request per directory, not per file. + +**Writes** are write-through by default (durability = the source filesystem); `--writeback` coalesces a +file's writes and flushes on `fsync`/close for write-heavy work. + +### Transport + +The wire protocol is a small framed binary protocol (24-byte little-endian header + payload) over a +stream socket. Two transports: + +- **Hyper-V sockets** (`AF_HYPERV` on Windows, `AF_VSOCK` in WSL) — the fast path. WSL2 routes hvsocket + host→guest, so the **WSL side listens** (`vsock://any:`) and the **Windows side connects** + (`hv://{}:`). Not IP, so no firewall involvement. This is what makes the + WSL→Windows direction fast; see [Enabling hvsocket](#enabling-the-hyper-v-socket-transport). +- **Loopback TCP** — the fallback (works in NAT and mirrored WSL networking). Fast for Windows→WSL + (Direction A); slow for the WSL→Windows request path, which is why Direction B wants hvsocket. + +### Semantics (ext4 ↔ Windows) + +| ext4 | Windows view | |---|---| -| `core/metadata_tree` | in-RAM directory tree: O(1) child lookup, case-insensitive view, readdir, snapshots | -| `core/string_pool`, `core/flat_map` | interned names and an open-addressing (parent, name) → node index | -| `core/protocol` | 24-byte framed, little-endian wire format with zero-copy decoding | -| `core/coalescer` | collapses watcher event bursts into minimal ordered invalidation batches | -| `core/content_cache` | content-addressed (BLAKE3) on-disk cache with atomic writes and LRU eviction | -| `core/unicode`, `core/path` | locale-independent case folding, path normalisation | +| case-sensitive names | case-preserving; case-colliding siblings flagged, the shadowed one hidden | +| symlinks | reported as symlinks/reparse points | +| mode bits | synthesised attributes (`READONLY` when no write bit); no ACL emulation | +| ns mtime | truncated to 100 ns | +| hard links | reported as independent files (`st_nlink` not preserved) — known limitation | +| names illegal on Windows (`: ? * < > \| "`, control chars, trailing dot/space) | mapped to U+F0xx (WSL/Cygwin convention), round-tripping | +| sockets / fifos / devices | hidden | + +A `.wsldriveignore` at the served root (gitignore-style: `node_modules/`, `*.log`, `/build`, …) excludes +paths from the mount and from sync. + +**Non-goals:** replacing `/mnt/c` (wsldrive mounts alongside it); general-purpose bidirectional sync; +kernel drivers or signing beyond WinFsp's. -### Mounting (Direction A) +## Usage -With [WinFsp](https://winfsp.dev) installed (runtime **and** the Developer/SDK feature), a WSL2 -ext4 tree can be mounted as a Windows drive letter: +### Direction A — mount a WSL ext4 tree as a Windows drive + +Requires [WinFsp](https://winfsp.dev) (runtime **and** the Developer/SDK feature). From any PowerShell: ```powershell -# One command: wsldrive launches the agent inside the distro and mounts it. -wsldrive mount W: --distro Ubuntu --wsl-root ~/project +wsldrive doctor # check WinFsp + WSL +wsldrive mount W: --distro Ubuntu --wsl-root ~/project # launches the agent in the distro, mounts W: +wsldrive mount W: --distro Ubuntu --wsl-root ~/project --hvsocket # ... over Hyper-V sockets +``` + +`--distro` auto-launches `wsldrived` in the distro over `wsl.exe`, mounts, and tears it down on exit +(the agent self-terminates if the Windows side goes away). Ctrl+C unmounts cleanly. To attach to an +agent you started yourself: `wsldrive mount W: --connect tcp://127.0.0.1:7788`. + +### Direction B — mount a Windows drive inside WSL -# Check the environment first (WinFsp + WSL): -wsldrive doctor +One command, run in WSL (launches the Windows agent via interop and mounts over hvsocket): -# Or attach to an already-running agent: -# in WSL: wsldrived --root ~/project --listen tcp://0.0.0.0:7788 -# on Windows: wsldrive mount W: --connect tcp://127.0.0.1:7788 +```bash +wsldrive mount /tmp/win --win-root 'C:/project' \ + --win-agent /mnt/c/path/to/wsldrived.exe --hvsocket ``` -With `--distro`, `wsldrive` starts `wsldrived` in the distro over `wsl.exe`, waits for it to -listen, mounts, and tears the agent down on exit (the agent also self-terminates if the Windows -side goes away). Ctrl+C unmounts cleanly. - -Add `--hvsocket` to use the **Hyper-V socket transport** instead of loopback TCP — the agent listens -on `vsock` and the client connects over `AF_HYPERV`, bypassing the localhost relay. This is what makes -the WSL→Windows direction fast (see `bench/RESULTS.md`). One-time setup: run -`scripts/register-hvsocket.ps1` elevated and `wsl --shutdown`. The WSL VM GUID is discovered via -`hcsdiag` (Hyper-V admin) or passed with `--vm-guid`. - -Drop a `.wsldriveignore` at the served root (gitignore-style: `node_modules/`, `*.log`, `/build`, ...) -to exclude directories from the mounted view and from sync — handy for keeping build output and vendored -trees off the drive. - -Metadata (`dir`, `stat`, directory listing) is served from the client's in-RAM mirror with no -round-trips; file contents are fetched over the socket on demand; live edits in WSL propagate via -pushed invalidations. **Read-write**: create, write, append, truncate, mkdir, rename, unlink and rmdir -on the drive letter are written through to ext4 (verified by mounting a WSL tree, writing from Windows, -and reading the bytes back inside WSL). ext4 names illegal on Windows (`: ? * < > | "`, control chars, -a trailing dot/space) are mapped to the Unicode private-use area (the WSL/Cygwin convention) so they -appear and round-trip on the drive. Pass `--writeback` to coalesce a file's writes and flush them on -`fsync`/close (fewer round-trips for write-heavy work such as build output; durability is at close -rather than per write). **Hardlinks** are currently reported as independent files (`st_nlink` is not -preserved) — a known limitation. Built via the FUSE3 API, so the same mount serves Linux -(Direction B) later. The mount target builds only when WinFsp is detected, so CI and non-Windows builds -are unaffected. Set `WSLDRIVE_FUSE_DEBUG=1` to log WinFsp FUSE operations. - -### Direction B (Windows drive cached in WSL, experimental) - -The mount is built on the FUSE3 API, so the same implementation runs in WSL via libfuse3: +Or attach to an agent started on Windows yourself: ```bash -# on Windows: serve a Windows path -wsldrived.exe --root C:\path\to\project --listen tcp://0.0.0.0:7788 -# in WSL: mount it -wsldrive mount /mnt/win --connect tcp://:7788 +# on Windows: wsldrived.exe --root C:\project --listen tcp://0.0.0.0:7788 +# in WSL: wsldrive mount /tmp/win --connect tcp://127.0.0.1:7788 ``` -It is functional (read/write across the transport, live invalidations via the Windows-side -`ReadDirectoryChangesW` watcher) and verified end-to-end. It is **gated and experimental**: reads -currently go over the socket rather than a local cache, so it does not yet beat `virtiofs=true` for -`/mnt/c`. For fast Linux access to Windows files today, enable virtiofs; Direction B becomes a -worthwhile replacement once the read-cache layer lands. +### Enabling the Hyper-V socket transport + +WSL2 only routes registered hvsocket services, so once per machine: + +1. `powershell -ExecutionPolicy Bypass -File scripts\register-hvsocket.ps1` **(elevated)** — registers + the vsock service GUIDs (ports 5700–5709). +2. `wsl --shutdown` so the VM picks them up. +3. The WSL VM GUID is discovered automatically via `hcsdiag` (needs Hyper-V admin) or passed with + `--vm-guid `. -Platform layers (Hyper-V socket transport) continue in later phases. +## Benchmarks -### Baseline micro-benchmarks +Real-world cross-boundary numbers from `scripts/bench-real.ps1` on this dev machine (3000 small files +across 60 dirs; median warm run, ms; "native" = the same workload run locally on the owning OS — the +floor). Full methodology and notes in [`bench/RESULTS.md`](bench/RESULTS.md). -`core_bench`, MSVC 19.42 RelWithDebInfo, 20-thread laptop CPU @ 2.8 GHz, 2026-08-27. Synthetic tree = -100 modules × 10 components × 100 files (100k files, 1.1k directories). +**Direction A — Windows reading a WSL2 ext4 tree:** + +| scenario | walk (enumerate) | read (all bytes) | +|---|--:|--:| +| native (in WSL, reference) | 4 ms | 39 ms | +| `\\wsl.localhost` (Plan 9) | 104 ms | 2856 ms | +| **wsldrive drive** | **39 ms** | **cold 574 / warm 366 ms** | + +**Direction B — WSL reading a Windows NTFS tree:** + +| scenario | walk (enumerate) | read (all bytes) | +|---|--:|--:| +| native (on Windows, reference) | 10 ms | 118 ms | +| `/mnt/c` (9P) | 119 ms | 5853 ms | +| `/mnt/c` (virtiofs=true) | 406 ms | 7686 ms | +| **wsldrive over hvsocket** | **12 ms** | **cold 621 / warm 383 ms** | + +So ~2.7× walk / ~5–8× read vs `\\wsl.localhost` (Direction A), and ~10× vs both `/mnt/c` transports +(Direction B) — with near-native metadata in both directions. Core micro-benchmarks (`core_bench`, +RelWithDebInfo): | operation | time | |---|---| -| path lookup, 3 components, exact hit | ~150–180 ns | -| path lookup, case-insensitive, wrong case | ~220 ns | -| negative lookup (name unknown anywhere) | ~140 ns | -| negative lookup (name exists in another dir) | ~100 ns | -| readdir, 100 entries (attrs + names) | ~150 ns | -| upsert existing path / insert+remove churn | ~230 ns / ~130 ns | +| path lookup, 3 components | ~150 ns | +| readdir, 100 entries | ~150 ns | | (parent,name)→node probe, 1M entries, hit / miss | 15 ns / 14 ns (`std::unordered_map`: 44 ns) | -| snapshot encode / streaming decode, 100k entries | 2.5 ms / 3.2 ms (36 bytes per entry) | -| snapshot decode + build tree, 100k entries | ~27 ms | +| snapshot encode / decode, 100k entries | 2.5 ms / 3.2 ms (36 bytes/entry) | | coalesce 30k watcher events (10k-file checkout) | ~7 ms | -| BLAKE3, 1 MiB / 16 MiB | 4.4 GiB/s | +| BLAKE3 | 4.4 GiB/s | -Run them yourself with `.\scripts\build.ps1 -Bench`. +Run them with `.\scripts\build.ps1 -Bench`, or the end-to-end suite with `.\scripts\bench-real.ps1`. ## Build -Windows (Visual Studio 2022, from any PowerShell): +Modern C++23, CMake ≥ 3.28 + presets; dependencies (GoogleTest, Google Benchmark, BLAKE3) are fetched by +CMake with pinned hashes. Builds warning-clean under `/W4` (MSVC) and `-Wall -Wextra -Wpedantic +-Wconversion …` (GCC), warnings-as-errors including the linker. + +Windows (Visual Studio 2022): ```powershell .\scripts\build.ps1 # configure + build + unit tests (RelWithDebInfo) @@ -120,8 +181,17 @@ Windows (Visual Studio 2022, from any PowerShell): WSL2 (Ubuntu 22.04+ / Debian 12+): ```bash -sudo ./scripts/wsl-setup.sh +sudo ./scripts/wsl-setup.sh # build-essential, cmake, ninja, libfuse3-dev cmake --preset linux-release && cmake --build --preset linux-release && ctest --preset linux-release ``` -Dependencies (GoogleTest, Google Benchmark, BLAKE3) are fetched by CMake at configure time. +The WinFsp mount builds only when WinFsp is detected (Windows); the libfuse3 mount only when libfuse3 is +detected (Linux) — so CI and minimal builds are unaffected. + +## Layout + +`src/core` — platform-independent library (metadata tree, string pool, framed protocol, coalescer, +content cache, 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/tools` — the `wsldrive` and `wsldrived` binaries. `tests`, `bench`, `scripts` as named. diff --git a/bench/RESULTS.md b/bench/RESULTS.md index c658597..4b56c1f 100644 --- a/bench/RESULTS.md +++ b/bench/RESULTS.md @@ -38,8 +38,8 @@ than both 9P and virtiofs** on reads and near-native on metadata — well past t `/mnt/c` reads are ~50-65× slower than native — the well-known WSL pain. Notably, on this machine **`virtiofs=true` did not beat 9P** for these workloads (walk 406 ms, read 7686 ms; five warm runs stayed 5.9-9.8 s), and it -shows no persistent read cache. So the Direction B bar the plan gates against is -still multi-second — leaving clear room for wsldrive to win once cold reads are +shows no persistent read cache. So the Direction B bar (virtiofs) is still +multi-second — leaving clear room for wsldrive to win once cold reads are addressed (metadata is already RAM-served; warm reads are cache hits). The wsldrive FUSE mount is functional (mounts, reads, writes; verified end-to-end) and serves **all metadata from the client's in-RAM mirror**, so `walk`/`stat` @@ -63,11 +63,21 @@ agent). Setup: 1. Register the vsock service GUIDs once, elevated (see `scripts/register-hvsocket.ps1`) — WSL2 only routes registered services. 2. Restart WSL (`wsl --shutdown`) so the VM picks up the registered services. -3. Find the WSL VM GUID (elevated / Hyper-V admin): `hcsdiag list`. -Auto-discovering the VM GUID and a one-flag `--hvsocket` UX (so the manual GUID -and port aren't needed) is the remaining productionization step. Direction A is -already fast over plain loopback TCP and does not require this. +The `--hvsocket` flag then wires everything: the VM GUID is auto-discovered via +`hcsdiag` (or passed with `--vm-guid`), a registered vsock port is chosen, the +agent is auto-launched, and the client connects over `AF_HYPERV`: + +```powershell +# Direction A (on Windows): mount a WSL tree over hvsocket +wsldrive mount W: --distro Ubuntu --wsl-root ~/project --hvsocket +``` +```bash +# Direction B (in WSL): mount a Windows tree over hvsocket +wsldrive mount /tmp/win --win-root 'C:/project' --win-agent /mnt/c/.../wsldrived.exe --hvsocket +``` + +Direction A is also fast over plain loopback TCP and does not require hvsocket. ## Performance work landed diff --git a/build.err b/build.err deleted file mode 100644 index e69de29..0000000 diff --git a/plan.md b/plan.md deleted file mode 100644 index cd67f05..0000000 --- a/plan.md +++ /dev/null @@ -1,120 +0,0 @@ -# wsldrive - -**One line:** Native-speed file access across the WSL2 boundary in both directions: mount a WSL2 distro path as a real Windows drive letter (primary — Visual Studio, GitHub Desktop, Explorer/Search, Unity, Office on ext4 trees), and a cached view of Windows drives inside WSL (secondary — Linux builds/tools on NTFS trees, shipped only where it measurably beats virtiofs). - -## Scope - -**WSL2 only.** WSL1 has no VM boundary (DrvFs runs in the NT kernel and `\\wsl$` is served in-process), so neither direction has the problem this project solves; WSL1 is unsupported by design. Requires WSL 2.x on Windows 11. - -## Problem - -WSL2 crosses the VM boundary in two directions, and they are not in the same state: - -| Direction | Today | Outlook | -|---|---|---| -| Linux → Windows (`/mnt/c`) | Plan 9 over hvsocket by default; **virtiofs available (`virtiofs=true` in `.wslconfig`)** and actively optimized by Microsoft (per-device DMA pools, May 2026). "Closed most of the gap." | Being solved upstream. **Secondary target**: cached FUSE view, kept only if benchmarks beat virtiofs; also covers hosts without virtiofs. | -| Windows → Linux (`\\wsl.localhost\`) | Plan 9 server in the distro + `p9rdr.sys` redirector. Every op is a serialized round trip. VS project open takes minutes; GitHub Desktop 10–20× slower; indexers can crash it. No virtiofs equivalent announced. | **Unserved gap. Primary target.** | - -The standard advice ("keep files in ext4, use VS Code Remote-WSL") covers editors that can run their backend in Linux. It does nothing for tools that must run on Windows. - -## Why not existing approaches - -- **Ext4Windows / lwext4+WinFsp:** mounts the raw ext4 disk. Cannot be used safely while WSL has the VHDX mounted; no coherence with a running distro. -- **Mutagen / Unison / Syncthing / lsyncd:** generic bidirectional sync; duplicate trees, conflict semantics, no WSL-native transport, Go/OCaml runtimes. Mutagen has no first-class WSL transport. -- **`\\wsl.localhost` itself:** correct but slow; we keep it as the coherence source of truth, not as the data path. - -## Architecture - -Two user-space components, no kernel drivers of our own. Each binary contains both a **server** role (watch + serve the local filesystem) and a **client** role (present a cached view of the remote one); the direction is just which role is active. One protocol, one cache implementation, one ignore/prefetch engine. - -### Direction A (primary): ext4 tree → Windows drive letter - -### Windows: `wsldrive.exe` (WinFsp filesystem) -- Presents `:\` (or a mount-point folder) for a configured ext4 root, e.g. `~/src`. -- **Metadata entirely in RAM**: full tree (names, sizes, mtimes, mode→attributes) loaded on mount and kept coherent by invalidations. `stat`/`readdir`/`open`/negative lookups never touch the VM. Negative lookups are cached (Windows tools probe thousands of nonexistent paths). -- **Content cache on NTFS** (`%LOCALAPPDATA%\wsldrive\cache`), content-addressed by BLAKE3. Unchanged files never re-transfer across remounts. -- **Prefetch**: on first access to a directory, stream the whole subtree in bulk (one request, many files), honoring `.gitignore`/`.wsldriveignore`. Configurable depth/size caps. -- **Writes**: write-through by default (durability = ext4). Opt-in per-path **write-back** with a local journal for build outputs / caches. -- **Oplocks / change notification**: WinFsp change notifications so Explorer, VS, and indexers update without polling. - -### WSL: `wsldrived` (Linux agent) -- Serves bulk tree snapshots, file content by hash, and applies writes. -- Watches with **fanotify `FAN_MARK_FILESYSTEM` + `FAN_REPORT_DFID_NAME`** (whole-filesystem, no per-directory watch limits; needs root — install as a systemd unit). inotify fallback for non-root. -- Coalesces events (checkout of 10k files → one batched invalidation), pushes over the transport. -- Periodic + on-reconnect full reconcile (scan → hash compare) to recover from missed events. - -### Transport -- **Hyper-V sockets** (`AF_HYPERV` on Windows, `AF_VSOCK` in WSL) — this is the one place where latency matters: invalidations must land before the next Windows `stat`. Length-prefixed binary frames, zero-copy where possible, multiplexed streams. -- localhost TCP fallback (works in NAT and mirrored networking modes) for debugging and unusual setups. -- Discovery via `wsl.exe -d -- wsldrived --handshake` at mount time. - -### Coherence model -- Source of truth is ext4. Windows sees ext4 within one invalidation round trip (target < 5 ms). -- Concurrent write to the same file from both sides in write-back mode: last writer wins, the loser is preserved as `name.conflict-`. Write-through mode has no conflicts (ext4 serializes). - -### Semantics mapping (must be documented and tested) -| ext4 | Windows view | -|---|---| -| Case-sensitive names | Case-preserving; colliding names (`Foo`/`foo`) flagged, second entry hidden with a warning | -| Symlinks | Reparse points where target is inside the mount; otherwise plain file containing target (configurable) | -| Mode bits | Synthesized attributes (`READONLY` when no write bit); no ACL emulation in v1 | -| ns mtime | Truncated to 100 ns FILETIME | -| Hard links | Reported as separate files (same content hash) | -| Names invalid on Windows (`:`, `?`, trailing dots) | Escaped (`\u{f03a}`-style like Cygwin/WSL) | -| Unix sockets/FIFOs/devices | Hidden | - -### Direction B (secondary): Windows drive → cached view inside WSL - -- **WSL: FUSE mount** (e.g. `/w/c` beside `/mnt/c`; libfuse3, low-level API, `direct_io` off, kernel attr/entry caching on). Metadata + negative lookups in RAM; content cache on ext4 under `~/.cache/wsldrive`, BLAKE3-addressed. Reads served locally; writes pass through to NTFS via the Windows agent (write-through), with the same opt-in write-back journal for build outputs. -- **Windows: watcher** — `ReadDirectoryChangesW` on the shared roots, IOCP-driven (WIL/tokio), `FILE_NOTIFY_INFORMATION` coalesced into batched invalidations. Buffer overflow (`ERROR_NOTIFY_ENUM_DIR`) → subtree rescan. USN journal as an optional faster/more robust source on NTFS/ReFS volumes. -- **Same transport and coherence model** as Direction A; source of truth is NTFS. -- **Gate:** ships only if it beats `virtiofs=true` by ≥ 2× on `git status` and incremental build workloads in the benchmark suite. If virtiofs wins, Direction B degrades to "documentation + ignore/prefetch tooling on top of virtiofs" rather than a FUSE layer. -- Additional semantics: NTFS case-insensitivity → exposed case-sensitively as stored (`git config core.ignorecase` guidance); Windows attributes → mode bits (READONLY → 0444), executable bit synthesized from extension list / `.gitattributes`; junctions/reparse points → symlinks where target is inside the mount. - -### Optional mirror mode (fallback) -For tools that refuse WinFsp volumes, mirror an ext4 tree one-directionally into a real NTFS folder using the same agent, watcher, and hash index. Same ignore rules. This is the only place the original "sync engine" lives, and it is not the core product. - -## Explicit non-goals (v1) -- Replacing `/mnt/c` itself: Direction B mounts alongside it, never over it. -- Bidirectional general-purpose sync. -- io_uring, SQLite, rolling-chunk delta transfer. Source trees are small files; whole-file transfer by hash is enough. Add chunked delta only if large-file workloads demand it. -- Kernel drivers or code signing beyond WinFsp's. - -## Technology -- **Language:** Modern C++23 (decided). One shared `core` library (metadata index, protocol, cache, coalescer, ignore rules) that is fully platform-independent and unit-tested; thin platform layers (WinFsp + `ReadDirectoryChangesW`/IOCP + `AF_HYPERV` on Windows; libfuse3 + fanotify + `AF_VSOCK` on Linux). Dependencies via CMake FetchContent with pinned SHA256: BLAKE3 1.5.5, GoogleTest 1.15.2, Google Benchmark 1.9.1 (WinFsp SDK and WIL added when the Windows platform layer lands). No exceptions on hot paths, `std::expected` for errors, `std::span`/`string_view` views over arena-owned data, no per-op heap allocation in lookup/readdir. -- **Build:** CMake ≥ 3.28 presets (`scripts/build.ps1` enters the VS dev shell); MSVC 2022 on Windows, Clang/GCC in WSL; `ctest` for unit tests, Google Benchmark micro-benchmarks + scripted end-to-end workload benchmarks. -- **Packaging:** winget/MSIX for `wsldrive.exe` (bundles WinFsp dependency), `.deb`/tarball + systemd unit for `wsldrived`; `wsldrive install` pushes the agent into the distro automatically. -- **Requirements:** Windows 11 (WinFsp 2.x), WSL 2.x with kernel ≥ 5.9 (fanotify DFID), libfuse3 in the distro for Direction B, Hyper-V socket support (present in all WSL2). - -## Performance targets (published benchmarks, measured before/after) -Workloads, each run on `\\wsl.localhost`, `wsldrive`, and native NTFS: -1. Visual Studio: open a 2k-file C# solution, full IntelliSense ready. -2. Git for Windows / GitHub Desktop: `git status` on a 50k-file repo with warm cache. -3. Windows Search indexing of a 100k-file tree. -4. `dir /s` and Explorer folder open on `node_modules`. -5. Write burst: `npm ci` output written from Windows (write-through vs write-back). - -Direction B, each run on `/mnt/c` (9P), `/mnt/c` (`virtiofs=true`), `wsldrive` FUSE, and native ext4: -6. `git status` on a 50k-file repo from WSL. -7. `npm ci` / `cargo build` incremental rebuild with sources on NTFS. -8. `find . -type f | wc -l` and `rg` over a 100k-file tree. - -Targets: metadata ops within 1.5× the native filesystem on that side; cached reads within 1.2× native; invalidation latency p99 < 5 ms; RSS < 150 MB per side for a 500k-file tree (metadata is the dominant cost; ~8 MB claims are not credible and are dropped). Direction B additionally must beat virtiofs by ≥ 2× on workloads 6–7 to ship. - -## Roadmap -1. **Spike (2–3 weeks):** WinFsp read-only mount fed by a full snapshot over TCP; measure VS open and `git status`. Go/no-go on the numbers. -2. **v0.1:** hvsocket transport, fanotify invalidations, content cache, write-through, ignore rules, semantics table implemented + tested. -3. **v0.2:** write-back journal, prefetch heuristics, change notifications, installer, multi-distro/multi-mount. -4. **v0.3 — Direction B spike:** FUSE read cache + `ReadDirectoryChangesW` watcher reusing the v0.1 protocol/cache; benchmark against `virtiofs=true`; ship or shelve on the ≥ 2× gate. -5. **v0.4:** mirror mode, telemetry-free diagnostics (`wsldrive doctor`), CI benchmark suite. - -## Risks -- **Microsoft ships a fast Windows→Linux path** (virtiofs-like for `\\wsl.localhost`). Nothing announced; if it happens, mirror mode and the drive-letter/ignore/prefetch UX still have value, but the core advantage shrinks. Mitigate by shipping the spike fast. -- **WinFsp compatibility** with specific tools (installers, some antivirus). Mirror mode is the escape hatch. -- **fanotify requires root** in the distro. inotify fallback with watch-count guidance. -- **Direction B upside shrinks** as virtiofs improves; it is explicitly gated and shares ~80% of its code with Direction A, so the sunk cost if shelved is small. -- **FUSE overhead** in the WSL kernel (context switches per op) may eat the gain for tiny files; mitigate with aggressive kernel attr/entry caching and `FUSE_PASSTHROUGH` (kernel ≥ 6.9) for cached reads. -- **Case/symlink edge cases** breaking git on the Windows side. Covered by the semantics test suite from day one. - -## Naming -`wsldrive` is descriptive and searchable ("mount WSL as a drive"); with Direction B the name still fits (a drive on either side). `WSLBridgeFS` collides in spirit with the established `wslbridge` (pty bridge) and a port-forwarding tool of the same name, and "bridge" reads as networking. Alternative brandable name: `Portside`. Availability checked 2026-08-27: no GitHub repository named or mentioning `wsldrive`, crate name free on crates.io; no winget package (checked). Spelling is all-lowercase everywhere: binaries `wsldrive.exe` / `wsldrived`, prose "wsldrive". diff --git a/scripts/bench/mount-listen.sh b/scripts/bench/mount-listen.sh deleted file mode 100644 index 9bf903a..0000000 --- a/scripts/bench/mount-listen.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash -# mount-listen.sh : start a wsldrive mount that WAITS for the agent -# to dial in (client listens). Used for Direction B so a Windows agent can reach -# the WSL client via localhost forwarding without a WSL->host firewall opening. -cli="$1"; port="$2" -mkdir -p /tmp/benchB -nohup "$cli" mount /tmp/benchB --listen "tcp://0.0.0.0:$port" >/tmp/benchB.log 2>&1 & -echo started diff --git a/src/platform/CMakeLists.txt b/src/platform/CMakeLists.txt index 1fb7184..a47672b 100644 --- a/src/platform/CMakeLists.txt +++ b/src/platform/CMakeLists.txt @@ -8,7 +8,8 @@ if(WIN32) win/wsl_launch.cpp) else() target_sources(wsldrive_platform PRIVATE - linux/dir_watcher.cpp) + linux/dir_watcher.cpp + linux/win_launch.cpp) endif() target_link_libraries(wsldrive_platform PUBLIC wsldrive::core) diff --git a/src/platform/linux/win_launch.cpp b/src/platform/linux/win_launch.cpp new file mode 100644 index 0000000..8d05a94 --- /dev/null +++ b/src/platform/linux/win_launch.cpp @@ -0,0 +1,80 @@ +#include "platform/linux/win_launch.hpp" + +#include +#include + +#include +#include +#include +#include + +namespace wsld::platform { + +namespace { +std::string first_guid(std::string_view s) { + auto is_hex = [](char c) { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + }; + for (std::size_t i = 0; i + 36 <= s.size(); ++i) { + bool ok = true; + for (std::size_t j = 0; j < 36 && ok; ++j) { + const char c = s[i + j]; + ok = (j == 8 || j == 13 || j == 18 || j == 23) ? (c == '-') : is_hex(c); + } + if (ok) return std::string(s.substr(i, 36)); + } + return {}; +} +} // namespace + +std::string build_win_agent_command(const WinAgentSpec& spec) { + return spec.exe + " --root " + spec.win_root + " --connect " + spec.connect + " --exit-when-idle"; +} + +std::string discover_wsl_vm_guid() { + std::FILE* p = ::popen("hcsdiag.exe list 2>/dev/null", "r"); + if (p == nullptr) return {}; + std::string out; + char buf[512]; + std::size_t n; + while ((n = std::fread(buf, 1, sizeof(buf), p)) > 0) out.append(buf, n); + ::pclose(p); + std::size_t start = 0; + std::string fallback; + for (std::size_t i = 0; i <= out.size(); ++i) { + if (i == out.size() || out[i] == '\n') { + const std::string_view line(out.data() + start, i - start); + if (const std::string g = first_guid(line); !g.empty()) { + if (line.find("WSL") != std::string_view::npos) return g; + if (fallback.empty()) fallback = g; + } + start = i + 1; + } + } + return fallback; +} + +WinAgent::~WinAgent() { stop(); } + +Result WinAgent::start(const WinAgentSpec& spec) { + const pid_t pid = ::fork(); + if (pid < 0) return fail(Errc::IoError); + if (pid == 0) { + // Child: run the Windows agent via WSL interop (binfmt routes the .exe). + ::execl(spec.exe.c_str(), spec.exe.c_str(), "--root", spec.win_root.c_str(), "--connect", spec.connect.c_str(), + "--exit-when-idle", static_cast(nullptr)); + _exit(127); + } + pid_ = pid; + return {}; +} + +void WinAgent::stop() { + if (pid_ <= 0) return; + ::kill(pid_, SIGTERM); + int status = 0; + ::waitpid(pid_, &status, 0); + pid_ = -1; +} + +} // namespace wsld::platform diff --git a/src/platform/linux/win_launch.hpp b/src/platform/linux/win_launch.hpp new file mode 100644 index 0000000..0c1313e --- /dev/null +++ b/src/platform/linux/win_launch.hpp @@ -0,0 +1,42 @@ +#pragma once + +#include "core/error.hpp" + +#include + +namespace wsld::platform { + +/// How to launch the Windows agent (wsldrived.exe) from inside WSL via interop, +/// for Direction B (WSL mounts a Windows path). The agent serves a Windows path +/// and dials back to this WSL client's listener. +struct WinAgentSpec { + std::string exe; // Linux-visible path to wsldrived.exe, e.g. /mnt/c/.../wsldrived.exe + std::string win_root; // the Windows path to serve, e.g. C:\project + std::string connect; // endpoint the agent connects to (this client's listener) +}; + +/// Builds the argv (space-joined, for logging/tests) used to launch the agent. +/// Pure — no process is spawned. +[[nodiscard]] std::string build_win_agent_command(const WinAgentSpec& spec); + +/// Best-effort discovery of the running WSL2 VM's GUID from inside WSL, by +/// running `hcsdiag.exe list` over interop. Returns "" if unavailable (e.g. the +/// Windows side is not a Hyper-V administrator). AF_HYPERV needs this GUID. +[[nodiscard]] std::string discover_wsl_vm_guid(); + +/// Launches and supervises the Windows agent via interop. +class WinAgent { + public: + WinAgent() = default; + ~WinAgent(); + WinAgent(const WinAgent&) = delete; + WinAgent& operator=(const WinAgent&) = delete; + + [[nodiscard]] Result start(const WinAgentSpec& spec); + void stop(); + + private: + int pid_ = -1; +}; + +} // namespace wsld::platform diff --git a/src/tools/wsldrive_main.cpp b/src/tools/wsldrive_main.cpp index 0680eff..105568b 100644 --- a/src/tools/wsldrive_main.cpp +++ b/src/tools/wsldrive_main.cpp @@ -19,6 +19,7 @@ #include "platform/win/wsl_launch.hpp" #endif #if defined(WSLDRIVE_HAVE_MOUNT) && !defined(_WIN32) +#include "platform/linux/win_launch.hpp" #include #endif @@ -42,6 +43,10 @@ void usage() { #ifdef WSLDRIVE_HAVE_MOUNT " wsldrive mount --connect [--writeback] (attach to a running agent)\n" #endif +#if defined(WSLDRIVE_HAVE_MOUNT) && !defined(_WIN32) + " wsldrive mount --win-root --win-agent [--hvsocket [--vm-guid G]]\n" + " (Direction B: serve a Windows path into WSL)\n" +#endif #ifdef WSLDRIVE_HAVE_WINFSP " wsldrive mount --distro --wsl-root [--agent ] [--port N]\n" " (auto-launch the agent in the distro)\n" @@ -154,7 +159,7 @@ int main(int argc, char** argv) { return 2; } const std::string mountpoint = argv[2]; - std::string connect, listen, distro, wsl_root, agent_path; + std::string connect, listen, distro, wsl_root, agent_path, win_root, win_agent; std::uint32_t port = 51789; bool have_distro = false; bool writeback = false; @@ -171,6 +176,10 @@ int main(int argc, char** argv) { hvsocket = true; else if (a == "--vm-guid") vm_guid = val(); + else if (a == "--win-root") + win_root = val(); + else if (a == "--win-agent") + win_agent = val(); else if (a == "--writeback") writeback = true; else if (a == "--distro") { @@ -225,7 +234,7 @@ int main(int argc, char** argv) { if (hvsocket) std::this_thread::sleep_for(std::chrono::milliseconds(2500)); } else #endif - if (connect.empty() && listen.empty()) { + if (connect.empty() && listen.empty() && win_root.empty()) { usage(); return 2; } @@ -244,7 +253,36 @@ int main(int argc, char** argv) { (void)port; (void)hvsocket; (void)vm_guid; + (void)win_root; + (void)win_agent; (void)have_distro; + +#if defined(WSLDRIVE_HAVE_MOUNT) && !defined(_WIN32) + // Direction B: this WSL client listens and auto-launches the Windows agent + // (via interop) to serve a Windows path and dial back in. + std::string win_agent_connect; + wsld::platform::WinAgent win_proc; + if (!win_root.empty()) { + if (win_agent.empty()) { + std::fprintf(stderr, "wsldrive: --win-agent is required with --win-root\n"); + return 2; + } + if (hvsocket && port == 51789) port = 5700; + if (hvsocket) { + std::string guid = vm_guid.empty() ? wsld::platform::discover_wsl_vm_guid() : vm_guid; + if (guid.empty()) { + std::fprintf(stderr, "wsldrive: could not determine the WSL VM GUID; pass --vm-guid \n"); + return 2; + } + listen = "vsock://any:" + std::to_string(port); + win_agent_connect = "hv://{" + guid + "}:" + std::to_string(port); + } else { + listen = "tcp://0.0.0.0:" + std::to_string(port); + win_agent_connect = "tcp://127.0.0.1:" + std::to_string(port); + } + } +#endif + std::string source; wsld::Result sock = wsld::fail(wsld::Errc::ConnectionClosed); if (!listen.empty()) { @@ -264,6 +302,18 @@ int main(int argc, char** argv) { source = l->local().to_string(); std::printf("waiting for agent on %s ...\n", source.c_str()); std::fflush(stdout); +#if defined(WSLDRIVE_HAVE_MOUNT) && !defined(_WIN32) + // The listener is up, so launch the Windows agent to dial back in now (an + // AF_HYPERV connect would block if it were started before we were listening). + if (!win_agent_connect.empty()) { + if (auto r = win_proc.start({.exe = win_agent, .win_root = win_root, .connect = win_agent_connect}); !r) { + std::fprintf(stderr, "wsldrive: failed to launch Windows agent '%s'\n", win_agent.c_str()); + return 1; + } + std::printf("launched Windows agent serving %s over %s\n", win_root.c_str(), hvsocket ? "hvsocket" : "tcp"); + std::fflush(stdout); + } +#endif while (!g_mount_stop.load()) { auto s = l->accept(std::chrono::milliseconds(200)); if (s) { diff --git a/tests/agent_test.cpp b/tests/agent_test.cpp index 911cc9a..3282f95 100644 --- a/tests/agent_test.cpp +++ b/tests/agent_test.cpp @@ -4,6 +4,8 @@ #include "platform/watcher.hpp" #ifdef _WIN32 #include "platform/win/wsl_launch.hpp" +#else +#include "platform/linux/win_launch.hpp" #endif #include @@ -505,6 +507,18 @@ TEST(WslLaunch, BuildsCommand) { EXPECT_EQ(v.find("tcp://"), std::string::npos); } +#else +TEST(WinLaunch, BuildsCommand) { + const std::string cmd = platform::build_win_agent_command( + {.exe = "/mnt/c/wsldrived.exe", .win_root = "C:/proj", .connect = "hv://{guid}:5700"}); + EXPECT_NE(cmd.find("/mnt/c/wsldrived.exe"), std::string::npos); + EXPECT_NE(cmd.find("--root C:/proj"), std::string::npos); + EXPECT_NE(cmd.find("--connect hv://{guid}:5700"), std::string::npos); + EXPECT_NE(cmd.find("--exit-when-idle"), std::string::npos); +} +#endif + +#ifdef _WIN32 TEST(Win32Watcher, ReportsEventsAndStopsCleanly) { const fs::path root = fs::temp_directory_path() / "wsldrive-watcher-test"; fs::remove_all(root);