Files
orca/src/main/wsl-availability.ts
T
NeilandOrca 75fc337141 fix(windows): classify async wsl/pwsh probe failures with the execFile error shape
The async twins feed `execFile` callback errors into classifiers written for
`execFileSync`: a non-zero exit lands on `error.code` as a number rather than
`error.status`, and a timeout is a SIGTERM kill rather than ETIMEDOUT.

So a Windows host without WSL (wsl.exe ships in System32, so it exits non-zero
instead of ENOENT) was cached as retryable, shrinking the shared window from
10min to 45s and making the still-sync callers re-pay their blocking spawn ~13x
more often; and a pwsh cold start past 5s cached "pwsh missing" for 30s,
demoting the user's PowerShell 7 preference — the exact case the ETIMEDOUT
branch exists to prevent.

Also drops a literal NUL byte from the new re-probe module's signature
separator, which made the file binary to git, and seeds `lastProbeAt` at
registration so focus churn right after mount cannot defer the first re-probe
indefinitely.

Co-authored-by: Orca <help@stably.ai>
2026-08-07 13:16:18 -07:00

200 lines
7.2 KiB
TypeScript

import { execFile, execFileSync } from 'node:child_process'
type WslAvailabilityCache =
| { available: true }
/** Not Windows — never re-probed. */
| { available: false; unsupported: true }
| { available: false; cachedAt: number; retryable: boolean; failures: number }
let wslAvailableCache: WslAvailabilityCache | null = null
let wslAvailabilityProbeInFlight: Promise<boolean> | null = null
const WSL_AVAILABILITY_PROBE_TIMEOUT_MS = 5000
// 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
// 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
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')
}
// Why: the probe spawns wsl.exe, 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 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
}
if (!('cachedAt' in cache)) {
return false
}
return Date.now() - cache.cachedAt < wslAvailabilityRetryDelayMs(cache)
}
function reusableWslAvailability(): boolean | null {
return wslAvailableCache && isWslAvailabilityCacheFresh(wslAvailableCache)
? wslAvailableCache.available
: null
}
function previousWslAvailabilityFailures(): number {
return wslAvailableCache && 'failures' in wslAvailableCache ? wslAvailableCache.failures : 0
}
function cacheWslAvailabilityProbeResult(error: unknown, previousFailures: number): boolean {
wslAvailableCache = error
? {
available: false,
cachedAt: Date.now(),
retryable: isRetryableWslProbeFailure(error),
failures: previousFailures + 1
}
: { available: true }
return wslAvailableCache.available
}
function probeWslStatus(): Promise<void> {
return new Promise((resolve, reject) => {
execFile(
'wsl.exe',
['--status'],
{ timeout: WSL_AVAILABILITY_PROBE_TIMEOUT_MS },
(error: unknown) => {
if (error) {
reject(error)
return
}
resolve()
}
)
})
}
/**
* Check whether wsl.exe is available and functional on this Windows machine.
* 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 previousFailures = previousWslAvailabilityFailures()
if (process.platform !== 'win32') {
wslAvailableCache = { available: false, unsupported: true }
return false
}
try {
execFileSync('wsl.exe', ['--status'], {
stdio: ['pipe', 'pipe', 'pipe'],
timeout: WSL_AVAILABILITY_PROBE_TIMEOUT_MS
})
return cacheWslAvailabilityProbeResult(null, previousFailures)
} catch (error) {
return cacheWslAvailabilityProbeResult(error, previousFailures)
}
}
/**
* 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.
*/
export function isWslAvailableAsync(): Promise<boolean> {
const cached = reusableWslAvailability()
if (cached !== null) {
return Promise.resolve(cached)
}
if (process.platform !== 'win32') {
wslAvailableCache = { available: false, unsupported: true }
return Promise.resolve(false)
}
if (wslAvailabilityProbeInFlight) {
return wslAvailabilityProbeInFlight
}
const previousFailures = previousWslAvailabilityFailures()
wslAvailabilityProbeInFlight = probeWslStatus()
.then(() => cacheWslAvailabilityProbeResult(null, previousFailures))
.catch((error: unknown) => cacheWslAvailabilityProbeResult(error, previousFailures))
.finally(() => {
wslAvailabilityProbeInFlight = null
})
return wslAvailabilityProbeInFlight
}
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?.available ?? null
}
// Why: the two caches expire independently, and `getWslRepairReason` checks availability
// 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.
export function dropStaleWslAvailabilityFailure(): void {
if (wslAvailableCache && !wslAvailableCache.available && !('unsupported' in wslAvailableCache)) {
wslAvailableCache = null
}
}
export function _resetWslAvailabilityCacheForTests(): void {
wslAvailableCache = null
wslAvailabilityProbeInFlight = null
}
export function _setWslAvailabilityCacheForTests(
available: boolean | null | undefined,
retryable: boolean
): void {
wslAvailableCache =
available === true
? { available: true }
: available === false
? { available: false, cachedAt: Date.now(), retryable, failures: 1 }
: null
}