perf(windows): stop the capability poll respawning blocking wsl.exe probes

#11295 added a 30s renderer interval to `useWindowsTerminalCapabilities` whose
early-return only fires when WSL is available with at least one distro, so on the
common Windows host (no WSL) it re-ran a full capability read forever. Each read
IPCs four probes whose main-process handlers were synchronous `execFileSync` calls
to wsl.exe/pwsh.exe, blocking the Electron main event loop for up to 5s a time.

The un-latching intent is kept: a host that answers "no WSL" is still re-checked,
now on an exponential backoff (30s, +60s, +120s) that parks once the answer stops
moving, re-arms on window focus, is shared by all consumers of an owner key, and
stops entirely when the last consumer unmounts. The wsl/pwsh IPC handlers now use
async twins that share the existing caches and back off identically.
This commit is contained in:
Neil
2026-08-07 13:16:17 -07:00
committed by OrcaWin
parent 8c3e9535c7
commit 04af40e3eb
12 changed files with 578 additions and 148 deletions
+1
View File
@@ -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",
+41
View File
@@ -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()
})
})
+7 -5
View File
@@ -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<boolean> => isWslAvailableAsync())
ipcMain.handle('wsl:listDistros', (): Promise<string[]> => listWslDistrosAsync())
ipcMain.handle('pwsh:isAvailable', (): Promise<boolean> => 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.
+45
View File
@@ -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)
+35
View File
@@ -10,6 +10,7 @@ type PwshAvailabilityCache =
let pwshAvailableCache: PwshAvailabilityCache | null = null
let pwshWarmupInFlight: Promise<boolean> | null = null
let pwshProbeInFlight: Promise<boolean> | 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<boolean> {
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<boolean> {
if (pwshAvailableCache?.available) {
return Promise.resolve(true)
+197
View File
@@ -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<boolean> | 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<void> {
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<boolean> {
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
}
+44
View File
@@ -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.
+13 -126
View File
@@ -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<string | null> {
}
}
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
@@ -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',
@@ -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) {
@@ -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<WindowsTerminalCapabilities>>
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()
})
})