diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json index f576fa77dc0..1034cc16ced 100644 --- a/config/tsconfig.tc.web.json +++ b/config/tsconfig.tc.web.json @@ -12,6 +12,7 @@ "../src/main/ipc/worktree-linked-work-item-metadata.ts", "../src/main/ipc/worktree-metadata-merge.ts", "../src/main/ipc/worktree-path-comparison.ts", + "../src/main/wsl-availability.ts", "../src/main/wsl-distro-list-output.ts", "../src/main/wsl-distro-retry.ts", "../src/main/wsl.ts", diff --git a/src/main/ipc/app.test.ts b/src/main/ipc/app.test.ts index 7702dcd6349..407a3dccf7d 100644 --- a/src/main/ipc/app.test.ts +++ b/src/main/ipc/app.test.ts @@ -109,6 +109,27 @@ vi.mock('./renderer-shutdown-checkpoint', () => ({ registerRendererShutdownCheckpointHandler: registerRendererShutdownCheckpointHandlerMock })) +const windowsProbes = vi.hoisted(() => ({ + isWslAvailable: vi.fn(() => true), + isWslAvailableAsync: vi.fn(async () => true), + listWslDistros: vi.fn(() => ['Ubuntu']), + listWslDistrosAsync: vi.fn(async () => ['Ubuntu']), + isPwshAvailable: vi.fn(() => true), + isPwshAvailableAsync: vi.fn(async () => true) +})) + +vi.mock('../wsl', () => ({ + isWslAvailable: windowsProbes.isWslAvailable, + isWslAvailableAsync: windowsProbes.isWslAvailableAsync, + listWslDistros: windowsProbes.listWslDistros, + listWslDistrosAsync: windowsProbes.listWslDistrosAsync +})) + +vi.mock('../pwsh', () => ({ + isPwshAvailable: windowsProbes.isPwshAvailable, + isPwshAvailableAsync: windowsProbes.isPwshAvailableAsync +})) + import { registerAppHandlers } from './app' describe('registerAppHandlers', () => { @@ -130,6 +151,9 @@ describe('registerAppHandlers', () => { showOpenDialogMock.mockReset() grantFloatingWorkspaceDirectoryMock.mockReset() registerRendererShutdownCheckpointHandlerMock.mockReset() + for (const probe of Object.values(windowsProbes)) { + probe.mockClear() + } processKillSpy = vi.spyOn(process, 'kill').mockReturnValue(true) Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) }) @@ -381,4 +405,21 @@ describe('registerAppHandlers', () => { }) expect(grantFloatingWorkspaceDirectoryMock).toHaveBeenCalledWith(store, '/Users/kaylee/notes') }) + + // Why: the renderer reads these on every Windows capability refresh; the sync probes + // execFileSync wsl.exe/pwsh.exe and would stall the main event loop for up to 5s each. + it('answers the Windows shell capability channels without a blocking spawn', async () => { + registerAppHandlers({} as never) + + await expect(handlers.get('wsl:isAvailable')?.(null)).resolves.toBe(true) + await expect(handlers.get('wsl:listDistros')?.(null)).resolves.toEqual(['Ubuntu']) + await expect(handlers.get('pwsh:isAvailable')?.(null)).resolves.toBe(true) + + expect(windowsProbes.isWslAvailableAsync).toHaveBeenCalledTimes(1) + expect(windowsProbes.listWslDistrosAsync).toHaveBeenCalledTimes(1) + expect(windowsProbes.isPwshAvailableAsync).toHaveBeenCalledTimes(1) + expect(windowsProbes.isWslAvailable).not.toHaveBeenCalled() + expect(windowsProbes.listWslDistros).not.toHaveBeenCalled() + expect(windowsProbes.isPwshAvailable).not.toHaveBeenCalled() + }) }) diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index 7485c03b5f0..dfa7fa3d1eb 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -9,8 +9,8 @@ import type { FloatingTerminalCwdRequest, MarkdownDocument } from '../../shared/ import { relaunchApp } from '../app-relaunch' import type { Store } from '../persistence' import { getDevInstanceIdentity } from '../startup/dev-instance-identity' -import { isPwshAvailable } from '../pwsh' -import { isWslAvailable, listWslDistros } from '../wsl' +import { isPwshAvailableAsync } from '../pwsh' +import { isWslAvailableAsync, listWslDistrosAsync } from '../wsl' import { isGitBashAvailable } from '../git-bash' import { setUnreadDockBadgeCount } from '../dock/unread-badge' import { destroySystemTray } from '../tray/system-tray' @@ -258,9 +258,11 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp } }) - ipcMain.handle('wsl:isAvailable', (): boolean => isWslAvailable()) - ipcMain.handle('wsl:listDistros', (): string[] => listWslDistros()) - ipcMain.handle('pwsh:isAvailable', (): boolean => isPwshAvailable()) + // Why: these probes spawn wsl.exe/pwsh.exe; the sync variants would block the main event + // loop — every PTY message, window IPC and watchdog beat — for up to 5s per renderer read. + ipcMain.handle('wsl:isAvailable', (): Promise => isWslAvailableAsync()) + ipcMain.handle('wsl:listDistros', (): Promise => listWslDistrosAsync()) + ipcMain.handle('pwsh:isAvailable', (): Promise => isPwshAvailableAsync()) ipcMain.handle('gitBash:isAvailable', (): boolean => isGitBashAvailable()) // Why: renderer layout fingerprint tags ABC/CJK-Roman as 'us', breaking Option+letter (#1205); HIToolbox prefs override it. diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 643f38b57f1..6f1a4f2cde9 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -180,7 +180,7 @@ vi.mock('../pi/titlebar-extension-service', () => ({ })) vi.mock('../pwsh', () => ({ - isPwshAvailable: isPwshAvailableMock + isPwshAvailableAsync: isPwshAvailableMock })) vi.mock('../telemetry/client', () => ({ diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index d32a16312bd..1771bc07106 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -65,7 +65,7 @@ import { isPiCompatibleAgentType, type PiAgentKind } from '../../shared/pi-agent-kind' -import { isPwshAvailable } from '../pwsh' +import { isPwshAvailableAsync } from '../pwsh' import { LocalPtyProvider } from '../providers/local-pty-provider' import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' import { isPtyWriteUnavailableError } from '../providers/pty-write-unavailable-error' @@ -2347,7 +2347,7 @@ export function registerPtyHandlers( getSettings ? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') : undefined, - pwshAvailable: () => isPwshAvailable(), + pwshAvailable: () => isPwshAvailableAsync(), buildSpawnEnv: (id, baseEnv, ctx) => { const codexSelectionTarget: CodexAccountSelectionTarget = ctx?.isWsl === true diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index e1c62e9f3cb..5567c11c957 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 = { @@ -1034,6 +1038,32 @@ describe('LocalPtyProvider', () => { expect(pwshAvailable).not.toHaveBeenCalled() }) + it('awaits PowerShell availability before resolving an automatic Windows shell', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + let resolveAvailability!: (available: boolean) => void + const pwshAvailable = vi.fn( + () => + new Promise((resolve) => { + resolveAvailability = resolve + }) + ) + provider.configure({ + getWindowsShell: () => 'powershell.exe', + getWindowsPowerShellImplementation: () => 'auto', + pwshAvailable + }) + + const callsBeforeSpawn = spawnMock.mock.calls.length + const spawn = provider.spawn({ cols: 80, rows: 24, cwd: 'C:\\Users\\jin\\repo' }) + await Promise.resolve() + expect(spawnMock).toHaveBeenCalledTimes(callsBeforeSpawn) + + resolveAvailability(true) + await spawn + expect(spawnMock).toHaveBeenCalledTimes(callsBeforeSpawn + 1) + expect(spawnMock.mock.calls.at(-1)?.[0]).toBe(PWSH7_ABS) + }) + it('marks Orca terminal handle for WSL import when buildSpawnEnv opts in', async () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const savedCodexHome = process.env.CODEX_HOME @@ -2008,6 +2038,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..34d2485d776 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, @@ -505,7 +505,7 @@ export type LocalPtyProviderOptions = { /** Why: COMSPEC is always cmd.exe, so this callback injects the user's persisted shell preference. Undefined when none set. */ getWindowsShell?: () => string | undefined getWindowsPowerShellImplementation?: () => 'auto' | 'powershell.exe' | 'pwsh.exe' | undefined - pwshAvailable?: () => boolean + pwshAvailable?: () => boolean | Promise onSpawned?: (id: string, incarnationId: string) => void onExit?: (id: string, code: number, incarnationId: string) => void onData?: ( @@ -617,6 +617,7 @@ export class LocalPtyProvider implements IPtyProvider { }) const shouldResolvePowerShellFamily = powerShellImplementation !== undefined || pathWin32.basename(shellFamily) === shellFamily + const pwshAvailable = shouldProbePwsh ? await (this.opts.pwshAvailable?.() ?? false) : false if (resolvedGitBashPath) { shellPath = resolvedGitBashPath } else if (shellFamily === WINDOWS_GIT_BASH_SHELL) { @@ -626,7 +627,7 @@ export class LocalPtyProvider implements IPtyProvider { ? (resolveEffectiveWindowsPowerShell({ shellFamily: resolvedShellFamily, implementation: powerShellImplementation, - pwshAvailable: shouldProbePwsh ? (this.opts.pwshAvailable?.() ?? false) : false + pwshAvailable }) ?? shellFamily) : shellFamily } @@ -1394,7 +1395,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 7c8ca266424..958d10d2e33 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) @@ -132,6 +132,121 @@ describe('isPwshAvailable', () => { } }) + // Why: the renderer's Windows capability read reaches this over IPC, and the sync probe + // blocks the Electron main thread for the full timeout when pwsh.exe cold-starts. + it('answers IPC callers without blocking the main thread', async () => { + const restorePlatform = setPlatform('win32') + execFileMock.mockImplementation((_file, _args, _options, callback) => { + setTimeout(() => callback(null, 'PowerShell 7.5.0', ''), 0) + }) + + try { + const { isPwshAvailableAsync } = await import('./pwsh') + const results = await Promise.all([isPwshAvailableAsync(), isPwshAvailableAsync()]) + expect(results).toEqual([true, true]) + // Concurrent readers share one spawn. + expect(execFileMock).toHaveBeenCalledTimes(1) + expect(execFileMock).toHaveBeenCalledWith( + 'pwsh.exe', + ['-Version'], + { timeout: 5000, windowsHide: true }, + expect.any(Function) + ) + expect(execFileSyncMock).not.toHaveBeenCalled() + } finally { + restorePlatform() + } + }) + + it('reuses the negative cache for async callers so a missing pwsh is not re-spawned', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const restorePlatform = setPlatform('win32') + execFileMock.mockImplementation((_file, _args, _options, callback) => { + callback(new Error('missing pwsh'), '', '') + }) + + try { + const { isPwshAvailableAsync } = await import('./pwsh') + await expect(isPwshAvailableAsync()).resolves.toBe(false) + await expect(isPwshAvailableAsync()).resolves.toBe(false) + expect(execFileMock).toHaveBeenCalledTimes(1) + } finally { + restorePlatform() + vi.useRealTimers() + } + }) + + it('does not let an older async failure overwrite a newer warmup success', async () => { + const restorePlatform = setPlatform('win32') + let finishAsyncProbe!: (error: Error | null) => void + let finishWarmup!: (error: Error | null) => void + execFileMock.mockImplementation((_file, _args, options, callback) => { + const finish = (error: Error | null): void => callback(error, '', '') + if (options.timeout === 30_000) { + finishWarmup = finish + } else { + finishAsyncProbe = finish + } + }) + + try { + const { isPwshAvailable, isPwshAvailableAsync, warmPwshAvailabilityCache } = + await import('./pwsh') + const staleProbe = isPwshAvailableAsync() + const warmup = warmPwshAvailabilityCache() + finishWarmup(null) + await expect(warmup).resolves.toBe(true) + + finishAsyncProbe(new Error('older failure')) + + await expect(staleProbe).resolves.toBe(true) + expect(isPwshAvailable()).toBe(true) + expect(execFileSyncMock).not.toHaveBeenCalled() + } finally { + restorePlatform() + } + }) + + it('does not duplicate an in-flight async probe for a synchronous caller', async () => { + const restorePlatform = setPlatform('win32') + let finishAsyncProbe!: (error: Error | null) => void + execFileMock.mockImplementation((_file, _args, _options, callback) => { + finishAsyncProbe = (error) => callback(error, '', '') + }) + + try { + const { isPwshAvailable, isPwshAvailableAsync } = await import('./pwsh') + const probe = isPwshAvailableAsync() + + expect(isPwshAvailable()).toBe(true) + expect(execFileSyncMock).not.toHaveBeenCalled() + + finishAsyncProbe(null) + await expect(probe).resolves.toBe(true) + } 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 () => { + const restorePlatform = setPlatform('win32') + execFileMock.mockImplementation((_file, _args, _options, callback) => { + callback(Object.assign(new Error('pwsh.exe timed out'), { killed: true, signal: 'SIGTERM' })) + }) + execFileSyncMock.mockReturnValue('PowerShell 7.5.0') + + try { + const { isPwshAvailable, isPwshAvailableAsync } = await import('./pwsh') + await expect(isPwshAvailableAsync()).resolves.toBe(false) + expect(isPwshAvailable()).toBe(true) + } finally { + restorePlatform() + } + }) + it('retries non-timeout failures after the negative cache TTL', async () => { vi.useFakeTimers() vi.setSystemTime(1_000) diff --git a/src/main/pwsh.ts b/src/main/pwsh.ts index 44f8d060618..ee1268ef188 100644 --- a/src/main/pwsh.ts +++ b/src/main/pwsh.ts @@ -10,6 +10,8 @@ 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 ( @@ -17,23 +19,34 @@ function isCacheFresh(cache: PwshAvailabilityCache): boolean { ) } +// Why: execFileSync reports a timeout as ETIMEDOUT, but the execFile callback reports it as a +// SIGTERM kill with no code — both shapes must be recognised or the async probe caches a +// cold-start timeout as "pwsh missing". function isTimeoutError(error: unknown): boolean { - return ( - typeof error === 'object' && - error !== null && - 'code' in error && - (error as NodeJS.ErrnoException).code === 'ETIMEDOUT' - ) + if (typeof error !== 'object' || error === null) { + return false + } + const failure = error as { code?: unknown; killed?: unknown; signal?: unknown } + 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 } /** @@ -46,47 +59,99 @@ export function isPwshAvailable(): boolean { return pwshAvailableCache.available } + // Why: daemon shell resolution is synchronous but has a spawn fallback chain; an optimistic + // cold answer avoids launching a duplicate probe while its startup warmup is still running. + if (pwshProbeInFlight || pwshWarmupInFlight) { + return pwshAvailableCache?.available ?? true + } + 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 } +/** + * Async twin of `isPwshAvailable`, sharing its cache. + * + * Why: the renderer's capability read reaches this over IPC, and the sync probe blocks the + * Electron main thread for up to 5s when pwsh.exe cold-starts. Concurrent callers share one spawn. + */ +export function isPwshAvailableAsync(): Promise { + if (pwshAvailableCache && isCacheFresh(pwshAvailableCache)) { + return Promise.resolve(pwshAvailableCache.available) + } + + if (process.platform !== 'win32') { + writePwshAvailabilityCache({ available: false, cachedAt: Date.now(), retryable: false }) + return Promise.resolve(false) + } + + if (pwshProbeInFlight) { + return pwshProbeInFlight + } + + const startedAtGeneration = pwshAvailabilityCacheGeneration + pwshProbeInFlight = new Promise((resolve) => { + 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) + } + } + ) + }) + return pwshProbeInFlight +} + export function warmPwshAvailabilityCache(): Promise { if (pwshAvailableCache?.available) { 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/runtime/rpc/methods/host-capabilities.test.ts b/src/main/runtime/rpc/methods/host-capabilities.test.ts index 64ff48e69a2..8e2f15cd25c 100644 --- a/src/main/runtime/rpc/methods/host-capabilities.test.ts +++ b/src/main/runtime/rpc/methods/host-capabilities.test.ts @@ -3,15 +3,31 @@ import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' import type { OrcaRuntimeService } from '../../orca-runtime' -const { isPwshAvailable, isWslAvailable, listWslDistros, isGitBashAvailable } = vi.hoisted(() => ({ +const { + isPwshAvailable, + isPwshAvailableAsync, + isWslAvailable, + isWslAvailableAsync, + listWslDistros, + listWslDistrosAsync, + isGitBashAvailable +} = vi.hoisted(() => ({ isPwshAvailable: vi.fn(), + isPwshAvailableAsync: vi.fn(), isWslAvailable: vi.fn(), + isWslAvailableAsync: vi.fn(), listWslDistros: vi.fn(), + listWslDistrosAsync: vi.fn(), isGitBashAvailable: vi.fn() })) -vi.mock('../../../pwsh', () => ({ isPwshAvailable })) -vi.mock('../../../wsl', () => ({ isWslAvailable, listWslDistros })) +vi.mock('../../../pwsh', () => ({ isPwshAvailable, isPwshAvailableAsync })) +vi.mock('../../../wsl', () => ({ + isWslAvailable, + isWslAvailableAsync, + listWslDistros, + listWslDistrosAsync +})) vi.mock('../../../git-bash', () => ({ isGitBashAvailable })) import { HOST_CAPABILITY_METHODS } from './host-capabilities' @@ -23,15 +39,18 @@ function makeRequest(method: string, params?: unknown): RpcRequest { describe('host capability RPC methods', () => { beforeEach(() => { isPwshAvailable.mockReset() + isPwshAvailableAsync.mockReset() isWslAvailable.mockReset() + isWslAvailableAsync.mockReset() listWslDistros.mockReset() + listWslDistrosAsync.mockReset() isGitBashAvailable.mockReset() }) it('reports Windows shell capability probes through explicit methods', async () => { - isPwshAvailable.mockReturnValue(true) - isWslAvailable.mockReturnValue(true) - listWslDistros.mockReturnValue(['Ubuntu']) + isPwshAvailableAsync.mockResolvedValue(true) + isWslAvailableAsync.mockResolvedValue(true) + listWslDistrosAsync.mockResolvedValue(['Ubuntu']) isGitBashAvailable.mockReturnValue(true) const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: HOST_CAPABILITY_METHODS }) @@ -55,4 +74,25 @@ describe('host capability RPC methods', () => { result: true }) }) + + // Why: web/mobile clients reach these over the relay, so a sync probe would stall the + // desktop main event loop on execFileSync wsl.exe/pwsh.exe for up to 5s per call. + it('answers the Windows shell capability methods without a blocking spawn', async () => { + isPwshAvailableAsync.mockResolvedValue(false) + isWslAvailableAsync.mockResolvedValue(false) + listWslDistrosAsync.mockResolvedValue([]) + const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: HOST_CAPABILITY_METHODS }) + + await dispatcher.dispatch(makeRequest('host.wsl.isAvailable')) + await dispatcher.dispatch(makeRequest('host.wsl.listDistros')) + await dispatcher.dispatch(makeRequest('host.pwsh.isAvailable')) + + expect(isWslAvailableAsync).toHaveBeenCalledTimes(1) + expect(listWslDistrosAsync).toHaveBeenCalledTimes(1) + expect(isPwshAvailableAsync).toHaveBeenCalledTimes(1) + expect(isWslAvailable).not.toHaveBeenCalled() + expect(listWslDistros).not.toHaveBeenCalled() + expect(isPwshAvailable).not.toHaveBeenCalled() + }) }) diff --git a/src/main/runtime/rpc/methods/host-capabilities.ts b/src/main/runtime/rpc/methods/host-capabilities.ts index be5e13dc2f0..afa1af88474 100644 --- a/src/main/runtime/rpc/methods/host-capabilities.ts +++ b/src/main/runtime/rpc/methods/host-capabilities.ts @@ -1,6 +1,6 @@ import { defineMethod, type RpcMethod } from '../core' -import { isPwshAvailable } from '../../../pwsh' -import { isWslAvailable, listWslDistros } from '../../../wsl' +import { isPwshAvailableAsync } from '../../../pwsh' +import { isWslAvailableAsync, listWslDistrosAsync } from '../../../wsl' import { isGitBashAvailable } from '../../../git-bash' export const HOST_CAPABILITY_METHODS: RpcMethod[] = [ @@ -9,20 +9,22 @@ export const HOST_CAPABILITY_METHODS: RpcMethod[] = [ params: null, handler: async () => ({ platform: process.platform }) }), + // Why: paired web/mobile clients route capability reads here, so a sync probe would + // execFileSync wsl.exe/pwsh.exe on this host's main event loop for up to 5s per call. defineMethod({ name: 'host.wsl.isAvailable', params: null, - handler: async () => isWslAvailable() + handler: async () => isWslAvailableAsync() }), defineMethod({ name: 'host.wsl.listDistros', params: null, - handler: async () => listWslDistros() + handler: async () => listWslDistrosAsync() }), defineMethod({ name: 'host.pwsh.isAvailable', params: null, - handler: async () => isPwshAvailable() + handler: async () => isPwshAvailableAsync() }), defineMethod({ name: 'host.gitBash.isAvailable', diff --git a/src/main/wsl-availability.ts b/src/main/wsl-availability.ts new file mode 100644 index 00000000000..c44476c9d60 --- /dev/null +++ b/src/main/wsl-availability.ts @@ -0,0 +1,213 @@ +import { execFile, execFileSync } from 'node:child_process' + +type WslAvailabilityCache = + | { available: true } + /** Not Windows — never re-probed. */ + | { available: false; unsupported: true } + | { available: false; cachedAt: number; retryable: boolean; failures: number } + +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 +// renderer's 30s capability TTL, so repeated refreshes don't land on this boundary and +// re-probe every cycle. +const WSL_AVAILABILITY_NEGATIVE_CACHE_TTL_MS = 45_000 +// Why: even a definitive-looking non-zero exit can be transient — wsl.exe reports one +// while the WSL package is servicing or LxssManager is still starting — so nothing +// latches for the whole session; it just waits much longer before paying the probe again. +const WSL_AVAILABILITY_DEFINITIVE_TTL_MS = 10 * 60_000 +const WSL_AVAILABILITY_MAX_RETRY_DELAY_MS = 30 * 60_000 + +function isPermanentWslAvailabilityCache(cache: WslAvailabilityCache): boolean { + // Why: re-check the platform so a cache seeded off-Windows can't suppress a real probe. + return cache.available || ('unsupported' in cache && process.platform !== 'win32') +} + +// Why: the probe spawns wsl.exe, so a host with a wedged wsl.exe must not pay it every +// window; back off per consecutive failure. +function wslAvailabilityRetryDelayMs(cache: { retryable: boolean; failures: number }): number { + const base = cache.retryable + ? WSL_AVAILABILITY_NEGATIVE_CACHE_TTL_MS + : WSL_AVAILABILITY_DEFINITIVE_TTL_MS + return Math.min(base * 2 ** (cache.failures - 1), WSL_AVAILABILITY_MAX_RETRY_DELAY_MS) +} + +// Why: a non-zero exit (wsl.exe ran and said no) or ENOENT (not installed) is answer-shaped, +// so it earns a long window rather than the short one a timeout gets. execFileSync reports the +// exit code as `status`, the execFile callback as a numeric `code`; both must count as +// definitive or the async twin poisons the shared cache with the short retryable window. +// Same numeric-status rule as `wslUncDirectoryExists`; neither latches forever. +function isRetryableWslProbeFailure(error: unknown): boolean { + const failure = error as { status?: unknown; code?: unknown } | null + if (typeof failure?.status === 'number' || typeof failure?.code === 'number') { + return false + } + return failure?.code !== 'ENOENT' +} + +function isWslAvailabilityCacheFresh(cache: WslAvailabilityCache): boolean { + if (isPermanentWslAvailabilityCache(cache)) { + return true + } + if (!('cachedAt' in cache)) { + return false + } + return Date.now() - cache.cachedAt < wslAvailabilityRetryDelayMs(cache) +} + +function reusableWslAvailability(): boolean | null { + return wslAvailableCache && isWslAvailabilityCacheFresh(wslAvailableCache) + ? wslAvailableCache.available + : null +} + +function previousWslAvailabilityFailures(): number { + return wslAvailableCache && 'failures' in wslAvailableCache ? wslAvailableCache.failures : 0 +} + +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 { + return new Promise((resolve, reject) => { + execFile( + 'wsl.exe', + ['--status'], + { timeout: WSL_AVAILABILITY_PROBE_TIMEOUT_MS, windowsHide: true }, + (error: unknown) => { + if (error) { + reject(error) + return + } + resolve() + } + ) + }) +} + +/** + * Check whether wsl.exe is available and functional on this Windows machine. + * Success caches for the process lifetime; every failure is re-probed eventually, so + * a slow wsl.exe activation on a just-installed or just-rebooted machine cannot latch + * WSL off for the whole session. + */ +export function isWslAvailable(): boolean { + const cached = reusableWslAvailability() + if (cached !== null) { + return cached + } + + const startedAtGeneration = wslAvailabilityCacheGeneration + + if (process.platform !== 'win32') { + writeWslAvailabilityCache({ available: false, unsupported: true }) + return false + } + + try { + execFileSync('wsl.exe', ['--status'], { + stdio: ['pipe', 'pipe', 'pipe'], + timeout: WSL_AVAILABILITY_PROBE_TIMEOUT_MS + }) + return cacheWslAvailabilityProbeResult(null, startedAtGeneration) + } catch (error) { + return cacheWslAvailabilityProbeResult(error, startedAtGeneration) + } +} + +/** + * Async twin of `isWslAvailable`, sharing its cache and backoff. + * + * Why: the renderer's capability read reaches this over IPC, and the sync probe blocks the + * Electron main thread — every PTY message, window IPC and watchdog beat — for up to 5s on a + * wedged wsl.exe. Concurrent callers share one spawn. + */ +export function isWslAvailableAsync(): Promise { + const cached = reusableWslAvailability() + if (cached !== null) { + return Promise.resolve(cached) + } + + if (process.platform !== 'win32') { + writeWslAvailabilityCache({ available: false, unsupported: true }) + return Promise.resolve(false) + } + + if (wslAvailabilityProbeInFlight) { + return wslAvailabilityProbeInFlight + } + + const startedAtGeneration = wslAvailabilityCacheGeneration + wslAvailabilityProbeInFlight = probeWslStatus() + .then(() => cacheWslAvailabilityProbeResult(null, startedAtGeneration)) + .catch((error: unknown) => cacheWslAvailabilityProbeResult(error, startedAtGeneration)) + .finally(() => { + wslAvailabilityProbeInFlight = null + }) + return wslAvailabilityProbeInFlight +} + +export function hasCachedWslAvailability(): boolean { + return wslAvailableCache !== null +} + +// Why: same contract as the distro getter — report the last observed answer. Going +// null on staleness would drop the `wsl-unavailable` repair prompt and let git and +// PTY silently resolve to a WSL that last failed to respond. `isWslAvailable` is what +// clears it, by re-probing once the retry window lapses. +export function getCachedWslAvailability(): boolean | null { + return wslAvailableCache?.available ?? null +} + +// Why: the two caches expire independently, and `getWslRepairReason` checks availability +// first — so a definitive failure held for 10-30min would report `wsl-unavailable` over a +// WSL that just listed a distro for us. A non-empty list proves wsl.exe ran, so drop the +// 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 + } +} + +export function _resetWslAvailabilityCacheForTests(): void { + wslAvailableCache = null + wslAvailabilityProbeInFlight = null + wslAvailabilityCacheGeneration = 0 +} + +export function _setWslAvailabilityCacheForTests( + available: boolean | null | undefined, + retryable: boolean +): void { + 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 0f618002b46..cd77d761caf 100644 --- a/src/main/wsl.test.ts +++ b/src/main/wsl.test.ts @@ -23,6 +23,7 @@ import { hasCachedWslAvailability, hasCachedWslDistros, isWslAvailable, + isWslAvailableAsync, listWslDistros, listWslDistrosAsync, parseWslPath, @@ -496,6 +497,114 @@ describe('WSL availability cache', () => { }) }) + // Why: the renderer's capability read reaches this over IPC; a blocking spawn there + // stalls every PTY message and window IPC for as long as wsl.exe takes to answer. + it('probes availability for IPC callers without blocking the main thread', async () => { + execFileMock.mockImplementation((_command, _args, _options, callback) => { + callback(null, '', '') + }) + + await withPlatformAsync('win32', async () => { + await expect(isWslAvailableAsync()).resolves.toBe(true) + expect(execFileMock).toHaveBeenCalledWith( + 'wsl.exe', + ['--status'], + expect.objectContaining({ timeout: 5000, windowsHide: true }), + expect.any(Function) + ) + expect(execFileSyncMock).not.toHaveBeenCalled() + }) + }) + + it('shares one wsl.exe spawn between concurrent async probes', async () => { + execFileMock.mockImplementation((_command, _args, _options, callback) => { + setTimeout(() => callback(null, '', ''), 0) + }) + + await withPlatformAsync('win32', async () => { + const results = await Promise.all([isWslAvailableAsync(), isWslAvailableAsync()]) + expect(results).toEqual([true, true]) + expect(execFileMock).toHaveBeenCalledTimes(1) + }) + }) + + 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' }), '', '') + }) + + await withPlatformAsync('win32', async () => { + await expect(isWslAvailableAsync()).resolves.toBe(false) + expect(isWslAvailable()).toBe(false) + expect(execFileSyncMock).not.toHaveBeenCalled() + }) + }) + + // Why: wsl.exe ships in System32 on every modern Windows, so a host without WSL answers + // with a non-zero exit, not ENOENT — and execFile reports that as a numeric `code`, not the + // `status` execFileSync uses. Misreading it as retryable would shrink the shared cache window + // to 45s and make the sync callers pay their blocking spawn ~13x more often. + it('treats a non-zero async exit as definitive, so the sync probe keeps the long window', async () => { + vi.useFakeTimers() + execFileMock.mockImplementation((_command, _args, _options, callback) => { + callback(Object.assign(new Error('wsl.exe exited 1'), { code: 1 }), '', '') + }) + execFileSyncMock.mockReturnValue('') + + try { + await withPlatformAsync('win32', async () => { + await expect(isWslAvailableAsync()).resolves.toBe(false) + vi.advanceTimersByTime(45_000) + expect(isWslAvailable()).toBe(false) + expect(execFileSyncMock).not.toHaveBeenCalled() + vi.advanceTimersByTime(10 * 60_000) + expect(isWslAvailable()).toBe(true) + }) + } finally { + vi.useRealTimers() + } + }) + // Why: the resolver reads the cached getter, not the probe. Reporting the last // observed answer keeps the `wsl-unavailable` repair prompt reachable; going null // on staleness would let git and PTY silently resolve to a WSL that just failed. diff --git a/src/main/wsl.ts b/src/main/wsl.ts index b45c40fe9d3..e415a3c937f 100644 --- a/src/main/wsl.ts +++ b/src/main/wsl.ts @@ -2,8 +2,19 @@ import { execFile, execFileSync } from 'node:child_process' import { parseWslUncPath, toWindowsWslPath } from '../shared/wsl-paths' import { filterUserWslDistros, parseWslDistros } from './wsl-distro-list-output' import { wslDistroListRetryDelayMs } from './wsl-distro-retry' +import { + _resetWslAvailabilityCacheForTests, + _setWslAvailabilityCacheForTests, + dropStaleWslAvailabilityFailure +} from './wsl-availability' export { toWindowsWslPath } from '../shared/wsl-paths' +export { + getCachedWslAvailability, + hasCachedWslAvailability, + isWslAvailable, + isWslAvailableAsync +} from './wsl-availability' export type WslPathInfo = { distro: string @@ -106,11 +117,6 @@ let wslDistroListRetryAfterMs = 0 let wslDistroListEmptyStreak = 0 let wslDistroProbeSequence = 0 let wslDistroCacheSequence = 0 -// Why: availability is a separate, blocking probe. Deliberately not a multiple of the -// renderer's 30s capability TTL, so repeated refreshes don't land on this boundary and -// re-probe every cycle. -const WSL_AVAILABILITY_NEGATIVE_CACHE_TTL_MS = 45_000 - function armWslDistroListRetry(): void { const now = Date.now() // Concurrent completions belong to the retry window already armed by the first result. @@ -281,115 +287,6 @@ export async function getWslHomeAsync(distro: string): Promise { } } -type WslAvailabilityCache = - | { available: true } - /** Not Windows — never re-probed. */ - | { available: false; unsupported: true } - | { available: false; cachedAt: number; retryable: boolean; failures: number } - -function isPermanentWslAvailabilityCache(cache: WslAvailabilityCache): boolean { - // Why: re-check the platform so a cache seeded off-Windows can't suppress a real probe. - return cache.available || ('unsupported' in cache && process.platform !== 'win32') -} - -let wslAvailableCache: WslAvailabilityCache | null = null - -// Why: even a definitive-looking non-zero exit can be transient — wsl.exe reports one -// while the WSL package is servicing or LxssManager is still starting — so nothing -// latches for the whole session; it just waits much longer before paying the probe again. -const WSL_AVAILABILITY_DEFINITIVE_TTL_MS = 10 * 60_000 -const WSL_AVAILABILITY_MAX_RETRY_DELAY_MS = 30 * 60_000 - -// Why: the probe blocks the main process for up to 5s, so a host with a wedged wsl.exe -// must not pay it every window; back off per consecutive failure. -function wslAvailabilityRetryDelayMs(cache: { retryable: boolean; failures: number }): number { - const base = cache.retryable - ? WSL_AVAILABILITY_NEGATIVE_CACHE_TTL_MS - : WSL_AVAILABILITY_DEFINITIVE_TTL_MS - return Math.min(base * 2 ** (cache.failures - 1), WSL_AVAILABILITY_MAX_RETRY_DELAY_MS) -} - -// Why: a numeric `status` (wsl.exe ran and said no) or ENOENT (not installed) is -// answer-shaped, so it earns a long window rather than the short one a timeout gets. -// Same numeric-status rule as `wslUncDirectoryExists`; neither latches forever. -function isRetryableWslProbeFailure(error: unknown): boolean { - const failure = error as { status?: unknown; code?: unknown } | null - if (typeof failure?.status === 'number') { - return false - } - return failure?.code !== 'ENOENT' -} - -// Why: the two caches expire independently, and `getWslRepairReason` checks availability -// first — so a definitive failure held for 10-30min would report `wsl-unavailable` over a -// WSL that just listed a distro for us. A non-empty list proves wsl.exe ran, so drop the -// 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. -function dropStaleWslAvailabilityFailure(): void { - if (wslAvailableCache && !wslAvailableCache.available && !('unsupported' in wslAvailableCache)) { - wslAvailableCache = null - } -} - -function isWslAvailabilityCacheFresh(cache: WslAvailabilityCache): boolean { - if (isPermanentWslAvailabilityCache(cache)) { - return true - } - if (!('cachedAt' in cache)) { - return false - } - return Date.now() - cache.cachedAt < wslAvailabilityRetryDelayMs(cache) -} - -/** - * Check whether wsl.exe is available and functional on this Windows machine. - * Success caches for the process lifetime; every failure is re-probed eventually, so - * a slow wsl.exe activation on a just-installed or just-rebooted machine cannot latch - * WSL off for the whole session. - */ -export function isWslAvailable(): boolean { - if (wslAvailableCache && isWslAvailabilityCacheFresh(wslAvailableCache)) { - return wslAvailableCache.available - } - - const previousFailures = - wslAvailableCache && 'failures' in wslAvailableCache ? wslAvailableCache.failures : 0 - - if (process.platform !== 'win32') { - wslAvailableCache = { available: false, unsupported: true } - return false - } - - try { - execFileSync('wsl.exe', ['--status'], { - stdio: ['pipe', 'pipe', 'pipe'], - timeout: 5000 - }) - wslAvailableCache = { available: true } - } catch (error) { - wslAvailableCache = { - available: false, - cachedAt: Date.now(), - retryable: isRetryableWslProbeFailure(error), - failures: previousFailures + 1 - } - } - - return wslAvailableCache.available -} - -export function hasCachedWslAvailability(): boolean { - return wslAvailableCache !== null -} - -// Why: same contract as the distro getter — report the last observed answer. Going -// null on staleness would drop the `wsl-unavailable` repair prompt and let git and -// PTY silently resolve to a WSL that last failed to respond. `isWslAvailable` is what -// clears it, by re-probing once the retry window lapses. -export function getCachedWslAvailability(): boolean | null { - return wslAvailableCache?.available ?? null -} - export function _resetWslCachesForTests(): void { wslHomeCache.clear() wslDistroCache = null @@ -397,7 +294,7 @@ export function _resetWslCachesForTests(): void { wslDistroListEmptyStreak = 0 wslDistroProbeSequence = 0 wslDistroCacheSequence = 0 - wslAvailableCache = null + _resetWslAvailabilityCacheForTests() } // Why: seeded state expires like real state — an `available: false` seed is re-probed @@ -408,17 +305,7 @@ export function _setWslCachesForTests(args: { distros?: string[] | null availabilityRetryable?: boolean }): void { - wslAvailableCache = - args.available === true - ? { available: true } - : args.available === false - ? { - available: false, - cachedAt: Date.now(), - retryable: args.availabilityRetryable ?? false, - failures: 1 - } - : null + _setWslAvailabilityCacheForTests(args.available, args.availabilityRetryable ?? false) // Why: seed through the real cache path so an empty seed arms the retry window // too — otherwise a seeded [] lets the next call spawn a real 5s wsl.exe. wslDistroListRetryAfterMs = 0 @@ -433,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/relay/preflight-handler.test.ts b/src/relay/preflight-handler.test.ts index 80bc2ba490a..06886180624 100644 --- a/src/relay/preflight-handler.test.ts +++ b/src/relay/preflight-handler.test.ts @@ -5,13 +5,17 @@ const { execFileAsyncMock } = vi.hoisted(() => ({ execFileAsyncMock: vi.fn() })) -const { isPwshAvailableMock, isWslAvailableMock, listWslDistrosMock, isGitBashAvailableMock } = - vi.hoisted(() => ({ - isPwshAvailableMock: vi.fn(), - isWslAvailableMock: vi.fn(), - listWslDistrosMock: vi.fn(), - isGitBashAvailableMock: vi.fn() - })) +const { + isPwshAvailableAsyncMock, + isWslAvailableAsyncMock, + listWslDistrosAsyncMock, + isGitBashAvailableMock +} = vi.hoisted(() => ({ + isPwshAvailableAsyncMock: vi.fn(), + isWslAvailableAsyncMock: vi.fn(), + listWslDistrosAsyncMock: vi.fn(), + isGitBashAvailableMock: vi.fn() +})) vi.mock('child_process', () => { const execFileWithPromisify = Object.assign(vi.fn(), { @@ -20,10 +24,10 @@ vi.mock('child_process', () => { return { execFile: execFileWithPromisify } }) -vi.mock('../main/pwsh', () => ({ isPwshAvailable: isPwshAvailableMock })) +vi.mock('../main/pwsh', () => ({ isPwshAvailableAsync: isPwshAvailableAsyncMock })) vi.mock('../main/wsl', () => ({ - isWslAvailable: isWslAvailableMock, - listWslDistros: listWslDistrosMock + isWslAvailableAsync: isWslAvailableAsyncMock, + listWslDistrosAsync: listWslDistrosAsyncMock })) vi.mock('../main/git-bash', () => ({ isGitBashAvailable: isGitBashAvailableMock })) @@ -61,9 +65,9 @@ function fishLookupArgs(command: string): string[] { beforeEach(() => { execFileAsyncMock.mockReset() - isPwshAvailableMock.mockReset() - isWslAvailableMock.mockReset() - listWslDistrosMock.mockReset() + isPwshAvailableAsyncMock.mockReset() + isWslAvailableAsyncMock.mockReset() + listWslDistrosAsyncMock.mockReset() isGitBashAvailableMock.mockReset() }) @@ -319,9 +323,9 @@ describe('PreflightHandler', () => { configurable: true, value: 'win32' }) - isWslAvailableMock.mockReturnValue(true) - listWslDistrosMock.mockReturnValue(['Ubuntu']) - isPwshAvailableMock.mockReturnValue(true) + isWslAvailableAsyncMock.mockResolvedValue(true) + listWslDistrosAsyncMock.mockResolvedValue(['Ubuntu']) + isPwshAvailableAsyncMock.mockResolvedValue(true) isGitBashAvailableMock.mockReturnValue(true) const requestHandlers = new Map) => Promise>() diff --git a/src/relay/preflight-handler.ts b/src/relay/preflight-handler.ts index 101c796137c..a703b26847e 100644 --- a/src/relay/preflight-handler.ts +++ b/src/relay/preflight-handler.ts @@ -4,8 +4,8 @@ import { promisify } from 'node:util' import path, { win32 } from 'node:path' import type { RelayDispatcher } from './dispatcher' import { buildRelayCommandEnv } from './relay-command-env' -import { isPwshAvailable } from '../main/pwsh' -import { isWslAvailable, listWslDistros } from '../main/wsl' +import { isPwshAvailableAsync } from '../main/pwsh' +import { isWslAvailableAsync, listWslDistrosAsync } from '../main/wsl' import { isGitBashAvailable } from '../main/git-bash' import { buildPosixCommandPathLookupScript } from '../shared/posix-command-path-lookup' @@ -101,11 +101,11 @@ export class PreflightHandler { hostPlatform: NodeJS.Platform | null }> { const [wslAvailable, pwshAvailable, gitBashAvailable] = await Promise.all([ - Promise.resolve(isWslAvailable()).catch(() => false), - Promise.resolve(isPwshAvailable()).catch(() => false), + isWslAvailableAsync().catch(() => false), + isPwshAvailableAsync().catch(() => false), Promise.resolve(isGitBashAvailable()).catch(() => false) ]) - const wslDistros = wslAvailable ? await Promise.resolve(listWslDistros()).catch(() => []) : [] + const wslDistros = wslAvailable ? await listWslDistrosAsync().catch(() => []) : [] return { wslAvailable, wslDistros, diff --git a/src/renderer/src/lib/windows-terminal-capabilities.test.ts b/src/renderer/src/lib/windows-terminal-capabilities.test.ts index 7cbd47bfdcb..1f1a83dec9e 100644 --- a/src/renderer/src/lib/windows-terminal-capabilities.test.ts +++ b/src/renderer/src/lib/windows-terminal-capabilities.test.ts @@ -3,6 +3,7 @@ import { act, createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' +import { resetWindowsTerminalCapabilityReprobeForTests } from './windows-terminal-capability-reprobe' import { getCachedWindowsTerminalCapabilities, getWindowsTerminalCapabilityOwnerKey, @@ -104,6 +105,7 @@ describe('windows terminal capabilities', () => { act(() => root.unmount()) } resetWindowsTerminalCapabilitiesForTests() + resetWindowsTerminalCapabilityReprobeForTests() vi.unstubAllGlobals() }) @@ -619,6 +621,43 @@ describe('windows terminal capabilities', () => { } }) + it('bounds re-probes for a Windows host that keeps answering "no WSL"', async () => { + vi.useFakeTimers() + const { wslIsAvailable, pwshIsAvailable } = stubTerminalCapabilityApi({ + wslAvailable: false, + pwshAvailable: false, + wslDistros: [] + }) + + function HookProbe(): null { + useWindowsTerminalCapabilities(true) + return null + } + + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + hookRoots.push(root) + + try { + await act(async () => { + root.render(createElement(HookProbe)) + await Promise.resolve() + await Promise.resolve() + }) + + await act(async () => { + await vi.advanceTimersByTimeAsync(30 * 60_000) + }) + + // 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() + } + }) + it.each([ { name: 'Windows to Linux', diff --git a/src/renderer/src/lib/windows-terminal-capabilities.ts b/src/renderer/src/lib/windows-terminal-capabilities.ts index 5d085cc8136..c759567df15 100644 --- a/src/renderer/src/lib/windows-terminal-capabilities.ts +++ b/src/renderer/src/lib/windows-terminal-capabilities.ts @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from 'react' +import { startWindowsTerminalCapabilityReprobe } from './windows-terminal-capability-reprobe' import { readWindowsTerminalCapabilities, type WindowsTerminalCapabilityLoadTarget @@ -239,34 +240,35 @@ export function useWindowsTerminalCapabilities( const subscribers = subscribersByOwnerKey.get(resolvedOwnerKey) ?? new Set() subscribers.add(setCapabilities) subscribersByOwnerKey.set(resolvedOwnerKey, subscribers) + const reprobe: { stop: (() => void) | null } = { stop: null } void loadWindowsTerminalCapabilities({ force: forceRefreshOnMount, ownerKey: resolvedOwnerKey, target: resolvedTarget, sshConnectionId: sshConnectionIdKey }).then((nextCapabilities) => { - if (!cancelled) { - setState({ ownerKey: resolvedOwnerKey, capabilities: nextCapabilities }) + if (cancelled) { + return + } + setState({ ownerKey: resolvedOwnerKey, capabilities: nextCapabilities }) + // 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, + readCached: () => getCachedWindowsTerminalCapabilities(resolvedOwnerKey), + probe: () => + loadWindowsTerminalCapabilities({ + ownerKey: resolvedOwnerKey, + target: resolvedTarget, + sshConnectionId: sshConnectionIdKey + }) + }) } }) - const refreshInterval = globalThis.setInterval(() => { - if (resolvedTarget.kind !== 'local' || sshConnectionIdKey) { - return - } - const cachedCapabilities = getCachedWindowsTerminalCapabilities(resolvedOwnerKey) - if (cachedCapabilities.wslAvailable && cachedCapabilities.wslDistros.length > 0) { - return - } - void loadWindowsTerminalCapabilities({ - ownerKey: resolvedOwnerKey, - target: resolvedTarget, - sshConnectionId: sshConnectionIdKey - }) - }, CAPABILITY_CACHE_TTL_MS) return () => { cancelled = true - globalThis.clearInterval(refreshInterval) + reprobe.stop?.() const currentSubscribers = subscribersByOwnerKey.get(resolvedOwnerKey) currentSubscribers?.delete(setCapabilities) if (currentSubscribers?.size === 0) { diff --git a/src/renderer/src/lib/windows-terminal-capability-reprobe.test.ts b/src/renderer/src/lib/windows-terminal-capability-reprobe.test.ts new file mode 100644 index 00000000000..ad35839ba3d --- /dev/null +++ b/src/renderer/src/lib/windows-terminal-capability-reprobe.test.ts @@ -0,0 +1,203 @@ +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' +import { + resetWindowsTerminalCapabilityReprobeForTests, + startWindowsTerminalCapabilityReprobe +} from './windows-terminal-capability-reprobe' +import type { WindowsTerminalCapabilities } from './windows-terminal-capabilities' + +const ABSENT_WSL: WindowsTerminalCapabilities = { + wslAvailable: false, + wslDistros: [], + pwshAvailable: false, + gitBashAvailable: true, + hostPlatform: 'win32', + isLoading: false +} + +const USABLE_WSL: WindowsTerminalCapabilities = { + ...ABSENT_WSL, + wslAvailable: true, + wslDistros: ['Ubuntu'] +} + +function createWatcher(answers: WindowsTerminalCapabilities[] = []): { + probe: Mock<() => Promise> + readCached: () => WindowsTerminalCapabilities +} { + let current = ABSENT_WSL + const probe = vi.fn(async () => { + current = answers.shift() ?? current + return current + }) + return { probe, readCached: () => current } +} + +afterEach(() => { + resetWindowsTerminalCapabilityReprobeForTests() + vi.useRealTimers() +}) + +describe('windows terminal capability re-probe', () => { + 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 reach the ceiling. + await vi.advanceTimersByTimeAsync(210_000) + expect(probe).toHaveBeenCalledTimes(3) + + await vi.advanceTimersByTimeAsync(30 * 60_000) + expect(probe).toHaveBeenCalledTimes(9) + }) + + it('still re-checks a transient absent answer, then stops once WSL answers', async () => { + vi.useFakeTimers() + const { probe, readCached } = createWatcher([USABLE_WSL]) + startWindowsTerminalCapabilityReprobe({ ownerKey: 'local', probe, readCached }) + + await vi.advanceTimersByTimeAsync(30_000) + expect(probe).toHaveBeenCalledTimes(1) + expect(readCached()).toMatchObject({ wslAvailable: true, wslDistros: ['Ubuntu'] }) + + await vi.advanceTimersByTimeAsync(30 * 60_000) + expect(probe).toHaveBeenCalledTimes(1) + }) + + it('keeps watching closely while the answer is still moving', async () => { + vi.useFakeTimers() + const { probe, readCached } = createWatcher([ + { ...ABSENT_WSL, pwshAvailable: true }, + { ...ABSENT_WSL, pwshAvailable: true, gitBashAvailable: false } + ]) + startWindowsTerminalCapabilityReprobe({ ownerKey: 'local', probe, readCached }) + + // Each changed answer resets the backoff to the base delay. + await vi.advanceTimersByTimeAsync(30_000) + expect(probe).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(30_000) + expect(probe).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(30_000) + expect(probe).toHaveBeenCalledTimes(3) + }) + + it('stops entirely once the last consumer unregisters', async () => { + vi.useFakeTimers() + const { probe, readCached } = createWatcher() + const stopFirst = startWindowsTerminalCapabilityReprobe({ + ownerKey: 'local', + probe, + readCached + }) + const stopSecond = startWindowsTerminalCapabilityReprobe({ + ownerKey: 'local', + probe, + readCached + }) + + // Two consumers share one schedule rather than each installing their own timer. + await vi.advanceTimersByTimeAsync(30_000) + expect(probe).toHaveBeenCalledTimes(1) + + stopFirst() + await vi.advanceTimersByTimeAsync(60_000) + expect(probe).toHaveBeenCalledTimes(2) + + stopSecond() + await vi.advanceTimersByTimeAsync(30 * 60_000) + expect(probe).toHaveBeenCalledTimes(2) + }) + + it('keeps the earned backoff when another consumer joins', async () => { + vi.useFakeTimers() + const { probe, readCached } = createWatcher() + startWindowsTerminalCapabilityReprobe({ ownerKey: 'local', probe, readCached }) + + 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) + + await vi.advanceTimersByTimeAsync(30_000) + globalThis.dispatchEvent(new Event('focus')) + await vi.advanceTimersByTimeAsync(0) + expect(probe).toHaveBeenCalledTimes(4) + }) + + // Why: each re-arm reschedules the first probe to now+30s, so an un-guarded focus handler + // lets a user alt-tabbing right after mount defer the re-check indefinitely. + it('does not let focus churn right after mount defer the first probe', async () => { + vi.useFakeTimers() + const { probe, readCached } = createWatcher() + startWindowsTerminalCapabilityReprobe({ ownerKey: 'local', probe, readCached }) + + for (let i = 0; i < 3; i += 1) { + await vi.advanceTimersByTimeAsync(10_000) + globalThis.dispatchEvent(new Event('focus')) + } + 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') + const { probe, readCached } = createWatcher() + + const stop = startWindowsTerminalCapabilityReprobe({ ownerKey: 'local', probe, readCached }) + expect(addEventListener).toHaveBeenCalledWith('focus', expect.any(Function)) + + stop() + expect(removeEventListener).toHaveBeenCalledWith('focus', expect.any(Function)) + addEventListener.mockRestore() + removeEventListener.mockRestore() + }) +}) diff --git a/src/renderer/src/lib/windows-terminal-capability-reprobe.ts b/src/renderer/src/lib/windows-terminal-capability-reprobe.ts new file mode 100644 index 00000000000..674adc565d7 --- /dev/null +++ b/src/renderer/src/lib/windows-terminal-capability-reprobe.ts @@ -0,0 +1,200 @@ +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, 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 before switching to the five-minute ceiling. */ +const REPROBE_SETTLE_STREAK = 3 + +type CapabilityReprobeTimerKind = 'backoff' | 'ceiling' + +type CapabilityReprobeRunner = { + consumers: number + timer: ReturnType | null + timerDeadline: number + timerKind: CapabilityReprobeTimerKind | null + unchangedStreak: number + signature: string + lastProbeAt: number + probeInFlight: boolean + probe: () => Promise + readCached: () => WindowsTerminalCapabilities +} + +const runnersByOwnerKey = new Map() +let focusListenerAttached = false + +function capabilitySignature(capabilities: WindowsTerminalCapabilities): string { + return [ + capabilities.wslAvailable, + capabilities.wslDistros.join('\u0000'), + capabilities.pwshAvailable, + capabilities.gitBashAvailable, + capabilities.hostPlatform ?? '' + ].join('|') +} + +/** The answer #11295 waits for: a usable WSL. Nothing further to watch for. */ +function isSettled(capabilities: WindowsTerminalCapabilities): boolean { + return capabilities.wslAvailable && capabilities.wslDistros.length > 0 +} + +function clearRunnerTimer(runner: CapabilityReprobeRunner): void { + if (runner.timer !== null) { + 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 { + 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 || runner.probeInFlight || isSettled(runner.readCached())) { + return + } + runner.probeInFlight = true + runner.lastProbeAt = Date.now() + 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 + } else { + // A moving answer means the host is still changing; watch it closely again. + runner.signature = capabilitySignature(capabilities) + runner.unchangedStreak = 0 + } + if (isSettled(capabilities)) { + return + } + scheduleNextProbe(runner) +} + +function armNewRunner(runner: CapabilityReprobeRunner): void { + if (isSettled(runner.readCached())) { + clearRunnerTimer(runner) + return + } + scheduleNextProbe(runner) +} + +function handleWindowFocus(): void { + const now = Date.now() + for (const runner of runnersByOwnerKey.values()) { + if (runner.probeInFlight || runner.timerKind !== 'ceiling') { + continue + } + const demandDeadline = Math.max(now, runner.lastProbeAt + REPROBE_BASE_DELAY_MS) + if (demandDeadline < runner.timerDeadline) { + scheduleProbe(runner, 'ceiling', demandDeadline) + } + } +} + +function attachFocusListener(): void { + if (focusListenerAttached || typeof globalThis.addEventListener !== 'function') { + return + } + focusListenerAttached = true + globalThis.addEventListener('focus', handleWindowFocus) +} + +function detachFocusListenerWhenIdle(): void { + if (!focusListenerAttached || runnersByOwnerKey.size > 0) { + return + } + focusListenerAttached = false + globalThis.removeEventListener('focus', handleWindowFocus) +} + +/** + * Watch a host whose capabilities are not settled yet, and return an unregister callback. + * Consumers of the same owner key share one backoff schedule; the last one to leave stops it. + */ +export function startWindowsTerminalCapabilityReprobe(options: { + ownerKey: string + probe: () => Promise + readCached: () => WindowsTerminalCapabilities +}): () => void { + const runner: CapabilityReprobeRunner = runnersByOwnerKey.get(options.ownerKey) ?? { + consumers: 0, + timer: null, + timerDeadline: 0, + timerKind: null, + unchangedStreak: 0, + signature: capabilitySignature(options.readCached()), + lastProbeAt: Date.now(), + probeInFlight: false, + probe: options.probe, + readCached: options.readCached + } + runner.probe = options.probe + runner.readCached = options.readCached + runner.consumers += 1 + runnersByOwnerKey.set(options.ownerKey, runner) + if (runner.consumers === 1) { + armNewRunner(runner) + } + attachFocusListener() + + let released = false + return () => { + if (released) { + return + } + released = true + runner.consumers -= 1 + if (runner.consumers > 0) { + return + } + clearRunnerTimer(runner) + if (runnersByOwnerKey.get(options.ownerKey) === runner) { + runnersByOwnerKey.delete(options.ownerKey) + } + detachFocusListenerWhenIdle() + } +} + +export function resetWindowsTerminalCapabilityReprobeForTests(): void { + for (const runner of runnersByOwnerKey.values()) { + clearRunnerTimer(runner) + runner.consumers = 0 + } + runnersByOwnerKey.clear() + detachFocusListenerWhenIdle() +}