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>
This commit is contained in:
Neil
2026-08-07 13:16:18 -07:00
committed by OrcaWin
co-authored by Orca
parent 04af40e3eb
commit 75fc337141
6 changed files with 75 additions and 10 deletions
+18
View File
@@ -177,6 +177,24 @@ describe('isPwshAvailable', () => {
}
})
// Why: execFile reports a timeout as a SIGTERM kill, not ETIMEDOUT, so caching it as a
// failure would disable the user's PowerShell 7 preference for 30s on every slow cold start.
it('does not cache a cold-start timeout from the async probe', async () => {
const restorePlatform = setPlatform('win32')
execFileMock.mockImplementation((_file, _args, _options, callback) => {
callback(Object.assign(new Error('pwsh.exe timed out'), { killed: true, signal: 'SIGTERM' }))
})
execFileSyncMock.mockReturnValue('PowerShell 7.5.0')
try {
const { isPwshAvailable, isPwshAvailableAsync } = await import('./pwsh')
await expect(isPwshAvailableAsync()).resolves.toBe(false)
expect(isPwshAvailable()).toBe(true)
} finally {
restorePlatform()
}
})
it('retries non-timeout failures after the negative cache TTL', async () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
+8 -6
View File
@@ -18,13 +18,15 @@ function isCacheFresh(cache: PwshAvailabilityCache): boolean {
)
}
// Why: execFileSync reports a timeout as ETIMEDOUT, but the execFile callback reports it as a
// SIGTERM kill with no code — both shapes must be recognised or the async probe caches a
// cold-start timeout as "pwsh missing".
function isTimeoutError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as NodeJS.ErrnoException).code === 'ETIMEDOUT'
)
if (typeof error !== 'object' || error === null) {
return false
}
const failure = error as { code?: unknown; killed?: unknown; signal?: unknown }
return failure.code === 'ETIMEDOUT' || (failure.killed === true && failure.signal === 'SIGTERM')
}
function cachePwshProbeFailure(error: unknown): void {
+5 -3
View File
@@ -34,12 +34,14 @@ function wslAvailabilityRetryDelayMs(cache: { retryable: boolean; failures: numb
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.
// 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') {
if (typeof failure?.status === 'number' || typeof failure?.code === 'number') {
return false
}
return failure?.code !== 'ENOENT'
+25
View File
@@ -540,6 +540,31 @@ describe('WSL availability cache', () => {
})
})
// 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.
@@ -86,7 +86,11 @@ describe('windows terminal capability re-probe', () => {
it('stops entirely once the last consumer unregisters', async () => {
vi.useFakeTimers()
const { probe, readCached } = createWatcher()
const stopFirst = startWindowsTerminalCapabilityReprobe({ ownerKey: 'local', probe, readCached })
const stopFirst = startWindowsTerminalCapabilityReprobe({
ownerKey: 'local',
probe,
readCached
})
const stopSecond = startWindowsTerminalCapabilityReprobe({
ownerKey: 'local',
probe,
@@ -119,6 +123,20 @@ describe('windows terminal capability re-probe', () => {
expect(probe).toHaveBeenCalledTimes(4)
})
// Why: each re-arm reschedules the first probe to now+30s, so an un-guarded focus handler
// lets a user alt-tabbing right after mount defer the re-check indefinitely.
it('does not let focus churn right after mount defer the first probe', async () => {
vi.useFakeTimers()
const { probe, readCached } = createWatcher()
startWindowsTerminalCapabilityReprobe({ ownerKey: 'local', probe, readCached })
for (let i = 0; i < 3; i += 1) {
await vi.advanceTimersByTimeAsync(10_000)
globalThis.dispatchEvent(new Event('focus'))
}
expect(probe).toHaveBeenCalledTimes(1)
})
it('drops the focus listener when no owner is watched', () => {
const addEventListener = vi.spyOn(globalThis, 'addEventListener')
const removeEventListener = vi.spyOn(globalThis, 'removeEventListener')