WSL2 sync architecture
Architecture name
wsl-watchman-mntc. The convention is <watcher-host>-<event-source>-<destination>:
- watcher-host: where the watcher process runs (
wsl or win)
- event-source: how it learns about changes (
watchman, watchdog, inotifywait, detect)
- destination: where it writes (
mntc = /mnt/c, unc = \\wsl$\...)
When proposing or evaluating a sync change, name it this way. The probe set that settled the WSL-side watcher decision was (iii) win-watchdog-unc, (iv) wsl-watchdog-mntc (python-watchdog), (v) wsl-detect-win-copy, (vi) wsl-inotifywait-mntc; arm (iv) won. The shipped daemon refines that: the watcher runs WSL-side (as the probe concluded) but the event source is Watchman, not the python-watchdog library — Watchman was already required for the cold since <clock> delta path (below), so sync.py unifies on it for live subscriptions too and tracks all file state through it. Production is therefore wsl-watchman-mntc. Don't confuse Facebook Watchman (what we ship) with the python watchdog library (the probe arm). Probe data lives in bar-design-docs/bifurcated_types/dev_setup_restructured.md.
Boundary facts (do not relitigate without a fresh probe)
- Plan 9 does not forward inotify across the WSL/Windows boundary. A native watchdog Observer on the Windows side watching
\\wsl$\... silently degrades to PollingObserver (stat-loop). Production must run the watcher on the Linux side.
/mnt/c ≠ NTFS native. drvfs adds per-stat round-trips. rsync against /mnt/c is correct but slow (~80s stat-walk on the BAR tree). That's why we use Watchman to skip the walk.
- Windows holds DLL handles. When
spring.exe or Beyond-All-Reason.exe is running, rsync from Linux through drvfs to engine DLLs hits EACCES (rsync exit 23). bar::stop (launch.sh's stop_wsl) kills these holders before engine::build runs.
- mmap inode stability. The engine mmaps Lua sources. If a sync replaces the file via tempfile + rename, the inode flips under the live mmap and the engine reads garbage. Always use
rsync --inplace — for both cold copies and per-event mirrors (sync.py:_apply_files re-runs rsync -a --inplace --files-from=- on each coalesced Watchman batch). Never shutil.move, shutil.copyfile+rename, or any rename-into-place.
- inotify watch limits. Default 8192 is below BAR's directory count.
setup::init bumps fs.inotify.max_user_watches to 524288 via /etc/sysctl.d/99-bar-devtools.conf.
Watchman is mandatory, no fallback
- Watchman lives inside the WSL2-only
bar-sync distrobox (installed by docker/sync.Containerfile), and sync.py runs inside that container — scripts/sync.sh launches it via distrobox enter bar-sync -- … sync.py. It is not in the bar-dev toolchain container; see the ABI note below for why it's quarantined.
sync.py imports pywatchman at module load and exits hard (SystemExit(1)) if it's missing — there is no _have_watchman() guard and no silent fallback. We want the loud failure, not a silent ~80s rsync stat-walk on /mnt/c that contributors mistake for normal. The daemon talks to the Watchman service via pywatchman.client() and drives everything off subscriptions.
- First call for a
(src, dst) pair: watch-project + initial clock + full rsync seed. Subsequent calls: since <clock> query + rsync --files-from for changed files only + unlink for deletions.
- Pair state is persisted to
${XDG_STATE_HOME:-~/.local/state}/bar-devtools/sync-state-<sha1>.json via atomic rename + fsync. (Lives on Linux ext4 — NOT under $BAR_DATA_DIR on /mnt/c — so watchman clock tokens survive daemon restarts without paying drvfs costs every read.)
Watchman / Fedora / RPM ABI
The watchman RPM Meta publishes is built against a specific Fedora release. This is exactly why Watchman is quarantined in its own bar-sync container instead of the bar-dev toolchain container: it lets dev.Containerfile track current Fedora (fedora:43) while sync.Containerfile stays pinned to the watchman-compatible release. As of v2026.05.04.00 that's fedora:42 (boost 1.83, libglog.so.0, libdwarf.so.0 — the last of which fc43 dropped). Bumping sync.Containerfile to fedora:latest will break the install with "nothing provides libboost_context.so.1.83.0" et al. When bumping WATCHMAN_VERSION: bump sync.Containerfile's FROM line in lockstep. (bar-sync is only built on WSL2, so Linux/macOS contributors never hit this.)
Daemon lifecycle
sync.sh start [--wait-ready] — spawns one sync.py per pair; readiness is the READY line in the log. --wait-ready tails the log and blocks until ready or the daemon dies.
sync.sh stop — SIGTERM, then SIGKILL.
- Validate pair list before "already running" early-return. A stale pid file with a daemon already up should not skip pair validation; otherwise a missing source symlink stays silent.
- Drift detection. If the pair list passed to
start differs from the pair list the running daemon was started with, restart the daemon.
- The engine pair is excluded from the watcher because
engine::build rsyncs into the same target. A live watcher would race the build.
Logging discipline
- One
FileHandler only when --log is set. Adding a StreamHandler(stderr) while shell redirects stderr to the log file double-writes every line.
- Per-event mirrors log at
INFO (not DEBUG) — the user wants to see what synced.
- Startup line includes the inotify watch limit, the chosen Observer class (native vs polling), and the watchman version.
- Signals are logged by name (
SIGTERM (kill), SIGINT (Ctrl-C), SIGHUP (terminal closed)) with recovery hints (just bar::stop, just link::create) — never just "received signal 15".
Re-probing the sync decision
wsl-watchdog-mntc was picked by measuring all six candidate architectures end-to-end with scripts/probe_wsl_sync.py — not by reasoning from first principles. A Windows-side watcher felt obvious; Plan 9 not forwarding inotify is what killed it. Don't relitigate the boundary facts above from a comment thread — re-run the probe.
Re-probe when:
- WSL has a major version change (1 → 2 was an inotify boundary; a future one might shift again).
- Watchdog / Watchman / rsync has a major bump touching the OS abstraction layer.
- The watched tree's file count grows ~10×.
- Someone proposes "just use X" where X is one of the rejected arms — re-run before deciding the platform changed.
A probe that earns a decision measures, on real hardware:
- Median + p99 round-trip — edit on source → content-equal on destination. Equality, not first-byte: partial-write windows matter.
- Cold-start cost — seed an empty destination with N files; this is what users feel on first daemon launch.
- Steady-state per-event cost — individual edits after the seed.
- Failure modes — kill the watcher mid-edit, exhaust the watch limit, hit
EACCES on locked files. The architecture has to survive these, not just win the happy-path benchmark.
And it documents its preconditions: if the result depends on fs.inotify.max_user_watches=524288, production must enforce that (ensure_sync_daemon_deps_wsl) — a probe that passes only because of a sysctl production never sets is a lie. Keep scripts/probe_wsl_sync.py runnable so the decision stays reproducible; killed candidates and their numbers live in bar-design-docs/bifurcated_types/dev_setup_restructured.md.
1---2name: wsl2-sync-architecture3description: Hard-won facts about the WSL2 ↔ Windows sync daemon (sync.py, sync.sh, launch.sh). Read this before changing how files cross the WSL/Windows boundary, before adjusting watchman/rsync invocations, before "improving" the cold-copy path, or before proposing a different sync architecture.4---56# WSL2 sync architecture78## Architecture name910`wsl-watchman-mntc`. The convention is `<watcher-host>-<event-source>-<destination>`:1112- watcher-host: where the watcher process runs (`wsl` or `win`)13- event-source: how it learns about changes (`watchman`, `watchdog`, `inotifywait`, `detect`)14- destination: where it writes (`mntc` = `/mnt/c`, `unc` = `\\wsl$\...`)1516When proposing or evaluating a sync change, name it this way. The probe set that settled the **WSL-side watcher** decision was (iii) `win-watchdog-unc`, (iv) `wsl-watchdog-mntc` (python-`watchdog`), (v) `wsl-detect-win-copy`, (vi) `wsl-inotifywait-mntc`; arm (iv) won. The **shipped daemon refines that**: the watcher runs WSL-side (as the probe concluded) but the event source is **Watchman**, not the python-`watchdog` library — Watchman was already required for the cold `since <clock>` delta path (below), so `sync.py` unifies on it for live subscriptions too and tracks all file state through it. Production is therefore `wsl-watchman-mntc`. Don't confuse Facebook **Watchman** (what we ship) with the python **watchdog** library (the probe arm). Probe data lives in `bar-design-docs/bifurcated_types/dev_setup_restructured.md`.1718## Boundary facts (do not relitigate without a fresh probe)1920- **Plan 9 does not forward inotify across the WSL/Windows boundary.** A native watchdog Observer on the Windows side watching `\\wsl$\...` silently degrades to `PollingObserver` (stat-loop). Production must run the watcher on the Linux side.21- **`/mnt/c` ≠ NTFS native.** drvfs adds per-stat round-trips. `rsync` against `/mnt/c` is correct but slow (~80s stat-walk on the BAR tree). That's why we use Watchman to skip the walk.22- **Windows holds DLL handles.** When `spring.exe` or `Beyond-All-Reason.exe` is running, rsync from Linux through drvfs to engine DLLs hits `EACCES` (rsync exit 23). `bar::stop` (`launch.sh`'s `stop_wsl`) kills these holders before `engine::build` runs.23- **mmap inode stability.** The engine mmaps Lua sources. If a sync replaces the file via tempfile + rename, the inode flips under the live mmap and the engine reads garbage. **Always use `rsync --inplace`** — for both cold copies *and* per-event mirrors (`sync.py:_apply_files` re-runs `rsync -a --inplace --files-from=-` on each coalesced Watchman batch). Never `shutil.move`, `shutil.copyfile`+rename, or any rename-into-place.24- **inotify watch limits.** Default 8192 is below BAR's directory count. `setup::init` bumps `fs.inotify.max_user_watches` to 524288 via `/etc/sysctl.d/99-bar-devtools.conf`.2526## Watchman is mandatory, no fallback2728- Watchman lives **inside the WSL2-only `bar-sync` distrobox** (installed by `docker/sync.Containerfile`), and `sync.py` runs *inside* that container — `scripts/sync.sh` launches it via `distrobox enter bar-sync -- … sync.py`. It is **not** in the `bar-dev` toolchain container; see the ABI note below for why it's quarantined.29- `sync.py` imports `pywatchman` at module load and exits hard (`SystemExit(1)`) if it's missing — there is no `_have_watchman()` guard and no silent fallback. We want the loud failure, not a silent ~80s rsync stat-walk on `/mnt/c` that contributors mistake for normal. The daemon talks to the Watchman service via `pywatchman.client()` and drives everything off subscriptions.30- First call for a `(src, dst)` pair: `watch-project` + initial `clock` + full rsync seed. Subsequent calls: `since <clock>` query + rsync `--files-from` for changed files only + unlink for deletions.31- Pair state is persisted to `${XDG_STATE_HOME:-~/.local/state}/bar-devtools/sync-state-<sha1>.json` via atomic rename + fsync. (Lives on Linux ext4 — NOT under `$BAR_DATA_DIR` on /mnt/c — so watchman clock tokens survive daemon restarts without paying drvfs costs every read.)3233## Watchman / Fedora / RPM ABI3435The watchman RPM Meta publishes is built against a specific Fedora release. This is exactly why Watchman is quarantined in its own `bar-sync` container instead of the `bar-dev` toolchain container: it lets `dev.Containerfile` track current Fedora (`fedora:43`) while **`sync.Containerfile` stays pinned to the watchman-compatible release.** As of `v2026.05.04.00` that's `fedora:42` (boost 1.83, libglog.so.0, libdwarf.so.0 — the last of which fc43 dropped). Bumping `sync.Containerfile` to `fedora:latest` will break the install with "nothing provides libboost_context.so.1.83.0" et al. When bumping `WATCHMAN_VERSION`: bump `sync.Containerfile`'s `FROM` line in lockstep. (`bar-sync` is only built on WSL2, so Linux/macOS contributors never hit this.)3637## Daemon lifecycle3839- `sync.sh start [--wait-ready]` — spawns one `sync.py` per pair; readiness is the `READY` line in the log. `--wait-ready` tails the log and blocks until ready or the daemon dies.40- `sync.sh stop` — SIGTERM, then SIGKILL.41- **Validate pair list before "already running" early-return.** A stale pid file with a daemon already up should not skip pair validation; otherwise a missing source symlink stays silent.42- **Drift detection.** If the pair list passed to `start` differs from the pair list the running daemon was started with, restart the daemon.43- The engine pair is excluded from the watcher because `engine::build` rsyncs into the same target. A live watcher would race the build.4445## Logging discipline4647- One `FileHandler` only when `--log` is set. Adding a `StreamHandler(stderr)` while shell redirects stderr to the log file double-writes every line.48- Per-event mirrors log at `INFO` (not `DEBUG`) — the user wants to see what synced.49- Startup line includes the inotify watch limit, the chosen Observer class (native vs polling), and the watchman version.50- Signals are logged by name (`SIGTERM (kill)`, `SIGINT (Ctrl-C)`, `SIGHUP (terminal closed)`) with recovery hints (`just bar::stop`, `just link::create`) — never just "received signal 15".5152## Re-probing the sync decision5354`wsl-watchdog-mntc` was picked by measuring all six candidate architectures end-to-end with `scripts/probe_wsl_sync.py` — not by reasoning from first principles. A Windows-side watcher *felt* obvious; Plan 9 not forwarding inotify is what killed it. Don't relitigate the boundary facts above from a comment thread — re-run the probe.5556Re-probe when:5758- WSL has a major version change (1 → 2 was an inotify boundary; a future one might shift again).59- Watchdog / Watchman / rsync has a major bump touching the OS abstraction layer.60- The watched tree's file count grows ~10×.61- Someone proposes "just use X" where X is one of the rejected arms — re-run before deciding the platform changed.6263A probe that earns a decision measures, on real hardware:6465- **Median + p99 round-trip** — edit on source → *content-equal* on destination. Equality, not first-byte: partial-write windows matter.66- **Cold-start cost** — seed an empty destination with N files; this is what users feel on first daemon launch.67- **Steady-state per-event cost** — individual edits after the seed.68- **Failure modes** — kill the watcher mid-edit, exhaust the watch limit, hit `EACCES` on locked files. The architecture has to survive these, not just win the happy-path benchmark.6970And it documents its preconditions: if the result depends on `fs.inotify.max_user_watches=524288`, production must enforce that (`ensure_sync_daemon_deps_wsl`) — a probe that passes only because of a sysctl production never sets is a lie. Keep `scripts/probe_wsl_sync.py` runnable so the decision stays reproducible; killed candidates and their numbers live in `bar-design-docs/bifurcated_types/dev_setup_restructured.md`.