diff --git a/config/main-sync-spawn-baseline.txt b/config/main-sync-spawn-baseline.txt index c9ab24eb435..645102b49aa 100644 --- a/config/main-sync-spawn-baseline.txt +++ b/config/main-sync-spawn-baseline.txt @@ -72,8 +72,7 @@ 1 src/main/startup/linux-secret-service-probe.ts 2 src/main/win32-utils.ts 1 src/main/worktree-runner-script.ts -2 src/main/wsl-availability.ts -5 src/main/wsl.ts +4 src/main/wsl.ts 3 src/main/zcode/zcode-plan-api-key-store.ts 2 src/main/zsh-startup-hook-pty-harness.ts 1 src/shared/bounded-secure-json-file.ts diff --git a/config/process-host-direct-import-baseline.txt b/config/process-host-direct-import-baseline.txt index 5462176350d..e3b7a961c5c 100644 --- a/config/process-host-direct-import-baseline.txt +++ b/config/process-host-direct-import-baseline.txt @@ -116,9 +116,6 @@ src/main/win32-utils.ts src/main/window/clipboard-ipc-handlers.ts src/main/windows-process-tree-kill.ts src/main/workspace-space-analysis.ts -src/main/wsl-availability.ts -src/main/wsl-unc-delete.ts -src/main/wsl.ts src/main/zsh-startup-hook-pty-harness.ts src/shared/ephemeral-vm-recipe-process.ts src/shared/ephemeral-vm-recipe-runner.ts diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json index 361a3479c6e..6dce8910ee8 100644 --- a/config/tsconfig.tc.web.json +++ b/config/tsconfig.tc.web.json @@ -35,6 +35,7 @@ "../src/main/wsl-home-cache.ts", "../src/main/wsl.ts", "../src/main/wsl-interop-spawn-directory.ts", + "../src/main/wsl-exe-process.ts", "../src/main/persistence/applying-settings/ui-state-read.ts", "../src/main/persistence/applying-settings/ui-state-update.ts", "../src/main/persistence/applying-settings/ui-selection-normalization.ts", diff --git a/src/main/ai-vault/cached-session-list-wsl-probe.test.ts b/src/main/ai-vault/cached-session-list-wsl-probe.test.ts index 14256c9a83a..c44f10eeb2d 100644 --- a/src/main/ai-vault/cached-session-list-wsl-probe.test.ts +++ b/src/main/ai-vault/cached-session-list-wsl-probe.test.ts @@ -1,15 +1,15 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as childProcess from 'node:child_process' +import type { ProcessResult } from '@orca/process-host/process-spec' -const { execFileMock, scanAiVaultSessionsInService } = vi.hoisted(() => ({ - execFileMock: vi.fn(), +const { runProcessMock, scanAiVaultSessionsInService } = vi.hoisted(() => ({ + runProcessMock: vi.fn(), scanAiVaultSessionsInService: vi.fn() })) -vi.mock('child_process', async (importOriginal) => ({ - ...(await importOriginal()), - execFile: execFileMock +vi.mock('@orca/process-host', async (importOriginal) => ({ + ...(await importOriginal()), + runProcess: runProcessMock })) vi.mock('./session-scanner-service-spawn', async (importOriginal) => ({ ...(await importOriginal()), @@ -31,10 +31,16 @@ import { const NATIVE_CODEX_HOME = 'C:\\Users\\ada\\.codex' const WSL_HOME = '\\\\wsl.localhost\\Ubuntu\\home\\ada' +function exited(stdout: string, code: number | null = 0): ProcessResult { + return { code, signal: null, stdout, stderr: '', timedOut: false } +} + +const FAILED = exited('', 1) + function wslSpawns(): string[][] { - return execFileMock.mock.calls - .filter(([command]) => command === 'wsl.exe') - .flatMap(([, args]) => (Array.isArray(args) ? [args.map(String)] : [])) + return runProcessMock.mock.calls + .filter(([spec]) => spec.program === 'wsl.exe') + .map(([spec]) => spec.args.map(String)) } // Why the real wsl module: the point is the wsl.exe spawn count across the WHOLE @@ -47,7 +53,7 @@ describe('AI Vault listing wsl.exe probes', () => { scanAiVaultSessionsInService.mockResolvedValue({ sessions: [], issues: [], scannedAt: 'scan' }) }) afterEach(() => { - execFileMock.mockReset() + runProcessMock.mockReset() _resetWslCachesForTests() resetAiVaultSessionListCacheForTests() vi.restoreAllMocks() @@ -70,9 +76,12 @@ describe('AI Vault listing wsl.exe probes', () => { it('still probes running distros when one is installed, so a later outage keeps the last-known-good list', async () => { _setWslCachesForTests({ distros: ['Ubuntu'] }) - execFileMock.mockImplementation((_command, args, _options, callback) => { - callback(null, args.includes('--running') ? 'Ubuntu\n' : '/home/ada\n') - }) + runProcessMock.mockImplementation( + ({ args }: { args: string[] }) => + new Promise((resolve) => { + resolve(exited(args.includes('--running') ? 'Ubuntu\n' : '/home/ada\n')) + }) + ) await listAiVaultSessions() @@ -85,9 +94,7 @@ describe('AI Vault listing wsl.exe probes', () => { expect.anything() ) - execFileMock.mockImplementation((_command, _args, _options, callback) => { - callback(new Error('wsl unavailable'), '') - }) + runProcessMock.mockResolvedValue(FAILED) await expect(filterPathsToRunningWslDistrosAsync([`${WSL_HOME}\\.codex`])).resolves.toEqual([ `${WSL_HOME}\\.codex` ]) @@ -96,15 +103,18 @@ describe('AI Vault listing wsl.exe probes', () => { // Why: a rejected `--list --quiet` yields [] without caching. Treating that as "no distro // installed" would narrow the allowed roots delete/subagent validation trusts. it('still discovers WSL homes after the installed-distro probe was rejected', async () => { - execFileMock.mockImplementation((_command, args, _options, callback) => { - if (args.includes('--running')) { - callback(null, 'Ubuntu\n') - } else if (args.includes('--list')) { - callback(new Error('wsl.exe transient failure'), '') - } else { - callback(null, '/home/ada\n') - } - }) + runProcessMock.mockImplementation( + ({ args }: { args: string[] }) => + new Promise((resolve) => { + if (args.includes('--running')) { + resolve(exited('Ubuntu\n')) + } else if (args.includes('--list')) { + resolve(FAILED) + } else { + resolve(exited('/home/ada\n')) + } + }) + ) await expect(listWslDistrosAsync()).resolves.toEqual([]) await expect(getAiVaultWslHomeDirs()).resolves.toEqual([WSL_HOME]) diff --git a/src/main/ipc/app.test.ts b/src/main/ipc/app.test.ts index 83b749350f7..822191d3cd8 100644 --- a/src/main/ipc/app.test.ts +++ b/src/main/ipc/app.test.ts @@ -118,7 +118,6 @@ vi.mock('./macos-keyboard-layout-change-notifications', () => ({ })) const windowsProbes = vi.hoisted(() => ({ - isWslAvailable: vi.fn(() => true), isWslAvailableAsync: vi.fn(async () => true), listWslDistros: vi.fn(() => ['Ubuntu']), listWslDistrosAsync: vi.fn(async () => ['Ubuntu']), @@ -127,7 +126,6 @@ const windowsProbes = vi.hoisted(() => ({ })) vi.mock('../wsl', () => ({ - isWslAvailable: windowsProbes.isWslAvailable, isWslAvailableAsync: windowsProbes.isWslAvailableAsync, listWslDistros: windowsProbes.listWslDistros, listWslDistrosAsync: windowsProbes.listWslDistrosAsync @@ -464,7 +462,6 @@ describe('registerAppHandlers', () => { expect(windowsProbes.isWslAvailableAsync).toHaveBeenCalledTimes(1) expect(windowsProbes.listWslDistrosAsync).toHaveBeenCalledTimes(1) expect(windowsProbes.isPwshAvailableAsync).toHaveBeenCalledTimes(1) - expect(windowsProbes.isWslAvailable).not.toHaveBeenCalled() expect(windowsProbes.listWslDistros).not.toHaveBeenCalled() expect(windowsProbes.isPwshAvailable).not.toHaveBeenCalled() }) diff --git a/src/main/runtime/rpc/methods/host-capabilities.test.ts b/src/main/runtime/rpc/methods/host-capabilities.test.ts index 726aa5d90a7..96a60178e25 100644 --- a/src/main/runtime/rpc/methods/host-capabilities.test.ts +++ b/src/main/runtime/rpc/methods/host-capabilities.test.ts @@ -7,7 +7,6 @@ import type { OrcaRuntimeService } from '../../orca-runtime' const { isPwshAvailable, isPwshAvailableAsync, - isWslAvailable, isWslAvailableAsync, listWslDistros, listWslDistrosAsync, @@ -15,7 +14,6 @@ const { } = vi.hoisted(() => ({ isPwshAvailable: vi.fn(), isPwshAvailableAsync: vi.fn(), - isWslAvailable: vi.fn(), isWslAvailableAsync: vi.fn(), listWslDistros: vi.fn(), listWslDistrosAsync: vi.fn(), @@ -24,7 +22,6 @@ const { vi.mock('../../../pwsh', () => ({ isPwshAvailable, isPwshAvailableAsync })) vi.mock('../../../wsl', () => ({ - isWslAvailable, isWslAvailableAsync, listWslDistros, listWslDistrosAsync @@ -41,7 +38,6 @@ describe('host capability RPC methods', () => { beforeEach(() => { isPwshAvailable.mockReset() isPwshAvailableAsync.mockReset() - isWslAvailable.mockReset() isWslAvailableAsync.mockReset() listWslDistros.mockReset() listWslDistrosAsync.mockReset() @@ -92,7 +88,6 @@ describe('host capability RPC methods', () => { expect(isWslAvailableAsync).toHaveBeenCalledTimes(1) expect(listWslDistrosAsync).toHaveBeenCalledTimes(1) expect(isPwshAvailableAsync).toHaveBeenCalledTimes(1) - expect(isWslAvailable).not.toHaveBeenCalled() expect(listWslDistros).not.toHaveBeenCalled() expect(isPwshAvailable).not.toHaveBeenCalled() }) diff --git a/src/main/wsl-availability-missing-kernel.test.ts b/src/main/wsl-availability-missing-kernel.test.ts index 95df76e7de5..56f857adf0d 100644 --- a/src/main/wsl-availability-missing-kernel.test.ts +++ b/src/main/wsl-availability-missing-kernel.test.ts @@ -1,17 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { execFile, execFileSync } from 'node:child_process' -import { runProcess, runProcessSync } from '@orca/process-host' -import type { ProcessResult } from '@orca/process-host/process-spec' -import { - _resetWslAvailabilityCacheForTests, - isWslAvailable, - isWslAvailableAsync -} from './wsl-availability' +import { runProcess } from '@orca/process-host' +import type { ProcessResult, ProcessSpec } from '@orca/process-host/process-spec' +import { _resetWslAvailabilityCacheForTests, isWslAvailableAsync } from './wsl-availability' -vi.mock('node:child_process', () => ({ execFile: vi.fn(), execFileSync: vi.fn() })) vi.mock('@orca/process-host', () => ({ - runProcess: vi.fn(), - runProcessSync: vi.fn() + runProcess: vi.fn() })) vi.mock('./wsl-interop-spawn-directory', () => ({ resolveWslInteropSpawnCwd: () => 'C:\\Windows' @@ -20,80 +13,76 @@ vi.mock('./wsl-interop-spawn-directory', () => ({ const originalPlatform = process.platform const success: ProcessResult = { code: 0, signal: null, stdout: '', stderr: '', timedOut: false } -beforeEach(() => { - vi.resetAllMocks() - Object.defineProperty(process, 'platform', { value: 'win32' }) - _resetWslAvailabilityCacheForTests() -}) -afterEach(() => { - Object.defineProperty(process, 'platform', { value: originalPlatform }) - _resetWslAvailabilityCacheForTests() -}) +describe('WSL1 availability without WSL2 kernel', () => { + const guestCalls = () => + vi.mocked(runProcess).mock.calls.filter(([spec]) => spec.args?.[0] === '--exec') + let statusCode = 0 + let guest: () => ProcessResult = () => success -for (const mode of ['sync', 'async'] as const) { - describe(`${mode} WSL1 availability without WSL2 kernel`, () => { - const probe = () => (mode === 'sync' ? isWslAvailable() : isWslAvailableAsync()) - const guestRunner = () => (mode === 'sync' ? runProcessSync : runProcess) + function failStatus(code: number): void { + statusCode = code + } + function guestResult(result: ProcessResult): void { + guest = () => result + } + function respond(spec: ProcessSpec): ProcessResult { + return spec.args?.[0] === '--status' ? { ...success, code: statusCode } : guest() + } - function failStatus(code: number): void { - vi.mocked(execFileSync).mockImplementation(() => { - throw { status: code } - }) - vi.mocked(execFile).mockImplementation((...args: unknown[]) => { - const callback = args.at(-1) as (error: unknown) => void - callback({ code }) - return {} as ReturnType - }) - } - function guestResult(result: ProcessResult): void { - vi.mocked(runProcess).mockResolvedValue(result) - vi.mocked(runProcessSync).mockReturnValue(result) - } - - // Node reports the Windows DWORD; the console prints its signed equivalent. - for (const status of [-444, 4_294_966_852]) { - it(`requires guest execution and caches its success for ${status}`, async () => { - failStatus(status) - guestResult(success) - expect(await probe()).toBe(true) - expect(await probe()).toBe(true) - expect(guestRunner()).toHaveBeenCalledTimes(1) - expect(guestRunner()).toHaveBeenCalledWith( - expect.objectContaining({ - program: 'wsl.exe', - args: ['--exec', '/bin/true'], - timeoutMs: 5000, - cwd: 'C:\\Windows' - }) - ) - }) - } - - for (const result of [ - { ...success, code: 1 }, - { ...success, code: null, timedOut: true } - ]) { - it(`keeps a failed guest unavailable: ${JSON.stringify(result)}`, async () => { - failStatus(-444) - guestResult(result) - expect(await probe()).toBe(false) - }) - } - - it('stays unavailable when the guest probe cannot be spawned', async () => { - failStatus(-444) - vi.mocked(runProcess).mockRejectedValue(new Error('EPERM')) - vi.mocked(runProcessSync).mockImplementation(() => { - throw new Error('EPERM') - }) - expect(await probe()).toBe(false) - }) - - it('does not probe a guest for unrelated status failures', async () => { - failStatus(1) - expect(await probe()).toBe(false) - expect(runProcess).not.toHaveBeenCalled() - expect(runProcessSync).not.toHaveBeenCalled() - }) + beforeEach(() => { + vi.resetAllMocks() + Object.defineProperty(process, 'platform', { value: 'win32' }) + _resetWslAvailabilityCacheForTests() + statusCode = 0 + guest = () => success + vi.mocked(runProcess).mockImplementation(async (spec) => respond(spec)) }) -} + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }) + _resetWslAvailabilityCacheForTests() + }) + + // Node reports the Windows DWORD; the console prints its signed equivalent. + for (const status of [-444, 4_294_966_852]) { + it(`requires guest execution and caches its success for ${status}`, async () => { + failStatus(status) + guestResult(success) + expect(await isWslAvailableAsync()).toBe(true) + expect(await isWslAvailableAsync()).toBe(true) + expect(guestCalls()).toHaveLength(1) + expect(runProcess).toHaveBeenCalledWith( + expect.objectContaining({ + program: 'wsl.exe', + args: ['--exec', '/bin/true'], + timeoutMs: 5000, + cwd: 'C:\\Windows' + }) + ) + }) + } + + for (const result of [ + { ...success, code: 1 }, + { ...success, code: null, timedOut: true } + ]) { + it(`keeps a failed guest unavailable: ${JSON.stringify(result)}`, async () => { + failStatus(-444) + guestResult(result) + expect(await isWslAvailableAsync()).toBe(false) + }) + } + + it('stays unavailable when the guest probe cannot be spawned', async () => { + failStatus(-444) + guest = () => { + throw new Error('EPERM') + } + expect(await isWslAvailableAsync()).toBe(false) + }) + + it('does not probe a guest for unrelated status failures', async () => { + failStatus(1) + expect(await isWslAvailableAsync()).toBe(false) + expect(guestCalls()).toHaveLength(0) + }) +}) diff --git a/src/main/wsl-availability.ts b/src/main/wsl-availability.ts index 1e133361e20..8b7abc37d93 100644 --- a/src/main/wsl-availability.ts +++ b/src/main/wsl-availability.ts @@ -1,8 +1,7 @@ -import { execFile, execFileSync } from 'node:child_process' -import { runProcess, runProcessSync } from '@orca/process-host' -import type { ProcessSpec } from '@orca/process-host/process-spec' +import { runProcess } from '@orca/process-host' +import type { ProcessResult, ProcessSpec } from '@orca/process-host/process-spec' import { buildWslExecArgs } from '../shared/wsl-login-shell-command' -import { resolveWslInteropSpawnCwd } from './wsl-interop-spawn-directory' +import { wslExeProcessSpec } from './wsl-exe-process' type WslAvailabilityCache = | { available: true } @@ -15,7 +14,7 @@ let wslAvailabilityProbeInFlight: Promise | null = null let wslAvailabilityCacheGeneration = 0 const WSL_AVAILABILITY_PROBE_TIMEOUT_MS = 5000 -// Why: availability is a separate, blocking probe. Deliberately not a multiple of the +// Why: availability is a separate wsl.exe spawn. Deliberately not a multiple of the // renderer's 30s capability TTL, so repeated refreshes don't land on this boundary and // re-probe every cycle. const WSL_AVAILABILITY_NEGATIVE_CACHE_TTL_MS = 45_000 @@ -39,22 +38,6 @@ function wslAvailabilityRetryDelayMs(cache: { retryable: boolean; failures: numb return Math.min(base * 2 ** (cache.failures - 1), WSL_AVAILABILITY_MAX_RETRY_DELAY_MS) } -// Why ENOENT stays definitive: it means wsl.exe is not on PATH. It used to also mean -// "the cwd this process inherited was deleted", which is not answer-shaped at all -- -// naming an explicit spawn directory below is what removes that source (#16463). -// Why: a non-zero exit (wsl.exe ran and said no) or ENOENT (not installed) is answer-shaped, -// so it earns a long window rather than the short one a timeout gets. execFileSync reports the -// exit code as `status`, the execFile callback as a numeric `code`; both must count as -// definitive or the async twin poisons the shared cache with the short retryable window. -// Same numeric-status rule as `wslUncDirectoryExists`; neither latches forever. -function isRetryableWslProbeFailure(error: unknown): boolean { - const failure = error as { status?: unknown; code?: unknown } | null - if (typeof failure?.status === 'number' || typeof failure?.code === 'number') { - return false - } - return failure?.code !== 'ENOENT' -} - function isWslAvailabilityCacheFresh(cache: WslAvailabilityCache): boolean { if (isPermanentWslAvailabilityCache(cache)) { return true @@ -80,94 +63,76 @@ function writeWslAvailabilityCache(cache: WslAvailabilityCache | null): void { wslAvailabilityCacheGeneration += 1 } -function cacheWslAvailabilityProbeResult(error: unknown, startedAtGeneration: number): boolean { - if (error && startedAtGeneration !== wslAvailabilityCacheGeneration) { +/** Why a probe said no; null means WSL answered. */ +type WslProbeFailure = { retryable: boolean } + +function cacheWslAvailabilityProbeResult( + failure: WslProbeFailure | null, + startedAtGeneration: number +): boolean { + if (failure && startedAtGeneration !== wslAvailabilityCacheGeneration) { return wslAvailableCache?.available ?? false } writeWslAvailabilityCache( - error + failure ? { available: false, cachedAt: Date.now(), - retryable: isRetryableWslProbeFailure(error), + retryable: failure.retryable, failures: previousWslAvailabilityFailures() + 1 } : { available: true } ) - return !error + return !failure } // `wsl --status` exits 0x1bc when the WSL2 kernel package is missing -- a package // a WSL1 distro never needed. Node keeps the Windows DWORD; the console prints the // signed form, and either spelling can reach us. -function isMissingWsl2KernelStatus(error: unknown): boolean { - const failure = error as { status?: unknown; code?: unknown } | null - return [failure?.status, failure?.code].some((code) => code === -444 || code === 4_294_966_852) -} +const MISSING_WSL2_KERNEL_EXIT_CODES: ReadonlySet = new Set([-444, 4_294_966_852]) // Cheapest proof the default guest runs: no login shell, no output to parse. function defaultGuestExecutionProbe(): ProcessSpec { return { - program: 'wsl.exe', - args: buildWslExecArgs(undefined, ['/bin/true']), - cwd: resolveWslInteropSpawnCwd(), - timeoutMs: WSL_AVAILABILITY_PROBE_TIMEOUT_MS, + ...wslExeProcessSpec( + buildWslExecArgs(undefined, ['/bin/true']), + WSL_AVAILABILITY_PROBE_TIMEOUT_MS + ), maxOutputBytes: 4096 } } /** - * The `--status` error still worth caching, or null once the guest ran anyway. - * - * Why it returns that error rather than a fresh negative: a guest probe that could not - * spawn means "could not ask", and minting an answer for that is the bug this subsystem - * keeps re-shipping (docs/reference/wsl-probe-failure-semantics.md). + * Whether the default guest runs. A guest probe that could not spawn means "could not + * ask", so the caller keeps the `--status` answer rather than minting a fresh negative + * (docs/reference/wsl-probe-failure-semantics.md). */ -function wslStatusErrorAfterGuestProbe(error: unknown): unknown { - if (!isMissingWsl2KernelStatus(error)) { - return error - } +async function defaultGuestRuns(): Promise { try { - return runProcessSync(defaultGuestExecutionProbe()).code === 0 ? null : error + return (await runProcess(defaultGuestExecutionProbe())).code === 0 } catch { - return error + return false } } -/** Async twin of `wslStatusErrorAfterGuestProbe`; the sync/async pair share one cache. */ -async function wslStatusErrorAfterGuestProbeAsync(error: unknown): Promise { - if (!isMissingWsl2KernelStatus(error)) { - return error - } +async function probeWslAvailability(): Promise { + let status: ProcessResult try { - return (await runProcess(defaultGuestExecutionProbe())).code === 0 ? null : error - } catch { - return error + status = await runProcess(wslExeProcessSpec(['--status'], WSL_AVAILABILITY_PROBE_TIMEOUT_MS)) + } catch (error) { + // Why ENOENT is definitive: it means wsl.exe is not on PATH. The explicit spawn + // directory in `wslExeProcessSpec` rules out a deleted inherited cwd (#16463). + return { retryable: !(error instanceof Error && 'code' in error && error.code === 'ENOENT') } } -} - -function probeWslStatus(): Promise { - return new Promise((resolve, reject) => { - execFile( - 'wsl.exe', - ['--status'], - { - timeout: WSL_AVAILABILITY_PROBE_TIMEOUT_MS, - windowsHide: true, - // Why explicit (#16463): inheriting a cwd the user deleted makes - // CreateProcessW fail ENOENT, which this cache reads as "WSL is not - // installed" and holds on the definitive TTL with backoff. - cwd: resolveWslInteropSpawnCwd() - }, - (error: unknown) => { - if (error) { - reject(error) - return - } - resolve() - } - ) - }) + if (status.code === 0) { + return null + } + if (MISSING_WSL2_KERNEL_EXIT_CODES.has(status.code) && (await defaultGuestRuns())) { + return null + } + // Why: a non-zero exit (wsl.exe ran and said no) is answer-shaped and earns the long + // window; a timeout or signal kill (no exit code) only earns the short one. + return { retryable: status.code === null } } /** @@ -175,43 +140,10 @@ function probeWslStatus(): Promise { * Success caches for the process lifetime; every failure is re-probed eventually, so * a slow wsl.exe activation on a just-installed or just-rebooted machine cannot latch * WSL off for the whole session. - */ -export function isWslAvailable(): boolean { - const cached = reusableWslAvailability() - if (cached !== null) { - return cached - } - - const startedAtGeneration = wslAvailabilityCacheGeneration - - if (process.platform !== 'win32') { - writeWslAvailabilityCache({ available: false, unsupported: true }) - return false - } - - try { - execFileSync('wsl.exe', ['--status'], { - stdio: ['pipe', 'pipe', 'pipe'], - timeout: WSL_AVAILABILITY_PROBE_TIMEOUT_MS, - // Same reason as the async twin: they share one cache, so a false ENOENT - // from either poisons both. - cwd: resolveWslInteropSpawnCwd() - }) - return cacheWslAvailabilityProbeResult(null, startedAtGeneration) - } catch (error) { - return cacheWslAvailabilityProbeResult( - wslStatusErrorAfterGuestProbe(error), - startedAtGeneration - ) - } -} - -/** - * Async twin of `isWslAvailable`, sharing its cache and backoff. * - * Why: the renderer's capability read reaches this over IPC, and the sync probe blocks the - * Electron main thread — every PTY message, window IPC and watchdog beat — for up to 5s on a - * wedged wsl.exe. Concurrent callers share one spawn. + * Why async: the renderer's capability read reaches this over IPC, and a sync probe would + * block the Electron main thread — every PTY message, window IPC and watchdog beat — for up + * to 5s on a wedged wsl.exe. Concurrent callers share one spawn. */ export function isWslAvailableAsync(): Promise { const cached = reusableWslAvailability() @@ -229,14 +161,8 @@ export function isWslAvailableAsync(): Promise { } const startedAtGeneration = wslAvailabilityCacheGeneration - wslAvailabilityProbeInFlight = probeWslStatus() - .then(() => cacheWslAvailabilityProbeResult(null, startedAtGeneration)) - .catch(async (error: unknown) => - cacheWslAvailabilityProbeResult( - await wslStatusErrorAfterGuestProbeAsync(error), - startedAtGeneration - ) - ) + wslAvailabilityProbeInFlight = probeWslAvailability() + .then((failure) => cacheWslAvailabilityProbeResult(failure, startedAtGeneration)) .finally(() => { wslAvailabilityProbeInFlight = null }) @@ -249,7 +175,7 @@ export function hasCachedWslAvailability(): boolean { // Why: same contract as the distro getter — report the last observed answer. Going // null on staleness would drop the `wsl-unavailable` repair prompt and let git and -// PTY silently resolve to a WSL that last failed to respond. `isWslAvailable` is what +// PTY silently resolve to a WSL that last failed to respond. `isWslAvailableAsync` is what // clears it, by re-probing once the retry window lapses. export function getCachedWslAvailability(): boolean | null { return wslAvailableCache?.available ?? null @@ -259,7 +185,7 @@ export function getCachedWslAvailability(): boolean | null { // first — so a definitive failure held for 10-30min would report `wsl-unavailable` over a // WSL that just listed a distro for us. A non-empty list proves wsl.exe ran, so drop the // stale failure and let the next call re-probe. Non-empty lists are cached for the process -// lifetime, so this cannot re-spawn the blocking probe more than once. +// lifetime, so this cannot re-spawn the probe more than once. export function dropStaleWslAvailabilityFailure(): void { wslAvailabilityCacheGeneration += 1 if (wslAvailableCache && !wslAvailableCache.available && !('unsupported' in wslAvailableCache)) { diff --git a/src/main/wsl-distro-list-single-flight.test.ts b/src/main/wsl-distro-list-single-flight.test.ts index 64e11591cd3..303347ccc5b 100644 --- a/src/main/wsl-distro-list-single-flight.test.ts +++ b/src/main/wsl-distro-list-single-flight.test.ts @@ -1,19 +1,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as childProcess from 'node:child_process' +import type { ProcessResult } from '@orca/process-host/process-spec' -const { execFileMock, execFileSyncMock } = vi.hoisted(() => ({ - execFileMock: vi.fn(), - execFileSyncMock: vi.fn() +const { runProcessMock, runProcessSyncMock } = vi.hoisted(() => ({ + runProcessMock: vi.fn(), + runProcessSyncMock: vi.fn() })) -vi.mock('child_process', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - execFile: execFileMock, - execFileSync: execFileSyncMock - } -}) +vi.mock('@orca/process-host', () => ({ + runProcess: runProcessMock, + runProcessSync: runProcessSyncMock +})) + +function exited(stdout: string, code: number | null = 0): ProcessResult { + return { code, signal: null, stdout, stderr: '', timedOut: false } +} + +const FAILED = exited('', 1) import { _resetWslCachesForTests, @@ -34,8 +36,8 @@ describe('WSL distro list single-flight', () => { afterEach(() => { Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) - execFileMock.mockReset() - execFileSyncMock.mockReset() + runProcessMock.mockReset() + runProcessSyncMock.mockReset() _resetWslCachesForTests() }) @@ -43,12 +45,15 @@ describe('WSL distro list single-flight', () => { // result lands. One host-wide answer should cost one physical wsl.exe spawn. it('single-flights concurrent asynchronous discovery', async () => { let finishProbe: ((output: string) => void) | undefined - execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { - finishProbe = (output) => callback(null, output) - }) + runProcessMock.mockImplementationOnce( + () => + new Promise((resolve) => { + finishProbe = (output) => resolve(exited(output)) + }) + ) const pending = [listWslDistrosAsync(), listWslDistrosAsync(), listWslDistrosAsync()] - expect(execFileMock).toHaveBeenCalledTimes(1) + expect(runProcessMock).toHaveBeenCalledTimes(1) finishProbe?.('Ubuntu\n') await expect(Promise.all(pending)).resolves.toEqual([['Ubuntu'], ['Ubuntu'], ['Ubuntu']]) @@ -58,26 +63,27 @@ describe('WSL distro list single-flight', () => { it('single-flights a concurrent empty result without hiding a distro installed later', async () => { vi.useFakeTimers() let finishProbe: ((output: string) => void) | undefined - execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { - finishProbe = (output) => callback(null, output) - }) - execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { - callback(null, 'Ubuntu\n') - }) + runProcessMock.mockImplementationOnce( + () => + new Promise((resolve) => { + finishProbe = (output) => resolve(exited(output)) + }) + ) + runProcessMock.mockResolvedValueOnce(exited('Ubuntu\n')) try { const pending = [listWslDistrosAsync(), listWslDistrosAsync(), listWslDistrosAsync()] - expect(execFileMock).toHaveBeenCalledTimes(1) + expect(runProcessMock).toHaveBeenCalledTimes(1) finishProbe?.('') await expect(Promise.all(pending)).resolves.toEqual([[], [], []]) // One shared empty answer arms one 15s window, not three doublings. vi.advanceTimersByTime(14_999) await expect(listWslDistrosAsync()).resolves.toEqual([]) - expect(execFileMock).toHaveBeenCalledTimes(1) + expect(runProcessMock).toHaveBeenCalledTimes(1) vi.advanceTimersByTime(1) await expect(listWslDistrosAsync()).resolves.toEqual(['Ubuntu']) - expect(execFileMock).toHaveBeenCalledTimes(2) + expect(runProcessMock).toHaveBeenCalledTimes(2) } finally { vi.useRealTimers() } @@ -87,25 +93,26 @@ describe('WSL distro list single-flight', () => { it('single-flights a concurrent failure and keeps the retry window bounded', async () => { vi.useFakeTimers() let failProbe: (() => void) | undefined - execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { - failProbe = () => callback(new Error('transient failure'), '') - }) - execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { - callback(null, 'Ubuntu\n') - }) + runProcessMock.mockImplementationOnce( + () => + new Promise((resolve) => { + failProbe = () => resolve(FAILED) + }) + ) + runProcessMock.mockResolvedValueOnce(exited('Ubuntu\n')) try { const pending = [listWslDistrosAsync(), listWslDistrosAsync(), listWslDistrosAsync()] - expect(execFileMock).toHaveBeenCalledTimes(1) + expect(runProcessMock).toHaveBeenCalledTimes(1) failProbe?.() await expect(Promise.all(pending)).resolves.toEqual([[], [], []]) vi.advanceTimersByTime(14_999) await expect(listWslDistrosAsync()).resolves.toEqual([]) - expect(execFileMock).toHaveBeenCalledTimes(1) + expect(runProcessMock).toHaveBeenCalledTimes(1) vi.advanceTimersByTime(1) await expect(listWslDistrosAsync()).resolves.toEqual(['Ubuntu']) - expect(execFileMock).toHaveBeenCalledTimes(2) + expect(runProcessMock).toHaveBeenCalledTimes(2) } finally { vi.useRealTimers() } @@ -116,10 +123,13 @@ describe('WSL distro list single-flight', () => { // for the whole window even though the pending probe is about to see the new distro. it('lets a caller joining a pending probe see a distro the sync empty result hid', async () => { let finishProbe: ((output: string) => void) | undefined - execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { - finishProbe = (output) => callback(null, output) - }) - execFileSyncMock.mockReturnValue('') + runProcessMock.mockImplementationOnce( + () => + new Promise((resolve) => { + finishProbe = (output) => resolve(exited(output)) + }) + ) + runProcessSyncMock.mockReturnValue(exited('')) const pending = listWslDistrosAsync() expect(listWslDistros()).toEqual([]) @@ -128,21 +138,21 @@ describe('WSL distro list single-flight', () => { await expect(joined).resolves.toEqual(['Ubuntu']) await expect(pending).resolves.toEqual(['Ubuntu']) - expect(execFileMock).toHaveBeenCalledTimes(1) + expect(runProcessMock).toHaveBeenCalledTimes(1) }) // Why: joining is only correct while the answer can still improve. A list already found // synchronously is lifetime-stable, and waiting on the probe would make an answered // question sit out the 5s wsl.exe timeout. it('returns a list found synchronously without waiting on the pending probe', async () => { - execFileMock.mockImplementationOnce(() => {}) - execFileSyncMock.mockReturnValue('Ubuntu\n') + runProcessMock.mockImplementationOnce(() => new Promise(() => {})) + runProcessSyncMock.mockReturnValue(exited('Ubuntu\n')) void listWslDistrosAsync() expect(listWslDistros()).toEqual(['Ubuntu']) await expect(listWslDistrosAsync()).resolves.toEqual(['Ubuntu']) - expect(execFileMock).toHaveBeenCalledTimes(1) + expect(runProcessMock).toHaveBeenCalledTimes(1) }) // Synchronous callers stay independent, so the cache sequence guard still has to protect a @@ -152,10 +162,13 @@ describe('WSL distro list single-flight', () => { ['different list', 'Debian\n'] ])('does not let an older async %s result overwrite a newer sync list', async (_label, late) => { let finishProbe: ((output: string) => void) | undefined - execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { - finishProbe = (output) => callback(null, output) - }) - execFileSyncMock.mockReturnValue('Ubuntu\n') + runProcessMock.mockImplementationOnce( + () => + new Promise((resolve) => { + finishProbe = (output) => resolve(exited(output)) + }) + ) + runProcessSyncMock.mockReturnValue(exited('Ubuntu\n')) const pending = listWslDistrosAsync() expect(listWslDistros()).toEqual(['Ubuntu']) @@ -168,22 +181,25 @@ describe('WSL distro list single-flight', () => { // Why: a cache reset retires the pending probe, and the retired one must not clear the // slot its successor now owns — that would leak an extra wsl.exe spawn into the next probe. it('does not let a probe retired by a cache reset clear the new in-flight slot', async () => { - const callbacks: ((error: Error | null, stdout: string) => void)[] = [] - execFileMock.mockImplementation((_command, _args, _options, callback) => { - callbacks.push(callback) - }) + const callbacks: ((result: ProcessResult) => void)[] = [] + runProcessMock.mockImplementation( + () => + new Promise((resolve) => { + callbacks.push(resolve) + }) + ) const retired = listWslDistrosAsync() _resetWslCachesForTests() const fresh = listWslDistrosAsync() expect(callbacks).toHaveLength(2) - callbacks[0]!(null, '') + callbacks[0]!(exited('')) await expect(retired).resolves.toEqual([]) const joined = listWslDistrosAsync() - expect(execFileMock).toHaveBeenCalledTimes(2) - callbacks[1]!(null, 'Ubuntu\n') + expect(runProcessMock).toHaveBeenCalledTimes(2) + callbacks[1]!(exited('Ubuntu\n')) await expect(Promise.all([fresh, joined])).resolves.toEqual([['Ubuntu'], ['Ubuntu']]) }) @@ -193,10 +209,13 @@ describe('WSL distro list single-flight', () => { it('holds the base window when a sync empty result and an async failure overlap', async () => { vi.useFakeTimers() let failProbe: (() => void) | undefined - execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { - failProbe = () => callback(new Error('transient failure'), '') - }) - execFileSyncMock.mockReturnValue('') + runProcessMock.mockImplementationOnce( + () => + new Promise((resolve) => { + failProbe = () => resolve(FAILED) + }) + ) + runProcessSyncMock.mockReturnValue(exited('')) try { const pending = listWslDistrosAsync() @@ -206,10 +225,10 @@ describe('WSL distro list single-flight', () => { vi.advanceTimersByTime(7_500) expect(listWslDistros()).toEqual([]) - expect(execFileSyncMock).toHaveBeenCalledTimes(1) + expect(runProcessSyncMock).toHaveBeenCalledTimes(1) vi.advanceTimersByTime(7_500) expect(listWslDistros()).toEqual([]) - expect(execFileSyncMock).toHaveBeenCalledTimes(2) + expect(runProcessSyncMock).toHaveBeenCalledTimes(2) } finally { vi.useRealTimers() } diff --git a/src/main/wsl-exe-process.ts b/src/main/wsl-exe-process.ts new file mode 100644 index 00000000000..a67709ff712 --- /dev/null +++ b/src/main/wsl-exe-process.ts @@ -0,0 +1,32 @@ +import type { ProcessResult, ProcessSpec } from '@orca/process-host/process-spec' +import { resolveWslInteropSpawnCwd } from './wsl-interop-spawn-directory' + +/** A host-side `wsl.exe` call (`--list`, `--status`, `-d --exec ...`). */ +export function wslExeProcessSpec( + args: readonly string[], + timeoutMs: number, + env?: NodeJS.ProcessEnv +): ProcessSpec { + // Why an explicit cwd (#16463): an inherited one can be a deleted worktree, which + // makes every spawn fail ENOENT and reads as "WSL is not installed". + return { program: 'wsl.exe', args, env, timeoutMs, cwd: resolveWslInteropSpawnCwd() } +} + +/** Why a `wsl.exe` call failed, or null for a clean exit. */ +export function wslExeFailure(result: ProcessResult): string | null { + if (result.code === 0) { + return null + } + return result.timedOut + ? 'wsl.exe timed out' + : `wsl.exe exited with ${result.code ?? result.signal}` +} + +/** Stdout of a clean exit; throws for any other outcome. */ +export function wslExeStdout(result: ProcessResult): string { + const failure = wslExeFailure(result) + if (failure) { + throw new Error(failure) + } + return result.stdout +} diff --git a/src/main/wsl-running-distros.test.ts b/src/main/wsl-running-distros.test.ts index 3da2481e451..147bdc488e7 100644 --- a/src/main/wsl-running-distros.test.ts +++ b/src/main/wsl-running-distros.test.ts @@ -1,12 +1,15 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import type * as childProcess from 'node:child_process' +import type { ProcessResult } from '@orca/process-host/process-spec' -const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() })) +const { runProcessMock } = vi.hoisted(() => ({ runProcessMock: vi.fn() })) -vi.mock('child_process', async (importOriginal) => ({ - ...(await importOriginal()), - execFile: execFileMock -})) +vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock })) + +function exited(stdout: string, code: number | null = 0): ProcessResult { + return { code, signal: null, stdout, stderr: '', timedOut: false } +} + +const FAILED = exited('', 1) import { _resetWslCachesForTests, @@ -31,28 +34,24 @@ async function withPlatform(value: NodeJS.Platform, fn: () => Promise): Pr describe('running WSL distro discovery', () => { afterEach(() => { - execFileMock.mockReset() + runProcessMock.mockReset() _resetWslCachesForTests() resetWslTranscriptRunningObserverForTests() vi.useRealTimers() }) it('lists only running user distros without starting them', async () => { - execFileMock.mockImplementation((_command, _args, _options, callback) => { - callback(null, 'Ubuntu\u0000\nDocker-Desktop\u0000\n') - }) + runProcessMock.mockResolvedValue(exited('Ubuntu\u0000\nDocker-Desktop\u0000\n')) await withPlatform('win32', async () => { await expect(listRunningWslDistrosAsync()).resolves.toEqual(['Ubuntu']) - expect(execFileMock).toHaveBeenCalledWith( - 'wsl.exe', - ['--list', '--running', '--quiet'], + expect(runProcessMock).toHaveBeenCalledWith( expect.objectContaining({ + program: 'wsl.exe', + args: ['--list', '--running', '--quiet'], env: expect.objectContaining({ WSL_UTF8: '1' }), - timeout: 5000, - windowsHide: true - }), - expect.any(Function) + timeoutMs: 5000 + }) ) }) }) @@ -60,12 +59,7 @@ describe('running WSL distro discovery', () => { it('confirms an empty list when no distro runs', async () => { // Captured on WSL 2.7: with nothing running, `--quiet` exits 0 with empty stdout in both // encodings; only the non-quiet form prints the localized message and exits -1. - execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { - callback(null, 'Ubuntu\r\n') - }) - execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { - callback(null, '') - }) + runProcessMock.mockResolvedValueOnce(exited('Ubuntu\r\n')).mockResolvedValueOnce(exited('')) await withPlatform('win32', async () => { await expect(listRunningWslDistrosAsync()).resolves.toEqual(['Ubuntu']) @@ -74,9 +68,7 @@ describe('running WSL distro discovery', () => { }) it('fails closed when running-distro discovery fails', async () => { - execFileMock.mockImplementation((_command, _args, _options, callback) => { - callback(new Error('wsl unavailable'), '') - }) + runProcessMock.mockResolvedValue(FAILED) await withPlatform('win32', async () => { await expect(listRunningWslDistrosAsync()).resolves.toEqual([]) @@ -85,15 +77,11 @@ describe('running WSL distro discovery', () => { it('rejects stale running state for launch admission through failure and backoff', async () => { vi.useFakeTimers() - execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { - callback(null, 'Ubuntu\n') - }) + runProcessMock.mockResolvedValueOnce(exited('Ubuntu\n')) await withPlatform('win32', async () => { await expect(listRunningWslDistrosAsync()).resolves.toEqual(['Ubuntu']) - execFileMock.mockImplementation((_command, _args, _options, callback) => { - callback(new Error('wsl unavailable'), '') - }) + runProcessMock.mockResolvedValue(FAILED) const paths = ['\\\\wsl.localhost\\Ubuntu\\home\\ada'] await expect( filterPathsToRunningWslDistrosAsync(paths, { requireConfirmed: true }) @@ -102,56 +90,58 @@ describe('running WSL distro discovery', () => { await expect(listRunningWslDistrosAsync({ requireConfirmed: true })).rejects.toThrow( 'discovery is unavailable' ) - expect(execFileMock).toHaveBeenCalledTimes(2) + expect(runProcessMock).toHaveBeenCalledTimes(2) - execFileMock.mockImplementation((_command, _args, _options, callback) => { - callback(null, '') - }) + runProcessMock.mockResolvedValue(exited('')) await vi.advanceTimersByTimeAsync(15_001) await expect( filterPathsToRunningWslDistrosAsync(paths, { requireConfirmed: true }) ).resolves.toEqual([]) - expect(execFileMock).toHaveBeenCalledTimes(3) + expect(runProcessMock).toHaveBeenCalledTimes(3) }) }) it.each([true, false])( 'shares the probe while preserving admission and observer failure policies (strict first: %s)', async (strictFirst) => { - execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { - callback(null, 'Ubuntu\n') - }) + runProcessMock.mockResolvedValueOnce(exited('Ubuntu\n')) await withPlatform('win32', async () => { await listRunningWslDistrosAsync() - let finishProbe: ((error: Error | null, output: string) => void) | undefined - execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { - finishProbe = callback - }) + let finishProbe: ((result: ProcessResult) => void) | undefined + runProcessMock.mockImplementationOnce( + () => + new Promise((resolve) => { + finishProbe = resolve + }) + ) const first = listRunningWslDistrosAsync({ requireConfirmed: strictFirst }) const second = listRunningWslDistrosAsync({ requireConfirmed: !strictFirst }) const strict = strictFirst ? first : second const observer = strictFirst ? second : first const rejected = expect(strict).rejects.toThrow('discovery is unavailable') - expect(execFileMock).toHaveBeenCalledTimes(2) - finishProbe?.(new Error('wsl unavailable'), '') + expect(runProcessMock).toHaveBeenCalledTimes(2) + finishProbe?.(FAILED) await rejected await expect(observer).resolves.toEqual(['Ubuntu']) - expect(execFileMock).toHaveBeenCalledTimes(2) + expect(runProcessMock).toHaveBeenCalledTimes(2) }) } ) it('resolves homes only for the running distro set', async () => { - execFileMock.mockImplementation((_command, args, _options, callback) => { - callback(null, args.includes('--running') ? 'Ubuntu\n' : '/home/ada\n') - }) + runProcessMock.mockImplementation( + ({ args }: { args: string[] }) => + new Promise((resolve) => { + resolve(exited(args.includes('--running') ? 'Ubuntu\n' : '/home/ada\n')) + }) + ) await withPlatform('win32', async () => { await expect(listRunningWslHomeDirsAsync()).resolves.toEqual([ '\\\\wsl.localhost\\Ubuntu\\home\\ada' ]) - expect(execFileMock.mock.calls.map(([, args]) => args)).toEqual([ + expect(runProcessMock.mock.calls.map(([spec]) => spec.args)).toEqual([ ['--list', '--running', '--quiet'], ['-d', 'Ubuntu', '--exec', 'bash', '-c', 'echo $HOME'] ]) @@ -160,9 +150,12 @@ describe('running WSL distro discovery', () => { it('single-flights concurrent probes without caching the result', async () => { let finishProbe: ((output: string) => void) | undefined - execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { - finishProbe = (output) => callback(null, output) - }) + runProcessMock.mockImplementationOnce( + () => + new Promise((resolve) => { + finishProbe = (output) => resolve(exited(output)) + }) + ) await withPlatform('win32', async () => { const concurrent = [ @@ -170,28 +163,29 @@ describe('running WSL distro discovery', () => { listRunningWslDistrosAsync(), listRunningWslDistrosAsync() ] - expect(execFileMock).toHaveBeenCalledTimes(1) + expect(runProcessMock).toHaveBeenCalledTimes(1) finishProbe?.('Ubuntu\n') await expect(Promise.all(concurrent)).resolves.toEqual([['Ubuntu'], ['Ubuntu'], ['Ubuntu']]) - execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { - callback(null, '') - }) + runProcessMock.mockResolvedValueOnce(exited('')) await expect(listRunningWslDistrosAsync()).resolves.toEqual([]) - expect(execFileMock).toHaveBeenCalledTimes(2) + expect(runProcessMock).toHaveBeenCalledTimes(2) }) }) it('single-flights cold HOME probes across concurrent callers', async () => { let finishList: ((output: string) => void) | undefined let finishHome: ((output: string) => void) | undefined - execFileMock.mockImplementation((_command, args, _options, callback) => { - if (args.includes('--running')) { - finishList = (output) => callback(null, output) - } else { - finishHome = (output) => callback(null, output) - } - }) + runProcessMock.mockImplementation( + ({ args }: { args: string[] }) => + new Promise((resolve) => { + if (args.includes('--running')) { + finishList = (output) => resolve(exited(output)) + } else { + finishHome = (output) => resolve(exited(output)) + } + }) + ) await withPlatform('win32', async () => { const concurrent = [ @@ -200,7 +194,7 @@ describe('running WSL distro discovery', () => { listRunningWslHomeDirsAsync() ] finishList?.('Ubuntu\n') - await vi.waitFor(() => expect(execFileMock).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => expect(runProcessMock).toHaveBeenCalledTimes(2)) finishHome?.('/home/ada\n') await expect(Promise.all(concurrent)).resolves.toEqual([ @@ -209,15 +203,13 @@ describe('running WSL distro discovery', () => { ['\\\\wsl.localhost\\Ubuntu\\home\\ada'] ]) expect( - execFileMock.mock.calls.filter(([, args]) => args.includes('echo $HOME')) + runProcessMock.mock.calls.filter(([spec]) => spec.args.includes('echo $HOME')) ).toHaveLength(1) }) }) it('shares a confirmed fresh probe between discovery and launch admission', async () => { - execFileMock.mockImplementationOnce((_command, _args, _options, callback) => - callback(null, 'Ubuntu\n') - ) + runProcessMock.mockResolvedValueOnce(exited('Ubuntu\n')) await withPlatform('win32', async () => { expect( await Promise.all([ @@ -225,14 +217,12 @@ describe('running WSL distro discovery', () => { listRunningWslDistrosAsync({ requireConfirmed: true }) ]) ).toEqual([['Ubuntu'], ['Ubuntu']]) - expect(execFileMock).toHaveBeenCalledOnce() + expect(runProcessMock).toHaveBeenCalledOnce() }) }) it('filters stopped-distro UNC paths while preserving host paths', async () => { - execFileMock.mockImplementation((_command, _args, _options, callback) => { - callback(null, 'Ubuntu\n') - }) + runProcessMock.mockResolvedValue(exited('Ubuntu\n')) await withPlatform('win32', async () => { await expect( @@ -248,7 +238,7 @@ describe('running WSL distro discovery', () => { it('does not probe WSL on non-Windows hosts', async () => { await withPlatform('linux', async () => { await expect(listRunningWslDistrosAsync()).resolves.toEqual([]) - expect(execFileMock).not.toHaveBeenCalled() + expect(runProcessMock).not.toHaveBeenCalled() }) }) @@ -256,10 +246,13 @@ describe('running WSL distro discovery', () => { // a whole polling session, not just a single failed call. it('keeps reporting a session running through a sustained wsl.exe outage, without unbounded spawns', async () => { let spawnCount = 0 - execFileMock.mockImplementation((_command, _args, _options, callback) => { - spawnCount += 1 - callback(null, 'Ubuntu\n') - }) + runProcessMock.mockImplementation( + () => + new Promise((resolve) => { + spawnCount += 1 + resolve(exited('Ubuntu\n')) + }) + ) await withPlatform('win32', async () => { // Seed a real last-known-good answer before the outage starts. @@ -267,10 +260,13 @@ describe('running WSL distro discovery', () => { expect(spawnCount).toBe(1) // wsl.exe now fails on every call — a persistent, not transient, break. - execFileMock.mockImplementation((_command, _args, _options, callback) => { - spawnCount += 1 - callback(new Error('wsl unavailable'), '') - }) + runProcessMock.mockImplementation( + () => + new Promise((resolve) => { + spawnCount += 1 + resolve(FAILED) + }) + ) vi.useFakeTimers() const observedStates: boolean[] = [] diff --git a/src/main/wsl-unc-delete-symlink-repro.test.ts b/src/main/wsl-unc-delete-symlink-repro.test.ts index a594b444e75..1f48c6cfd41 100644 --- a/src/main/wsl-unc-delete-symlink-repro.test.ts +++ b/src/main/wsl-unc-delete-symlink-repro.test.ts @@ -1,8 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() })) +const { runProcessMock } = vi.hoisted(() => ({ runProcessMock: vi.fn() })) -vi.mock('node:child_process', () => ({ execFile: execFileMock })) +vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock })) import { tryDeleteWslUncPath } from './wsl-unc-delete' @@ -37,18 +37,22 @@ describe('WSL vault intermediate-symlink reproduction', () => { `${OUTSIDE_ROOT}/session/inside`, SENTINEL ]) - execFileMock.mockReset() - execFileMock.mockImplementation((_binary, args: string[], _options, callback) => { + runProcessMock.mockReset() + runProcessMock.mockImplementation(async ({ args }: { args: string[] }) => { const separator = args.findIndex((arg) => arg === '--' || arg === '--exec') const command = args.slice(separator + 1) if (command[0] === 'rm') { removalReached = true - callback(null, '', '') - return + return { code: 0, signal: null, stdout: '', stderr: '', timedOut: false } } - const error = Object.assign(new Error('Command failed'), { code: 65 }) - callback(error, '', 'Refusing WSL delete: symbolic-link path component') + return { + code: 65, + signal: null, + stdout: '', + stderr: 'Refusing WSL delete: symbolic-link path component', + timedOut: false + } }) }) diff --git a/src/main/wsl-unc-delete.test.ts b/src/main/wsl-unc-delete.test.ts index a902b4b6d53..160ca24ab44 100644 --- a/src/main/wsl-unc-delete.test.ts +++ b/src/main/wsl-unc-delete.test.ts @@ -1,8 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ProcessResult } from '@orca/process-host/process-spec' -const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() })) +const { runProcessMock } = vi.hoisted(() => ({ runProcessMock: vi.fn() })) -vi.mock('child_process', () => ({ execFile: execFileMock })) +vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock })) + +function exited(code: number, stderr = ''): ProcessResult { + return { code, signal: null, stdout: '', stderr, timedOut: false } +} import { tryDeleteWslUncPath } from './wsl-unc-delete' @@ -20,17 +25,15 @@ async function withPlatform(platform: NodeJS.Platform, run: () => Promise) describe('tryDeleteWslUncPath', () => { beforeEach(() => { - execFileMock.mockReset() - execFileMock.mockImplementation((_cmd, _args, _opts, cb) => { - cb(null, '', '') - }) + runProcessMock.mockReset() + runProcessMock.mockResolvedValue(exited(0)) }) it('returns false and never spawns for a normal local path', async () => { await withPlatform('win32', async () => { await expect(tryDeleteWslUncPath('C:\\Users\\me\\repo\\file.txt')).resolves.toBe(false) }) - expect(execFileMock).not.toHaveBeenCalled() + expect(runProcessMock).not.toHaveBeenCalled() }) it('returns false off Windows so non-Windows callers keep trashing', async () => { @@ -39,7 +42,7 @@ describe('tryDeleteWslUncPath', () => { tryDeleteWslUncPath('\\\\wsl.localhost\\Ubuntu\\home\\me\\repo\\file.txt') ).resolves.toBe(false) }) - expect(execFileMock).not.toHaveBeenCalled() + expect(runProcessMock).not.toHaveBeenCalled() }) it('hard-deletes a WSL UNC file via rm inside the distro', async () => { @@ -49,12 +52,13 @@ describe('tryDeleteWslUncPath', () => { ).resolves.toBe(true) }) - expect(execFileMock).toHaveBeenCalledTimes(1) - const [binary, spawnArgs, spawnOptions] = execFileMock.mock.calls[0] - expect(binary).toBe('wsl.exe') + expect(runProcessMock).toHaveBeenCalledTimes(1) + const [{ program, args: spawnArgs, cwd, timeoutMs }] = runProcessMock.mock.calls[0] + expect(program).toBe('wsl.exe') + expect(timeoutMs).toBe(30000) // Why a concrete directory (#16463): this deletes worktrees, so the cwd it // would otherwise inherit is the very directory about to disappear. - expect(spawnOptions).toEqual(expect.objectContaining({ cwd: expect.any(String) })) + expect(cwd).toEqual(expect.any(String)) expect(spawnArgs).toEqual([ '-d', 'Ubuntu', @@ -73,7 +77,7 @@ describe('tryDeleteWslUncPath', () => { }) }) - const [, spawnArgs] = execFileMock.mock.calls[0] + const [{ args: spawnArgs }] = runProcessMock.mock.calls[0] expect(spawnArgs).toEqual(['-d', 'Ubuntu', '--exec', 'rm', '-rf', '--', '/home/me/repo/dir']) }) @@ -87,7 +91,7 @@ describe('tryDeleteWslUncPath', () => { ).resolves.toBe(true) }) - const [, spawnArgs] = execFileMock.mock.calls[0] + const [{ args: spawnArgs }] = runProcessMock.mock.calls[0] expect(spawnArgs.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--exec', 'sh', '-c']) expect(spawnArgs.slice(-7)).toEqual([ 'directory', @@ -101,9 +105,7 @@ describe('tryDeleteWslUncPath', () => { }) it('rejects an inspection failure instead of falling back to direct rm', async () => { - execFileMock.mockImplementation((_cmd, _args, _opts, cb) => { - cb(new Error('Command failed'), '', 'ORCA_WSL_DELETE_REJECT:inspection') - }) + runProcessMock.mockResolvedValue(exited(65, 'ORCA_WSL_DELETE_REJECT:inspection')) await withPlatform('win32', async () => { await expect( @@ -112,7 +114,7 @@ describe('tryDeleteWslUncPath', () => { }) ).rejects.toMatchObject({ reason: 'path-outside-known-roots' }) }) - expect(execFileMock).toHaveBeenCalledTimes(1) + expect(runProcessMock).toHaveBeenCalledTimes(1) }) it('rejects a target without a same-distro containing root before spawning', async () => { @@ -123,7 +125,7 @@ describe('tryDeleteWslUncPath', () => { }) ).rejects.toMatchObject({ reason: 'path-outside-known-roots' }) }) - expect(execFileMock).not.toHaveBeenCalled() + expect(runProcessMock).not.toHaveBeenCalled() }) it('also handles the legacy \\\\wsl$ UNC prefix', async () => { @@ -131,14 +133,12 @@ describe('tryDeleteWslUncPath', () => { await tryDeleteWslUncPath('\\\\wsl$\\Debian\\srv\\app.log') }) - const [, spawnArgs] = execFileMock.mock.calls[0] + const [{ args: spawnArgs }] = runProcessMock.mock.calls[0] expect(spawnArgs).toEqual(['-d', 'Debian', '--exec', 'rm', '-f', '--', '/srv/app.log']) }) it('surfaces the distro stderr when rm fails', async () => { - execFileMock.mockImplementation((_cmd, _args, _opts, cb) => { - cb(new Error('Command failed'), '', 'rm: cannot remove: Permission denied') - }) + runProcessMock.mockResolvedValue(exited(1, 'rm: cannot remove: Permission denied')) await withPlatform('win32', async () => { await expect( diff --git a/src/main/wsl-unc-delete.ts b/src/main/wsl-unc-delete.ts index b094a152beb..f068b22a333 100644 --- a/src/main/wsl-unc-delete.ts +++ b/src/main/wsl-unc-delete.ts @@ -1,6 +1,7 @@ -import { execFile } from 'node:child_process' +import { runProcess } from '@orca/process-host' +import { buildWslExecArgs } from '../shared/wsl-login-shell-command' import { parseWslPath } from './wsl' -import { resolveWslInteropSpawnCwd } from './wsl-interop-spawn-directory' +import { wslExeFailure, wslExeProcessSpec } from './wsl-exe-process' import { containedDeleteCommand, rejectionFromWslDeleteStderr, @@ -59,39 +60,27 @@ export async function tryDeleteWslUncPath( const flags = options.recursive ? '-rf' : '-f' command = ['rm', flags, '--', info.linuxPath] } - await execFileWsl(info.distro, command) + await runWslDelete(info.distro, command) return true } -function execFileWsl(distro: string, command: string[]): Promise { - return new Promise((resolve, reject) => { - execFile( - 'wsl.exe', - ['-d', distro, '--exec', ...command], - // Why: a generous bound so deleting a large directory tree on the WSL fs - // doesn't abort mid-delete, while still capping a wedged wsl.exe. - // Why an explicit cwd (#16463): the target rides in argv, and this deletes - // worktrees -- so an inherited cwd is exactly the directory about to go. - { encoding: 'utf-8', timeout: 30000, cwd: resolveWslInteropSpawnCwd() }, - (error, _stdout, stderr) => { - if (error) { - reject(wslDeleteError(error, stderr)) - return - } - resolve() - } - ) - }) +async function runWslDelete(distro: string, command: string[]): Promise { + // Why: a generous bound so deleting a large directory tree on the WSL fs + // doesn't abort mid-delete, while still capping a wedged wsl.exe. + // The explicit cwd matters here (#16463): this deletes worktrees, so an + // inherited cwd is exactly the directory about to go. + const result = await runProcess(wslExeProcessSpec(buildWslExecArgs(distro, command), 30000)) + const failure = wslExeFailure(result) + if (failure) { + throw wslDeleteError(failure, result.stderr) + } } -function wslDeleteError(error: Error, stderr: string): Error { +function wslDeleteError(failure: string, stderr: string): Error { const detail = stderr.trim() const rejection = rejectionFromWslDeleteStderr(stderr) if (rejection) { return new WslDeleteValidationError(rejection, `Refusing WSL delete: ${detail}`) } - if (!detail) { - return error - } - return new Error(`Failed to delete WSL path: ${detail}`) + return new Error(`Failed to delete WSL path: ${detail || failure}`) } diff --git a/src/main/wsl.test.ts b/src/main/wsl.test.ts index e81aac84714..9834d020cf0 100644 --- a/src/main/wsl.test.ts +++ b/src/main/wsl.test.ts @@ -1,19 +1,31 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import type * as childProcess from 'node:child_process' +import type { ProcessResult } from '@orca/process-host/process-spec' -const { execFileMock, execFileSyncMock } = vi.hoisted(() => ({ - execFileMock: vi.fn(), - execFileSyncMock: vi.fn() +const { runProcessMock, runProcessSyncMock } = vi.hoisted(() => ({ + runProcessMock: vi.fn(), + runProcessSyncMock: vi.fn() })) -vi.mock('child_process', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - execFile: execFileMock, - execFileSync: execFileSyncMock - } -}) +vi.mock('@orca/process-host', () => ({ + runProcess: runProcessMock, + runProcessSync: runProcessSyncMock +})) + +function exited(stdout: string, code: number | null = 0): ProcessResult { + return { code, signal: null, stdout, stderr: '', timedOut: false } +} + +const TIMED_OUT: ProcessResult = { + code: null, + signal: 'SIGTERM', + stdout: '', + stderr: '', + timedOut: true +} + +function spawnError(code: string): Error { + return Object.assign(new Error(`spawn wsl.exe ${code}`), { code }) +} import { _resetWslCachesForTests, @@ -23,7 +35,6 @@ import { getWslHome, hasCachedWslAvailability, hasCachedWslDistros, - isWslAvailable, isWslAvailableAsync, listWslDistros, listWslDistrosAsync, @@ -56,20 +67,14 @@ async function withPlatformAsync(value: NodeJS.Platform, fn: () => Promise describe('WSL distro discovery cache', () => { afterEach(() => { - execFileMock.mockReset() - execFileSyncMock.mockReset() + runProcessMock.mockReset() + runProcessSyncMock.mockReset() _resetWslCachesForTests() }) it('retries asynchronous discovery after a transient wsl.exe failure', async () => { vi.useFakeTimers() - execFileMock - .mockImplementationOnce((_command, _args, _options, callback) => { - callback(new Error('transient failure'), '') - }) - .mockImplementationOnce((_command, _args, _options, callback) => { - callback(null, 'Ubuntu\n') - }) + runProcessMock.mockResolvedValueOnce(TIMED_OUT).mockResolvedValueOnce(exited('Ubuntu\n')) try { await withPlatformAsync('win32', async () => { @@ -77,7 +82,7 @@ describe('WSL distro discovery cache', () => { expect(getCachedWslDistros()).toBeNull() // Brief negative caching bounds the wsl.exe spawn rate between retries. await expect(listWslDistrosAsync()).resolves.toEqual([]) - expect(execFileMock).toHaveBeenCalledTimes(1) + expect(runProcessMock).toHaveBeenCalledTimes(1) vi.advanceTimersByTime(15_000) await expect(listWslDistrosAsync()).resolves.toEqual(['Ubuntu']) }) @@ -88,17 +93,15 @@ describe('WSL distro discovery cache', () => { it('retries synchronous discovery after a transient wsl.exe failure', () => { vi.useFakeTimers() - execFileSyncMock.mockImplementationOnce(() => { - throw new Error('transient failure') - }) - execFileSyncMock.mockReturnValueOnce('Ubuntu\n') + runProcessSyncMock.mockReturnValueOnce(TIMED_OUT) + runProcessSyncMock.mockReturnValueOnce(exited('Ubuntu\n')) try { withPlatform('win32', () => { expect(listWslDistros()).toEqual([]) expect(getCachedWslDistros()).toBeNull() expect(listWslDistros()).toEqual([]) - expect(execFileSyncMock).toHaveBeenCalledTimes(1) + expect(runProcessSyncMock).toHaveBeenCalledTimes(1) vi.advanceTimersByTime(15_000) expect(listWslDistros()).toEqual(['Ubuntu']) }) @@ -113,13 +116,7 @@ describe('WSL distro discovery cache', () => { // and then permanently absent from the terminal picker. it('retries asynchronous discovery after wsl.exe reports no distros yet', async () => { vi.useFakeTimers() - execFileMock - .mockImplementationOnce((_command, _args, _options, callback) => { - callback(null, '') - }) - .mockImplementationOnce((_command, _args, _options, callback) => { - callback(null, 'Ubuntu\n') - }) + runProcessMock.mockResolvedValueOnce(exited('')).mockResolvedValueOnce(exited('Ubuntu\n')) try { await withPlatformAsync('win32', async () => { @@ -134,8 +131,8 @@ describe('WSL distro discovery cache', () => { it('retries synchronous discovery after wsl.exe reports no distros yet', () => { vi.useFakeTimers() - execFileSyncMock.mockReturnValueOnce('') - execFileSyncMock.mockReturnValueOnce('Ubuntu\n') + runProcessSyncMock.mockReturnValueOnce(exited('')) + runProcessSyncMock.mockReturnValueOnce(exited('Ubuntu\n')) try { withPlatform('win32', () => { @@ -151,34 +148,32 @@ describe('WSL distro discovery cache', () => { // Why: an empty result must still not let every caller re-spawn a blocking // wsl.exe; it reuses the same brief negative-cache window as a hard failure. it('bounds the wsl.exe spawn rate while no distros are installed', () => { - execFileSyncMock.mockReturnValue('') + runProcessSyncMock.mockReturnValue(exited('')) withPlatform('win32', () => { expect(listWslDistros()).toEqual([]) expect(listWslDistros()).toEqual([]) - expect(execFileSyncMock).toHaveBeenCalledTimes(1) + expect(runProcessSyncMock).toHaveBeenCalledTimes(1) }) }) it('bounds the asynchronous spawn rate while no distros are installed', async () => { - execFileMock.mockImplementation((_command, _args, _options, callback) => { - callback(null, '') - }) + runProcessMock.mockResolvedValue(exited('')) await withPlatformAsync('win32', async () => { await expect(listWslDistrosAsync()).resolves.toEqual([]) await expect(listWslDistrosAsync()).resolves.toEqual([]) - expect(execFileMock).toHaveBeenCalledTimes(1) + expect(runProcessMock).toHaveBeenCalledTimes(1) }) }) it('still caches a non-empty distro list for the process lifetime', () => { - execFileSyncMock.mockReturnValueOnce('Ubuntu\n') + runProcessSyncMock.mockReturnValueOnce(exited('Ubuntu\n')) withPlatform('win32', () => { expect(listWslDistros()).toEqual(['Ubuntu']) expect(listWslDistros()).toEqual(['Ubuntu']) - expect(execFileSyncMock).toHaveBeenCalledTimes(1) + expect(runProcessSyncMock).toHaveBeenCalledTimes(1) }) }) @@ -188,15 +183,15 @@ describe('WSL distro discovery cache', () => { // still visible to `isKnownMissingDistro`. it('re-probes a docker-desktop-only machine once a user distro can appear', () => { vi.useFakeTimers() - execFileSyncMock.mockReturnValueOnce('docker-desktop\ndocker-desktop-data\n') - execFileSyncMock.mockReturnValueOnce('docker-desktop\nUbuntu\n') + runProcessSyncMock.mockReturnValueOnce(exited('docker-desktop\ndocker-desktop-data\n')) + runProcessSyncMock.mockReturnValueOnce(exited('docker-desktop\nUbuntu\n')) try { withPlatform('win32', () => { expect(listWslDistros()).toEqual([]) expect(getCachedWslDistros()).toEqual([]) expect(listWslDistros()).toEqual([]) - expect(execFileSyncMock).toHaveBeenCalledTimes(1) + expect(runProcessSyncMock).toHaveBeenCalledTimes(1) vi.advanceTimersByTime(15_000) expect(listWslDistros()).toEqual(['Ubuntu']) }) @@ -209,21 +204,21 @@ describe('WSL distro discovery cache', () => { // window would re-spawn wsl.exe every 15s for the whole session. it('backs off while the list keeps coming back empty', () => { vi.useFakeTimers() - execFileSyncMock.mockReturnValue('') + runProcessSyncMock.mockReturnValue(exited('')) try { withPlatform('win32', () => { expect(listWslDistros()).toEqual([]) vi.advanceTimersByTime(15_000) expect(listWslDistros()).toEqual([]) - expect(execFileSyncMock).toHaveBeenCalledTimes(2) + expect(runProcessSyncMock).toHaveBeenCalledTimes(2) // Second empty result doubles the window, so 15s more is not enough. vi.advanceTimersByTime(15_000) expect(listWslDistros()).toEqual([]) - expect(execFileSyncMock).toHaveBeenCalledTimes(2) + expect(runProcessSyncMock).toHaveBeenCalledTimes(2) vi.advanceTimersByTime(15_000) expect(listWslDistros()).toEqual([]) - expect(execFileSyncMock).toHaveBeenCalledTimes(3) + expect(runProcessSyncMock).toHaveBeenCalledTimes(3) }) } finally { vi.useRealTimers() @@ -232,19 +227,17 @@ describe('WSL distro discovery cache', () => { it('backs off after repeated distro-list failures', () => { vi.useFakeTimers() - execFileSyncMock.mockImplementation(() => { - throw new Error('transient failure') - }) + runProcessSyncMock.mockReturnValue(TIMED_OUT) try { withPlatform('win32', () => { expect(listWslDistros()).toEqual([]) vi.advanceTimersByTime(15_000) expect(listWslDistros()).toEqual([]) - expect(execFileSyncMock).toHaveBeenCalledTimes(2) + expect(runProcessSyncMock).toHaveBeenCalledTimes(2) vi.advanceTimersByTime(15_000) expect(listWslDistros()).toEqual([]) - expect(execFileSyncMock).toHaveBeenCalledTimes(2) + expect(runProcessSyncMock).toHaveBeenCalledTimes(2) }) } finally { vi.useRealTimers() @@ -255,7 +248,7 @@ describe('WSL distro discovery cache', () => { // invisible, so pin it rather than letting the doubling run away. it('caps the empty-list backoff at five minutes', () => { vi.useFakeTimers() - execFileSyncMock.mockReturnValue('') + runProcessSyncMock.mockReturnValue(exited('')) try { withPlatform('win32', () => { @@ -264,10 +257,10 @@ describe('WSL distro discovery cache', () => { vi.advanceTimersByTime(delayMs) listWslDistros() } - expect(execFileSyncMock).toHaveBeenCalledTimes(6) + expect(runProcessSyncMock).toHaveBeenCalledTimes(6) vi.advanceTimersByTime(300_000) listWslDistros() - expect(execFileSyncMock).toHaveBeenCalledTimes(7) + expect(runProcessSyncMock).toHaveBeenCalledTimes(7) }) } finally { vi.useRealTimers() @@ -279,7 +272,7 @@ describe('WSL distro discovery cache', () => { // open and silently spawns `wsl.exe -d ` for a distro Orca saw was absent. it('keeps reporting an empty result after it goes stale', () => { vi.useFakeTimers() - execFileSyncMock.mockReturnValue('') + runProcessSyncMock.mockReturnValue(exited('')) try { withPlatform('win32', () => { @@ -298,7 +291,7 @@ describe('WSL distro discovery cache', () => { // so repair-required still fires for the case that warrants it. it('keeps reporting a known distro list indefinitely', () => { vi.useFakeTimers() - execFileSyncMock.mockReturnValueOnce('Ubuntu\n') + runProcessSyncMock.mockReturnValueOnce(exited('Ubuntu\n')) try { withPlatform('win32', () => { @@ -314,104 +307,71 @@ describe('WSL distro discovery cache', () => { describe('WSL home cache', () => { afterEach(() => { - execFileMock.mockReset() - execFileSyncMock.mockReset() + runProcessMock.mockReset() + runProcessSyncMock.mockReset() _resetWslCachesForTests() }) it('bounds cached homes while retaining the most recently used distros', () => { - execFileSyncMock.mockImplementation((_command, args) => `/home/${args[1]}\n`) + runProcessSyncMock.mockImplementation(({ args }: { args: string[] }) => + exited(`/home/${args[1]}\n`) + ) withPlatform('win32', () => { for (let index = 0; index < 68; index += 1) { expect(getWslHome(`Distro-${index}`)).toContain(`Distro-${index}`) } expect(getWslHome('Distro-4')).toContain('Distro-4') - expect(execFileSyncMock).toHaveBeenCalledTimes(68) + expect(runProcessSyncMock).toHaveBeenCalledTimes(68) expect(getWslHome('Distro-0')).toContain('Distro-0') - expect(execFileSyncMock).toHaveBeenCalledTimes(69) + expect(runProcessSyncMock).toHaveBeenCalledTimes(69) }) }) }) describe('WSL availability cache', () => { afterEach(() => { - execFileMock.mockReset() - execFileSyncMock.mockReset() + runProcessMock.mockReset() + runProcessSyncMock.mockReset() _resetWslCachesForTests() }) // Why: a cold WSL2 utility-VM boot on a just-installed or just-rebooted // machine routinely exceeds the 5s probe timeout. Latching false for the // process lifetime is what makes WSL vanish from the picker after setup. - it('retries availability after a probe timeout instead of latching false', () => { + it('retries availability after a probe timeout instead of latching false', async () => { vi.useFakeTimers() - execFileSyncMock.mockImplementationOnce(() => { - // Real execFileSync timeout shape on Windows: status null, signal SIGTERM. - throw Object.assign(new Error('spawnSync ETIMEDOUT'), { - code: 'ETIMEDOUT', - status: null, - signal: 'SIGTERM' - }) - }) - execFileSyncMock.mockReturnValueOnce('') + runProcessMock.mockResolvedValueOnce(TIMED_OUT).mockResolvedValueOnce(exited('')) try { - withPlatform('win32', () => { - expect(isWslAvailable()).toBe(false) + await withPlatformAsync('win32', async () => { + await expect(isWslAvailableAsync()).resolves.toBe(false) vi.advanceTimersByTime(45_000) - expect(isWslAvailable()).toBe(true) + await expect(isWslAvailableAsync()).resolves.toBe(true) }) } finally { vi.useRealTimers() } }) - // Why: the probe blocks the main process for up to 5s, so a timeout must not - // let every caller re-spawn it immediately. - it('bounds the probe rate while the failure window is open', () => { - execFileSyncMock.mockImplementation(() => { - // Real execFileSync timeout shape on Windows: status null, signal SIGTERM. - throw Object.assign(new Error('spawnSync ETIMEDOUT'), { - code: 'ETIMEDOUT', - status: null, - signal: 'SIGTERM' - }) - }) + // Why: a timeout must not let every caller re-spawn wsl.exe immediately. + it('bounds the probe rate while the failure window is open', async () => { + runProcessMock.mockResolvedValue(TIMED_OUT) - withPlatform('win32', () => { - expect(isWslAvailable()).toBe(false) - expect(isWslAvailable()).toBe(false) - expect(execFileSyncMock).toHaveBeenCalledTimes(1) + await withPlatformAsync('win32', async () => { + await expect(isWslAvailableAsync()).resolves.toBe(false) + await expect(isWslAvailableAsync()).resolves.toBe(false) + expect(runProcessMock).toHaveBeenCalledTimes(1) }) }) - it('caches a successful probe for the process lifetime', () => { - execFileSyncMock.mockReturnValueOnce('') - - withPlatform('win32', () => { - expect(isWslAvailable()).toBe(true) - expect(isWslAvailable()).toBe(true) - expect(execFileSyncMock).toHaveBeenCalledTimes(1) - }) - }) - - // Why: the renderer's capability read reaches this over IPC; a blocking spawn there - // stalls every PTY message and window IPC for as long as wsl.exe takes to answer. - it('probes availability for IPC callers without blocking the main thread', async () => { - execFileMock.mockImplementation((_command, _args, _options, callback) => { - callback(null, '', '') - }) + it('caches a successful probe for the process lifetime', async () => { + runProcessMock.mockResolvedValueOnce(exited('')) await withPlatformAsync('win32', async () => { await expect(isWslAvailableAsync()).resolves.toBe(true) - expect(execFileMock).toHaveBeenCalledWith( - 'wsl.exe', - ['--status'], - expect.objectContaining({ timeout: 5000, windowsHide: true }), - expect.any(Function) - ) - expect(execFileSyncMock).not.toHaveBeenCalled() + await expect(isWslAvailableAsync()).resolves.toBe(true) + expect(runProcessMock).toHaveBeenCalledTimes(1) }) }) @@ -421,140 +381,69 @@ describe('WSL availability cache', () => { // 10-minute definitive TTL with exponential backoff. Git kept working and Orca // reported WSL unavailable -- a worse state than the bug being fixed. Naming // the directory is what keeps ENOENT meaning "wsl.exe is not on PATH". - it('names an explicit spawn directory on both probes, so no deleted cwd can read as ENOENT', async () => { - execFileSyncMock.mockReturnValueOnce('') - execFileMock.mockImplementation((_command, _args, _options, callback) => { - callback(null, '', '') - }) + it('names an explicit spawn directory, so no deleted cwd can read as ENOENT', async () => { + runProcessMock.mockResolvedValue(exited('')) - withPlatform('win32', () => { - expect(isWslAvailable()).toBe(true) - }) - expect(execFileSyncMock).toHaveBeenCalledWith( - 'wsl.exe', - ['--status'], - expect.objectContaining({ cwd: expect.any(String) }) - ) - - // The two probes share one cache, so a false ENOENT from either poisons both. - _resetWslCachesForTests() await withPlatformAsync('win32', async () => { await expect(isWslAvailableAsync()).resolves.toBe(true) }) - expect(execFileMock).toHaveBeenCalledWith( - 'wsl.exe', - ['--status'], - expect.objectContaining({ cwd: expect.any(String) }), - expect.any(Function) + expect(runProcessMock).toHaveBeenCalledWith( + expect.objectContaining({ + program: 'wsl.exe', + args: ['--status'], + timeoutMs: 5000, + cwd: expect.any(String) + }) ) }) - it('shares one wsl.exe spawn between concurrent async probes', async () => { - execFileMock.mockImplementation((_command, _args, _options, callback) => { - setTimeout(() => callback(null, '', ''), 0) - }) + it('shares one wsl.exe spawn between concurrent probes', async () => { + runProcessMock.mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve(exited('')), 0)) + ) await withPlatformAsync('win32', async () => { const results = await Promise.all([isWslAvailableAsync(), isWslAvailableAsync()]) expect(results).toEqual([true, true]) - expect(execFileMock).toHaveBeenCalledTimes(1) - }) - }) - - it('does not let an older async failure overwrite a newer sync success', async () => { - let finishAsyncProbe: ((error: Error | null) => void) | null = null - execFileMock.mockImplementation((_command, _args, _options, callback) => { - finishAsyncProbe = (error) => callback(error, '', '') - }) - execFileSyncMock.mockReturnValue('') - - await withPlatformAsync('win32', async () => { - const staleProbe = isWslAvailableAsync() - expect(isWslAvailable()).toBe(true) - - finishAsyncProbe?.(Object.assign(new Error('older failure'), { code: 1 })) - - await expect(staleProbe).resolves.toBe(true) - expect(getCachedWslAvailability()).toBe(true) + expect(runProcessMock).toHaveBeenCalledTimes(1) }) }) it('does not restore a failure after distro discovery disproves it mid-probe', async () => { - const callbacks = new Map void>() - execFileMock.mockImplementation((_command, args, _options, callback) => { - callbacks.set(args.join(' '), callback) - }) + const callbacks = new Map void>() + runProcessMock.mockImplementation( + ({ args }: { args: string[] }) => + new Promise((resolve) => { + callbacks.set(args.join(' '), resolve) + }) + ) await withPlatformAsync('win32', async () => { const staleAvailability = isWslAvailableAsync() const distroProbe = listWslDistrosAsync() - callbacks.get('--list --quiet')?.(null, 'Ubuntu\n') + callbacks.get('--list --quiet')?.(exited('Ubuntu\n')) await expect(distroProbe).resolves.toEqual(['Ubuntu']) - callbacks.get('--status')?.(Object.assign(new Error('older failure'), { code: 1 }), '') + callbacks.get('--status')?.(exited('', 1)) await expect(staleAvailability).resolves.toBe(false) expect(getCachedWslAvailability()).toBeNull() const retry = isWslAvailableAsync() - callbacks.get('--status')?.(null, '') + callbacks.get('--status')?.(exited('')) await expect(retry).resolves.toBe(true) }) }) - it('shares the failure backoff between the async and sync probes', async () => { - execFileMock.mockImplementation((_command, _args, _options, callback) => { - callback(Object.assign(new Error('not installed'), { code: 'ENOENT' }), '', '') - }) - - await withPlatformAsync('win32', async () => { - await expect(isWslAvailableAsync()).resolves.toBe(false) - expect(isWslAvailable()).toBe(false) - expect(execFileSyncMock).not.toHaveBeenCalled() - }) - }) - - // Why: wsl.exe ships in System32 on every modern Windows, so a host without WSL answers - // with a non-zero exit, not ENOENT — and execFile reports that as a numeric `code`, not the - // `status` execFileSync uses. Misreading it as retryable would shrink the shared cache window - // to 45s and make the sync callers pay their blocking spawn ~13x more often. - it('treats a non-zero async exit as definitive, so the sync probe keeps the long window', async () => { - vi.useFakeTimers() - execFileMock.mockImplementation((_command, _args, _options, callback) => { - callback(Object.assign(new Error('wsl.exe exited 1'), { code: 1 }), '', '') - }) - execFileSyncMock.mockReturnValue('') - - try { - await withPlatformAsync('win32', async () => { - await expect(isWslAvailableAsync()).resolves.toBe(false) - vi.advanceTimersByTime(45_000) - expect(isWslAvailable()).toBe(false) - expect(execFileSyncMock).not.toHaveBeenCalled() - vi.advanceTimersByTime(10 * 60_000) - expect(isWslAvailable()).toBe(true) - }) - } finally { - vi.useRealTimers() - } - }) - // Why: the resolver reads the cached getter, not the probe. Reporting the last // observed answer keeps the `wsl-unavailable` repair prompt reachable; going null // on staleness would let git and PTY silently resolve to a WSL that just failed. - it('keeps reporting the last observed answer after it goes stale', () => { + it('keeps reporting the last observed answer after it goes stale', async () => { vi.useFakeTimers() - execFileSyncMock.mockImplementation(() => { - // Real execFileSync timeout shape on Windows: status null, signal SIGTERM. - throw Object.assign(new Error('spawnSync ETIMEDOUT'), { - code: 'ETIMEDOUT', - status: null, - signal: 'SIGTERM' - }) - }) + runProcessMock.mockResolvedValue(TIMED_OUT) try { - withPlatform('win32', () => { - expect(isWslAvailable()).toBe(false) + await withPlatformAsync('win32', async () => { + await expect(isWslAvailableAsync()).resolves.toBe(false) vi.advanceTimersByTime(45_000) expect(getCachedWslAvailability()).toBe(false) expect(hasCachedWslAvailability()).toBe(true) @@ -566,54 +455,46 @@ describe('WSL availability cache', () => { // Why: an answer-shaped failure earns a long window, not a session-long latch — // wsl.exe also exits non-zero while the WSL package is servicing or LxssManager is - // still starting, which is transient. + // still starting, which is transient. wsl.exe ships in System32 on every modern + // Windows, so a host without WSL usually answers with a non-zero exit, not ENOENT. it.each([ - ['wsl.exe reports WSL unusable', { status: 1 }], - ['wsl.exe is not installed', { code: 'ENOENT' }] - ])('holds a definitive failure far longer than a timeout when %s', (_label, errorFields) => { + ['wsl.exe reports WSL unusable', () => Promise.resolve(exited('', 1))], + ['wsl.exe is not installed', () => Promise.reject(spawnError('ENOENT'))] + ])('holds a definitive failure far longer than a timeout when %s', async (_label, probe) => { vi.useFakeTimers() - execFileSyncMock.mockImplementationOnce(() => { - throw Object.assign(new Error('definitive failure'), errorFields) - }) - execFileSyncMock.mockReturnValueOnce('') + runProcessMock.mockImplementationOnce(probe).mockResolvedValueOnce(exited('')) try { - withPlatform('win32', () => { - expect(isWslAvailable()).toBe(false) + await withPlatformAsync('win32', async () => { + await expect(isWslAvailableAsync()).resolves.toBe(false) vi.advanceTimersByTime(45_000) - expect(isWslAvailable()).toBe(false) + await expect(isWslAvailableAsync()).resolves.toBe(false) expect(getCachedWslAvailability()).toBe(false) - expect(execFileSyncMock).toHaveBeenCalledTimes(1) + expect(runProcessMock).toHaveBeenCalledTimes(1) vi.advanceTimersByTime(10 * 60_000) - expect(isWslAvailable()).toBe(true) + await expect(isWslAvailableAsync()).resolves.toBe(true) }) } finally { vi.useRealTimers() } }) - // Why: the probe blocks the main process for up to 5s, so a wedged wsl.exe must not - // be re-probed on every window boundary for the rest of the session. - it('backs off after repeated probe failures', () => { + // Why: a wedged wsl.exe must not be re-probed on every window boundary for the + // rest of the session. + it('backs off after repeated probe failures', async () => { vi.useFakeTimers() - execFileSyncMock.mockImplementation(() => { - throw Object.assign(new Error('spawnSync ETIMEDOUT'), { - code: 'ETIMEDOUT', - status: null, - signal: 'SIGTERM' - }) - }) + runProcessMock.mockResolvedValue(TIMED_OUT) try { - withPlatform('win32', () => { - expect(isWslAvailable()).toBe(false) + await withPlatformAsync('win32', async () => { + await expect(isWslAvailableAsync()).resolves.toBe(false) vi.advanceTimersByTime(45_000) - expect(isWslAvailable()).toBe(false) - expect(execFileSyncMock).toHaveBeenCalledTimes(2) + await expect(isWslAvailableAsync()).resolves.toBe(false) + expect(runProcessMock).toHaveBeenCalledTimes(2) // Second failure doubles the window, so the next boundary is not enough. vi.advanceTimersByTime(45_000) - expect(isWslAvailable()).toBe(false) - expect(execFileSyncMock).toHaveBeenCalledTimes(2) + await expect(isWslAvailableAsync()).resolves.toBe(false) + expect(runProcessMock).toHaveBeenCalledTimes(2) }) } finally { vi.useRealTimers() @@ -622,16 +503,16 @@ describe('WSL availability cache', () => { // Why: a seeded retryable failure must re-probe once its window lapses, the same // as one observed live — otherwise test setup can hide the latch this fixes. - it('re-probes a seeded retryable failure once its window lapses', () => { + it('re-probes a seeded retryable failure once its window lapses', async () => { vi.useFakeTimers() - execFileSyncMock.mockReturnValueOnce('') + runProcessMock.mockResolvedValueOnce(exited('')) try { - withPlatform('win32', () => { + await withPlatformAsync('win32', async () => { _setWslCachesForTests({ available: false, availabilityRetryable: true }) - expect(isWslAvailable()).toBe(false) + await expect(isWslAvailableAsync()).resolves.toBe(false) vi.advanceTimersByTime(45_000) - expect(isWslAvailable()).toBe(true) + await expect(isWslAvailableAsync()).resolves.toBe(true) }) } finally { vi.useRealTimers() @@ -642,25 +523,23 @@ describe('WSL availability cache', () => { // first, so a definitive failure held for 10min would report `wsl-unavailable` over a // WSL that just listed a distro. Finding a distro must drop the stale failure. it.each([ - ['a definitive failure', { status: 1 }], - ['a timeout', { code: 'ETIMEDOUT', status: null, signal: 'SIGTERM' }] - ])('re-probes availability once a distro list succeeds after %s', (_label, errorFields) => { + ['a definitive failure', exited('', 1)], + ['a timeout', TIMED_OUT] + ])('re-probes availability once a distro list succeeds after %s', async (_label, failure) => { vi.useFakeTimers() - execFileSyncMock.mockImplementationOnce(() => { - throw Object.assign(new Error('probe failed'), errorFields) - }) + runProcessMock.mockResolvedValueOnce(failure) try { - withPlatform('win32', () => { - expect(isWslAvailable()).toBe(false) + await withPlatformAsync('win32', async () => { + await expect(isWslAvailableAsync()).resolves.toBe(false) // A distro turns up (WSL finished provisioning / was repaired mid-session). - execFileSyncMock.mockReturnValueOnce('Ubuntu\n') + runProcessSyncMock.mockReturnValueOnce(exited('Ubuntu\n')) expect(listWslDistros()).toEqual(['Ubuntu']) // Without dropping the stale failure this would stay false for 10min. - execFileSyncMock.mockReturnValueOnce('') - expect(isWslAvailable()).toBe(true) + runProcessMock.mockResolvedValueOnce(exited('')) + await expect(isWslAvailableAsync()).resolves.toBe(true) }) } finally { vi.useRealTimers() @@ -668,34 +547,31 @@ describe('WSL availability cache', () => { }) // Why: an empty list re-probes on a 15s-to-5min schedule, so clearing the availability - // failure on every empty probe would re-spawn the blocking 5s probe far too often. - it('does not drop an availability failure for an empty distro list', () => { + // failure on every empty probe would re-spawn the availability probe far too often. + it('does not drop an availability failure for an empty distro list', async () => { vi.useFakeTimers() - execFileSyncMock.mockImplementationOnce(() => { - throw Object.assign(new Error('probe failed'), { status: 1 }) - }) + runProcessMock.mockResolvedValueOnce(exited('', 1)) try { - withPlatform('win32', () => { - expect(isWslAvailable()).toBe(false) - execFileSyncMock.mockReturnValueOnce('') + await withPlatformAsync('win32', async () => { + await expect(isWslAvailableAsync()).resolves.toBe(false) + runProcessSyncMock.mockReturnValueOnce(exited('')) expect(listWslDistros()).toEqual([]) expect(getCachedWslAvailability()).toBe(false) // Still inside the definitive window, so no re-probe was paid. - expect(execFileSyncMock).toHaveBeenCalledTimes(2) - expect(isWslAvailable()).toBe(false) - expect(execFileSyncMock).toHaveBeenCalledTimes(2) + await expect(isWslAvailableAsync()).resolves.toBe(false) + expect(runProcessMock).toHaveBeenCalledTimes(1) }) } finally { vi.useRealTimers() } }) - it('keeps reporting unavailable off Windows without probing', () => { - withPlatform('darwin', () => { - expect(isWslAvailable()).toBe(false) + it('keeps reporting unavailable off Windows without probing', async () => { + await withPlatformAsync('darwin', async () => { + await expect(isWslAvailableAsync()).resolves.toBe(false) expect(getCachedWslAvailability()).toBe(false) - expect(execFileSyncMock).not.toHaveBeenCalled() + expect(runProcessMock).not.toHaveBeenCalled() }) }) }) @@ -734,33 +610,35 @@ describe('wsl path helpers', () => { describe('wslUncDirectoryExists', () => { afterEach(() => { - execFileSyncMock.mockReset() + runProcessSyncMock.mockReset() }) it('returns true when the distro reports the directory exists', () => { - execFileSyncMock.mockReturnValue('__ORCA_DIRECTORY_EXISTS__') + runProcessSyncMock.mockReturnValue(exited('__ORCA_DIRECTORY_EXISTS__')) const result = withPlatform('win32', () => wslUncDirectoryExists('\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo') ) expect(result).toBe(true) - expect(execFileSyncMock).toHaveBeenCalledWith( - 'wsl.exe', - [ - '-d', - 'Ubuntu', - '--exec', - 'sh', - '-c', - expect.stringContaining('__ORCA_DIRECTORY_EXISTS__'), - 'sh', - '/home/jin/repo' - ], - expect.objectContaining({ timeout: 5000 }) + expect(runProcessSyncMock).toHaveBeenCalledWith( + expect.objectContaining({ + program: 'wsl.exe', + timeoutMs: 5000, + args: [ + '-d', + 'Ubuntu', + '--exec', + 'sh', + '-c', + expect.stringContaining('__ORCA_DIRECTORY_EXISTS__'), + 'sh', + '/home/jin/repo' + ] + }) ) }) it('returns false when the guest reports the directory missing', () => { - execFileSyncMock.mockReturnValue('__ORCA_DIRECTORY_MISSING__') + runProcessSyncMock.mockReturnValue(exited('__ORCA_DIRECTORY_MISSING__')) const result = withPlatform('win32', () => wslUncDirectoryExists('\\\\wsl.localhost\\Ubuntu\\home\\jin\\missing') ) @@ -768,11 +646,7 @@ describe('wslUncDirectoryExists', () => { }) it('returns null when wsl.exe or the distro is unavailable', () => { - execFileSyncMock.mockImplementation(() => { - const error = new Error('distro unavailable') as Error & { status: number } - error.status = 4294967295 - throw error - }) + runProcessSyncMock.mockReturnValue(exited('', 4294967295)) const result = withPlatform('win32', () => wslUncDirectoryExists('\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo') ) @@ -784,50 +658,45 @@ describe('wslUncDirectoryExists', () => { expect( withPlatform('linux', () => wslUncDirectoryExists('\\\\wsl.localhost\\Ubuntu\\home\\jin')) ).toBeNull() - expect(execFileSyncMock).not.toHaveBeenCalled() + expect(runProcessSyncMock).not.toHaveBeenCalled() }) }) describe('wslUncDirectoryExistsAsync', () => { afterEach(() => { - execFileMock.mockReset() + runProcessMock.mockReset() }) it('returns true when the distro reports the directory exists', async () => { - execFileMock.mockImplementation((_command, _args, _options, callback) => - callback(null, '__ORCA_DIRECTORY_EXISTS__') - ) + runProcessMock.mockResolvedValue(exited('__ORCA_DIRECTORY_EXISTS__')) await expect( withPlatformAsync('win32', () => wslUncDirectoryExistsAsync('\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo') ) ).resolves.toBe(true) - expect(execFileMock).toHaveBeenCalledWith( - 'wsl.exe', - [ - '-d', - 'Ubuntu', - '--exec', - 'sh', - '-c', - expect.stringContaining('__ORCA_DIRECTORY_EXISTS__'), - 'sh', - '/home/jin/repo' - ], - expect.objectContaining({ timeout: 5000 }), - expect.any(Function) + expect(runProcessMock).toHaveBeenCalledWith( + expect.objectContaining({ + program: 'wsl.exe', + timeoutMs: 5000, + args: [ + '-d', + 'Ubuntu', + '--exec', + 'sh', + '-c', + expect.stringContaining('__ORCA_DIRECTORY_EXISTS__'), + 'sh', + '/home/jin/repo' + ] + }) ) }) it('distinguishes a missing directory from an inconclusive probe', async () => { - execFileMock - .mockImplementationOnce((_command, _args, _options, callback) => - callback(null, '__ORCA_DIRECTORY_MISSING__') - ) - .mockImplementationOnce((_command, _args, _options, callback) => - callback(Object.assign(new Error('distro unavailable'), { code: 4294967295 }), '') - ) + runProcessMock + .mockResolvedValueOnce(exited('__ORCA_DIRECTORY_MISSING__')) + .mockResolvedValueOnce(exited('', 4294967295)) await withPlatformAsync('win32', async () => { await expect( @@ -848,6 +717,6 @@ describe('wslUncDirectoryExistsAsync', () => { wslUncDirectoryExistsAsync('\\\\wsl.localhost\\Ubuntu\\home\\jin') ) ).resolves.toBeNull() - expect(execFileMock).not.toHaveBeenCalled() + expect(runProcessMock).not.toHaveBeenCalled() }) }) diff --git a/src/main/wsl.ts b/src/main/wsl.ts index 007b2f1c8bc..45a288fdd78 100644 --- a/src/main/wsl.ts +++ b/src/main/wsl.ts @@ -1,4 +1,5 @@ -import { execFile, execFileSync } from 'node:child_process' +import { runProcess, runProcessSync } from '@orca/process-host' +import { buildWslExecArgs } from '../shared/wsl-login-shell-command' import { parseWslUncPath, toWindowsWslPath } from '../shared/wsl-paths' import { filterUserWslDistros, parseWslDistros } from './wsl-distro-list-output' import { wslDistroListRetryDelayMs } from './wsl-distro-retry' @@ -7,7 +8,7 @@ import { _setWslAvailabilityCacheForTests, dropStaleWslAvailabilityFailure } from './wsl-availability' -import { resolveWslInteropSpawnCwd } from './wsl-interop-spawn-directory' +import { wslExeProcessSpec, wslExeStdout } from './wsl-exe-process' import { _resetRunningWslDistroCacheForTests, resolveRunningWslDistros @@ -24,13 +25,19 @@ export { toLinuxPath, toWindowsWslPath } from '../shared/wsl-paths' export { getCachedWslAvailability, hasCachedWslAvailability, - isWslAvailable, isWslAvailableAsync } from './wsl-availability' export type WslPathInfo = { distro: string linuxPath: string } + +const WSL_PROBE_TIMEOUT_MS = 5000 + +function wslHomeProbeArgs(distro: string): string[] { + return buildWslExecArgs(distro, ['bash', '-c', 'echo $HOME']) +} + /** Detect and parse a WSL UNC path on Windows. */ export function parseWslPath(windowsPath: string): WslPathInfo | null { if (process.platform !== 'win32') { @@ -65,12 +72,9 @@ export function wslUncDirectoryExists(uncPath: string): boolean | null { return null } try { - const stdout = execFileSync('wsl.exe', getWslDirectoryProbeArgs(info), { - stdio: ['pipe', 'pipe', 'pipe'], - timeout: 5000, - encoding: 'utf8', - cwd: resolveWslInteropSpawnCwd() - }) + const stdout = wslExeStdout( + runProcessSync(wslExeProcessSpec(getWslDirectoryProbeArgs(info), WSL_PROBE_TIMEOUT_MS)) + ) return parseWslDirectoryProbeOutput(stdout) } catch { return null @@ -85,13 +89,12 @@ export function wslUncDirectoryExistsAsync(uncPath: string): Promise { - const probeOpts = { timeout: 5000, cwd: resolveWslInteropSpawnCwd() } - execFile('wsl.exe', getWslDirectoryProbeArgs(info), probeOpts, (_error, stdout) => { + return ( + runProcess(wslExeProcessSpec(getWslDirectoryProbeArgs(info), WSL_PROBE_TIMEOUT_MS)) // Why: wsl.exe uses numeric exits for both guest results and host failures; only the guest marker is authoritative. - resolve(parseWslDirectoryProbeOutput(stdout)) - }) - }) + .then((result) => parseWslDirectoryProbeOutput(result.stdout)) + .catch(() => null) + ) } const wslHomeProbeCache = new Map>() @@ -170,12 +173,9 @@ export function listWslDistros(): string[] { try { const probeSequence = ++wslDistroProbeSequence - const output = execFileSync('wsl.exe', ['--list', '--quiet'], { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - timeout: 5000, - cwd: resolveWslInteropSpawnCwd() - }) + const output = wslExeStdout( + runProcessSync(wslExeProcessSpec(['--list', '--quiet'], WSL_PROBE_TIMEOUT_MS)) + ) return cacheWslDistroList(parseWslDistros(output), probeSequence) } catch { armWslDistroListRetry() @@ -212,7 +212,7 @@ export async function listWslDistrosAsync(): Promise { // lands; one host-wide answer must cost one wsl.exe spawn. `catch` sits ahead of the // stored promise, so joiners get the same fail-safe [] a per-caller catch returned. const probeSequence = ++wslDistroProbeSequence - const probe = execFileUtf8('wsl.exe', ['--list', '--quiet']) + const probe = runWslExe(['--list', '--quiet']) .then((output) => cacheWslDistroList(parseWslDistros(output), probeSequence)) .catch(() => { armWslDistroListRetry() @@ -238,7 +238,7 @@ export async function listRunningWslDistrosAsync( } return resolveRunningWslDistros( () => - execFileUtf8('wsl.exe', ['--list', '--running', '--quiet'], { + runWslExe(['--list', '--running', '--quiet'], { ...process.env, WSL_UTF8: '1' }).then((output) => filterUserWslDistros(parseWslDistros(output))), @@ -278,12 +278,9 @@ export function getWslHome(distro: string): string | null { } try { - const home = execFileSync('wsl.exe', ['-d', distro, '--exec', 'bash', '-c', 'echo $HOME'], { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - timeout: 5000, - cwd: resolveWslInteropSpawnCwd() - }).trim() + const home = wslExeStdout( + runProcessSync(wslExeProcessSpec(wslHomeProbeArgs(distro), WSL_PROBE_TIMEOUT_MS)) + ).trim() if (!home || !home.startsWith('/')) { return null @@ -307,7 +304,7 @@ export async function getWslHomeAsync(distro: string): Promise { return inflight } - const probe = execFileUtf8('wsl.exe', ['-d', distro, '--exec', 'bash', '-c', 'echo $HOME']) + const probe = runWslExe(wslHomeProbeArgs(distro)) .then((output) => { const home = output.trim() if (!home || !home.startsWith('/')) { @@ -355,7 +352,7 @@ export function _resetWslCachesForTests(): void { // Why: seeded state expires like real state — an `available: false` seed is re-probed // once its window lapses, and a `distros` seed is filtered and arms the retry window. -// Tests that advance timers past either window must mock child_process. +// Tests that advance timers past either window must mock @orca/process-host. export function _setWslCachesForTests(args: { available?: boolean | null distros?: string[] | null @@ -370,25 +367,6 @@ export function _setWslCachesForTests(args: { } } -function execFileUtf8(command: string, args: string[], env?: NodeJS.ProcessEnv): Promise { - return new Promise((resolve, reject) => { - execFile( - command, - args, - { - encoding: 'utf-8', - env, - timeout: 5000, - windowsHide: true, - cwd: resolveWslInteropSpawnCwd() - }, - (error, stdout) => { - if (error) { - reject(error) - return - } - resolve(stdout) - } - ) - }) +async function runWslExe(args: string[], env?: NodeJS.ProcessEnv): Promise { + return wslExeStdout(await runProcess(wslExeProcessSpec(args, WSL_PROBE_TIMEOUT_MS, env))) }