fix(wsl): watch the whole worktree so nested external edits reach open editors

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 <help@stably.ai>
This commit is contained in:
Neil
2026-08-04 00:34:41 -07:00
co-authored by Orca
parent c498d2d405
commit d7408cc197
4 changed files with 126 additions and 32 deletions
@@ -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)
+1 -32
View File
@@ -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<void>
@@ -40,7 +41,6 @@ export type WslWatcherDeps = {
watchedRoots: Map<string, WatchedRoot>
}
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')) {
@@ -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'
)
})
})
+53
View File
@@ -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')
}