fix(windows): make the GPU fallback actually remove the GPU child, and stop WSL latching absent

Three Windows crash/regression fixes from shipped 1.4.156/1.4.158/1.4.159 crash reports.

GPU fallback (cluster D, 14 reports, exit 0x80000003 STATUS_BREAKPOINT):
the software-rendering fallback called disableHardwareAcceleration() plus
--disable-gpu, neither of which removes the GPU child process — Chromium still
spawns it to host Viz and merely drops the backend to software GL. Measured on
Windows 11 / Electron 43.1.0: gpuProcessCount stays 1. So a GPU process being
killed by a bad driver or an injected DLL kept dying after the fallback engaged,
on every launch, for the life of that build (the marker is sticky per version).
The crash tails show exactly this: gpu_fallback_applied followed by another GPU
crash 1.3s later. --in-process-gpu is the only switch that drops the child count
to 0; --disable-software-rasterizer is deliberately excluded because it also
kills SwiftShader, which would drop every terminal to the DOM renderer.

WSL distro list: a successful-but-empty `wsl --list --quiet` was cached for the
process lifetime. `wsl --install` reports zero distros while one is still
provisioning, so an early probe latched "no WSL" until restart — WSL appeared
during setup and then vanished from the terminal picker. Empty results now
re-probe on an exponential window (15s doubling to a 5min cap) while staying
readable, so a missing distro is still visible to isKnownMissingDistro.

WSL availability: isWslAvailable() latched false on any failure via a bare catch,
so one slow wsl.exe activation disabled WSL for the whole session. Failures are
now classified — a numeric exit status or ENOENT is answer-shaped and holds for
10min, anything else (timeout, spawn failure) retries after 45s — and both back
off per consecutive failure, mirroring isPwshAvailable.

Windows-only: every changed path is behind an existing process.platform check,
so macOS and Linux behaviour is unchanged.
This commit is contained in:
OrcaWin
2026-07-29 17:36:24 -07:00
parent 32926bc831
commit 3f90d2cf91
6 changed files with 670 additions and 37 deletions
+6 -3
View File
@@ -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
+6 -2
View File
@@ -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(',')
})
}
@@ -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'])
})
})
+32
View File
@@ -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
}
+441
View File
@@ -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 <distro>` 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', () => {
+163 -32
View File
@@ -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<string[]> {
if (wslDistroCache !== null) {
return wslDistroCache
if (shouldReuseCachedWslDistros()) {
return wslDistroCache ?? []
}
if (process.platform !== 'win32') {
@@ -154,17 +201,16 @@ export async function listWslDistrosAsync(): Promise<string[]> {
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 <distro>` 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<string | null> {
}
}
// 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<string> {