From d7408cc19754d8b00c467685ba13faa975e1feb5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 4 Aug 2026 00:34:41 -0700 Subject: [PATCH] fix(wsl): watch the whole worktree so nested external edits reach open editors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WSL in-distro poll scan was capped at `-maxdepth 2`, so external edits to any file nested deeper never produced an `fs:changed` event. An open editor tab kept its in-memory content until it was closed and reopened — the reported workaround. Remove the depth cap so the scan matches the recursive watcher behaviour on native platforms. Fixes #7792 Co-authored-by: Orca --- src/main/ipc/filesystem-watcher-wsl.test.ts | 30 +++++++++++ src/main/ipc/filesystem-watcher-wsl.ts | 33 +----------- src/main/ipc/wsl-snapshot-poll-script.test.ts | 42 +++++++++++++++ src/main/ipc/wsl-snapshot-poll-script.ts | 53 +++++++++++++++++++ 4 files changed, 126 insertions(+), 32 deletions(-) create mode 100644 src/main/ipc/wsl-snapshot-poll-script.test.ts create mode 100644 src/main/ipc/wsl-snapshot-poll-script.ts diff --git a/src/main/ipc/filesystem-watcher-wsl.test.ts b/src/main/ipc/filesystem-watcher-wsl.test.ts index 5b1d1347a15..63727ebbaf4 100644 --- a/src/main/ipc/filesystem-watcher-wsl.test.ts +++ b/src/main/ipc/filesystem-watcher-wsl.test.ts @@ -174,6 +174,36 @@ describe('createWslWatcher', () => { ]) }) + it('emits update events for files nested more than two levels deep', async () => { + const scheduleBatchFlush = vi.fn() + const { child, promise } = startWatcher(makeDeps(scheduleBatchFlush)) + + child.stdout.write( + snapshotFrame([ + ['d', '1.0', '/home/me/repo/docs'], + ['d', '1.0', '/home/me/repo/docs/api'], + ['f', '1.0', '/home/me/repo/docs/api/reference.md'] + ]) + ) + const root = await promise + child.stdout.write( + snapshotFrame([ + ['d', '1.0', '/home/me/repo/docs'], + ['d', '1.0', '/home/me/repo/docs/api'], + ['f', '2.0', '/home/me/repo/docs/api/reference.md'], + ['f', '1.0', '/home/me/repo/src/main/deep/new.md'] + ]) + ) + + expect(root.batch.events).toEqual([ + { + type: 'update', + path: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo\\docs\\api\\reference.md' + }, + { type: 'create', path: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo\\src\\main\\deep\\new.md' } + ]) + }) + it('turns watcher exit into an overflow refresh without retaining UNC paths', async () => { const scheduleBatchFlush = vi.fn() const deps = makeDeps(scheduleBatchFlush) diff --git a/src/main/ipc/filesystem-watcher-wsl.ts b/src/main/ipc/filesystem-watcher-wsl.ts index 49ff8f20c7d..f793c0f3298 100644 --- a/src/main/ipc/filesystem-watcher-wsl.ts +++ b/src/main/ipc/filesystem-watcher-wsl.ts @@ -13,6 +13,7 @@ import { queueWatcherEvents } from './filesystem-watcher-event-batch' import { parseWslUncPath } from '../../shared/wsl-paths' import { createWslWatcherProcessExit, createWslWatcherStartup } from './wsl-watcher-process-exit' import { reserveWatcherChild, WatcherChildCapacityError } from './parcel-watcher-child-registry' +import { buildSnapshotScript } from './wsl-snapshot-poll-script' export type WatcherSubscription = { unsubscribe(): Promise @@ -40,7 +41,6 @@ export type WslWatcherDeps = { watchedRoots: Map } -const POLL_INTERVAL_SECONDS = 2 const STARTUP_TIMEOUT_MS = 10_000 const [SNAPSHOT_START, SNAPSHOT_END] = ['\x1e', '\x1f'] const MAX_STREAM_BUFFER_CHARS = 10 * 1024 * 1024 @@ -57,37 +57,6 @@ function toWslUncPath(linuxPath: string, distro: string): string { return `\\\\wsl.localhost\\${distro}${linuxPath.replace(/\//g, '\\')}` } -function quoteSafeFindName(name: string): string { - if (!/^[A-Za-z0-9_.-]+$/.test(name)) { - throw new Error(`Unsupported WSL watcher ignore name: ${name}`) - } - return `'${name}'` -} - -function buildPruneExpression(ignoreDirs: readonly string[]): string { - if (ignoreDirs.length === 0) { - return '' - } - const names = ignoreDirs.map((name) => `-name ${quoteSafeFindName(name)}`).join(' -o ') - return `\\( -type d \\( ${names} \\) -prune \\) -o` -} - -function buildSnapshotScript(ignoreDirs: readonly string[]): string { - const prune = buildPruneExpression(ignoreDirs) - return [ - 'set -efu', - 'root=$1', - 'while :; do', - " printf '\\036'", - ' if [ -d "$root" ]; then', - ` find "$root" -mindepth 1 -maxdepth 2 ${prune} -printf '%y\\t%T@\\t%p\\0' 2>/dev/null || true`, - ' fi', - " printf '\\037'", - ` sleep ${POLL_INTERVAL_SECONDS} || exit 0`, - 'done' - ].join('\n') -} - function parseSnapshotFrame(frame: string, distro: string): WslSnapshot { const snapshot: WslSnapshot = new Map() for (const rawEntry of frame.split('\0')) { diff --git a/src/main/ipc/wsl-snapshot-poll-script.test.ts b/src/main/ipc/wsl-snapshot-poll-script.test.ts new file mode 100644 index 00000000000..5823d943add --- /dev/null +++ b/src/main/ipc/wsl-snapshot-poll-script.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' + +import { buildSnapshotScript } from './wsl-snapshot-poll-script' + +function scanLine(script: string): string { + const line = script.split('\n').find((entry) => entry.includes('find "$root"')) + expect(line).toBeDefined() + return line as string +} + +describe('buildSnapshotScript', () => { + it('scans the whole worktree so files nested below two levels are seen', () => { + const line = scanLine(buildSnapshotScript(['node_modules', '.git'])) + + expect(line).toContain('-mindepth 1') + expect(line).not.toContain('-maxdepth') + }) + + it('prunes ignored directories at every depth', () => { + const line = scanLine(buildSnapshotScript(['node_modules', '.git'])) + + expect(line).toContain("\\( -type d \\( -name 'node_modules' -o -name '.git' \\) -prune \\) -o") + }) + + it('backs the poll interval off on large trees instead of capping depth', () => { + const script = buildSnapshotScript(['node_modules']) + + expect(script).toContain('size=$(wc -c < "$snapshot" 2>/dev/null || echo 0)') + expect(script).toContain('interval=10') + expect(script).toContain('sleep "$interval" || exit 0') + }) + + it('fails loudly on distros whose find lacks GNU -printf', () => { + expect(buildSnapshotScript([])).toContain('orca-watcher-unsupported-find') + }) + + it('rejects ignore names that would break out of the find expression', () => { + expect(() => buildSnapshotScript(["a' -o -delete '"])).toThrow( + 'Unsupported WSL watcher ignore name' + ) + }) +}) diff --git a/src/main/ipc/wsl-snapshot-poll-script.ts b/src/main/ipc/wsl-snapshot-poll-script.ts new file mode 100644 index 00000000000..af3ea1acdd8 --- /dev/null +++ b/src/main/ipc/wsl-snapshot-poll-script.ts @@ -0,0 +1,53 @@ +/** + * The `sh` program the WSL watcher pipes into the distro: it polls the worktree + * with `find` and streams one framed snapshot per interval. + */ +const POLL_INTERVAL_SECONDS = 2 +// Why: a recursive scan of a huge tree costs more per poll than the freshness it +// buys, so back the in-distro loop off instead of capping depth (which blinds it). +const SLOW_POLL_INTERVAL_SECONDS = 10 +const LARGE_SNAPSHOT_BYTES = 2_000_000 + +function quoteSafeFindName(name: string): string { + if (!/^[A-Za-z0-9_.-]+$/.test(name)) { + throw new Error(`Unsupported WSL watcher ignore name: ${name}`) + } + return `'${name}'` +} + +function buildPruneExpression(ignoreDirs: readonly string[]): string { + if (ignoreDirs.length === 0) { + return '' + } + const names = ignoreDirs.map((name) => `-name ${quoteSafeFindName(name)}`).join(' -o ') + return `\\( -type d \\( ${names} \\) -prune \\) -o` +} + +export function buildSnapshotScript(ignoreDirs: readonly string[]): string { + const prune = buildPruneExpression(ignoreDirs) + return [ + 'set -efu', + 'root=$1', + // Why: BusyBox/Alpine find has no -printf; without this probe every frame is + // silently empty and the watcher looks healthy while seeing nothing. + "find / -maxdepth 0 -printf '' 2>/dev/null || { printf 'orca-watcher-unsupported-find\\n' >&2; exit 3; }", + 'snapshot=$(mktemp) || exit 4', + `trap 'rm -f "$snapshot"' EXIT`, + `trap 'rm -f "$snapshot"; exit 0' INT TERM`, + `interval=${POLL_INTERVAL_SECONDS}`, + 'while :; do', + ' : > "$snapshot"', + ' if [ -d "$root" ]; then', + // The prune expression drops .git/node_modules/etc at every depth, so full + // recursion here matches what @parcel/watcher reports on native platforms. + ` find "$root" -mindepth 1 ${prune} -printf '%y\\t%T@\\t%p\\0' > "$snapshot" 2>/dev/null || true`, + ' fi', + " printf '\\036'", + ' cat "$snapshot"', + " printf '\\037'", + ' size=$(wc -c < "$snapshot" 2>/dev/null || echo 0)', + ` if [ "\${size:-0}" -gt ${LARGE_SNAPSHOT_BYTES} ]; then interval=${SLOW_POLL_INTERVAL_SECONDS}; else interval=${POLL_INTERVAL_SECONDS}; fi`, + ' sleep "$interval" || exit 0', + 'done' + ].join('\n') +}