Fix pwsh availability cold-start cache (#6313)
This commit is contained in:
parent
c3241bdb91
commit
bad9afe090
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue