diff --git a/src/main/daemon/daemon-entry.ts b/src/main/daemon/daemon-entry.ts index 4ed1abe30..07430b5fa 100644 --- a/src/main/daemon/daemon-entry.ts +++ b/src/main/daemon/daemon-entry.ts @@ -8,6 +8,7 @@ */ import { startDaemon, type DaemonHandle } from './daemon-main' import { createPtySubprocess } from './pty-subprocess' +import { warmPwshAvailabilityCache } from '../pwsh' export function parseArgs(argv: string[]): { socketPath: string; tokenPath: string } { let socketPath = '' @@ -32,6 +33,7 @@ export function parseArgs(argv: string[]): { socketPath: string; tokenPath: stri async function main(): Promise { const { socketPath, tokenPath } = parseArgs(process.argv.slice(2)) + void warmPwshAvailabilityCache() // Why: node-pty can throw a C++ Napi::Error that escapes all JS try/catch // blocks (e.g. writing to a PTY whose fd was closed between the native diff --git a/src/main/pwsh.test.ts b/src/main/pwsh.test.ts index 2aeb91fa1..7c8ca2664 100644 --- a/src/main/pwsh.test.ts +++ b/src/main/pwsh.test.ts @@ -1,10 +1,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { execFileSyncMock } = vi.hoisted(() => ({ +const { execFileMock, execFileSyncMock } = vi.hoisted(() => ({ + execFileMock: vi.fn(), execFileSyncMock: vi.fn() })) vi.mock('child_process', () => ({ + execFile: execFileMock, execFileSync: execFileSyncMock })) @@ -26,6 +28,8 @@ function setPlatform(platform: NodeJS.Platform): () => void { describe('isPwshAvailable', () => { beforeEach(() => { vi.resetModules() + vi.useRealTimers() + execFileMock.mockReset() execFileSyncMock.mockReset() }) @@ -84,4 +88,70 @@ describe('isPwshAvailable', () => { restorePlatform() } }) + + it('repro: does not keep a cold-start timeout cached for the daemon lifetime', async () => { + const restorePlatform = setPlatform('win32') + execFileSyncMock + .mockImplementationOnce(() => { + const error = Object.assign(new Error('spawnSync pwsh.exe ETIMEDOUT'), { + code: 'ETIMEDOUT' + }) + throw error + }) + .mockReturnValue('PowerShell 7.5.0') + + try { + const { isPwshAvailable } = await import('./pwsh') + expect(isPwshAvailable()).toBe(false) + expect(isPwshAvailable()).toBe(true) + expect(execFileSyncMock).toHaveBeenCalledTimes(2) + } finally { + restorePlatform() + } + }) + + it('warms pwsh availability asynchronously with a longer timeout', async () => { + const restorePlatform = setPlatform('win32') + execFileMock.mockImplementation((_file, _args, _options, callback) => { + callback(null, 'PowerShell 7.5.0', '') + }) + + try { + const { isPwshAvailable, warmPwshAvailabilityCache } = await import('./pwsh') + await expect(warmPwshAvailabilityCache()).resolves.toBe(true) + expect(execFileMock).toHaveBeenCalledWith( + 'pwsh.exe', + ['-Version'], + { timeout: 30_000 }, + expect.any(Function) + ) + expect(isPwshAvailable()).toBe(true) + expect(execFileSyncMock).not.toHaveBeenCalled() + } finally { + restorePlatform() + } + }) + + it('retries non-timeout failures after the negative cache TTL', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const restorePlatform = setPlatform('win32') + execFileSyncMock + .mockImplementationOnce(() => { + throw new Error('missing pwsh') + }) + .mockReturnValue('PowerShell 7.5.0') + + try { + const { isPwshAvailable } = await import('./pwsh') + expect(isPwshAvailable()).toBe(false) + expect(isPwshAvailable()).toBe(false) + vi.setSystemTime(31_001) + expect(isPwshAvailable()).toBe(true) + expect(execFileSyncMock).toHaveBeenCalledTimes(2) + } finally { + restorePlatform() + vi.useRealTimers() + } + }) }) diff --git a/src/main/pwsh.ts b/src/main/pwsh.ts index c29881aa7..74258ae5d 100644 --- a/src/main/pwsh.ts +++ b/src/main/pwsh.ts @@ -1,31 +1,92 @@ -import { execFileSync } from 'child_process' +import { execFile, execFileSync } from 'child_process' -// Cached pwsh availability check — evaluated once per process lifetime -let pwshAvailableCache: boolean | null = null +const PWSH_SYNC_PROBE_TIMEOUT_MS = 5000 +const PWSH_WARMUP_PROBE_TIMEOUT_MS = 30_000 +const PWSH_NEGATIVE_CACHE_TTL_MS = 30_000 + +type PwshAvailabilityCache = + | { available: true } + | { available: false; cachedAt: number; retryable: boolean } + +let pwshAvailableCache: PwshAvailabilityCache | null = null +let pwshWarmupInFlight: Promise | null = null + +function isCacheFresh(cache: PwshAvailabilityCache): boolean { + return ( + cache.available || !cache.retryable || Date.now() - cache.cachedAt < PWSH_NEGATIVE_CACHE_TTL_MS + ) +} + +function isTimeoutError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ETIMEDOUT' + ) +} + +function cachePwshProbeFailure(error: unknown): void { + // Why: pwsh.exe cold starts can exceed the sync timeout; do not let one slow + // .NET startup disable the user's PowerShell 7 preference for the daemon. + if (isTimeoutError(error)) { + pwshAvailableCache = null + return + } + pwshAvailableCache = { available: false, cachedAt: Date.now(), retryable: true } +} /** * Check whether pwsh.exe is available on this Windows machine. - * Result is cached for the process lifetime. + * Positive results are cached for the process lifetime; negative results are + * retried so transient cold-start failures cannot outlive the daemon. */ export function isPwshAvailable(): boolean { - if (pwshAvailableCache !== null) { - return pwshAvailableCache + if (pwshAvailableCache && isCacheFresh(pwshAvailableCache)) { + return pwshAvailableCache.available } if (process.platform !== 'win32') { - pwshAvailableCache = false + pwshAvailableCache = { available: false, cachedAt: Date.now(), retryable: false } return false } try { execFileSync('pwsh.exe', ['-Version'], { stdio: ['pipe', 'pipe', 'pipe'], - timeout: 5000 + timeout: PWSH_SYNC_PROBE_TIMEOUT_MS }) - pwshAvailableCache = true - } catch { - pwshAvailableCache = false + pwshAvailableCache = { available: true } + } catch (error) { + cachePwshProbeFailure(error) } - return pwshAvailableCache + return pwshAvailableCache?.available ?? false +} + +export function warmPwshAvailabilityCache(): Promise { + if (pwshAvailableCache?.available) { + return Promise.resolve(true) + } + if (process.platform !== 'win32') { + pwshAvailableCache = { available: false, cachedAt: Date.now(), retryable: false } + return Promise.resolve(false) + } + if (pwshWarmupInFlight) { + return pwshWarmupInFlight + } + + pwshWarmupInFlight = new Promise((resolve) => { + execFile('pwsh.exe', ['-Version'], { timeout: PWSH_WARMUP_PROBE_TIMEOUT_MS }, (error) => { + pwshWarmupInFlight = null + if (!error) { + pwshAvailableCache = { available: true } + resolve(true) + return + } + cachePwshProbeFailure(error) + resolve(false) + }) + }) + return pwshWarmupInFlight }