mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(wsl): start every host wsl.exe call through process-host so no console window flashes (#27628)
* fix(wsl): start host-side wsl.exe probes through process-host so they never open a console wsl --list, wsl --status, the WSL home and directory probes, and WSL UNC deletes were started with node:child_process directly. The synchronous ones omitted windowsHide, so each spawn from the GUI-subsystem main process flashed a conhost window that could take keyboard focus. Route them through @orca/process-host, which pins windowsHide, and keep the execFile-shaped exit errors the availability cache classifies. Refs #27390 * build(tc): list wsl-exe-process.ts in the web typecheck project wsl-availability.ts is in the explicit web file list and now imports it. * refactor(wsl): drop the unused sync availability probe; classify ProcessResult directly No production code calls the sync isWslAvailable (the IPC and RPC paths already assert they never do), so delete it and its sync guest-probe twin instead of porting them. The async probe now reads the --status ProcessResult itself: an exit code is definitive, a timeout or signal kill is retryable, ENOENT on spawn is definitive. The WSL2-missing-kernel exit codes live in one named set. Availability tests now drive the async probe. * refactor(wsl): shrink wsl-exe-process to a spec builder and stdout reader With the availability cache classifying ProcessResult itself, nothing reads the execFile-shaped code/status error any more. wslExeFailure returns a plain reason string; wslExeStdout throws it; the UNC delete folds it into its own error. The home probe builds its argv with buildWslExecArgs. * test(wsl): mock process-host in the empty running-list case from main * docs(wsl): the availability probe no longer blocks; drop the stale word * build(ratchet): prune the wsl.exe sync launches this branch removed --------- Co-authored-by: Orca Worker <orca-worker@localhost>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<typeof childProcess>()),
|
||||
execFile: execFileMock
|
||||
vi.mock('@orca/process-host', async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
runProcess: runProcessMock
|
||||
}))
|
||||
vi.mock('./session-scanner-service-spawn', async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
@@ -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<ProcessResult>((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<ProcessResult>((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])
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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<typeof execFile>
|
||||
})
|
||||
}
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
+49
-123
@@ -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<boolean> | 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<number | null> = 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<boolean> {
|
||||
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<unknown> {
|
||||
if (!isMissingWsl2KernelStatus(error)) {
|
||||
return error
|
||||
}
|
||||
async function probeWslAvailability(): Promise<WslProbeFailure | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
* 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<boolean> {
|
||||
const cached = reusableWslAvailability()
|
||||
@@ -229,14 +161,8 @@ export function isWslAvailableAsync(): Promise<boolean> {
|
||||
}
|
||||
|
||||
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)) {
|
||||
|
||||
@@ -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<typeof childProcess>()
|
||||
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<ProcessResult>((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<ProcessResult>((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<ProcessResult>((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<ProcessResult>((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<ProcessResult>((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<ProcessResult>((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<ProcessResult>((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()
|
||||
}
|
||||
|
||||
@@ -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 <distro> --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
|
||||
}
|
||||
@@ -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<typeof childProcess>()),
|
||||
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<T>(value: NodeJS.Platform, fn: () => Promise<T>): 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<ProcessResult>((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<ProcessResult>((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<ProcessResult>((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<ProcessResult>((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<ProcessResult>((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<ProcessResult>((resolve) => {
|
||||
spawnCount += 1
|
||||
resolve(FAILED)
|
||||
})
|
||||
)
|
||||
|
||||
vi.useFakeTimers()
|
||||
const observedStates: boolean[] = []
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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<T>(platform: NodeJS.Platform, run: () => Promise<T>)
|
||||
|
||||
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(
|
||||
|
||||
+16
-27
@@ -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<void> {
|
||||
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<void> {
|
||||
// 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}`)
|
||||
}
|
||||
|
||||
+196
-327
@@ -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<typeof childProcess>()
|
||||
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<T>(value: NodeJS.Platform, fn: () => Promise<T>
|
||||
|
||||
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 <distro>` 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<string, (error: Error | null, stdout: string) => void>()
|
||||
execFileMock.mockImplementation((_command, args, _options, callback) => {
|
||||
callbacks.set(args.join(' '), callback)
|
||||
})
|
||||
const callbacks = new Map<string, (result: ProcessResult) => 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()
|
||||
})
|
||||
})
|
||||
|
||||
+30
-52
@@ -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<boolean | n
|
||||
if (!info) {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
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<string, Promise<string | null>>()
|
||||
@@ -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<string[]> {
|
||||
// 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<string | null> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
return wslExeStdout(await runProcess(wslExeProcessSpec(args, WSL_PROBE_TIMEOUT_MS, env)))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user