mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
[P2] perf(windows): stop the capability poll respawning blocking wsl.exe probes (#11698)
* 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. * fix(windows): classify async wsl/pwsh probe failures with the execFile error shape The async twins feed `execFile` callback errors into classifiers written for `execFileSync`: a non-zero exit lands on `error.code` as a number rather than `error.status`, and a timeout is a SIGTERM kill rather than ETIMEDOUT. So a Windows host without WSL (wsl.exe ships in System32, so it exits non-zero instead of ENOENT) was cached as retryable, shrinking the shared window from 10min to 45s and making the still-sync callers re-pay their blocking spawn ~13x more often; and a pwsh cold start past 5s cached "pwsh missing" for 30s, demoting the user's PowerShell 7 preference — the exact case the ETIMEDOUT branch exists to prevent. Also drops a literal NUL byte from the new re-probe module's signature separator, which made the file binary to git, and seeds `lastProbeAt` at registration so focus churn right after mount cannot defer the first re-probe indefinitely. Co-authored-by: Orca <help@stably.ai> * perf(windows): route relay host-capability probes through the async wsl/pwsh twins A paired web/mobile client resolves `useWindowsTerminalCapabilities` to a local target (TabBar's `isWebClient` gate, and `useSettingsNavigationMetadata` forces `{kind:'local'}`), so the new re-probe arms there too. But `window.api.wsl/pwsh` on a web client is not the ipc/app.ts channel — it is `host.wsl.*`/`host.pwsh.*` over the runtime RPC, which still ran the sync probes and blocked the desktop main event loop on `execFileSync('wsl.exe' | 'pwsh.exe')` for up to 5s per call. Switch those handlers and the relay preflight capability probe to the async twins added here; they share the same caches, dedupe and backoff, so remote callers see no behavior change. * fix(windows): harden async capability reprobes * fix(windows): dedupe PowerShell shell probes --------- Co-authored-by: Orca <help@stably.ai> Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -180,7 +180,7 @@ vi.mock('../pi/titlebar-extension-service', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../pwsh', () => ({
|
||||
isPwshAvailable: isPwshAvailableMock
|
||||
isPwshAvailableAsync: isPwshAvailableMock
|
||||
}))
|
||||
|
||||
vi.mock('../telemetry/client', () => ({
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -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<boolean>((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
|
||||
|
||||
@@ -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<boolean>
|
||||
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
|
||||
|
||||
+116
-1
@@ -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)
|
||||
|
||||
+88
-23
@@ -10,6 +10,8 @@ type PwshAvailabilityCache =
|
||||
|
||||
let pwshAvailableCache: PwshAvailabilityCache | null = null
|
||||
let pwshWarmupInFlight: Promise<boolean> | null = null
|
||||
let pwshProbeInFlight: Promise<boolean> | null = null
|
||||
let pwshAvailabilityCacheGeneration = 0
|
||||
|
||||
function isCacheFresh(cache: PwshAvailabilityCache): boolean {
|
||||
return (
|
||||
@@ -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<boolean> {
|
||||
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<boolean> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<boolean> | null = null
|
||||
let wslAvailabilityCacheGeneration = 0
|
||||
|
||||
const WSL_AVAILABILITY_PROBE_TIMEOUT_MS = 5000
|
||||
// Why: availability is a separate, blocking probe. Deliberately not a multiple of the
|
||||
// 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<void> {
|
||||
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<boolean> {
|
||||
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
|
||||
)
|
||||
}
|
||||
@@ -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<string, (error: Error | null, stdout: string) => void>()
|
||||
execFileMock.mockImplementation((_command, args, _options, callback) => {
|
||||
callbacks.set(args.join(' '), callback)
|
||||
})
|
||||
|
||||
await withPlatformAsync('win32', async () => {
|
||||
const staleAvailability = isWslAvailableAsync()
|
||||
const distroProbe = listWslDistrosAsync()
|
||||
callbacks.get('--list --quiet')?.(null, 'Ubuntu\n')
|
||||
await expect(distroProbe).resolves.toEqual(['Ubuntu'])
|
||||
|
||||
callbacks.get('--status')?.(Object.assign(new Error('older failure'), { code: 1 }), '')
|
||||
await expect(staleAvailability).resolves.toBe(false)
|
||||
expect(getCachedWslAvailability()).toBeNull()
|
||||
|
||||
const retry = isWslAvailableAsync()
|
||||
callbacks.get('--status')?.(null, '')
|
||||
await expect(retry).resolves.toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('shares the failure backoff between the async and sync probes', async () => {
|
||||
execFileMock.mockImplementation((_command, _args, _options, callback) => {
|
||||
callback(Object.assign(new Error('not installed'), { code: 'ENOENT' }), '', '')
|
||||
})
|
||||
|
||||
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.
|
||||
|
||||
+24
-132
@@ -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
|
||||
@@ -433,12 +320,17 @@ export function _setWslCachesForTests(args: {
|
||||
|
||||
function execFileUtf8(command: string, args: string[]): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(command, args, { encoding: 'utf-8', timeout: 5000 }, (error, stdout) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
execFile(
|
||||
command,
|
||||
args,
|
||||
{ encoding: 'utf-8', timeout: 5000, windowsHide: true },
|
||||
(error, stdout) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
resolve(stdout)
|
||||
}
|
||||
resolve(stdout)
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<string, (params: Record<string, unknown>) => Promise<unknown>>()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<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 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()
|
||||
})
|
||||
})
|
||||
@@ -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<typeof globalThis.setTimeout> | null
|
||||
timerDeadline: number
|
||||
timerKind: CapabilityReprobeTimerKind | null
|
||||
unchangedStreak: number
|
||||
signature: string
|
||||
lastProbeAt: number
|
||||
probeInFlight: boolean
|
||||
probe: () => Promise<WindowsTerminalCapabilities>
|
||||
readCached: () => WindowsTerminalCapabilities
|
||||
}
|
||||
|
||||
const runnersByOwnerKey = new Map<string, CapabilityReprobeRunner>()
|
||||
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<void> {
|
||||
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<WindowsTerminalCapabilities>
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user