From 392583cabac7cc392ef4f5fc003d40be7512d3a5 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 12 Sep 2026 21:18:50 -0700 Subject: [PATCH] perf: skip WSL discovery when filtering native-only paths (#20266) * perf: skip WSL discovery when filtering native-only paths * fix: skip the AI Vault running-distro probe on WSL-less hosts - getAiVaultWslHomeDirs, the sibling in the same Promise.all as the native-path filter, still spawned wsl.exe unconditionally on win32; gate it on the cached installed-distro list so a host with no distro performs no probe when only native Codex homes are configured. - Hosts with a distro installed keep probing from that sibling, so the running-distro last-known-good cache is still warmed by the listing and a later probe outage falls back to the observed list, not []. - Add a test against the real wsl module asserting zero wsl.exe spawns across the whole listing Promise.all, plus the warmed-cache fallback. * fix(ai-vault): gate WSL home discovery on the cached distro list, not a probe `listWslDistrosAsync()` resolves `[]` when the `wsl.exe` probe is rejected, so a transient failure made `getAiVaultWslHomeDirs()` conclude "no WSL distros" and skip discovery. That narrowed the allowed-roots set `ai-vault-delete` and `ai-vault-subagent-list` validate against, wrongly rejecting WSL-hosted paths. Gate on `hasCachedWslDistros()` / `getCachedWslDistros()` instead: a pure cache read that only skips discovery once a successful probe has reported zero user distros. It also never probes, so the AI Vault listing cannot be the first to cache `[]` and flip a configured distro to "missing" in runtime resolution. * test(ai-vault): drop the type assertion tripping the casting gate check-changed-code-quality runs config/oxlint-code-quality-casting.json with assertionStyle:'never' over changed lines, and `args as string[]` in the new wsl-probe spy failed it. Narrow through Array.isArray instead, which is also honest about execFile's argv being optional. cached-session-list-wsl-probe + cached-session-list: 9/9 pass; tc:node clean; changed-code quality gate passes. --------- Co-authored-by: Orca Worker Co-authored-by: Neil --- .../cached-session-list-wsl-probe.test.ts | 110 ++++++++++++++++++ src/main/ai-vault/cached-session-list.test.ts | 24 ++++ src/main/ai-vault/cached-session-list.ts | 9 +- src/main/ipc/ai-vault-scan-coalescing.test.ts | 3 +- src/main/ipc/ai-vault.test.ts | 3 +- src/main/wsl-running-path-filter.test.ts | 43 +++++++ src/main/wsl-running-path-filter.ts | 5 +- 7 files changed, 192 insertions(+), 5 deletions(-) create mode 100644 src/main/ai-vault/cached-session-list-wsl-probe.test.ts create mode 100644 src/main/wsl-running-path-filter.test.ts diff --git a/src/main/ai-vault/cached-session-list-wsl-probe.test.ts b/src/main/ai-vault/cached-session-list-wsl-probe.test.ts new file mode 100644 index 00000000000..e5857faf378 --- /dev/null +++ b/src/main/ai-vault/cached-session-list-wsl-probe.test.ts @@ -0,0 +1,110 @@ +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as childProcess from 'node:child_process' + +const { execFileMock, scanAiVaultSessionsInWorker } = vi.hoisted(() => ({ + execFileMock: vi.fn(), + scanAiVaultSessionsInWorker: vi.fn() +})) + +vi.mock('child_process', async (importOriginal) => ({ + ...(await importOriginal()), + execFile: execFileMock +})) +vi.mock('./session-scanner-worker-spawn', () => ({ + scanAiVaultSessionsInWorker, + resetAiVaultScannerWorkerForTests: vi.fn() +})) + +import { _resetWslCachesForTests, _setWslCachesForTests, listWslDistrosAsync } from '../wsl' +import { filterPathsToRunningWslDistrosAsync } from '../wsl-running-path-filter' +import { + configureAiVaultSessionSources, + getAiVaultWslHomeDirs, + listAiVaultSessions, + resetAiVaultSessionListCacheForTests +} from './cached-session-list' + +const NATIVE_CODEX_HOME = 'C:\\Users\\ada\\.codex' +const WSL_HOME = '\\\\wsl.localhost\\Ubuntu\\home\\ada' + +function wslSpawns(): string[][] { + return execFileMock.mock.calls + .filter(([command]) => command === 'wsl.exe') + .flatMap(([, args]) => (Array.isArray(args) ? [args.map(String)] : [])) +} + +// Why the real wsl module: the point is the wsl.exe spawn count across the WHOLE +// listing Promise.all, which a per-function mock cannot observe. +describe('AI Vault listing wsl.exe probes', () => { + beforeEach(() => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + resetAiVaultSessionListCacheForTests() + configureAiVaultSessionSources({ getAdditionalCodexHomePaths: () => [NATIVE_CODEX_HOME] }) + scanAiVaultSessionsInWorker.mockResolvedValue({ sessions: [], issues: [], scannedAt: 'scan' }) + }) + afterEach(() => { + execFileMock.mockReset() + _resetWslCachesForTests() + resetAiVaultSessionListCacheForTests() + vi.restoreAllMocks() + }) + + it('spawns no wsl.exe for native-only codex homes when no distro is installed', async () => { + _setWslCachesForTests({ distros: [] }) + + await listAiVaultSessions() + + expect(wslSpawns()).toEqual([]) + expect(scanAiVaultSessionsInWorker).toHaveBeenCalledWith( + expect.objectContaining({ + additionalCodexSessionsDirs: [join(NATIVE_CODEX_HOME, 'sessions')], + wslHomeDirs: [] + }), + expect.anything() + ) + }) + + it('still probes running distros when one is installed, so a later outage keeps the last-known-good list', async () => { + _setWslCachesForTests({ distros: ['Ubuntu'] }) + execFileMock.mockImplementation((_command, args, _options, callback) => { + callback(null, args.includes('--running') ? 'Ubuntu\n' : '/home/ada\n') + }) + + await listAiVaultSessions() + + expect(wslSpawns()).toEqual([ + ['--list', '--running', '--quiet'], + ['-d', 'Ubuntu', '--exec', 'bash', '-c', 'echo $HOME'] + ]) + expect(scanAiVaultSessionsInWorker).toHaveBeenCalledWith( + expect.objectContaining({ wslHomeDirs: [WSL_HOME] }), + expect.anything() + ) + + execFileMock.mockImplementation((_command, _args, _options, callback) => { + callback(new Error('wsl unavailable'), '') + }) + await expect(filterPathsToRunningWslDistrosAsync([`${WSL_HOME}\\.codex`])).resolves.toEqual([ + `${WSL_HOME}\\.codex` + ]) + }) + + // Why: a rejected `--list --quiet` yields [] without caching. Treating that as "no distro + // installed" would narrow the allowed roots delete/subagent validation trusts. + it('still discovers WSL homes after the installed-distro probe was rejected', async () => { + execFileMock.mockImplementation((_command, args, _options, callback) => { + if (args.includes('--running')) { + callback(null, 'Ubuntu\n') + } else if (args.includes('--list')) { + callback(new Error('wsl.exe transient failure'), '') + } else { + callback(null, '/home/ada\n') + } + }) + await expect(listWslDistrosAsync()).resolves.toEqual([]) + + await expect(getAiVaultWslHomeDirs()).resolves.toEqual([WSL_HOME]) + expect(wslSpawns()).toContainEqual(['--list', '--running', '--quiet']) + }) +}) diff --git a/src/main/ai-vault/cached-session-list.test.ts b/src/main/ai-vault/cached-session-list.test.ts index 2edf6236153..bbdd4be2877 100644 --- a/src/main/ai-vault/cached-session-list.test.ts +++ b/src/main/ai-vault/cached-session-list.test.ts @@ -3,10 +3,14 @@ import type { AiVaultListResult } from '../../shared/ai-vault-types' const { filterPathsToRunningWslDistrosAsync, + getCachedWslDistros, + hasCachedWslDistros, listRunningWslHomeDirsAsync, scanAiVaultSessionsInWorker } = vi.hoisted(() => ({ filterPathsToRunningWslDistrosAsync: vi.fn(async (paths: readonly string[]) => [...paths]), + getCachedWslDistros: vi.fn((): string[] | null => null), + hasCachedWslDistros: vi.fn(() => false), listRunningWslHomeDirsAsync: vi.fn().mockResolvedValue([]), scanAiVaultSessionsInWorker: vi.fn() })) @@ -16,6 +20,8 @@ vi.mock('./session-scanner-worker-spawn', () => ({ resetAiVaultScannerWorkerForTests: vi.fn() })) vi.mock('../wsl', () => ({ + getCachedWslDistros, + hasCachedWslDistros, listRunningWslHomeDirsAsync })) vi.mock('../wsl-running-path-filter', () => ({ filterPathsToRunningWslDistrosAsync })) @@ -51,6 +57,8 @@ describe('invalidateAiVaultSessionListCache generation guard', () => { vi.spyOn(process, 'platform', 'get').mockImplementation(() => platform) resetAiVaultSessionListCacheForTests() filterPathsToRunningWslDistrosAsync.mockClear() + getCachedWslDistros.mockReset().mockReturnValue(null) + hasCachedWslDistros.mockReset().mockReturnValue(false) listRunningWslHomeDirsAsync.mockReset().mockResolvedValue([]) scanAiVaultSessionsInWorker.mockReset() }) @@ -99,6 +107,22 @@ describe('invalidateAiVaultSessionListCache generation guard', () => { expect(listRunningWslHomeDirsAsync).toHaveBeenCalledTimes(1) }) + it('skips running-distro discovery once a probe has reported no installed WSL distro', async () => { + hasCachedWslDistros.mockReturnValue(true) + getCachedWslDistros.mockReturnValue([]) + + await expect(getAiVaultWslHomeDirs()).resolves.toEqual([]) + expect(listRunningWslHomeDirsAsync).not.toHaveBeenCalled() + }) + + it('still discovers running distros before any distro probe has succeeded', async () => { + hasCachedWslDistros.mockReturnValue(false) + listRunningWslHomeDirsAsync.mockResolvedValue(['\\\\wsl.localhost\\Ubuntu\\home\\ada']) + + await expect(getAiVaultWslHomeDirs()).resolves.toEqual(['\\\\wsl.localhost\\Ubuntu\\home\\ada']) + expect(listRunningWslHomeDirsAsync).toHaveBeenCalledTimes(1) + }) + it('skips WSL home discovery off Windows', async () => { platform = 'linux' diff --git a/src/main/ai-vault/cached-session-list.ts b/src/main/ai-vault/cached-session-list.ts index c46e4bdd4a6..c9feb5b9daf 100644 --- a/src/main/ai-vault/cached-session-list.ts +++ b/src/main/ai-vault/cached-session-list.ts @@ -4,7 +4,7 @@ import { resetAiVaultScannerBackgroundForTests, scanAiVaultSessionsInBackground } from './session-scanner-background' -import { listRunningWslHomeDirsAsync } from '../wsl' +import { getCachedWslDistros, hasCachedWslDistros, listRunningWslHomeDirsAsync } from '../wsl' import { filterPathsToRunningWslDistrosAsync } from '../wsl-running-path-filter' import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' @@ -137,6 +137,13 @@ export async function getAiVaultWslHomeDirs(): Promise { if (process.platform !== 'win32') { return [] } + // No installed distro can be running: spares WSL-less hosts the running-distro probe. + // Cache read only: a rejected wsl.exe probe yields [] without caching, so it must not + // narrow the WSL roots delete/subagent validation trusts; and probing here would let this + // listing be the first to cache [] and flip a configured distro to "missing". + if (hasCachedWslDistros() && getCachedWslDistros()?.length === 0) { + return [] + } return listRunningWslHomeDirsAsync() } diff --git a/src/main/ipc/ai-vault-scan-coalescing.test.ts b/src/main/ipc/ai-vault-scan-coalescing.test.ts index 9439bc457f8..8a8ad0a3665 100644 --- a/src/main/ipc/ai-vault-scan-coalescing.test.ts +++ b/src/main/ipc/ai-vault-scan-coalescing.test.ts @@ -26,7 +26,8 @@ vi.mock('../ai-vault/remote-session-scanner', () => ({ scanRemoteAiVaultSessions: mocks.scanRemoteAiVaultSessions })) vi.mock('../wsl', () => ({ - listRunningWslHomeDirsAsync: vi.fn().mockResolvedValue([]) + listRunningWslHomeDirsAsync: vi.fn().mockResolvedValue([]), + hasCachedWslDistros: vi.fn(() => false) })) vi.mock('../wsl-running-path-filter', () => ({ filterPathsToRunningWslDistrosAsync: vi.fn(async (paths: readonly string[]) => [...paths]) diff --git a/src/main/ipc/ai-vault.test.ts b/src/main/ipc/ai-vault.test.ts index 59b077b72d0..81f9e72f859 100644 --- a/src/main/ipc/ai-vault.test.ts +++ b/src/main/ipc/ai-vault.test.ts @@ -75,7 +75,8 @@ vi.mock('../ai-vault/session-scanner-parse-cache', async (importOriginal) => { vi.mock('../wsl', () => ({ listRunningWslDistrosAsync: vi.fn().mockResolvedValue([]), - listRunningWslHomeDirsAsync: vi.fn().mockResolvedValue([]) + listRunningWslHomeDirsAsync: vi.fn().mockResolvedValue([]), + hasCachedWslDistros: vi.fn(() => false) })) vi.mock('../providers/ssh-filesystem-dispatch', () => ({ diff --git a/src/main/wsl-running-path-filter.test.ts b/src/main/wsl-running-path-filter.test.ts new file mode 100644 index 00000000000..517c6c6aa6c --- /dev/null +++ b/src/main/wsl-running-path-filter.test.ts @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { filterPathsToRunningWslDistrosAsync } from './wsl-running-path-filter' +import { listRunningWslDistrosAsync } from './wsl' + +vi.mock('./wsl', () => ({ listRunningWslDistrosAsync: vi.fn() })) + +afterEach(() => { + vi.restoreAllMocks() + vi.resetAllMocks() +}) + +describe('filterPathsToRunningWslDistrosAsync', () => { + it.each([[], ['C:\\Users\\user\\.codex'], ['\\\\server\\share', '/local/path']])( + 'does not query WSL for native paths %j', + async (...paths: string[]) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + const result = await filterPathsToRunningWslDistrosAsync(paths) + expect(result).toEqual(paths) + expect(result).not.toBe(paths) + expect(listRunningWslDistrosAsync).not.toHaveBeenCalled() + } + ) + + it('still queries running distros for a mixed list and preserves path order', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + vi.mocked(listRunningWslDistrosAsync).mockResolvedValue(['Ubuntu']) + const paths = [ + 'C:\\local', + '\\\\wsl$\\ubuntu\\home', + '//wsl.localhost/Debian/home', + 'D:\\local' + ] + expect(await filterPathsToRunningWslDistrosAsync(paths)).toEqual([paths[0], paths[1], paths[3]]) + expect(listRunningWslDistrosAsync).toHaveBeenCalledTimes(1) + }) + + it('does not interpret WSL-shaped paths on a non-Windows host', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + const paths = ['//wsl.localhost/Ubuntu/home'] + expect(await filterPathsToRunningWslDistrosAsync(paths)).toEqual(paths) + expect(listRunningWslDistrosAsync).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/wsl-running-path-filter.ts b/src/main/wsl-running-path-filter.ts index 0ba6694b82f..7384229757f 100644 --- a/src/main/wsl-running-path-filter.ts +++ b/src/main/wsl-running-path-filter.ts @@ -1,4 +1,4 @@ -import { parseWslUncPath } from '../shared/wsl-paths' +import { isWslUncPath, parseWslUncPath } from '../shared/wsl-paths' import { listRunningWslDistrosAsync } from './wsl' export function filterPathsToWslDistros( @@ -19,5 +19,6 @@ export async function filterPathsToRunningWslDistrosAsync( if (process.platform !== 'win32') { return [...paths] } - return filterPathsToWslDistros(paths, await listRunningWslDistrosAsync()) + const runningDistros = paths.some(isWslUncPath) ? await listRunningWslDistrosAsync() : [] + return filterPathsToWslDistros(paths, runningDistros) }