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/pwsh.test.ts b/src/main/pwsh.test.ts index 7c8ca266424..cf71f8b8c63 100644 --- a/src/main/pwsh.test.ts +++ b/src/main/pwsh.test.ts @@ -132,6 +132,51 @@ 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 }, + 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('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..7c07e43e096 100644 --- a/src/main/pwsh.ts +++ b/src/main/pwsh.ts @@ -10,6 +10,7 @@ type PwshAvailabilityCache = let pwshAvailableCache: PwshAvailabilityCache | null = null let pwshWarmupInFlight: Promise | null = null +let pwshProbeInFlight: Promise | null = null function isCacheFresh(cache: PwshAvailabilityCache): boolean { return ( @@ -64,6 +65,40 @@ export function isPwshAvailable(): boolean { 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') { + pwshAvailableCache = { available: false, cachedAt: Date.now(), retryable: false } + return Promise.resolve(false) + } + + if (pwshProbeInFlight) { + return pwshProbeInFlight + } + + 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 } + } + resolve(pwshAvailableCache?.available ?? false) + }) + }) + return pwshProbeInFlight +} + export function warmPwshAvailabilityCache(): Promise { if (pwshAvailableCache?.available) { return Promise.resolve(true) diff --git a/src/main/wsl-availability.ts b/src/main/wsl-availability.ts new file mode 100644 index 00000000000..4e1f90ef480 --- /dev/null +++ b/src/main/wsl-availability.ts @@ -0,0 +1,197 @@ +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 + +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 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' +} + +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 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 probeWslStatus(): Promise { + return new Promise((resolve, reject) => { + execFile( + 'wsl.exe', + ['--status'], + { timeout: WSL_AVAILABILITY_PROBE_TIMEOUT_MS }, + (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 previousFailures = previousWslAvailabilityFailures() + + if (process.platform !== 'win32') { + wslAvailableCache = { available: false, unsupported: true } + return false + } + + try { + execFileSync('wsl.exe', ['--status'], { + stdio: ['pipe', 'pipe', 'pipe'], + timeout: WSL_AVAILABILITY_PROBE_TIMEOUT_MS + }) + return cacheWslAvailabilityProbeResult(null, previousFailures) + } catch (error) { + return cacheWslAvailabilityProbeResult(error, previousFailures) + } +} + +/** + * 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') { + wslAvailableCache = { available: false, unsupported: true } + return Promise.resolve(false) + } + + if (wslAvailabilityProbeInFlight) { + return wslAvailabilityProbeInFlight + } + + const previousFailures = previousWslAvailabilityFailures() + wslAvailabilityProbeInFlight = probeWslStatus() + .then(() => cacheWslAvailabilityProbeResult(null, previousFailures)) + .catch((error: unknown) => cacheWslAvailabilityProbeResult(error, previousFailures)) + .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 { + if (wslAvailableCache && !wslAvailableCache.available && !('unsupported' in wslAvailableCache)) { + wslAvailableCache = null + } +} + +export function _resetWslAvailabilityCacheForTests(): void { + wslAvailableCache = null + wslAvailabilityProbeInFlight = null +} + +export function _setWslAvailabilityCacheForTests( + available: boolean | null | undefined, + retryable: boolean +): void { + wslAvailableCache = + 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..d77137f8fd8 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,49 @@ 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.anything(), + 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('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: 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..6a8436a9a1f 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 diff --git a/src/renderer/src/lib/windows-terminal-capabilities.test.ts b/src/renderer/src/lib/windows-terminal-capabilities.test.ts index 7cbd47bfdcb..2f033f39cc1 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,44 @@ describe('windows terminal capabilities', () => { } }) + it('stops re-probing 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) + }) + + // 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) + } 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..eef3f974234 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,36 @@ 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 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. + 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..7ec0f655b1c --- /dev/null +++ b/src/renderer/src/lib/windows-terminal-capability-reprobe.test.ts @@ -0,0 +1,135 @@ +// @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 and parks on a stable answer instead of probing forever', async () => { + vi.useFakeTimers() + const { probe, readCached } = createWatcher() + startWindowsTerminalCapabilityReprobe({ ownerKey: 'local', probe, readCached }) + + // 30s, then +60s, then +120s: three unchanged answers settle the watcher. + await vi.advanceTimersByTimeAsync(210_000) + expect(probe).toHaveBeenCalledTimes(3) + + await vi.advanceTimersByTimeAsync(30 * 60_000) + expect(probe).toHaveBeenCalledTimes(3) + }) + + 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('re-arms a parked watcher when the window regains focus', async () => { + vi.useFakeTimers() + const { probe, readCached } = createWatcher() + startWindowsTerminalCapabilityReprobe({ ownerKey: 'local', probe, readCached }) + + await vi.advanceTimersByTimeAsync(210_000 + 30 * 60_000) + expect(probe).toHaveBeenCalledTimes(3) + + globalThis.dispatchEvent(new Event('focus')) + await vi.advanceTimersByTimeAsync(30_000) + 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..3fe63a2a37f Binary files /dev/null and b/src/renderer/src/lib/windows-terminal-capability-reprobe.ts differ