diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index e1c62e9f3cb..bba4658aa69 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -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 diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 64dd9850930..48272a5bcf6 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -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 diff --git a/src/main/pwsh.test.ts b/src/main/pwsh.test.ts index dbb4d47bd46..9c5538b5ea4 100644 --- a/src/main/pwsh.test.ts +++ b/src/main/pwsh.test.ts @@ -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 () => { diff --git a/src/main/pwsh.ts b/src/main/pwsh.ts index acba99ab933..a2abc382ee1 100644 --- a/src/main/pwsh.ts +++ b/src/main/pwsh.ts @@ -11,6 +11,7 @@ type PwshAvailabilityCache = let pwshAvailableCache: PwshAvailabilityCache | null = null let pwshWarmupInFlight: Promise | null = null let pwshProbeInFlight: Promise | 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 { } 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 { 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 { 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 } diff --git a/src/main/wsl-availability.ts b/src/main/wsl-availability.ts index 7ba5aa7181f..c44476c9d60 100644 --- a/src/main/wsl-availability.ts +++ b/src/main/wsl-availability.ts @@ -8,6 +8,7 @@ type WslAvailabilityCache = let wslAvailableCache: WslAvailabilityCache | null = null let wslAvailabilityProbeInFlight: Promise | 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 { @@ -84,7 +95,7 @@ function probeWslStatus(): Promise { 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 { } 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 { 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 + ) } diff --git a/src/main/wsl.test.ts b/src/main/wsl.test.ts index ac165a78786..cd77d761caf 100644 --- a/src/main/wsl.test.ts +++ b/src/main/wsl.test.ts @@ -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 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' }), '', '') diff --git a/src/main/wsl.ts b/src/main/wsl.ts index 6a8436a9a1f..e415a3c937f 100644 --- a/src/main/wsl.ts +++ b/src/main/wsl.ts @@ -320,12 +320,17 @@ export function _setWslCachesForTests(args: { function execFileUtf8(command: string, args: string[]): Promise { 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) - }) + ) }) } diff --git a/src/renderer/src/lib/windows-terminal-capabilities.test.ts b/src/renderer/src/lib/windows-terminal-capabilities.test.ts index 2f033f39cc1..1f1a83dec9e 100644 --- a/src/renderer/src/lib/windows-terminal-capabilities.test.ts +++ b/src/renderer/src/lib/windows-terminal-capabilities.test.ts @@ -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() } diff --git a/src/renderer/src/lib/windows-terminal-capabilities.ts b/src/renderer/src/lib/windows-terminal-capabilities.ts index eef3f974234..c759567df15 100644 --- a/src/renderer/src/lib/windows-terminal-capabilities.ts +++ b/src/renderer/src/lib/windows-terminal-capabilities.ts @@ -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, 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 45cf119a76b..ad35839ba3d 100644 --- a/src/renderer/src/lib/windows-terminal-capability-reprobe.test.ts +++ b/src/renderer/src/lib/windows-terminal-capability-reprobe.test.ts @@ -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') diff --git a/src/renderer/src/lib/windows-terminal-capability-reprobe.ts b/src/renderer/src/lib/windows-terminal-capability-reprobe.ts index 5829f602791..674adc565d7 100644 --- a/src/renderer/src/lib/windows-terminal-capability-reprobe.ts +++ b/src/renderer/src/lib/windows-terminal-capability-reprobe.ts @@ -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 | null - attempt: number + timerDeadline: number + timerKind: CapabilityReprobeTimerKind | null unchangedStreak: number signature: string lastProbeAt: number + probeInFlight: boolean probe: () => Promise 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 { - 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