diff --git a/docs/agent-status-over-wsl.md b/docs/agent-status-over-wsl.md index e5a6ac9aa3b..5ea8992eda1 100644 --- a/docs/agent-status-over-wsl.md +++ b/docs/agent-status-over-wsl.md @@ -286,9 +286,12 @@ Four independent review lenses over the full diff; confirmed findings fixed: Accepted gaps (reviewed, deliberately not addressed here): old version-namespaced install dirs accrete across upgrades (~200KB each); an outdated running daemon /p-translates the guest endpoint path until it restarts (hook scripts fall back to env -coords, which same-port binding keeps correct); `wslDistroCache` caches a transient -empty list for the app run (pre-existing semantics, now load-bearing for default-distro -resolution); default-distro resolution caches the first answer for the app run. +coords, which same-port binding keeps correct); default-distro resolution caches the +first non-empty answer for the app run. + +`wslDistroCache` no longer caches a transient empty list for the app run — an empty +result is re-probed after a short window, so a distro registered mid-session is picked +up. `isWslAvailable` likewise re-probes instead of latching a failure. ## 2026-07-09 round-4 external adversarial review diff --git a/src/main/index.ts b/src/main/index.ts index 748a2acc208..6944b47d95e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -126,6 +126,7 @@ import { type GpuFallbackEnvironment, type WindowsGpuFallbackEnvironment } from './startup/gpu-fallback-marker' +import { applyGpuFallbackCommandLineSwitches } from './startup/gpu-fallback-switches' import { DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD, DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS, @@ -1517,10 +1518,13 @@ function maybeApplyGpuFallbackForThisLaunch(): void { return } app.disableHardwareAcceleration() - app.commandLine.appendSwitch('disable-gpu') + const appliedSwitches = applyGpuFallbackCommandLineSwitches(app.commandLine, process.platform) gpuFallbackActiveThisLaunch = true + // Why: with no GPU child left, child-process-gone can't report a GPU fault, so + // name the applied switches in the trail any later crash report carries. recordCrashBreadcrumb('gpu_fallback_applied', { - crashesInWindow: marker.crashesInWindow + crashesInWindow: marker.crashesInWindow, + switches: appliedSwitches.join(',') }) } diff --git a/src/main/startup/gpu-fallback-switches.test.ts b/src/main/startup/gpu-fallback-switches.test.ts new file mode 100644 index 00000000000..db02c789b25 --- /dev/null +++ b/src/main/startup/gpu-fallback-switches.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it, vi } from 'vitest' +import { applyGpuFallbackCommandLineSwitches } from './gpu-fallback-switches' + +describe('GPU fallback command-line switches', () => { + it('applies nothing outside Windows', () => { + for (const platform of ['darwin', 'linux'] as const) { + const appendSwitch = vi.fn() + expect(applyGpuFallbackCommandLineSwitches({ appendSwitch }, platform)).toEqual([]) + expect(appendSwitch).not.toHaveBeenCalled() + } + }) + + // Why: measured on Windows 11 / Electron 43.1.0 — `--disable-gpu` alone still reports + // GPU: 1 in app.getAppMetrics(); adding --in-process-gpu drops it to 0. And + // --disable-software-rasterizer must stay absent: it kills SwiftShader, so + // getContext('webgl2') returns null and every terminal drops to the DOM renderer. + it('appends exactly the measured switch set to the Electron command line', () => { + const appendSwitch = vi.fn() + applyGpuFallbackCommandLineSwitches({ appendSwitch }, 'win32') + expect(appendSwitch.mock.calls.map(([name]) => name)).toEqual(['disable-gpu', 'in-process-gpu']) + }) +}) diff --git a/src/main/startup/gpu-fallback-switches.ts b/src/main/startup/gpu-fallback-switches.ts new file mode 100644 index 00000000000..7ea6ba41447 --- /dev/null +++ b/src/main/startup/gpu-fallback-switches.ts @@ -0,0 +1,32 @@ +/** Minimal surface of `app.commandLine` this module needs, so callers can be tested. */ +export type GpuFallbackCommandLine = { + appendSwitch(name: string, value?: string): void +} + +/** + * Why: `disableHardwareAcceleration()` + `--disable-gpu` still spawns a GPU child + * (measured on Windows 11 / Electron 43.1.0) — it only drops the backend to software + * GL, so a GPU process being killed by a bad driver or injected DLL keeps dying after + * the fallback engages. `--in-process-gpu` is the only switch that removes the child. + * `--disable-software-rasterizer` is deliberately absent: it would also kill + * SwiftShader, so `getContext('webgl2')` returns null and every terminal falls back to + * the DOM renderer. Note the fallback path skips `enableMainProcessGpuFeatures`, so the + * WebGL context ceiling is Chromium's default 16, not Orca's raised limit. + */ +const GPU_FALLBACK_COMMAND_LINE_SWITCHES = ['disable-gpu', 'in-process-gpu'] as const + +/** Software rendering is a Windows-only post-crash fallback; every other platform gets nothing. */ +function resolveGpuFallbackSwitches(platform: NodeJS.Platform): readonly string[] { + return platform === 'win32' ? GPU_FALLBACK_COMMAND_LINE_SWITCHES : [] +} + +export function applyGpuFallbackCommandLineSwitches( + commandLine: GpuFallbackCommandLine, + platform: NodeJS.Platform +): readonly string[] { + const switches = resolveGpuFallbackSwitches(platform) + for (const name of switches) { + commandLine.appendSwitch(name) + } + return switches +} diff --git a/src/main/wsl.test.ts b/src/main/wsl.test.ts index 597b1e795f4..855f7212219 100644 --- a/src/main/wsl.test.ts +++ b/src/main/wsl.test.ts @@ -17,7 +17,12 @@ vi.mock('child_process', async (importOriginal) => { import { _resetWslCachesForTests, + _setWslCachesForTests, + getCachedWslAvailability, getCachedWslDistros, + hasCachedWslAvailability, + hasCachedWslDistros, + isWslAvailable, listWslDistros, listWslDistrosAsync, parseWslPath, @@ -98,6 +103,442 @@ describe('WSL distro discovery cache', () => { vi.useRealTimers() } }) + + // Why: `wsl --install` succeeds and reports zero distros while the distro is + // still being provisioned (or until the required reboot). Caching that empty + // success for the process lifetime is why users saw WSL offered during setup + // 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') + }) + + try { + await withPlatformAsync('win32', async () => { + await expect(listWslDistrosAsync()).resolves.toEqual([]) + vi.advanceTimersByTime(15_000) + await expect(listWslDistrosAsync()).resolves.toEqual(['Ubuntu']) + }) + } finally { + vi.useRealTimers() + } + }) + + it('retries synchronous discovery after wsl.exe reports no distros yet', () => { + vi.useFakeTimers() + execFileSyncMock.mockReturnValueOnce('') + execFileSyncMock.mockReturnValueOnce('Ubuntu\n') + + try { + withPlatform('win32', () => { + expect(listWslDistros()).toEqual([]) + vi.advanceTimersByTime(15_000) + expect(listWslDistros()).toEqual(['Ubuntu']) + }) + } finally { + vi.useRealTimers() + } + }) + + // 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('') + + withPlatform('win32', () => { + expect(listWslDistros()).toEqual([]) + expect(listWslDistros()).toEqual([]) + expect(execFileSyncMock).toHaveBeenCalledTimes(1) + }) + }) + + it('bounds the asynchronous spawn rate while no distros are installed', async () => { + execFileMock.mockImplementation((_command, _args, _options, callback) => { + callback(null, '') + }) + + await withPlatformAsync('win32', async () => { + await expect(listWslDistrosAsync()).resolves.toEqual([]) + await expect(listWslDistrosAsync()).resolves.toEqual([]) + expect(execFileMock).toHaveBeenCalledTimes(1) + }) + }) + + it('still caches a non-empty distro list for the process lifetime', () => { + execFileSyncMock.mockReturnValueOnce('Ubuntu\n') + + withPlatform('win32', () => { + expect(listWslDistros()).toEqual(['Ubuntu']) + expect(listWslDistros()).toEqual(['Ubuntu']) + expect(execFileSyncMock).toHaveBeenCalledTimes(1) + }) + }) + + // Why: docker-desktop entries filter to zero user distros, which is the same + // "nothing installed yet" state as an empty list — a distro installed later + // must still appear. The answer stays readable as [] so a missing distro is + // 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') + + try { + withPlatform('win32', () => { + expect(listWslDistros()).toEqual([]) + expect(getCachedWslDistros()).toEqual([]) + expect(listWslDistros()).toEqual([]) + expect(execFileSyncMock).toHaveBeenCalledTimes(1) + vi.advanceTimersByTime(15_000) + expect(listWslDistros()).toEqual(['Ubuntu']) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: a docker-desktop-only machine filters to empty on every probe, so a flat + // 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('') + + try { + withPlatform('win32', () => { + expect(listWslDistros()).toEqual([]) + vi.advanceTimersByTime(15_000) + expect(listWslDistros()).toEqual([]) + expect(execFileSyncMock).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) + vi.advanceTimersByTime(15_000) + expect(listWslDistros()).toEqual([]) + expect(execFileSyncMock).toHaveBeenCalledTimes(3) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: listWslDistrosAsync has no in-flight dedupe, so two probes overlap and can + // resolve out of order — a slow pre-registration wsl.exe landing after a fast one. + // The late empty answer must not erase the list the newer probe already found. + it('does not let a late empty probe erase a list a newer probe found', async () => { + const callbacks: ((error: Error | null, stdout: string) => void)[] = [] + execFileMock.mockImplementation((_command, _args, _options, callback) => { + callbacks.push(callback) + }) + + await withPlatformAsync('win32', async () => { + const stale = listWslDistrosAsync() + const fresh = listWslDistrosAsync() + expect(callbacks).toHaveLength(2) + + callbacks[1](null, 'Ubuntu\n') + callbacks[0](null, '') + + await expect(fresh).resolves.toEqual(['Ubuntu']) + await expect(stale).resolves.toEqual(['Ubuntu']) + expect(getCachedWslDistros()).toEqual(['Ubuntu']) + }) + }) + + // Why: N startup callers each land with the same empty answer; counting each one + // would arm the 5min cap on the first round and hide a distro for that whole time. + it('counts one empty result per window when probes overlap', async () => { + vi.useFakeTimers() + const callbacks: ((error: Error | null, stdout: string) => void)[] = [] + execFileMock.mockImplementation((_command, _args, _options, callback) => { + callbacks.push(callback) + }) + + try { + await withPlatformAsync('win32', async () => { + const pending = [listWslDistrosAsync(), listWslDistrosAsync(), listWslDistrosAsync()] + expect(callbacks).toHaveLength(3) + for (const callback of callbacks) { + callback(null, '') + } + await Promise.all(pending) + + // One window elapsed, not three doublings, so the next probe runs at 15s. + vi.advanceTimersByTime(15_000) + expect(listWslDistros()).toEqual([]) + expect(execFileSyncMock).toHaveBeenCalledTimes(1) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: the catch path arms a window without advancing the streak, so an overlapping + // empty probe computes its delay from a streak of 0 — unclamped that halves the base + // window to 7.5s and re-spawns wsl.exe early. + it('holds the base window when a failure and an empty result overlap', async () => { + vi.useFakeTimers() + const callbacks: ((error: Error | null, stdout: string) => void)[] = [] + execFileMock.mockImplementation((_command, _args, _options, callback) => { + callbacks.push(callback) + }) + + try { + await withPlatformAsync('win32', async () => { + const failing = listWslDistrosAsync() + const empty = listWslDistrosAsync() + expect(callbacks).toHaveLength(2) + callbacks[0](new Error('transient failure'), '') + callbacks[1](null, '') + await Promise.all([failing, empty]) + + vi.advanceTimersByTime(7_500) + expect(listWslDistros()).toEqual([]) + expect(execFileSyncMock).not.toHaveBeenCalled() + vi.advanceTimersByTime(7_500) + expect(listWslDistros()).toEqual([]) + expect(execFileSyncMock).toHaveBeenCalledTimes(1) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: the cap is the only bound on how long a distro installed mid-session stays + // invisible, so pin it rather than letting the doubling run away. + it('caps the empty-list backoff at five minutes', () => { + vi.useFakeTimers() + execFileSyncMock.mockReturnValue('') + + try { + withPlatform('win32', () => { + // Windows double to 15/30/60/120/240s, so the 6th would be 480s uncapped. + for (const delayMs of [0, 15_000, 30_000, 60_000, 120_000, 240_000]) { + vi.advanceTimersByTime(delayMs) + listWslDistros() + } + expect(execFileSyncMock).toHaveBeenCalledTimes(6) + vi.advanceTimersByTime(300_000) + listWslDistros() + expect(execFileSyncMock).toHaveBeenCalledTimes(7) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: an empty list is a real probe result and must keep driving the + // `wsl-distro-missing` repair prompt even once stale. Going null instead fails + // open and silently spawns `wsl.exe -d ` for a distro Orca saw was absent. + it('keeps reporting an empty result after it goes stale', () => { + vi.useFakeTimers() + execFileSyncMock.mockReturnValue('') + + try { + withPlatform('win32', () => { + expect(listWslDistros()).toEqual([]) + expect(getCachedWslDistros()).toEqual([]) + vi.advanceTimersByTime(15_000) + expect(getCachedWslDistros()).toEqual([]) + expect(hasCachedWslDistros()).toBe(true) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: a genuinely missing distro yields a non-empty list, which never expires, + // 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') + + try { + withPlatform('win32', () => { + expect(listWslDistros()).toEqual(['Ubuntu']) + vi.advanceTimersByTime(600_000) + expect(getCachedWslDistros()).toEqual(['Ubuntu']) + }) + } finally { + vi.useRealTimers() + } + }) +}) + +describe('WSL availability cache', () => { + afterEach(() => { + execFileMock.mockReset() + execFileSyncMock.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', () => { + 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('') + + try { + withPlatform('win32', () => { + expect(isWslAvailable()).toBe(false) + vi.advanceTimersByTime(45_000) + expect(isWslAvailable()).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' + }) + }) + + withPlatform('win32', () => { + expect(isWslAvailable()).toBe(false) + expect(isWslAvailable()).toBe(false) + expect(execFileSyncMock).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 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', () => { + 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' + }) + }) + + try { + withPlatform('win32', () => { + expect(isWslAvailable()).toBe(false) + vi.advanceTimersByTime(45_000) + expect(getCachedWslAvailability()).toBe(false) + expect(hasCachedWslAvailability()).toBe(true) + }) + } finally { + vi.useRealTimers() + } + }) + + // 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. + 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, errorShape) => { + vi.useFakeTimers() + execFileSyncMock.mockImplementationOnce(() => { + throw Object.assign(new Error('definitive failure'), errorShape) + }) + execFileSyncMock.mockReturnValueOnce('') + + try { + withPlatform('win32', () => { + expect(isWslAvailable()).toBe(false) + vi.advanceTimersByTime(45_000) + expect(isWslAvailable()).toBe(false) + expect(getCachedWslAvailability()).toBe(false) + expect(execFileSyncMock).toHaveBeenCalledTimes(1) + vi.advanceTimersByTime(10 * 60_000) + expect(isWslAvailable()).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', () => { + vi.useFakeTimers() + execFileSyncMock.mockImplementation(() => { + throw Object.assign(new Error('spawnSync ETIMEDOUT'), { + code: 'ETIMEDOUT', + status: null, + signal: 'SIGTERM' + }) + }) + + try { + withPlatform('win32', () => { + expect(isWslAvailable()).toBe(false) + vi.advanceTimersByTime(45_000) + expect(isWslAvailable()).toBe(false) + expect(execFileSyncMock).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) + }) + } finally { + vi.useRealTimers() + } + }) + + // 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', () => { + vi.useFakeTimers() + execFileSyncMock.mockReturnValueOnce('') + + try { + withPlatform('win32', () => { + _setWslCachesForTests({ available: false, availabilityRetryable: true }) + expect(isWslAvailable()).toBe(false) + vi.advanceTimersByTime(45_000) + expect(isWslAvailable()).toBe(true) + }) + } finally { + vi.useRealTimers() + } + }) + + it('keeps reporting unavailable off Windows without probing', () => { + withPlatform('darwin', () => { + expect(isWslAvailable()).toBe(false) + expect(getCachedWslAvailability()).toBe(false) + expect(execFileSyncMock).not.toHaveBeenCalled() + }) + }) }) describe('wsl path helpers', () => { diff --git a/src/main/wsl.ts b/src/main/wsl.ts index 504ac981010..54b259cc4ea 100644 --- a/src/main/wsl.ts +++ b/src/main/wsl.ts @@ -99,8 +99,17 @@ let wslDistroCache: string[] | null = null // otherwise hide every distro until restart), but repeated failures cannot // re-spawn a blocking wsl.exe on every caller; brief negative caching bounds // the spawn rate on machines where WSL is absent or persistently broken. -const WSL_DISTRO_LIST_FAILURE_TTL_MS = 15_000 -let wslDistroListFailedUntilMs = 0 +const WSL_DISTRO_LIST_RETRY_DELAY_MS = 15_000 +// Why: a machine with only docker-desktop registered filters to empty on every probe, +// so a flat window would re-spawn wsl.exe every 15s for the whole session. Provisioning +// is a one-time event, so backing off still clears the picker well inside setup. +const WSL_DISTRO_LIST_MAX_RETRY_DELAY_MS = 5 * 60_000 +let wslDistroListRetryAfterMs = 0 +let wslDistroListEmptyStreak = 0 +// Why: availability is a separate, blocking probe. 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 function normalizeWslListOutput(output: string): string[] { // Why: wsl.exe can emit UTF-16-looking NUL bytes when inherited through @@ -116,18 +125,57 @@ function isUserWslDistro(distro: string): boolean { return !distro.toLowerCase().startsWith('docker-desktop') } -export function listWslDistros(): string[] { - if (wslDistroCache) { +// Why: `wsl --install` reports zero distros while one is still provisioning, so an +// empty answer is transient and must stay retryable — caching it for the process +// lifetime is what made WSL vanish from the picker after setup. It is still cached +// for reads, so a missing distro stays visible to `isKnownMissingDistro`. +function cacheWslDistroList(rawDistros: string[]): string[] { + const userDistros = rawDistros.filter(isUserWslDistro) + // Why: probes overlap and can resolve out of order — a slow pre-registration wsl.exe + // can land after a fast one that already found the distro. A late empty answer must + // not erase that list, or provisioning reverts to "no distros" and backs off again. + if (userDistros.length === 0 && wslDistroCache !== null && wslDistroCache.length > 0) { return wslDistroCache } + wslDistroCache = userDistros + if (wslDistroCache.length === 0) { + const now = Date.now() + // Why: concurrent probes all land here with the same empty answer; counting each + // one would jump the very first round straight to the cap. + if (now >= wslDistroListRetryAfterMs) { + wslDistroListEmptyStreak += 1 + } + wslDistroListRetryAfterMs = + now + + Math.min( + // Why: the catch path arms a window without touching the streak, so clamp the + // floor — a streak of 0 would halve the delay instead of holding it at the base. + WSL_DISTRO_LIST_RETRY_DELAY_MS * 2 ** (Math.max(wslDistroListEmptyStreak, 1) - 1), + WSL_DISTRO_LIST_MAX_RETRY_DELAY_MS + ) + } + return wslDistroCache +} + +/** A non-empty list is stable; an empty one re-probes once the retry window elapses. */ +function shouldReuseCachedWslDistros(): boolean { + return ( + wslDistroCache !== null && (wslDistroCache.length > 0 || Date.now() < wslDistroListRetryAfterMs) + ) +} + +export function listWslDistros(): string[] { + if (shouldReuseCachedWslDistros()) { + return wslDistroCache ?? [] + } if (process.platform !== 'win32') { wslDistroCache = [] return wslDistroCache } - if (Date.now() < wslDistroListFailedUntilMs) { - return [] + if (Date.now() < wslDistroListRetryAfterMs) { + return wslDistroCache ?? [] } try { @@ -136,17 +184,16 @@ export function listWslDistros(): string[] { stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 }) - wslDistroCache = normalizeWslListOutput(output).filter(isUserWslDistro) - return wslDistroCache + return cacheWslDistroList(normalizeWslListOutput(output)) } catch { - wslDistroListFailedUntilMs = Date.now() + WSL_DISTRO_LIST_FAILURE_TTL_MS - return [] + wslDistroListRetryAfterMs = Date.now() + WSL_DISTRO_LIST_RETRY_DELAY_MS + return wslDistroCache ?? [] } } export async function listWslDistrosAsync(): Promise { - if (wslDistroCache !== null) { - return wslDistroCache + if (shouldReuseCachedWslDistros()) { + return wslDistroCache ?? [] } if (process.platform !== 'win32') { @@ -154,17 +201,16 @@ export async function listWslDistrosAsync(): Promise { return wslDistroCache } - if (Date.now() < wslDistroListFailedUntilMs) { - return [] + if (Date.now() < wslDistroListRetryAfterMs) { + return wslDistroCache ?? [] } try { const output = await execFileUtf8('wsl.exe', ['--list', '--quiet']) - wslDistroCache = normalizeWslListOutput(output).filter(isUserWslDistro) - return wslDistroCache + return cacheWslDistroList(normalizeWslListOutput(output)) } catch { - wslDistroListFailedUntilMs = Date.now() + WSL_DISTRO_LIST_FAILURE_TTL_MS - return [] + wslDistroListRetryAfterMs = Date.now() + WSL_DISTRO_LIST_RETRY_DELAY_MS + return wslDistroCache ?? [] } } @@ -172,6 +218,11 @@ export function hasCachedWslDistros(): boolean { return wslDistroCache !== null } +// Why: report the last observed answer even once it is stale. An empty list is a real +// probe result, so it must keep driving the `wsl-distro-missing` repair prompt; going +// null instead fails open and silently spawns `wsl.exe -d ` for a distro Orca +// last saw was absent. Staleness self-corrects — `listWslDistros` re-probes after the +// retry window and a distro installed since clears the prompt on its own. export function getCachedWslDistros(): string[] | null { return wslDistroCache } @@ -234,20 +285,69 @@ export async function getWslHomeAsync(distro: string): Promise { } } -// Cached WSL availability check — evaluated once per process lifetime -let wslAvailableCache: boolean | null = null +type WslAvailabilityCache = + | { available: true } + /** Not Windows — never re-probed. */ + | { available: false; unsupported: true } + | { available: false; cachedAt: number; retryable: boolean; failures: number } + +function isPermanentWslAvailabilityCache(cache: WslAvailabilityCache): boolean { + // Why: re-check the platform so a cache seeded off-Windows can't suppress a real probe. + return cache.available || ('unsupported' in cache && process.platform !== 'win32') +} + +let wslAvailableCache: WslAvailabilityCache | null = null + +// Why: even a definitive-looking non-zero exit can be transient — wsl.exe reports one +// while the WSL package is servicing or LxssManager is still starting — so nothing +// latches for the whole session; it just waits much longer before paying the probe again. +const WSL_AVAILABILITY_DEFINITIVE_TTL_MS = 10 * 60_000 +const WSL_AVAILABILITY_MAX_RETRY_DELAY_MS = 30 * 60_000 + +// Why: the probe blocks the main process for up to 5s, so a host with a wedged wsl.exe +// must not pay it every window; back off per consecutive failure. +function wslAvailabilityRetryDelayMs(cache: { retryable: boolean; failures: number }): number { + const base = cache.retryable + ? WSL_AVAILABILITY_NEGATIVE_CACHE_TTL_MS + : WSL_AVAILABILITY_DEFINITIVE_TTL_MS + return Math.min(base * 2 ** (cache.failures - 1), WSL_AVAILABILITY_MAX_RETRY_DELAY_MS) +} + +// Why: a numeric `status` (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. +// 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') { + return false + } + return failure?.code !== 'ENOENT' +} + +function isWslAvailabilityCacheFresh(cache: WslAvailabilityCache): boolean { + if (isPermanentWslAvailabilityCache(cache)) { + return true + } + const failed = cache as { cachedAt: number; retryable: boolean; failures: number } + return Date.now() - failed.cachedAt < wslAvailabilityRetryDelayMs(failed) +} /** * Check whether wsl.exe is available and functional on this Windows machine. - * Result is cached for the process lifetime. + * 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 { - if (wslAvailableCache !== null) { - return wslAvailableCache + if (wslAvailableCache && isWslAvailabilityCacheFresh(wslAvailableCache)) { + return wslAvailableCache.available } + const previousFailures = + wslAvailableCache && 'failures' in wslAvailableCache ? wslAvailableCache.failures : 0 + if (process.platform !== 'win32') { - wslAvailableCache = false + wslAvailableCache = { available: false, unsupported: true } return false } @@ -256,35 +356,66 @@ export function isWslAvailable(): boolean { stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 }) - wslAvailableCache = true - } catch { - wslAvailableCache = false + wslAvailableCache = { available: true } + } catch (error) { + wslAvailableCache = { + available: false, + cachedAt: Date.now(), + retryable: isRetryableWslProbeFailure(error), + failures: previousFailures + 1 + } } - return wslAvailableCache + return wslAvailableCache.available } export function hasCachedWslAvailability(): boolean { return wslAvailableCache !== null } +// 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 +// clears it, by re-probing once the retry window lapses. export function getCachedWslAvailability(): boolean | null { - return wslAvailableCache + return wslAvailableCache?.available ?? null } export function _resetWslCachesForTests(): void { wslHomeCache.clear() wslDistroCache = null - wslDistroListFailedUntilMs = 0 + wslDistroListRetryAfterMs = 0 + wslDistroListEmptyStreak = 0 wslAvailableCache = null } +// 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. export function _setWslCachesForTests(args: { available?: boolean | null distros?: string[] | null + availabilityRetryable?: boolean }): void { - wslAvailableCache = args.available ?? null - wslDistroCache = args.distros ?? null + wslAvailableCache = + args.available === true + ? { available: true } + : args.available === false + ? { + available: false, + cachedAt: Date.now(), + retryable: args.availabilityRetryable ?? false, + failures: 1 + } + : null + // Why: seed through the real cache path so an empty seed arms the retry window + // too — otherwise a seeded [] lets the next call spawn a real 5s wsl.exe. + wslDistroListRetryAfterMs = 0 + wslDistroListEmptyStreak = 0 + wslDistroCache = null + if (args.distros) { + cacheWslDistroList(args.distros) + } } function execFileUtf8(command: string, args: string[]): Promise {