diff --git a/src/main/wsl-missing-kernel-probe.test.ts b/src/main/wsl-availability-missing-kernel.test.ts similarity index 71% rename from src/main/wsl-missing-kernel-probe.test.ts rename to src/main/wsl-availability-missing-kernel.test.ts index b3d29b1f2b5..3ab7b6d4a26 100644 --- a/src/main/wsl-missing-kernel-probe.test.ts +++ b/src/main/wsl-availability-missing-kernel.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { execFile, execFileSync } from 'node:child_process' -import { runProcess, runProcessSync } from '../shared/child-process/run-process' +import { runProcess, runProcessSync, type ProcessResult } from '../shared/child-process/run-process' import { _resetWslAvailabilityCacheForTests, isWslAvailable, @@ -17,7 +17,7 @@ vi.mock('./wsl-interop-spawn-directory', () => ({ })) const originalPlatform = process.platform -const success = { code: 0, signal: null, stdout: '', stderr: '', timedOut: false } +const success: ProcessResult = { code: 0, signal: null, stdout: '', stderr: '', timedOut: false } beforeEach(() => { vi.resetAllMocks() @@ -32,6 +32,8 @@ afterEach(() => { 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 { vi.mocked(execFileSync).mockImplementation(() => { throw { status: code } @@ -42,20 +44,20 @@ for (const mode of ['sync', 'async'] as const) { return {} as ReturnType }) } - function guestResult(result: typeof success): void { + 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) - const runner = mode === 'sync' ? runProcessSync : runProcess - expect(runner).toHaveBeenCalledTimes(1) - expect(runner).toHaveBeenCalledWith( + expect(guestRunner()).toHaveBeenCalledTimes(1) + expect(guestRunner()).toHaveBeenCalledWith( expect.objectContaining({ program: 'wsl.exe', args: ['--exec', '/bin/true'], @@ -68,15 +70,24 @@ for (const mode of ['sync', 'async'] as const) { for (const result of [ { ...success, code: 1 }, - { ...success, timedOut: true } + { ...success, code: null, timedOut: true } ]) { - it(`keeps failed guest unavailable: ${JSON.stringify(result)}`, async () => { + 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) diff --git a/src/main/wsl-availability.ts b/src/main/wsl-availability.ts index f8505cf6b84..ad5e5645c30 100644 --- a/src/main/wsl-availability.ts +++ b/src/main/wsl-availability.ts @@ -1,10 +1,7 @@ import { execFile, execFileSync } from 'node:child_process' +import { runProcess, runProcessSync, type ProcessSpec } from '../shared/child-process/run-process' +import { buildWslExecArgs } from '../shared/wsl-login-shell-command' import { resolveWslInteropSpawnCwd } from './wsl-interop-spawn-directory' -import { - canExecuteWslWithoutKernel, - canExecuteWslWithoutKernelSync, - isWslMissingKernelError -} from './wsl-missing-kernel-probe' type WslAvailabilityCache = | { available: true } @@ -99,6 +96,55 @@ function cacheWslAvailabilityProbeResult(error: unknown, startedAtGeneration: nu return !error } +// `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) +} + +// 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, + 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). + */ +function wslStatusErrorAfterGuestProbe(error: unknown): unknown { + if (!isMissingWsl2KernelStatus(error)) { + return error + } + try { + return runProcessSync(defaultGuestExecutionProbe()).code === 0 ? null : error + } catch { + return error + } +} + +/** Async twin of `wslStatusErrorAfterGuestProbe`; the sync/async pair share one cache. */ +async function wslStatusErrorAfterGuestProbeAsync(error: unknown): Promise { + if (!isMissingWsl2KernelStatus(error)) { + return error + } + try { + return (await runProcess(defaultGuestExecutionProbe())).code === 0 ? null : error + } catch { + return error + } +} + function probeWslStatus(): Promise { return new Promise((resolve, reject) => { execFile( @@ -152,10 +198,10 @@ export function isWslAvailable(): boolean { }) return cacheWslAvailabilityProbeResult(null, startedAtGeneration) } catch (error) { - if (isWslMissingKernelError(error) && canExecuteWslWithoutKernelSync()) { - return cacheWslAvailabilityProbeResult(null, startedAtGeneration) - } - return cacheWslAvailabilityProbeResult(error, startedAtGeneration) + return cacheWslAvailabilityProbeResult( + wslStatusErrorAfterGuestProbe(error), + startedAtGeneration + ) } } @@ -184,12 +230,12 @@ export function isWslAvailableAsync(): Promise { const startedAtGeneration = wslAvailabilityCacheGeneration wslAvailabilityProbeInFlight = probeWslStatus() .then(() => cacheWslAvailabilityProbeResult(null, startedAtGeneration)) - .catch(async (error: unknown) => { - if (isWslMissingKernelError(error) && (await canExecuteWslWithoutKernel())) { - return cacheWslAvailabilityProbeResult(null, startedAtGeneration) - } - return cacheWslAvailabilityProbeResult(error, startedAtGeneration) - }) + .catch(async (error: unknown) => + cacheWslAvailabilityProbeResult( + await wslStatusErrorAfterGuestProbeAsync(error), + startedAtGeneration + ) + ) .finally(() => { wslAvailabilityProbeInFlight = null }) diff --git a/src/main/wsl-missing-kernel-probe.ts b/src/main/wsl-missing-kernel-probe.ts deleted file mode 100644 index 4bd11fb6c4a..00000000000 --- a/src/main/wsl-missing-kernel-probe.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { runProcess, runProcessSync, type ProcessSpec } from '../shared/child-process/run-process' -import { buildWslExecArgs } from '../shared/wsl-login-shell-command' -import { resolveWslInteropSpawnCwd } from './wsl-interop-spawn-directory' - -export function isWslMissingKernelError(error: unknown): boolean { - const failure = error as { code?: unknown; status?: unknown } | null - // Node preserves the Windows DWORD; PowerShell displays its signed equivalent. - return [failure?.code, failure?.status].some((code) => code === -444 || code === 4_294_966_852) -} - -function defaultGuestProbe(): ProcessSpec { - return { - program: 'wsl.exe', - args: buildWslExecArgs(undefined, ['/bin/true']), - cwd: resolveWslInteropSpawnCwd(), - timeoutMs: 5000, - maxOutputBytes: 4096 - } -} - -// WSL1 can execute normally while --status rejects a missing WSL2 kernel. -export async function canExecuteWslWithoutKernel(): Promise { - try { - const result = await runProcess(defaultGuestProbe()) - return result.code === 0 && !result.timedOut - } catch { - return false - } -} - -export function canExecuteWslWithoutKernelSync(): boolean { - try { - const result = runProcessSync(defaultGuestProbe()) - return result.code === 0 && !result.timedOut - } catch { - return false - } -}