mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 16:02:42 +00:00
fix(windows): harden async capability reprobes
This commit is contained in:
@@ -13,7 +13,8 @@ const {
|
||||
prepareMacosTccLoginShellMock,
|
||||
resolveAgentForegroundProcessMock,
|
||||
readWindowsConptyProcessIdsMock,
|
||||
killWithDescendantSweepMock
|
||||
killWithDescendantSweepMock,
|
||||
isWslAvailableAsyncMock
|
||||
} = vi.hoisted(() => ({
|
||||
existsSyncMock: vi.fn(),
|
||||
statSyncMock: vi.fn(),
|
||||
@@ -24,7 +25,8 @@ const {
|
||||
prepareMacosTccLoginShellMock: vi.fn(),
|
||||
resolveAgentForegroundProcessMock: vi.fn(),
|
||||
readWindowsConptyProcessIdsMock: vi.fn(),
|
||||
killWithDescendantSweepMock: vi.fn()
|
||||
killWithDescendantSweepMock: vi.fn(),
|
||||
isWslAvailableAsyncMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
@@ -97,7 +99,7 @@ vi.mock('../wsl', () => ({
|
||||
toWindowsWslPath: (path: string, distro: string) =>
|
||||
`\\\\wsl.localhost\\${distro}${path.replace(/\//g, '\\')}`,
|
||||
getDefaultWslDistro: () => 'Ubuntu',
|
||||
isWslAvailable: () => true,
|
||||
isWslAvailableAsync: () => isWslAvailableAsyncMock(),
|
||||
// Why: WSL worktree validation now asks the distro; these tests use WSL UNC
|
||||
// cwds that are meant to exist, so report them present without spawning wsl.exe.
|
||||
wslUncDirectoryExists: () => true
|
||||
@@ -166,6 +168,8 @@ describe('LocalPtyProvider', () => {
|
||||
)
|
||||
readWindowsConptyProcessIdsMock.mockReset()
|
||||
readWindowsConptyProcessIdsMock.mockResolvedValue(null)
|
||||
isWslAvailableAsyncMock.mockReset()
|
||||
isWslAvailableAsyncMock.mockResolvedValue(true)
|
||||
|
||||
exitCb = undefined
|
||||
mockProc = {
|
||||
@@ -2008,6 +2012,30 @@ describe('LocalPtyProvider', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getProfiles', () => {
|
||||
it('awaits asynchronous WSL availability on Windows', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
let resolveAvailability!: (available: boolean) => void
|
||||
isWslAvailableAsyncMock.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveAvailability = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const profiles = provider.getProfiles()
|
||||
let settled = false
|
||||
void profiles.then(() => {
|
||||
settled = true
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
|
||||
resolveAvailability(true)
|
||||
await expect(profiles).resolves.toContainEqual({ name: 'WSL', path: 'wsl.exe' })
|
||||
expect(isWslAvailableAsyncMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('killAll', () => {
|
||||
it('kills all PTY processes', async () => {
|
||||
// Why: each spawn needs its own proc so the onExit-triggered POSIX kill
|
||||
|
||||
@@ -11,7 +11,7 @@ import { buildWindowsPowerShellSpawnAttempts } from './windows-shell-fallback-ch
|
||||
import { resolveProcessCwd } from './process-cwd'
|
||||
import { existsSync } from 'node:fs'
|
||||
import * as pty from 'node-pty'
|
||||
import { getDefaultWslDistro, parseWslPath, isWslAvailable } from '../wsl'
|
||||
import { getDefaultWslDistro, parseWslPath, isWslAvailableAsync } from '../wsl'
|
||||
import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id'
|
||||
import {
|
||||
injectHistoryEnv,
|
||||
@@ -1394,7 +1394,7 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
if (gitBashPath) {
|
||||
profiles.push({ name: 'Git Bash', path: gitBashPath })
|
||||
}
|
||||
if (isWslAvailable()) {
|
||||
if (await isWslAvailableAsync()) {
|
||||
profiles.push({ name: 'WSL', path: 'wsl.exe' })
|
||||
}
|
||||
return profiles
|
||||
|
||||
+25
-2
@@ -122,7 +122,7 @@ describe('isPwshAvailable', () => {
|
||||
expect(execFileMock).toHaveBeenCalledWith(
|
||||
'pwsh.exe',
|
||||
['-Version'],
|
||||
{ timeout: 30_000 },
|
||||
{ timeout: 30_000, windowsHide: true },
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(isPwshAvailable()).toBe(true)
|
||||
@@ -149,7 +149,7 @@ describe('isPwshAvailable', () => {
|
||||
expect(execFileMock).toHaveBeenCalledWith(
|
||||
'pwsh.exe',
|
||||
['-Version'],
|
||||
{ timeout: 5000 },
|
||||
{ timeout: 5000, windowsHide: true },
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(execFileSyncMock).not.toHaveBeenCalled()
|
||||
@@ -177,6 +177,29 @@ describe('isPwshAvailable', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('does not let an older async failure overwrite a newer sync success', async () => {
|
||||
const restorePlatform = setPlatform('win32')
|
||||
let finishAsyncProbe!: (error: Error | null) => void
|
||||
execFileMock.mockImplementation((_file, _args, _options, callback) => {
|
||||
finishAsyncProbe = (error) => callback(error, '', '')
|
||||
})
|
||||
execFileSyncMock.mockReturnValue('PowerShell 7.5.0')
|
||||
|
||||
try {
|
||||
const { isPwshAvailable, isPwshAvailableAsync } = await import('./pwsh')
|
||||
const staleProbe = isPwshAvailableAsync()
|
||||
expect(isPwshAvailable()).toBe(true)
|
||||
|
||||
finishAsyncProbe(new Error('older failure'))
|
||||
|
||||
await expect(staleProbe).resolves.toBe(true)
|
||||
expect(isPwshAvailable()).toBe(true)
|
||||
expect(execFileSyncMock).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
restorePlatform()
|
||||
}
|
||||
})
|
||||
|
||||
// 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 () => {
|
||||
|
||||
+48
-26
@@ -11,6 +11,7 @@ type PwshAvailabilityCache =
|
||||
let pwshAvailableCache: PwshAvailabilityCache | null = null
|
||||
let pwshWarmupInFlight: Promise<boolean> | null = null
|
||||
let pwshProbeInFlight: Promise<boolean> | null = null
|
||||
let pwshAvailabilityCacheGeneration = 0
|
||||
|
||||
function isCacheFresh(cache: PwshAvailabilityCache): boolean {
|
||||
return (
|
||||
@@ -29,14 +30,23 @@ function isTimeoutError(error: unknown): boolean {
|
||||
return failure.code === 'ETIMEDOUT' || (failure.killed === true && failure.signal === 'SIGTERM')
|
||||
}
|
||||
|
||||
function cachePwshProbeFailure(error: unknown): void {
|
||||
function writePwshAvailabilityCache(cache: PwshAvailabilityCache | null): void {
|
||||
pwshAvailableCache = cache
|
||||
pwshAvailabilityCacheGeneration += 1
|
||||
}
|
||||
|
||||
function cachePwshProbeFailure(error: unknown, startedAtGeneration: number): boolean {
|
||||
if (startedAtGeneration !== pwshAvailabilityCacheGeneration) {
|
||||
return pwshAvailableCache?.available ?? false
|
||||
}
|
||||
// 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
|
||||
writePwshAvailabilityCache(null)
|
||||
return false
|
||||
}
|
||||
pwshAvailableCache = { available: false, cachedAt: Date.now(), retryable: true }
|
||||
writePwshAvailabilityCache({ available: false, cachedAt: Date.now(), retryable: true })
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,18 +60,19 @@ export function isPwshAvailable(): boolean {
|
||||
}
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
pwshAvailableCache = { available: false, cachedAt: Date.now(), retryable: false }
|
||||
writePwshAvailabilityCache({ available: false, cachedAt: Date.now(), retryable: false })
|
||||
return false
|
||||
}
|
||||
|
||||
const startedAtGeneration = pwshAvailabilityCacheGeneration
|
||||
try {
|
||||
execFileSync('pwsh.exe', ['-Version'], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: PWSH_SYNC_PROBE_TIMEOUT_MS
|
||||
})
|
||||
pwshAvailableCache = { available: true }
|
||||
writePwshAvailabilityCache({ available: true })
|
||||
} catch (error) {
|
||||
cachePwshProbeFailure(error)
|
||||
return cachePwshProbeFailure(error, startedAtGeneration)
|
||||
}
|
||||
|
||||
return pwshAvailableCache?.available ?? false
|
||||
@@ -79,7 +90,7 @@ export function isPwshAvailableAsync(): Promise<boolean> {
|
||||
}
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
pwshAvailableCache = { available: false, cachedAt: Date.now(), retryable: false }
|
||||
writePwshAvailabilityCache({ available: false, cachedAt: Date.now(), retryable: false })
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
|
||||
@@ -87,16 +98,22 @@ export function isPwshAvailableAsync(): Promise<boolean> {
|
||||
return pwshProbeInFlight
|
||||
}
|
||||
|
||||
const startedAtGeneration = pwshAvailabilityCacheGeneration
|
||||
pwshProbeInFlight = new Promise((resolve) => {
|
||||
execFile('pwsh.exe', ['-Version'], { timeout: PWSH_SYNC_PROBE_TIMEOUT_MS }, (error) => {
|
||||
pwshProbeInFlight = null
|
||||
if (error) {
|
||||
cachePwshProbeFailure(error)
|
||||
} else {
|
||||
pwshAvailableCache = { available: true }
|
||||
execFile(
|
||||
'pwsh.exe',
|
||||
['-Version'],
|
||||
{ timeout: PWSH_SYNC_PROBE_TIMEOUT_MS, windowsHide: true },
|
||||
(error) => {
|
||||
pwshProbeInFlight = null
|
||||
if (error) {
|
||||
resolve(cachePwshProbeFailure(error, startedAtGeneration))
|
||||
} else {
|
||||
writePwshAvailabilityCache({ available: true })
|
||||
resolve(true)
|
||||
}
|
||||
}
|
||||
resolve(pwshAvailableCache?.available ?? false)
|
||||
})
|
||||
)
|
||||
})
|
||||
return pwshProbeInFlight
|
||||
}
|
||||
@@ -106,24 +123,29 @@ export function warmPwshAvailabilityCache(): Promise<boolean> {
|
||||
return Promise.resolve(true)
|
||||
}
|
||||
if (process.platform !== 'win32') {
|
||||
pwshAvailableCache = { available: false, cachedAt: Date.now(), retryable: false }
|
||||
writePwshAvailabilityCache({ available: false, cachedAt: Date.now(), retryable: false })
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
if (pwshWarmupInFlight) {
|
||||
return pwshWarmupInFlight
|
||||
}
|
||||
|
||||
const startedAtGeneration = pwshAvailabilityCacheGeneration
|
||||
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
|
||||
execFile(
|
||||
'pwsh.exe',
|
||||
['-Version'],
|
||||
{ timeout: PWSH_WARMUP_PROBE_TIMEOUT_MS, windowsHide: true },
|
||||
(error) => {
|
||||
pwshWarmupInFlight = null
|
||||
if (!error) {
|
||||
writePwshAvailabilityCache({ available: true })
|
||||
resolve(true)
|
||||
return
|
||||
}
|
||||
resolve(cachePwshProbeFailure(error, startedAtGeneration))
|
||||
}
|
||||
cachePwshProbeFailure(error)
|
||||
resolve(false)
|
||||
})
|
||||
)
|
||||
})
|
||||
return pwshWarmupInFlight
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ type WslAvailabilityCache =
|
||||
|
||||
let wslAvailableCache: WslAvailabilityCache | null = null
|
||||
let wslAvailabilityProbeInFlight: Promise<boolean> | null = null
|
||||
let wslAvailabilityCacheGeneration = 0
|
||||
|
||||
const WSL_AVAILABILITY_PROBE_TIMEOUT_MS = 5000
|
||||
// Why: availability is a separate, blocking probe. Deliberately not a multiple of the
|
||||
@@ -67,16 +68,26 @@ function previousWslAvailabilityFailures(): number {
|
||||
return wslAvailableCache && 'failures' in wslAvailableCache ? wslAvailableCache.failures : 0
|
||||
}
|
||||
|
||||
function cacheWslAvailabilityProbeResult(error: unknown, previousFailures: number): boolean {
|
||||
wslAvailableCache = error
|
||||
? {
|
||||
available: false,
|
||||
cachedAt: Date.now(),
|
||||
retryable: isRetryableWslProbeFailure(error),
|
||||
failures: previousFailures + 1
|
||||
}
|
||||
: { available: true }
|
||||
return wslAvailableCache.available
|
||||
function writeWslAvailabilityCache(cache: WslAvailabilityCache | null): void {
|
||||
wslAvailableCache = cache
|
||||
wslAvailabilityCacheGeneration += 1
|
||||
}
|
||||
|
||||
function cacheWslAvailabilityProbeResult(error: unknown, startedAtGeneration: number): boolean {
|
||||
if (error && startedAtGeneration !== wslAvailabilityCacheGeneration) {
|
||||
return wslAvailableCache?.available ?? false
|
||||
}
|
||||
writeWslAvailabilityCache(
|
||||
error
|
||||
? {
|
||||
available: false,
|
||||
cachedAt: Date.now(),
|
||||
retryable: isRetryableWslProbeFailure(error),
|
||||
failures: previousWslAvailabilityFailures() + 1
|
||||
}
|
||||
: { available: true }
|
||||
)
|
||||
return !error
|
||||
}
|
||||
|
||||
function probeWslStatus(): Promise<void> {
|
||||
@@ -84,7 +95,7 @@ function probeWslStatus(): Promise<void> {
|
||||
execFile(
|
||||
'wsl.exe',
|
||||
['--status'],
|
||||
{ timeout: WSL_AVAILABILITY_PROBE_TIMEOUT_MS },
|
||||
{ timeout: WSL_AVAILABILITY_PROBE_TIMEOUT_MS, windowsHide: true },
|
||||
(error: unknown) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
@@ -108,10 +119,10 @@ export function isWslAvailable(): boolean {
|
||||
return cached
|
||||
}
|
||||
|
||||
const previousFailures = previousWslAvailabilityFailures()
|
||||
const startedAtGeneration = wslAvailabilityCacheGeneration
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
wslAvailableCache = { available: false, unsupported: true }
|
||||
writeWslAvailabilityCache({ available: false, unsupported: true })
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -120,9 +131,9 @@ export function isWslAvailable(): boolean {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: WSL_AVAILABILITY_PROBE_TIMEOUT_MS
|
||||
})
|
||||
return cacheWslAvailabilityProbeResult(null, previousFailures)
|
||||
return cacheWslAvailabilityProbeResult(null, startedAtGeneration)
|
||||
} catch (error) {
|
||||
return cacheWslAvailabilityProbeResult(error, previousFailures)
|
||||
return cacheWslAvailabilityProbeResult(error, startedAtGeneration)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +151,7 @@ export function isWslAvailableAsync(): Promise<boolean> {
|
||||
}
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
wslAvailableCache = { available: false, unsupported: true }
|
||||
writeWslAvailabilityCache({ available: false, unsupported: true })
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
|
||||
@@ -148,10 +159,10 @@ export function isWslAvailableAsync(): Promise<boolean> {
|
||||
return wslAvailabilityProbeInFlight
|
||||
}
|
||||
|
||||
const previousFailures = previousWslAvailabilityFailures()
|
||||
const startedAtGeneration = wslAvailabilityCacheGeneration
|
||||
wslAvailabilityProbeInFlight = probeWslStatus()
|
||||
.then(() => cacheWslAvailabilityProbeResult(null, previousFailures))
|
||||
.catch((error: unknown) => cacheWslAvailabilityProbeResult(error, previousFailures))
|
||||
.then(() => cacheWslAvailabilityProbeResult(null, startedAtGeneration))
|
||||
.catch((error: unknown) => cacheWslAvailabilityProbeResult(error, startedAtGeneration))
|
||||
.finally(() => {
|
||||
wslAvailabilityProbeInFlight = null
|
||||
})
|
||||
@@ -176,6 +187,7 @@ export function getCachedWslAvailability(): boolean | null {
|
||||
// stale failure and let the next call re-probe. Non-empty lists are cached for the process
|
||||
// lifetime, so this cannot re-spawn the blocking probe more than once.
|
||||
export function dropStaleWslAvailabilityFailure(): void {
|
||||
wslAvailabilityCacheGeneration += 1
|
||||
if (wslAvailableCache && !wslAvailableCache.available && !('unsupported' in wslAvailableCache)) {
|
||||
wslAvailableCache = null
|
||||
}
|
||||
@@ -184,16 +196,18 @@ export function dropStaleWslAvailabilityFailure(): void {
|
||||
export function _resetWslAvailabilityCacheForTests(): void {
|
||||
wslAvailableCache = null
|
||||
wslAvailabilityProbeInFlight = null
|
||||
wslAvailabilityCacheGeneration = 0
|
||||
}
|
||||
|
||||
export function _setWslAvailabilityCacheForTests(
|
||||
available: boolean | null | undefined,
|
||||
retryable: boolean
|
||||
): void {
|
||||
wslAvailableCache =
|
||||
writeWslAvailabilityCache(
|
||||
available === true
|
||||
? { available: true }
|
||||
: available === false
|
||||
? { available: false, cachedAt: Date.now(), retryable, failures: 1 }
|
||||
: null
|
||||
)
|
||||
}
|
||||
|
||||
+41
-1
@@ -509,7 +509,7 @@ describe('WSL availability cache', () => {
|
||||
expect(execFileMock).toHaveBeenCalledWith(
|
||||
'wsl.exe',
|
||||
['--status'],
|
||||
expect.anything(),
|
||||
expect.objectContaining({ timeout: 5000, windowsHide: true }),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(execFileSyncMock).not.toHaveBeenCalled()
|
||||
@@ -528,6 +528,46 @@ describe('WSL availability cache', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not let an older async failure overwrite a newer sync success', async () => {
|
||||
let finishAsyncProbe: ((error: Error | null) => void) | null = null
|
||||
execFileMock.mockImplementation((_command, _args, _options, callback) => {
|
||||
finishAsyncProbe = (error) => callback(error, '', '')
|
||||
})
|
||||
execFileSyncMock.mockReturnValue('')
|
||||
|
||||
await withPlatformAsync('win32', async () => {
|
||||
const staleProbe = isWslAvailableAsync()
|
||||
expect(isWslAvailable()).toBe(true)
|
||||
|
||||
finishAsyncProbe?.(Object.assign(new Error('older failure'), { code: 1 }))
|
||||
|
||||
await expect(staleProbe).resolves.toBe(true)
|
||||
expect(getCachedWslAvailability()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not restore a failure after distro discovery disproves it mid-probe', async () => {
|
||||
const callbacks = new Map<string, (error: Error | null, stdout: string) => void>()
|
||||
execFileMock.mockImplementation((_command, args, _options, callback) => {
|
||||
callbacks.set(args.join(' '), callback)
|
||||
})
|
||||
|
||||
await withPlatformAsync('win32', async () => {
|
||||
const staleAvailability = isWslAvailableAsync()
|
||||
const distroProbe = listWslDistrosAsync()
|
||||
callbacks.get('--list --quiet')?.(null, 'Ubuntu\n')
|
||||
await expect(distroProbe).resolves.toEqual(['Ubuntu'])
|
||||
|
||||
callbacks.get('--status')?.(Object.assign(new Error('older failure'), { code: 1 }), '')
|
||||
await expect(staleAvailability).resolves.toBe(false)
|
||||
expect(getCachedWslAvailability()).toBeNull()
|
||||
|
||||
const retry = isWslAvailableAsync()
|
||||
callbacks.get('--status')?.(null, '')
|
||||
await expect(retry).resolves.toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('shares the failure backoff between the async and sync probes', async () => {
|
||||
execFileMock.mockImplementation((_command, _args, _options, callback) => {
|
||||
callback(Object.assign(new Error('not installed'), { code: 'ENOENT' }), '', '')
|
||||
|
||||
+11
-6
@@ -320,12 +320,17 @@ export function _setWslCachesForTests(args: {
|
||||
|
||||
function execFileUtf8(command: string, args: string[]): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(command, args, { encoding: 'utf-8', timeout: 5000 }, (error, stdout) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
execFile(
|
||||
command,
|
||||
args,
|
||||
{ encoding: 'utf-8', timeout: 5000, windowsHide: true },
|
||||
(error, stdout) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
resolve(stdout)
|
||||
}
|
||||
resolve(stdout)
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -621,7 +621,7 @@ describe('windows terminal capabilities', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('stops re-probing a Windows host that keeps answering "no WSL"', async () => {
|
||||
it('bounds re-probes for a Windows host that keeps answering "no WSL"', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { wslIsAvailable, pwshIsAvailable } = stubTerminalCapabilityApi({
|
||||
wslAvailable: false,
|
||||
@@ -650,10 +650,9 @@ describe('windows terminal capabilities', () => {
|
||||
await vi.advanceTimersByTimeAsync(30 * 60_000)
|
||||
})
|
||||
|
||||
// Each read spawns a blocking wsl.exe/pwsh.exe on the main process, so the backoff
|
||||
// must park rather than keep firing every 30s for the lifetime of the app.
|
||||
expect(wslIsAvailable.mock.calls.length).toBeLessThanOrEqual(4)
|
||||
expect(pwshIsAvailable.mock.calls.length).toBeLessThanOrEqual(4)
|
||||
// The ceiling poll preserves install discovery while cutting the old 30s spawn rate.
|
||||
expect(wslIsAvailable.mock.calls.length).toBeLessThanOrEqual(10)
|
||||
expect(pwshIsAvailable.mock.calls.length).toBeLessThanOrEqual(10)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
|
||||
@@ -251,8 +251,7 @@ export function useWindowsTerminalCapabilities(
|
||||
return
|
||||
}
|
||||
setState({ ownerKey: resolvedOwnerKey, capabilities: nextCapabilities })
|
||||
// Why: each re-probe is a blocking wsl.exe/pwsh.exe spawn on the main process, so only a
|
||||
// local host is watched, only once it has answered, and only on a backoff that parks itself.
|
||||
// Why: each re-probe spawns wsl.exe/pwsh.exe, so local consumers share a bounded backoff.
|
||||
if (resolvedTarget.kind === 'local' && !sshConnectionIdKey) {
|
||||
reprobe.stop = startWindowsTerminalCapabilityReprobe({
|
||||
ownerKey: resolvedOwnerKey,
|
||||
|
||||
@@ -40,17 +40,17 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('windows terminal capability re-probe', () => {
|
||||
it('backs off and parks on a stable answer instead of probing forever', async () => {
|
||||
it('backs off to a five-minute ceiling on a stable answer', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { probe, readCached } = createWatcher()
|
||||
startWindowsTerminalCapabilityReprobe({ ownerKey: 'local', probe, readCached })
|
||||
|
||||
// 30s, then +60s, then +120s: three unchanged answers settle the watcher.
|
||||
// 30s, then +60s, then +120s: three unchanged answers reach the ceiling.
|
||||
await vi.advanceTimersByTimeAsync(210_000)
|
||||
expect(probe).toHaveBeenCalledTimes(3)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30 * 60_000)
|
||||
expect(probe).toHaveBeenCalledTimes(3)
|
||||
expect(probe).toHaveBeenCalledTimes(9)
|
||||
})
|
||||
|
||||
it('still re-checks a transient absent answer, then stops once WSL answers', async () => {
|
||||
@@ -110,16 +110,33 @@ describe('windows terminal capability re-probe', () => {
|
||||
expect(probe).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('re-arms a parked watcher when the window regains focus', async () => {
|
||||
it('keeps the earned backoff when another consumer joins', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { probe, readCached } = createWatcher()
|
||||
startWindowsTerminalCapabilityReprobe({ ownerKey: 'local', probe, readCached })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(210_000 + 30 * 60_000)
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
startWindowsTerminalCapabilityReprobe({ ownerKey: 'local', probe, readCached })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(49_999)
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(probe).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('accelerates a ceiling poll when the window regains focus', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { probe, readCached } = createWatcher()
|
||||
startWindowsTerminalCapabilityReprobe({ ownerKey: 'local', probe, readCached })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(210_000)
|
||||
expect(probe).toHaveBeenCalledTimes(3)
|
||||
|
||||
globalThis.dispatchEvent(new Event('focus'))
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
globalThis.dispatchEvent(new Event('focus'))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(probe).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
@@ -137,6 +154,39 @@ describe('windows terminal capability re-probe', () => {
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not let focus churn starve an already-backed-off probe', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { probe, readCached } = createWatcher()
|
||||
startWindowsTerminalCapabilityReprobe({ ownerKey: 'local', probe, readCached })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
for (let elapsed = 0; elapsed < 10 * 60_000; elapsed += 25_000) {
|
||||
await vi.advanceTimersByTimeAsync(25_000)
|
||||
globalThis.dispatchEvent(new Event('focus'))
|
||||
}
|
||||
|
||||
expect(probe.mock.calls.length).toBeGreaterThan(1)
|
||||
expect(probe.mock.calls.length).toBeLessThanOrEqual(21)
|
||||
})
|
||||
|
||||
it('defers a parked watcher demand signal to the base-delay deadline', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { probe, readCached } = createWatcher()
|
||||
startWindowsTerminalCapabilityReprobe({ ownerKey: 'local', probe, readCached })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(210_000)
|
||||
expect(probe).toHaveBeenCalledTimes(3)
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
globalThis.dispatchEvent(new Event('focus'))
|
||||
await vi.advanceTimersByTimeAsync(9_000)
|
||||
globalThis.dispatchEvent(new Event('focus'))
|
||||
|
||||
await vi.advanceTimersByTimeAsync(19_999)
|
||||
expect(probe).toHaveBeenCalledTimes(3)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(probe).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('drops the focus listener when no owner is watched', () => {
|
||||
const addEventListener = vi.spyOn(globalThis, 'addEventListener')
|
||||
const removeEventListener = vi.spyOn(globalThis, 'removeEventListener')
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import type { WindowsTerminalCapabilities } from './windows-terminal-capabilities'
|
||||
|
||||
// Why: an absent WSL must stay re-checkable (a distro can finish provisioning while Orca runs),
|
||||
// but every re-check spawns wsl.exe/pwsh.exe on the Electron main process, so the re-check backs
|
||||
// off and parks on a stable answer instead of running on a fixed interval for the app's lifetime.
|
||||
// but every re-check spawns wsl.exe/pwsh.exe, so stable answers fall back to a low-frequency poll.
|
||||
const REPROBE_BASE_DELAY_MS = 30_000
|
||||
const REPROBE_MAX_DELAY_MS = 5 * 60_000
|
||||
/** Consecutive identical answers that count as settled; window focus re-arms afterwards. */
|
||||
/** Consecutive identical answers before switching to the five-minute ceiling. */
|
||||
const REPROBE_SETTLE_STREAK = 3
|
||||
|
||||
type CapabilityReprobeTimerKind = 'backoff' | 'ceiling'
|
||||
|
||||
type CapabilityReprobeRunner = {
|
||||
consumers: number
|
||||
timer: ReturnType<typeof globalThis.setTimeout> | null
|
||||
attempt: number
|
||||
timerDeadline: number
|
||||
timerKind: CapabilityReprobeTimerKind | null
|
||||
unchangedStreak: number
|
||||
signature: string
|
||||
lastProbeAt: number
|
||||
probeInFlight: boolean
|
||||
probe: () => Promise<WindowsTerminalCapabilities>
|
||||
readCached: () => WindowsTerminalCapabilities
|
||||
}
|
||||
@@ -42,58 +45,84 @@ function clearRunnerTimer(runner: CapabilityReprobeRunner): void {
|
||||
globalThis.clearTimeout(runner.timer)
|
||||
runner.timer = null
|
||||
}
|
||||
runner.timerDeadline = 0
|
||||
runner.timerKind = null
|
||||
}
|
||||
|
||||
function scheduleProbe(
|
||||
runner: CapabilityReprobeRunner,
|
||||
kind: CapabilityReprobeTimerKind,
|
||||
deadline: number
|
||||
): void {
|
||||
clearRunnerTimer(runner)
|
||||
runner.timerKind = kind
|
||||
runner.timerDeadline = deadline
|
||||
runner.timer = globalThis.setTimeout(
|
||||
() => {
|
||||
runner.timer = null
|
||||
runner.timerDeadline = 0
|
||||
runner.timerKind = null
|
||||
void runProbe(runner)
|
||||
},
|
||||
Math.max(0, deadline - Date.now())
|
||||
)
|
||||
}
|
||||
|
||||
function scheduleNextProbe(runner: CapabilityReprobeRunner): void {
|
||||
clearRunnerTimer(runner)
|
||||
const delay = Math.min(REPROBE_BASE_DELAY_MS * 2 ** runner.attempt, REPROBE_MAX_DELAY_MS)
|
||||
runner.timer = globalThis.setTimeout(() => {
|
||||
runner.timer = null
|
||||
void runProbe(runner)
|
||||
}, delay)
|
||||
if (runner.unchangedStreak >= REPROBE_SETTLE_STREAK) {
|
||||
scheduleProbe(runner, 'ceiling', Date.now() + REPROBE_MAX_DELAY_MS)
|
||||
return
|
||||
}
|
||||
const delay = REPROBE_BASE_DELAY_MS * 2 ** runner.unchangedStreak
|
||||
scheduleProbe(runner, 'backoff', Date.now() + delay)
|
||||
}
|
||||
|
||||
async function runProbe(runner: CapabilityReprobeRunner): Promise<void> {
|
||||
if (runner.consumers <= 0 || isSettled(runner.readCached())) {
|
||||
if (runner.consumers <= 0 || runner.probeInFlight || isSettled(runner.readCached())) {
|
||||
return
|
||||
}
|
||||
runner.probeInFlight = true
|
||||
runner.lastProbeAt = Date.now()
|
||||
const capabilities = await runner.probe().catch(() => runner.readCached())
|
||||
let capabilities: WindowsTerminalCapabilities
|
||||
try {
|
||||
capabilities = await runner.probe().catch(() => runner.readCached())
|
||||
} finally {
|
||||
runner.probeInFlight = false
|
||||
}
|
||||
if (runner.consumers <= 0) {
|
||||
return
|
||||
}
|
||||
if (capabilitySignature(capabilities) === runner.signature) {
|
||||
runner.unchangedStreak += 1
|
||||
runner.attempt += 1
|
||||
} else {
|
||||
// A moving answer means the host is still changing; watch it closely again.
|
||||
runner.signature = capabilitySignature(capabilities)
|
||||
runner.unchangedStreak = 0
|
||||
runner.attempt = 0
|
||||
}
|
||||
if (isSettled(capabilities) || runner.unchangedStreak >= REPROBE_SETTLE_STREAK) {
|
||||
if (isSettled(capabilities)) {
|
||||
return
|
||||
}
|
||||
scheduleNextProbe(runner)
|
||||
}
|
||||
|
||||
function armRunner(runner: CapabilityReprobeRunner): void {
|
||||
function armNewRunner(runner: CapabilityReprobeRunner): void {
|
||||
if (isSettled(runner.readCached())) {
|
||||
clearRunnerTimer(runner)
|
||||
return
|
||||
}
|
||||
runner.attempt = 0
|
||||
runner.unchangedStreak = 0
|
||||
scheduleNextProbe(runner)
|
||||
}
|
||||
|
||||
function handleWindowFocus(): void {
|
||||
const now = Date.now()
|
||||
for (const runner of runnersByOwnerKey.values()) {
|
||||
if (now - runner.lastProbeAt < REPROBE_BASE_DELAY_MS) {
|
||||
if (runner.probeInFlight || runner.timerKind !== 'ceiling') {
|
||||
continue
|
||||
}
|
||||
armRunner(runner)
|
||||
const demandDeadline = Math.max(now, runner.lastProbeAt + REPROBE_BASE_DELAY_MS)
|
||||
if (demandDeadline < runner.timerDeadline) {
|
||||
scheduleProbe(runner, 'ceiling', demandDeadline)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,12 +154,12 @@ export function startWindowsTerminalCapabilityReprobe(options: {
|
||||
const runner: CapabilityReprobeRunner = runnersByOwnerKey.get(options.ownerKey) ?? {
|
||||
consumers: 0,
|
||||
timer: null,
|
||||
attempt: 0,
|
||||
timerDeadline: 0,
|
||||
timerKind: null,
|
||||
unchangedStreak: 0,
|
||||
signature: capabilitySignature(options.readCached()),
|
||||
// Why: seed from now, or the focus guard has nothing to compare against and repeated
|
||||
// alt-tabbing right after mount re-arms every time, pushing the first probe out forever.
|
||||
lastProbeAt: Date.now(),
|
||||
probeInFlight: false,
|
||||
probe: options.probe,
|
||||
readCached: options.readCached
|
||||
}
|
||||
@@ -138,8 +167,9 @@ export function startWindowsTerminalCapabilityReprobe(options: {
|
||||
runner.readCached = options.readCached
|
||||
runner.consumers += 1
|
||||
runnersByOwnerKey.set(options.ownerKey, runner)
|
||||
// A newly mounted surface is a demand signal, so restart the backoff window.
|
||||
armRunner(runner)
|
||||
if (runner.consumers === 1) {
|
||||
armNewRunner(runner)
|
||||
}
|
||||
attachFocusListener()
|
||||
|
||||
let released = false
|
||||
|
||||
Reference in New Issue
Block a user