diff --git a/src/main/pwsh.test.ts b/src/main/pwsh.test.ts index cf71f8b8c63..dbb4d47bd46 100644 --- a/src/main/pwsh.test.ts +++ b/src/main/pwsh.test.ts @@ -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) diff --git a/src/main/pwsh.ts b/src/main/pwsh.ts index 7c07e43e096..acba99ab933 100644 --- a/src/main/pwsh.ts +++ b/src/main/pwsh.ts @@ -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 { diff --git a/src/main/wsl-availability.ts b/src/main/wsl-availability.ts index 4e1f90ef480..7ba5aa7181f 100644 --- a/src/main/wsl-availability.ts +++ b/src/main/wsl-availability.ts @@ -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' diff --git a/src/main/wsl.test.ts b/src/main/wsl.test.ts index d77137f8fd8..ac165a78786 100644 --- a/src/main/wsl.test.ts +++ b/src/main/wsl.test.ts @@ -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. diff --git a/src/renderer/src/lib/windows-terminal-capability-reprobe.test.ts b/src/renderer/src/lib/windows-terminal-capability-reprobe.test.ts index 7ec0f655b1c..45cf119a76b 100644 --- a/src/renderer/src/lib/windows-terminal-capability-reprobe.test.ts +++ b/src/renderer/src/lib/windows-terminal-capability-reprobe.test.ts @@ -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') diff --git a/src/renderer/src/lib/windows-terminal-capability-reprobe.ts b/src/renderer/src/lib/windows-terminal-capability-reprobe.ts index 3fe63a2a37f..5829f602791 100644 Binary files a/src/renderer/src/lib/windows-terminal-capability-reprobe.ts and b/src/renderer/src/lib/windows-terminal-capability-reprobe.ts differ