diff --git a/src/main/ipc/filesystem-watcher-wsl-failure-policy.ts b/src/main/ipc/filesystem-watcher-wsl-failure-policy.ts index 6fd995f2119..2c5ef2eaa60 100644 --- a/src/main/ipc/filesystem-watcher-wsl-failure-policy.ts +++ b/src/main/ipc/filesystem-watcher-wsl-failure-policy.ts @@ -1,9 +1,7 @@ import { WslWatcherCompatibilityError } from './filesystem-watcher-wsl-runtime' +// Why: compatibility failures (unsupported distro, missing/corrupt bundle) +// cannot heal at runtime, so reprobing native watching would spin forever. export function isPermanentWslNativeFailure(error: unknown): boolean { - return ( - error instanceof WslWatcherCompatibilityError || - (error instanceof Error && - error.message.startsWith('Packaged managed WSL watcher resource is missing')) - ) + return error instanceof WslWatcherCompatibilityError } diff --git a/src/main/ipc/filesystem-watcher-wsl-recovery.test.ts b/src/main/ipc/filesystem-watcher-wsl-recovery.test.ts index e1486513b27..2d20b7ff85b 100644 --- a/src/main/ipc/filesystem-watcher-wsl-recovery.test.ts +++ b/src/main/ipc/filesystem-watcher-wsl-recovery.test.ts @@ -263,8 +263,12 @@ describe('WSL watcher recovery policy', () => { }) it('does not re-probe a packaged build with a missing managed resource', async () => { + // Why: the runtime layer wraps missing/corrupt bundles in the + // compatibility error, which is the only permanent-failure signal. ensureRuntimeMock.mockRejectedValue( - new Error('Packaged managed WSL watcher resource is missing at C:\\Orca\\resources') + new CompatibilityError( + 'Unreadable managed WSL watcher bundle at C:\\Orca\\resources\\wsl-watcher' + ) ) const snapshot = new FakeChildProcess() spawnMock.mockReturnValueOnce(snapshot) diff --git a/src/main/ipc/filesystem-watcher-wsl-runtime.test.ts b/src/main/ipc/filesystem-watcher-wsl-runtime.test.ts index 6801255321f..705e3973071 100644 --- a/src/main/ipc/filesystem-watcher-wsl-runtime.test.ts +++ b/src/main/ipc/filesystem-watcher-wsl-runtime.test.ts @@ -9,7 +9,9 @@ import { PROBE_BUNDLE_SENTINEL, parseInstallerResponse, parseRunningWslDistros, - resolveWslWatcherBundlePath + readWslWatcherBundleManifest, + resolveWslWatcherBundlePath, + WslWatcherCompatibilityError } from './filesystem-watcher-wsl-runtime' const temporaryRoots: string[] = [] @@ -77,6 +79,46 @@ describe('managed WSL watcher runtime', () => { }) }) + it('classifies missing or corrupt bundles as permanent compatibility failures', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-wsl-runtime-manifest-')) + temporaryRoots.push(root) + + await expect(readWslWatcherBundleManifest(root)).rejects.toBeInstanceOf( + WslWatcherCompatibilityError + ) + await writeFile(join(root, 'manifest.json'), 'not json') + await expect(readWslWatcherBundleManifest(root)).rejects.toBeInstanceOf( + WslWatcherCompatibilityError + ) + await writeFile( + join(root, 'manifest.json'), + JSON.stringify({ + protocol: 2, + installLayout: 1, + nodeVersion: '24.15.0', + bundleVersion: 'a'.repeat(20) + }) + ) + await expect(readWslWatcherBundleManifest(root)).rejects.toThrow( + 'Invalid managed WSL watcher manifest' + ) + await writeFile( + join(root, 'manifest.json'), + JSON.stringify({ + protocol: 1, + installLayout: 1, + nodeVersion: '24.15.0', + bundleVersion: 'a'.repeat(20) + }) + ) + await expect(readWslWatcherBundleManifest(root)).resolves.toEqual({ + protocol: 1, + installLayout: 1, + nodeVersion: '24.15.0', + bundleVersion: 'a'.repeat(20) + }) + }) + it('fails closed for a missing packaged resource but resolves explicit development output', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-wsl-runtime-path-')) temporaryRoots.push(root) diff --git a/src/main/ipc/filesystem-watcher-wsl-runtime.ts b/src/main/ipc/filesystem-watcher-wsl-runtime.ts index 38e547fb480..55ab5edc0ff 100644 --- a/src/main/ipc/filesystem-watcher-wsl-runtime.ts +++ b/src/main/ipc/filesystem-watcher-wsl-runtime.ts @@ -114,14 +114,29 @@ function validateManifest(value: unknown): WslWatcherManifest { return manifest as WslWatcherManifest } +export async function readWslWatcherBundleManifest( + bundlePath: string +): Promise { + try { + return validateManifest(JSON.parse(await readFile(join(bundlePath, 'manifest.json'), 'utf8'))) + } catch (error) { + if (error instanceof WslWatcherCompatibilityError) { + throw error + } + // Why: a missing or corrupt bundle cannot heal at runtime; classifying it + // permanent stops the native reprobe loop from retrying every 30s forever. + throw new WslWatcherCompatibilityError( + `Unreadable managed WSL watcher bundle at ${bundlePath}: ${error instanceof Error ? error.message : String(error)}` + ) + } +} + async function installRuntime( distro: string, signal?: AbortSignal ): Promise { const bundlePath = resolveWslWatcherBundlePath() - const manifest = validateManifest( - JSON.parse(await readFile(join(bundlePath, 'manifest.json'), 'utf8')) - ) + const manifest = await readWslWatcherBundleManifest(bundlePath) return installWslWatcherRuntime(distro, bundlePath, manifest, signal) } @@ -224,7 +239,10 @@ export function parseRunningWslDistros(output: string | Buffer): string[] { async function queryWslDistroRunning(distro: string): Promise { try { + // Why: wsl.exe emits UTF-16LE; a raw buffer lets decodeWslListOutput pick + // the encoding so non-ASCII distro names survive instead of garbling. const { stdout } = await execFileAsync('wsl.exe', ['--list', '--running', '--quiet'], { + encoding: 'buffer', timeout: 5_000, maxBuffer: 64 * 1024 }) diff --git a/src/main/ipc/filesystem-watcher-wsl-snapshot.test.ts b/src/main/ipc/filesystem-watcher-wsl-snapshot.test.ts index 0e815fe2c77..3f195dd57b4 100644 --- a/src/main/ipc/filesystem-watcher-wsl-snapshot.test.ts +++ b/src/main/ipc/filesystem-watcher-wsl-snapshot.test.ts @@ -109,25 +109,31 @@ done ) it.skipIf(process.platform === 'win32')( - 'surfaces real portable stat failures instead of treating them as churn', + 'degrades unreadable entries to a partial frame instead of aborting the poll loop', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-wsl-portable-error-')) temporaryRoots.push(root) const denied = join(root, 'denied.md') + const readable = join(root, 'readable.md') await writeFile(denied, 'still exists') + await writeFile(readable, 'fine') const result = await runSnapshotScript( root, `#!/bin/sh shift 3 if test "$#" -gt 1; then exit 1; fi if test "$1" = ${JSON.stringify(denied)}; then echo 'permission denied' >&2; exit 1; fi -printf 'directory\\t1\\n' +printf 'regular file\\t1\\n' ` ) - expect(result.code).toEqual(expect.any(Number)) - expect(result.code).not.toBe(0) + // Why: one unreadable subtree must not orphan the whole worktree; the + // frame still terminates so the engine keeps polling the readable rest. + expect(result.code).toBe(0) expect(result.stderr).toContain('permission denied') + const frame = result.stdout.toString('utf8') + expect(frame).toContain(`regular file\t1\t${readable}\0`) + expect(frame.endsWith('\0\0') || frame === '\0').toBe(true) } ) }) diff --git a/src/main/ipc/filesystem-watcher-wsl-snapshot.ts b/src/main/ipc/filesystem-watcher-wsl-snapshot.ts index f4fa57048ca..b28bfb48318 100644 --- a/src/main/ipc/filesystem-watcher-wsl-snapshot.ts +++ b/src/main/ipc/filesystem-watcher-wsl-snapshot.ts @@ -38,7 +38,8 @@ function buildPruneExpression(ignoreDirs: readonly string[]): string { } // Why: files can vanish after find batches them. Retry a failed batch one path -// at a time so churn is skipped without hiding errors for paths that still exist. +// at a time so churn is skipped; unreadable survivors are logged and skipped +// too, because a partial snapshot beats aborting the whole watcher. const BUSYBOX_STAT_BATCH_SCRIPT = 'tmp="${TMPDIR:-/tmp}/orca-wsl-snapshot-$$"; error="$tmp.error"; ' + 'trap \'rm -f "$tmp" "$error"\' EXIT; ' + @@ -48,7 +49,7 @@ const BUSYBOX_STAT_BATCH_SCRIPT = 'else for path do if metadata=$(stat -c "%F\t%y" -- "$path" 2>"$error"); then ' + 'printf "%s\\t%s\\0" "$metadata" "$path"; ' + 'elif test ! -e "$path" && test ! -L "$path"; then :; ' + - 'else cat "$error" >&2; exit 75; fi; done; fi' + 'else cat "$error" >&2; fi; done; fi' function quoteShellArgument(value: string): string { return `'${value.replaceAll("'", `'"'"'`)}'` @@ -79,8 +80,10 @@ export function buildSnapshotScript( ' if [ -d "$root" ]; then', // Why: GNU find is the zero-fork fast path. BusyBox batches paths through // one stat process per ARG_MAX group while retaining NUL-safe filenames. - ` if [ "$snapshot_find" = gnu ]; then find "$root" -mindepth 1 ${prune} -printf '%y\\t%T@\\t%p\\0';`, - ` else find "$root" -mindepth 1 ${prune} -exec sh -c ${quoteShellArgument(BUSYBOX_STAT_BATCH_SCRIPT)} sh {} +; fi`, + // Why: `|| true` — an unreadable subtree or mid-scan churn must degrade to + // a partial snapshot, not abort the engine and orphan the whole worktree. + ` if [ "$snapshot_find" = gnu ]; then find "$root" -mindepth 1 ${prune} -printf '%y\\t%T@\\t%p\\0' || true;`, + ` else find "$root" -mindepth 1 ${prune} -exec sh -c ${quoteShellArgument(BUSYBOX_STAT_BATCH_SCRIPT)} sh {} + || true; fi`, ' fi', " printf '\\0'", repeat, diff --git a/src/main/ipc/wsl-watcher-host-canary.test.ts b/src/main/ipc/wsl-watcher-host-canary.test.ts index feed002c0c3..bdcf4909c67 100644 --- a/src/main/ipc/wsl-watcher-host-canary.test.ts +++ b/src/main/ipc/wsl-watcher-host-canary.test.ts @@ -1,12 +1,15 @@ import { spawn } from 'node:child_process' -import { existsSync } from 'node:fs' +import { existsSync, mkdirSync } from 'node:fs' import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { build } from 'esbuild' import { afterEach, describe, expect, it, vi } from 'vitest' import { createWatcherBindingWatchdog } from './wsl-watcher-host-binding-watchdog' -import { startWslWatcherCanary } from './wsl-watcher-host-canary' +import { + startWslWatcherCanary, + sweepStaleWslWatcherCanaryDirectories +} from './wsl-watcher-host-canary' afterEach(() => { vi.useRealTimers() @@ -130,6 +133,28 @@ describe('WSL watcher host canary', () => { expect(vi.getTimerCount()).toBe(0) }) + it('sweeps only dead-owner canary directories at host start', async () => { + const base = await mkdtemp(join(tmpdir(), 'orca-canary-sweep-test-')) + try { + const stale = join(base, 'orca-wsl-watcher-4242-abc123') + const live = join(base, 'orca-wsl-watcher-5151-def456') + const own = join(base, `orca-wsl-watcher-${process.pid}-ghi789`) + const unrelated = join(base, 'orca-wsl-watcher-legacyname') + for (const dir of [stale, live, own, unrelated]) { + mkdirSync(dir) + } + + sweepStaleWslWatcherCanaryDirectories(base, (pid) => pid === 5151) + + expect(existsSync(stale)).toBe(false) + expect(existsSync(live)).toBe(true) + expect(existsSync(own)).toBe(true) + expect(existsSync(unrelated)).toBe(true) + } finally { + await rm(base, { recursive: true, force: true }) + } + }) + it('unsubscribes and cleans resources on an ordinary close', async () => { vi.useFakeTimers() let canaryDir = '' diff --git a/src/main/ipc/wsl-watcher-host-canary.ts b/src/main/ipc/wsl-watcher-host-canary.ts index 991f2e3e201..41a49d3e10a 100644 --- a/src/main/ipc/wsl-watcher-host-canary.ts +++ b/src/main/ipc/wsl-watcher-host-canary.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { WatcherBindingWatchdog } from './wsl-watcher-host-binding-watchdog' @@ -13,12 +13,41 @@ export type WslWatcherCanary = { close(): Promise } const CANARY_INTERVAL_MS = 10_000 const CANARY_EVENT_TIMEOUT_MS = 5_000 const CANARY_MAX_MISSES = 2 +const CANARY_DIR_PATTERN = /^orca-wsl-watcher-(\d+)-/ + +// Why: hard kills from the Windows side (wsl.exe teardown) never run the +// 'exit' handler, so each host start reclaims directories whose owner died. +export function sweepStaleWslWatcherCanaryDirectories( + baseDir: string = tmpdir(), + isProcessAlive: (pid: number) => boolean = (pid) => existsSync(`/proc/${pid}`) +): void { + let entries: string[] + try { + entries = readdirSync(baseDir) + } catch { + return + } + for (const entry of entries) { + const owner = Number(CANARY_DIR_PATTERN.exec(entry)?.[1]) + if (!Number.isSafeInteger(owner) || owner === process.pid || isProcessAlive(owner)) { + continue + } + try { + rmSync(join(baseDir, entry), { recursive: true, force: true }) + } catch { + // Another sweeper or the filesystem may win the race; try again next start. + } + } +} export async function startWslWatcherCanary( binding: CanaryWatcherBinding, watchdog: WatcherBindingWatchdog ): Promise { - const dir = mkdtempSync(join(tmpdir(), 'orca-wsl-watcher-')) + if (process.platform === 'linux') { + sweepStaleWslWatcherCanaryDirectories() + } + const dir = mkdtempSync(join(tmpdir(), `orca-wsl-watcher-${process.pid}-`)) let lastEventAt = 0 const callback = (error: Error | null): void => { if (!error) { diff --git a/src/main/ipc/wsl-watcher-host-checkpoint.ts b/src/main/ipc/wsl-watcher-host-checkpoint.ts index 85da6890bef..51abde5ba6f 100644 --- a/src/main/ipc/wsl-watcher-host-checkpoint.ts +++ b/src/main/ipc/wsl-watcher-host-checkpoint.ts @@ -111,6 +111,14 @@ export function fileSystemErrorCode(error: unknown): string | undefined { return (error as NodeJS.ErrnoException | undefined)?.code } +// Why: unreadable entries are invisible to every engine (native watches and +// snapshot scans alike), so treating them as absent keeps a worktree with one +// root-owned subtree watchable instead of failing the whole subscription. +export function isInvisibleFileSystemError(error: unknown): boolean { + const code = fileSystemErrorCode(error) + return code === 'ENOENT' || code === 'EACCES' || code === 'EPERM' +} + export function createSafetySnapshotEntry(stats: Stats): SafetySnapshotEntry { const directory = stats.isDirectory() return { @@ -260,7 +268,7 @@ async function scanWithLimits( return statResult } if (statResult.kind === 'error') { - if (fileSystemErrorCode(statResult.error) === 'ENOENT') { + if (isInvisibleFileSystemError(statResult.error)) { continue } return { kind: 'io-error' } @@ -283,7 +291,7 @@ async function scanWithLimits( return readResult } if (readResult.kind === 'error') { - if (fileSystemErrorCode(readResult.error) !== 'ENOENT') { + if (!isInvisibleFileSystemError(readResult.error)) { return { kind: 'io-error' } } continue diff --git a/src/main/ipc/wsl-watcher-host-safety.test.ts b/src/main/ipc/wsl-watcher-host-safety.test.ts index 5657f7baf3f..601be242025 100644 --- a/src/main/ipc/wsl-watcher-host-safety.test.ts +++ b/src/main/ipc/wsl-watcher-host-safety.test.ts @@ -110,6 +110,21 @@ describe('WSL watcher host safety', () => { ).resolves.toBe('topology') expect(snapshot.get(directory)).toEqual(baseline) + const failing = join(root, 'failing') + await expect( + applyNativeEventsToSafetySnapshot(root, snapshot, [{ type: 'update', path: failing }], { + fileSystem: { + lstat: async () => { + throw Object.assign(new Error('io failure'), { code: 'EIO' }) + }, + readdir: async () => [] + } + }) + ).resolves.toBe('topology') + expect(snapshot.get(directory)).toEqual(baseline) + + // Why: unreadable paths are invisible to safety scans, so events about + // them must not interrupt the subscription the way unknown I/O does. const denied = join(root, 'denied') await expect( applyNativeEventsToSafetySnapshot(root, snapshot, [{ type: 'update', path: denied }], { @@ -120,10 +135,38 @@ describe('WSL watcher host safety', () => { readdir: async () => [] } }) - ).resolves.toBe('topology') + ).resolves.toBe('applied') + expect(snapshot.has(denied)).toBe(false) expect(snapshot.get(directory)).toEqual(baseline) }) + it('scans past unreadable subtrees instead of failing the subscription', async () => { + const root = makeTemporaryRoot() + const locked = join(root, 'locked') + const blocked = join(root, 'blocked.md') + const readable = join(root, 'readable.md') + mkdirSync(locked) + writeFileSync(join(locked, 'secret.md'), 'hidden') + writeFileSync(blocked, 'lstat denied') + writeFileSync(readable, 'fine') + const { lstat, readdir } = await import('node:fs/promises') + const denied = (): Promise => + Promise.reject(Object.assign(new Error('denied'), { code: 'EACCES' })) + const result = await scanWatcherSafetySnapshot(root, new Set(), { + fileSystem: { + lstat: (path) => (path === blocked ? denied() : lstat(path)), + readdir: (path) => (path === locked ? denied() : readdir(path, { withFileTypes: true })) + } + }) + + expect(result.kind).toBe('complete') + const snapshot = (result as Extract).snapshot + expect(snapshot.has(readable)).toBe(true) + expect(snapshot.get(locked)?.directory).toBe(true) + expect(snapshot.has(join(locked, 'secret.md'))).toBe(false) + expect(snapshot.has(blocked)).toBe(false) + }) + it('bounds asynchronous native-event inspection without committing partial state', async () => { const snapshot: SafetySnapshot = new Map([ ['/repo', { directory: true, signature: 'd:1' }], diff --git a/src/main/ipc/wsl-watcher-host-safety.ts b/src/main/ipc/wsl-watcher-host-safety.ts index c236afbae66..5bf67711a3d 100644 --- a/src/main/ipc/wsl-watcher-host-safety.ts +++ b/src/main/ipc/wsl-watcher-host-safety.ts @@ -2,7 +2,7 @@ import { boundedFileSystemOperation, createSafetySnapshotEntry, defaultSafetyFileSystem, - fileSystemErrorCode, + isInvisibleFileSystemError, type SafetyScanOptions, type SafetySnapshot, type SafetySnapshotEntry @@ -87,9 +87,9 @@ export async function applyNativeEventsToSafetySnapshot( return 'topology' } if (statResult.kind === 'error') { - if (fileSystemErrorCode(statResult.error) !== 'ENOENT') { - // Why: keep the known-good baseline when permissions or I/O failures - // leave the path's recursive-watch topology uncertain. + // Why: vanished and unreadable paths are equally invisible to safety + // scans; only unknown I/O failures leave the topology uncertain. + if (!isInvisibleFileSystemError(statResult.error)) { return 'topology' } if (previous?.directory) {