fix(wsl): fold the missing-kernel guest probe into wsl-availability

The separate wsl-missing-kernel-probe module failed three CI gates: it was
not in the web typecheck project (TS6307), it added a new direct wsl.exe
spawn outside wsl-runner, and its `catch { return false }` tripped the
probe-failure-semantics ratchet.

wsl-availability.ts already owns the answer and is already on the invocation
allowlist, so the probe lives there now. A guest probe that cannot spawn
keeps the real --status failure instead of minting a fresh negative, which
is what the ratchet exists to prevent -- and is the more correct semantics.
This commit is contained in:
Neil
2026-09-06 14:31:59 -07:00
parent 458d97b765
commit 94bbbc65c2
3 changed files with 80 additions and 61 deletions
@@ -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<typeof execFile>
})
}
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)
+61 -15
View File
@@ -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<unknown> {
if (!isMissingWsl2KernelStatus(error)) {
return error
}
try {
return (await runProcess(defaultGuestExecutionProbe())).code === 0 ? null : error
} catch {
return error
}
}
function probeWslStatus(): Promise<void> {
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<boolean> {
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
})
-38
View File
@@ -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<boolean> {
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
}
}