mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
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:
+19
-8
@@ -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)
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user