Fix pwsh availability cold-start cache (#6313)

This commit is contained in:
Jinwoo Hong 2026-06-24 18:07:15 -07:00 committed by GitHub
parent c3241bdb91
commit bad9afe090
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 146 additions and 13 deletions

View File

@ -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<void> {
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

View File

@ -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()
}
})
})

View File

@ -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<boolean> | 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<boolean> {
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
}