From e741ff131813a39329d36bbb3147f64d5554e869 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 30 Aug 2026 01:49:05 -0700 Subject: [PATCH] fix(wsl): scan agent sessions only in running distros (#17072) * fix(wsl): scan sessions only in running distros * test(ai-vault): pin WSL discovery platform * fix(wsl): suspend transcript watchers for stopped distros * test(wsl): pin transcript scan gate platform * fix(wsl): settle stopped transcript loading * fix(wsl): add last-known-good fallback and backoff to running-distro discovery listRunningWslDistrosAsync failed closed on any probe error (timeout, ENOENT, wsl.exe hiccup), indistinguishable from "no distros running". A 2s poll (wsl-transcript-running-observer.ts) calls it indefinitely while any WSL transcript tab is open, so a persistently broken wsl.exe silently made every WSL session vanish app-wide with no way to tell "discovery broken" from "distro stopped", and re-spawned wsl.exe every 2s forever. Extract a dedicated cache/backoff module (wsl-running-distro-cache.ts, mirroring the sibling machinery already in wsl.ts for the full distro list) so a probe failure falls back to the last-known-good running-distro list and backs off further probes, while a genuine empty result (no distros running) stays authoritative. Add a consumer-level test simulating a sustained wsl.exe outage across a live transcript-watcher polling session, asserting the observer keeps reporting "running" and that real wsl.exe spawns stay bounded. * fix(build): list the new WSL cache module in the web typecheck project config/tsconfig.tc.web.json enumerates its files explicitly, so a new module imported by wsl.ts fails the full typecheck with TS6307 until it is listed. pnpm tc:node passes without it, which is how this got missed. src/main/wsl.ts(13,8): error TS6307: File 'src/main/wsl-running-distro-cache.ts' is not listed within the file list of project 'config/tsconfig.tc.web.json'. --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> --- config/tsconfig.tc.web.json | 1 + src/main/agent-hooks/wsl-hook-relay-launch.ts | 29 +-- src/main/ai-vault/cached-session-list.test.ts | 35 ++- src/main/ai-vault/cached-session-list.ts | 20 +- src/main/ipc/ai-vault-scan-coalescing.test.ts | 6 +- src/main/ipc/ai-vault.test.ts | 4 +- .../host-readable-transcript-path.test.ts | 82 +++++-- .../host-readable-transcript-path.ts | 83 ++++++- .../session-file-resolver-codex-roots.test.ts | 12 +- ...ession-file-resolver-wsl-scan-gate.test.ts | 36 ++- .../session-file-resolver-wsl.test.ts | 18 +- src/main/native-chat/session-file-resolver.ts | 30 ++- .../transcript-incremental-reader.ts | 10 + .../transcript-unavailable-snapshot.ts | 14 ++ ...nscript-watch-engine-wsl-lifecycle.test.ts | 173 +++++++++++++++ .../native-chat/transcript-watch-engine.ts | 117 +++++----- .../transcript-watch-error.test.ts | 33 ++- .../transcript-watch-resolve-poll.test.ts | 40 +++- .../transcript-watch-wsl-exact-path.test.ts | 167 +++++++++++++- .../transcript-watch-wsl-stall.test.ts | 3 + src/main/native-chat/transcript-watch.ts | 60 +++-- .../native-chat/transcript-watcher-count.ts | 5 + .../transcript-watcher-install-probe.ts | 21 ++ .../wsl-transcript-running-observer.test.ts | 78 +++++++ .../wsl-transcript-running-observer.ts | 108 +++++++++ .../wsl-transcript-watcher-running-guard.ts | 46 ++++ src/main/wsl-running-distro-cache.ts | 64 ++++++ src/main/wsl-running-distros.test.ts | 210 ++++++++++++++++++ src/main/wsl-running-path-filter.ts | 23 ++ src/main/wsl.ts | 70 ++++-- 30 files changed, 1386 insertions(+), 212 deletions(-) create mode 100644 src/main/native-chat/transcript-unavailable-snapshot.ts create mode 100644 src/main/native-chat/transcript-watch-engine-wsl-lifecycle.test.ts create mode 100644 src/main/native-chat/transcript-watcher-count.ts create mode 100644 src/main/native-chat/transcript-watcher-install-probe.ts create mode 100644 src/main/native-chat/wsl-transcript-running-observer.test.ts create mode 100644 src/main/native-chat/wsl-transcript-running-observer.ts create mode 100644 src/main/native-chat/wsl-transcript-watcher-running-guard.ts create mode 100644 src/main/wsl-running-distro-cache.ts create mode 100644 src/main/wsl-running-distros.test.ts create mode 100644 src/main/wsl-running-path-filter.ts diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json index d6adfd471ce..3dcc8b43a34 100644 --- a/config/tsconfig.tc.web.json +++ b/config/tsconfig.tc.web.json @@ -16,6 +16,7 @@ "../src/main/wsl-availability.ts", "../src/main/wsl-distro-list-output.ts", "../src/main/wsl-distro-retry.ts", + "../src/main/wsl-running-distro-cache.ts", "../src/main/wsl.ts", "../src/main/persistence/applying-settings/ui-state-read.ts", "../src/main/persistence/applying-settings/ui-state-update.ts", diff --git a/src/main/agent-hooks/wsl-hook-relay-launch.ts b/src/main/agent-hooks/wsl-hook-relay-launch.ts index 770258b972a..9f32de10bd5 100644 --- a/src/main/agent-hooks/wsl-hook-relay-launch.ts +++ b/src/main/agent-hooks/wsl-hook-relay-launch.ts @@ -3,20 +3,20 @@ // and the sentinel wait that turns a wsl.exe child's stdio into a // MultiplexerTransport. Kept separate from the manager so the state machine // stays readable. See docs/agent-status-over-wsl.md (STA-1515). -import { execFile, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { getAppEnvironment } from '../../shared/app-environment' import type { MultiplexerTransport } from '../ssh/ssh-channel-multiplexer' import { - decodeWslText, MAX_STARTUP_BUFFER_BYTES, type waitForWslRelaySentinel, type WslRelayStartupFailure } from './wsl-hook-relay-sentinel' import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env' import { runWslProcess } from '../wsl/wsl-runner' +import { listRunningWslDistrosAsync } from '../wsl' import { WSL_HOOK_RELAY_BUNDLE_NAME, WSL_HOOK_RELAY_DIR, @@ -149,27 +149,10 @@ export function spawnWslRelayProcess( * (the next WSL PTY spawn re-ensures), and a wsl.exe too wedged to list * distros would not have launched the relay anyway. */ export function isWslDistroRunning(distro: string): Promise { - return new Promise((resolve) => { - execFile( - 'wsl.exe', - ['--list', '--running', '--quiet'], - // Why: WSL_UTF8=1 forces UTF-8 output; without it wsl.exe emits - // UTF-16LE that reads as NUL-riddled text. - { env: { ...process.env, WSL_UTF8: '1' }, timeout: 10_000, windowsHide: true }, - (err, stdout) => { - if (err) { - resolve(false) - return - } - const wanted = distro.trim().toLowerCase() - const running = decodeWslText(String(stdout)) - .split(/\r?\n/) - .map((line) => line.trim().toLowerCase()) - .filter(Boolean) - resolve(running.includes(wanted)) - } - ) - }) + const wanted = distro.trim().toLowerCase() + return listRunningWslDistrosAsync().then((running) => + running.some((candidate) => candidate.toLowerCase() === wanted) + ) } export async function runWslInstallProcess( diff --git a/src/main/ai-vault/cached-session-list.test.ts b/src/main/ai-vault/cached-session-list.test.ts index 2743c909bdc..2edf6236153 100644 --- a/src/main/ai-vault/cached-session-list.test.ts +++ b/src/main/ai-vault/cached-session-list.test.ts @@ -1,7 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AiVaultListResult } from '../../shared/ai-vault-types' -const { scanAiVaultSessionsInWorker } = vi.hoisted(() => ({ +const { + filterPathsToRunningWslDistrosAsync, + listRunningWslHomeDirsAsync, + scanAiVaultSessionsInWorker +} = vi.hoisted(() => ({ + filterPathsToRunningWslDistrosAsync: vi.fn(async (paths: readonly string[]) => [...paths]), + listRunningWslHomeDirsAsync: vi.fn().mockResolvedValue([]), scanAiVaultSessionsInWorker: vi.fn() })) @@ -10,16 +16,19 @@ vi.mock('./session-scanner-worker-spawn', () => ({ resetAiVaultScannerWorkerForTests: vi.fn() })) vi.mock('../wsl', () => ({ - getWslHomeAsync: vi.fn(), - listWslDistrosAsync: vi.fn().mockResolvedValue([]) + listRunningWslHomeDirsAsync })) +vi.mock('../wsl-running-path-filter', () => ({ filterPathsToRunningWslDistrosAsync })) import { + getAiVaultWslHomeDirs, invalidateAiVaultSessionListCache, listAiVaultSessions, resetAiVaultSessionListCacheForTests } from './cached-session-list' +let platform: NodeJS.Platform + function scanResult(scannedAt: string): AiVaultListResult { return { sessions: [], issues: [], scannedAt } } @@ -38,11 +47,16 @@ function deferredScan(): { resolve: (value: AiVaultListResult) => void } { describe('invalidateAiVaultSessionListCache generation guard', () => { beforeEach(() => { + platform = 'win32' + vi.spyOn(process, 'platform', 'get').mockImplementation(() => platform) resetAiVaultSessionListCacheForTests() + filterPathsToRunningWslDistrosAsync.mockClear() + listRunningWslHomeDirsAsync.mockReset().mockResolvedValue([]) scanAiVaultSessionsInWorker.mockReset() }) afterEach(() => { resetAiVaultSessionListCacheForTests() + vi.restoreAllMocks() }) it('does not let a scan that started before an invalidation repopulate the cache', async () => { @@ -75,5 +89,20 @@ describe('invalidateAiVaultSessionListCache generation guard', () => { expect(cached.scannedAt).toBe('scan-A') expect(scanAiVaultSessionsInWorker).toHaveBeenCalledTimes(1) + expect(listRunningWslHomeDirsAsync).toHaveBeenCalledTimes(1) + }) + + it('resolves homes only for distros currently reported as running', async () => { + 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' + + await expect(getAiVaultWslHomeDirs()).resolves.toEqual([]) + expect(listRunningWslHomeDirsAsync).not.toHaveBeenCalled() }) }) diff --git a/src/main/ai-vault/cached-session-list.ts b/src/main/ai-vault/cached-session-list.ts index 5ecb45f3469..c8d04205091 100644 --- a/src/main/ai-vault/cached-session-list.ts +++ b/src/main/ai-vault/cached-session-list.ts @@ -4,7 +4,8 @@ import { resetAiVaultScannerBackgroundForTests, scanAiVaultSessionsInBackground } from './session-scanner-background' -import { getWslHomeAsync, listWslDistrosAsync } from '../wsl' +import { 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' import { AiVaultScanCoordinator } from './ai-vault-scan-coordinator' @@ -79,15 +80,21 @@ export async function listAiVaultSessions( force: args?.force, signal: options.signal, start: async (scanSignal) => { - const additionalCodexSessionsDirs = - sources.getAdditionalCodexHomePaths?.().map((homePath) => join(homePath, 'sessions')) ?? [] + const configuredCodexHomes = sources.getAdditionalCodexHomePaths?.() ?? [] + const [additionalCodexHomes, wslHomeDirs] = await Promise.all([ + filterPathsToRunningWslDistrosAsync(configuredCodexHomes), + getAiVaultWslHomeDirs() + ]) + const additionalCodexSessionsDirs = additionalCodexHomes.map((homePath) => + join(homePath, 'sessions') + ) const result = await scanAiVaultSessionsInBackground( { limit: args?.limit, unlimited: args?.unlimited, scopePaths: args?.scopePaths, additionalCodexSessionsDirs, - wslHomeDirs: await getAiVaultWslHomeDirs(), + wslHomeDirs, // Why: this scan is always host-local; callers addressing this host by a // runtime id get the result restamped at the RPC edge, never rescanned. executionHostId: LOCAL_EXECUTION_HOST_ID @@ -124,10 +131,7 @@ export async function getAiVaultWslHomeDirs(): Promise { if (process.platform !== 'win32') { return [] } - const homes = await Promise.all( - (await listWslDistrosAsync()).map((distro) => getWslHomeAsync(distro)) - ) - return homes.filter((homeDir): homeDir is string => Boolean(homeDir)) + return listRunningWslHomeDirsAsync() } // Drops the scan-result cache after a session is deleted so a non-force diff --git a/src/main/ipc/ai-vault-scan-coalescing.test.ts b/src/main/ipc/ai-vault-scan-coalescing.test.ts index a7ba5835611..9439bc457f8 100644 --- a/src/main/ipc/ai-vault-scan-coalescing.test.ts +++ b/src/main/ipc/ai-vault-scan-coalescing.test.ts @@ -26,8 +26,10 @@ vi.mock('../ai-vault/remote-session-scanner', () => ({ scanRemoteAiVaultSessions: mocks.scanRemoteAiVaultSessions })) vi.mock('../wsl', () => ({ - getWslHomeAsync: vi.fn(), - listWslDistrosAsync: vi.fn().mockResolvedValue([]) + listRunningWslHomeDirsAsync: vi.fn().mockResolvedValue([]) +})) +vi.mock('../wsl-running-path-filter', () => ({ + filterPathsToRunningWslDistrosAsync: vi.fn(async (paths: readonly string[]) => [...paths]) })) vi.mock('../providers/ssh-filesystem-dispatch', () => ({ SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE: 'SSH unavailable', diff --git a/src/main/ipc/ai-vault.test.ts b/src/main/ipc/ai-vault.test.ts index 13b2bc51926..59b077b72d0 100644 --- a/src/main/ipc/ai-vault.test.ts +++ b/src/main/ipc/ai-vault.test.ts @@ -74,8 +74,8 @@ vi.mock('../ai-vault/session-scanner-parse-cache', async (importOriginal) => { }) vi.mock('../wsl', () => ({ - getWslHomeAsync: mocks.getAiVaultWslHomeDirs, - listWslDistrosAsync: vi.fn().mockResolvedValue([]) + listRunningWslDistrosAsync: vi.fn().mockResolvedValue([]), + listRunningWslHomeDirsAsync: vi.fn().mockResolvedValue([]) })) vi.mock('../providers/ssh-filesystem-dispatch', () => ({ diff --git a/src/main/native-chat/host-readable-transcript-path.test.ts b/src/main/native-chat/host-readable-transcript-path.test.ts index cc655dcb9c6..3fb80b926e0 100644 --- a/src/main/native-chat/host-readable-transcript-path.test.ts +++ b/src/main/native-chat/host-readable-transcript-path.test.ts @@ -1,5 +1,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +const wslMocks = vi.hoisted(() => ({ + filterPathsToRunningWslDistrosAsync: vi.fn(), + listRunningWslHomeDirsAsync: vi.fn() +})) + +vi.mock('../wsl', () => ({ listRunningWslHomeDirsAsync: wslMocks.listRunningWslHomeDirsAsync })) +vi.mock('../wsl-running-path-filter', () => ({ + filterPathsToRunningWslDistrosAsync: wslMocks.filterPathsToRunningWslDistrosAsync +})) + import { configureHostReadableTranscriptPathSources, isGuestAbsoluteLinuxPath, @@ -19,6 +29,10 @@ const ROLLOUT_UNC = beforeEach(() => { resetHostReadableTranscriptPathCacheForTests() + wslMocks.filterPathsToRunningWslDistrosAsync + .mockReset() + .mockImplementation(async (paths: readonly string[]) => [...paths]) + wslMocks.listRunningWslHomeDirsAsync.mockReset().mockResolvedValue([]) }) describe('isGuestAbsoluteLinuxPath', () => { @@ -71,19 +85,36 @@ describe('toHostReadableTranscriptPath', () => { expect(seen).not.toContain('/home/ada/x.jsonl') }) - it('leaves drive-letter and UNC paths untranslated', async () => { - const existing = ['C:/home/ada/x.jsonl', ROLLOUT_UNC] - for (const path of existing) { - await expect( - toHostReadableTranscriptPath(path, { - platform: 'win32', - pathExists: async (candidate) => candidate === path, - listWslHomeDirs: async () => { - throw new Error('should not enumerate distros') - } - }) - ).resolves.toBe(path) - } + it('leaves drive-letter paths untranslated', async () => { + const path = 'C:/home/ada/x.jsonl' + await expect( + toHostReadableTranscriptPath(path, { + platform: 'win32', + pathExists: async (candidate) => candidate === path, + listWslHomeDirs: async () => { + throw new Error('should not enumerate distros') + } + }) + ).resolves.toBe(path) + expect(wslMocks.filterPathsToRunningWslDistrosAsync).not.toHaveBeenCalled() + }) + + it('probes an already-UNC transcript only while its distro is running', async () => { + const pathExists = vi.fn().mockResolvedValue(true) + await expect( + toHostReadableTranscriptPath(ROLLOUT_UNC, { platform: 'win32', pathExists }) + ).resolves.toBe(ROLLOUT_UNC) + expect(wslMocks.filterPathsToRunningWslDistrosAsync).toHaveBeenCalledWith([ROLLOUT_UNC]) + expect(pathExists).toHaveBeenCalledWith(ROLLOUT_UNC) + }) + + it('does not probe an already-UNC transcript after its distro stops', async () => { + const pathExists = vi.fn().mockResolvedValue(true) + wslMocks.filterPathsToRunningWslDistrosAsync.mockResolvedValue([]) + await expect( + toHostReadableTranscriptPath(ROLLOUT_UNC, { platform: 'win32', pathExists }) + ).resolves.toBeNull() + expect(pathExists).not.toHaveBeenCalled() }) it('tries the distro whose $HOME prefixes the guest path first', async () => { @@ -200,6 +231,20 @@ describe('toHostReadableTranscriptPath', () => { vi.useRealTimers() } }) + + it('drops a cached home after its distro stops before probing UNC', async () => { + wslMocks.listRunningWslHomeDirsAsync + .mockResolvedValueOnce([UBUNTU_HOME]) + .mockResolvedValueOnce([]) + const pathExists = vi.fn(async () => false) + + await toHostReadableTranscriptPath(ROLLOUT_LINUX, { platform: 'win32', pathExists }) + pathExists.mockClear() + await toHostReadableTranscriptPath(ROLLOUT_LINUX, { platform: 'win32', pathExists }) + + expect(wslMocks.listRunningWslHomeDirsAsync).toHaveBeenCalledTimes(2) + expect(pathExists).not.toHaveBeenCalled() + }) }) describe('wslCodexSessionsDirs', () => { @@ -228,4 +273,15 @@ describe('wslCodexSessionsDirs', () => { wslCodexSessionsDirs({ platform: 'win32', listWslHomeDirs: async () => [UBUNTU_HOME] }) ).resolves.toContain(`${accountHome}\\sessions`) }) + + it('excludes managed-account roots in stopped distros', async () => { + configureHostReadableTranscriptPathSources({ + getAdditionalCodexHomePaths: () => [`${UBUNTU_HOME}\\.codex-account`] + }) + wslMocks.filterPathsToRunningWslDistrosAsync.mockResolvedValue([]) + + await expect( + wslCodexSessionsDirs({ platform: 'win32', listWslHomeDirs: async () => [] }) + ).resolves.toEqual([]) + }) }) diff --git a/src/main/native-chat/host-readable-transcript-path.ts b/src/main/native-chat/host-readable-transcript-path.ts index 8650477e430..dfec75ea6bf 100644 --- a/src/main/native-chat/host-readable-transcript-path.ts +++ b/src/main/native-chat/host-readable-transcript-path.ts @@ -2,7 +2,11 @@ import { existsSync } from 'node:fs' import { isAbsolute } from 'node:path' import { isWslUncPath, parseWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths' import { WSL_CODEX_RUNTIME_HOME_SEGMENTS } from '../pty/codex-home-wsl-env' -import { getWslHomeAsync, listWslDistrosAsync } from '../wsl' +import { getWslHomeAsync, listRunningWslDistrosAsync, listRunningWslHomeDirsAsync } from '../wsl' +import { + filterPathsToRunningWslDistrosAsync, + filterPathsToWslDistros +} from '../wsl-running-path-filter' import { wslGatedAccess } from './wsl-transcript-fs-access' import { WslTranscriptFsError, wslTranscriptFsRefusal } from './wsl-transcript-fs-gate' @@ -30,12 +34,47 @@ export function needsWslHostTranslation( return platform === 'win32' && isGuestAbsoluteLinuxPath(path.trim()) } +export function needsWslHostResolution( + path: string, + platform: NodeJS.Platform = process.platform +): boolean { + return needsWslHostTranslation(path, platform) || (platform === 'win32' && isWslUncPath(path)) +} + +export type WslTranscriptResolutionSnapshot = { + runningDistros: string[] + homeDirs?: string[] +} + +/** One running-distro view shared by every WSL lookup in a resolve attempt. */ +export async function createWslTranscriptResolutionSnapshot( + options: { + includeHomes?: boolean + } = {} +): Promise { + const runningDistros = await listRunningWslDistrosAsync() + if (options.includeHomes === false) { + return { runningDistros } + } + const homes = await Promise.all(runningDistros.map((distro) => getWslHomeAsync(distro))) + return { runningDistros, homeDirs: homes.filter((home): home is string => home !== null) } +} + +async function snapshotHomeDirs(snapshot: WslTranscriptResolutionSnapshot): Promise { + if (snapshot.homeDirs) { + return snapshot.homeDirs + } + const homes = await Promise.all(snapshot.runningDistros.map((distro) => getWslHomeAsync(distro))) + return homes.filter((home): home is string => home !== null) +} + export type HostReadableTranscriptPathDeps = { platform?: NodeJS.Platform pathExists?: (path: string) => Promise signal?: AbortSignal /** Each installed WSL distro's `$HOME` as a Windows UNC path. */ listWslHomeDirs?: () => Promise + wslSnapshot?: WslTranscriptResolutionSnapshot } // Why: candidates are `\\wsl.localhost` UNC paths served over 9P. A sync probe @@ -60,9 +99,8 @@ async function pathExistsAsync(path: string, signal?: AbortSignal): Promise { - const homes = await Promise.all( - (await listWslDistrosAsync()).map((distro) => getWslHomeAsync(distro)) - ) - return homes.filter((home): home is string => Boolean(home)) + return listRunningWslHomeDirsAsync() +} + +function resolveWslHomeDirs(load?: () => Promise): Promise { + return load ? wslHomeDirs(load) : defaultListWslHomeDirs() } async function wslHomeDirs(load: () => Promise): Promise { @@ -141,10 +180,22 @@ export async function toHostReadableTranscriptPath( // current drive (`C:\home\…`), so a probe first could bind chat to a local // look-alike file instead of the real WSL transcript. if (!needsWslHostTranslation(path, platform)) { + if ( + platform === 'win32' && + isWslUncPath(path) && + (deps.wslSnapshot + ? filterPathsToWslDistros([path], deps.wslSnapshot.runningDistros) + : await filterPathsToRunningWslDistrosAsync([path]) + ).length === 0 + ) { + return null + } return (await pathExists(path)) ? path : null } - const homeDirs = await wslHomeDirs(deps.listWslHomeDirs ?? defaultListWslHomeDirs) + const homeDirs = deps.wslSnapshot + ? await snapshotHomeDirs(deps.wslSnapshot) + : await resolveWslHomeDirs(deps.listWslHomeDirs) // Sequential on purpose: the ranked order picks the owning distro, and probing // every distro at once would fan out 9P calls to ones the user left stopped. let unavailable: WslTranscriptFsError | undefined @@ -190,18 +241,26 @@ function rankDistrosForGuestPath(wslHomeUncDirs: readonly string[], guestPath: s * hook path is absent. */ export async function wslCodexSessionsDirs( - deps: Pick = {} + deps: Pick = {} ): Promise { const platform = deps.platform ?? process.platform if (platform !== 'win32') { return [] } - const homeDirs = await wslHomeDirs(deps.listWslHomeDirs ?? defaultListWslHomeDirs) + const additionalHomes = getAdditionalCodexHomePaths?.() ?? [] + const [homeDirs, runningAdditionalHomes] = await Promise.all([ + deps.wslSnapshot + ? snapshotHomeDirs(deps.wslSnapshot) + : resolveWslHomeDirs(deps.listWslHomeDirs), + deps.wslSnapshot + ? filterPathsToWslDistros(additionalHomes, deps.wslSnapshot.runningDistros) + : filterPathsToRunningWslDistrosAsync(additionalHomes) + ]) const dirs = homeDirs.flatMap((home) => [ joinUnderWslHome(home, ...WSL_CODEX_RUNTIME_HOME_SEGMENTS, 'sessions'), joinUnderWslHome(home, '.codex', 'sessions') ]) - for (const home of getAdditionalCodexHomePaths?.() ?? []) { + for (const home of runningAdditionalHomes) { if (parseWslUncPath(home)) { dirs.push(joinUnderWslHome(home, 'sessions')) } diff --git a/src/main/native-chat/session-file-resolver-codex-roots.test.ts b/src/main/native-chat/session-file-resolver-codex-roots.test.ts index 5e0001e1137..270629573ab 100644 --- a/src/main/native-chat/session-file-resolver-codex-roots.test.ts +++ b/src/main/native-chat/session-file-resolver-codex-roots.test.ts @@ -1,4 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as WslModule from '../wsl' +import type * as WslRunningPathFilterModule from '../wsl-running-path-filter' const MANAGED_HOME = '/tmp/orca-user-data/codex-runtime-home/home' @@ -11,9 +13,13 @@ vi.mock('../codex/codex-home-paths', () => ({ })) // Keeps the WSL fallback tier inert so this stays a host-roots test on any platform. -vi.mock('../wsl', () => ({ - listWslDistrosAsync: vi.fn(async () => []), - getWslHomeAsync: vi.fn(async () => null) +vi.mock('../wsl', async (importOriginal) => ({ + ...(await importOriginal()), + listRunningWslHomeDirsAsync: vi.fn(async () => []) +})) +vi.mock('../wsl-running-path-filter', async (importOriginal) => ({ + ...(await importOriginal()), + filterPathsToRunningWslDistrosAsync: vi.fn(async (paths: readonly string[]) => [...paths]) })) const scanned = vi.hoisted(() => ({ dirs: [] as string[] })) diff --git a/src/main/native-chat/session-file-resolver-wsl-scan-gate.test.ts b/src/main/native-chat/session-file-resolver-wsl-scan-gate.test.ts index 4d84e49b3c5..5b619bea5aa 100644 --- a/src/main/native-chat/session-file-resolver-wsl-scan-gate.test.ts +++ b/src/main/native-chat/session-file-resolver-wsl-scan-gate.test.ts @@ -1,4 +1,5 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as WslRunningPathFilterModule from '../wsl-running-path-filter' import type * as WslTranscriptFsGateModule from './wsl-transcript-fs-gate' const WSL_SESSIONS_DIR = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex\\sessions' @@ -6,6 +7,7 @@ const DEBIAN_SESSIONS_DIR = '\\\\wsl.localhost\\Debian\\home\\ada\\.codex\\sessi const LOCAL_SESSIONS_DIR = 'C:\\Users\\ada\\.codex\\sessions' const mocks = vi.hoisted(() => ({ + filterPathsToRunningWslDistrosAsync: vi.fn(async (paths: readonly string[]) => [...paths]), gate: vi.fn(async (_options: { path: string }) => []), walk: vi.fn( async ( @@ -27,15 +29,34 @@ vi.mock('./wsl-transcript-fs-gate', async (importOriginal) => ({ vi.mock('../ai-vault/session-scanner-discovery', () => ({ walkSessionFiles: mocks.walk })) +vi.mock('../wsl', () => ({ + getWslHomeAsync: vi.fn(async () => '\\\\wsl.localhost\\Ubuntu\\home\\ada'), + listRunningWslDistrosAsync: vi.fn(async () => ['Ubuntu']), + listRunningWslHomeDirsAsync: vi.fn(async () => ['\\\\wsl.localhost\\Ubuntu\\home\\ada']) +})) +vi.mock('../wsl-running-path-filter', async (importOriginal) => ({ + ...(await importOriginal()), + filterPathsToRunningWslDistrosAsync: mocks.filterPathsToRunningWslDistrosAsync +})) import { resolveSessionFilePath } from './session-file-resolver' import { WslTranscriptFsError } from './wsl-transcript-fs-gate' +const realPlatform = process.platform + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }) +} + beforeEach(() => { + setPlatform('win32') + mocks.filterPathsToRunningWslDistrosAsync.mockClear() mocks.gate.mockClear() mocks.walk.mockClear() }) +afterEach(() => setPlatform(realPlatform)) + describe('Codex WSL scan gate', () => { it('routes WSL session-tree scans through the shared filesystem gate', async () => { await resolveSessionFilePath('codex', 'session-id', { @@ -89,25 +110,22 @@ describe('Codex WSL scan gate', () => { ).rejects.toBe(refusal) }) - it('falls through a gate-refused hook path to an id-based hit', async () => { - const hit = `${DEBIAN_SESSIONS_DIR}\\2026\\rollout-1-session-id.jsonl` + it('does not scan by id after an authoritative WSL hook path is refused', async () => { + const refusal = new WslTranscriptFsError('timeout', 'slow share') mocks.gate.mockImplementation(async (options: { operation?: string; path: string }) => { if (options.operation === 'access') { - throw new WslTranscriptFsError('timeout', 'slow share') + throw refusal } return [] }) - mocks.walk.mockImplementation(async (dir, _agent, _issues, options) => { - await options.readDirectory?.(dir) - return dir === DEBIAN_SESSIONS_DIR ? [hit] : [] - }) await expect( resolveSessionFilePath('codex', 'session-id', { transcriptPath: `${WSL_SESSIONS_DIR}\\2026\\rollout-1-session-id.jsonl`, codexSessionsDirs: [DEBIAN_SESSIONS_DIR] }) - ).resolves.toBe(hit) + ).rejects.toBe(refusal) + expect(mocks.walk).not.toHaveBeenCalled() }) it('surfaces the hook-path refusal when the id search also misses', async () => { diff --git a/src/main/native-chat/session-file-resolver-wsl.test.ts b/src/main/native-chat/session-file-resolver-wsl.test.ts index e57461d9047..da26f43afc3 100644 --- a/src/main/native-chat/session-file-resolver-wsl.test.ts +++ b/src/main/native-chat/session-file-resolver-wsl.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as NodeFsPromisesModule from 'node:fs/promises' +import type * as WslRunningPathFilterModule from '../wsl-running-path-filter' const UBUNTU_HOME = '\\\\wsl.localhost\\Ubuntu\\home\\ada' const WSL_MANAGED_SESSIONS_DIR = `${UBUNTU_HOME}\\.local\\share\\orca\\codex-runtime-home\\home\\sessions` @@ -9,8 +10,13 @@ const ROLLOUT_UNC = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.local\\share\\orca\\codex-runtime-home\\home\\sessions\\2026\\07\\24\\rollout-wsl-sess.jsonl' vi.mock('../wsl', () => ({ - listWslDistrosAsync: vi.fn(async () => ['Ubuntu']), - getWslHomeAsync: vi.fn(async () => UBUNTU_HOME) + getWslHomeAsync: vi.fn(async () => UBUNTU_HOME), + listRunningWslDistrosAsync: vi.fn(async () => ['Ubuntu']), + listRunningWslHomeDirsAsync: vi.fn(async () => [UBUNTU_HOME]) +})) +vi.mock('../wsl-running-path-filter', async (importOriginal) => ({ + ...(await importOriginal()), + filterPathsToRunningWslDistrosAsync: vi.fn(async (paths: readonly string[]) => [...paths]) })) // Only these UNC fixtures are readable. Every other `\\wsl.localhost\` path — @@ -49,7 +55,7 @@ vi.mock('../ai-vault/session-scanner-discovery', () => ({ import { resetHostReadableTranscriptPathCacheForTests } from './host-readable-transcript-path' import { resolveSessionFilePath } from './session-file-resolver' -import { getWslHomeAsync, listWslDistrosAsync } from '../wsl' +import { listRunningWslHomeDirsAsync } from '../wsl' const realPlatform = process.platform @@ -59,8 +65,7 @@ function setPlatform(platform: NodeJS.Platform): void { beforeEach(() => { resetHostReadableTranscriptPathCacheForTests() - vi.mocked(getWslHomeAsync).mockClear() - vi.mocked(listWslDistrosAsync).mockClear() + vi.mocked(listRunningWslHomeDirsAsync).mockClear() scanned.dirs = [] scanned.hostRootHasRollout = false setPlatform('win32') @@ -101,8 +106,7 @@ describe('resolveSessionFilePath on a Windows host with WSL', () => { await expect(resolveSessionFilePath('codex', 'wsl-sess')).resolves.toBe(HOST_ROLLOUT) expect(scanned.dirs.some((dir) => dir.startsWith('\\\\wsl.localhost\\'))).toBe(false) - expect(vi.mocked(listWslDistrosAsync)).not.toHaveBeenCalled() - expect(vi.mocked(getWslHomeAsync)).not.toHaveBeenCalled() + expect(vi.mocked(listRunningWslHomeDirsAsync)).not.toHaveBeenCalled() }) it('leaves the guest path alone on non-Windows hosts', async () => { diff --git a/src/main/native-chat/session-file-resolver.ts b/src/main/native-chat/session-file-resolver.ts index aa3d781fb2d..0ee73fddc8d 100644 --- a/src/main/native-chat/session-file-resolver.ts +++ b/src/main/native-chat/session-file-resolver.ts @@ -14,7 +14,14 @@ import { findGrokChatHistoryBySessionId, resolveGrokSessionsDir } from '../../shared/grok-session-paths' -import { toHostReadableTranscriptPath, wslCodexSessionsDirs } from './host-readable-transcript-path' +import { + createWslTranscriptResolutionSnapshot, + needsWslHostResolution, + needsWslHostTranslation, + toHostReadableTranscriptPath, + wslCodexSessionsDirs, + type WslTranscriptResolutionSnapshot +} from './host-readable-transcript-path' import { findWslCodexSessionPath } from './wsl-codex-session-path-scan' import { wslTranscriptFsRefusal, type WslTranscriptFsError } from './wsl-transcript-fs-gate' import { proveClaudeTranscriptBranch } from '../claude/claude-transcript-branch-proof' @@ -73,6 +80,8 @@ export type ResolveSessionFileOptions = { * directly — recent Claude Code names the transcript with a UUID that differs * from the hook session_id, so the id-based glob below would miss it. */ transcriptPath?: string + /** Internal running-distro view shared across one resolve attempt. */ + wslSnapshot?: WslTranscriptResolutionSnapshot } /** @@ -102,9 +111,15 @@ export async function resolveSessionFilePath( // stale/missing paths fall through to the id-based search. let unavailable: WslTranscriptFsError | undefined const hookPath = options.transcriptPath?.trim() + let wslSnapshot = options.wslSnapshot if (hookPath && extname(hookPath) === '.jsonl') { try { - const hostReadable = await toHostReadableTranscriptPath(hookPath, { signal }) + if (!wslSnapshot && needsWslHostResolution(hookPath)) { + wslSnapshot = await createWslTranscriptResolutionSnapshot({ + includeHomes: needsWslHostTranslation(hookPath) + }) + } + const hostReadable = await toHostReadableTranscriptPath(hookPath, { signal, wslSnapshot }) if (hostReadable) { return hostReadable } @@ -115,9 +130,16 @@ export async function resolveSessionFilePath( // it does not, so a stalled distro reads as unavailable, never "missing". unavailable = wslTranscriptFsRefusal(error) } + if (needsWslHostResolution(hookPath)) { + if (unavailable) { + throw unavailable + } + return null + } } - const resolved = await resolveSessionFileById(transcriptAgent, sessionId, options, signal) + const resolveOptions = wslSnapshot === options.wslSnapshot ? options : { ...options, wslSnapshot } + const resolved = await resolveSessionFileById(transcriptAgent, sessionId, resolveOptions, signal) if (!resolved && unavailable) { throw unavailable } @@ -164,7 +186,7 @@ async function resolveSessionFileById( overrideDirs ?? codexSessionsDirs(), // Why: enumerating WSL homes spawns wsl.exe per distro, which boots ones the // user left stopped. Only pay that after this host's own Codex roots miss. - overrideDirs ? undefined : wslCodexSessionsDirs, + overrideDirs ? undefined : () => wslCodexSessionsDirs({ wslSnapshot: options.wslSnapshot }), signal ) } diff --git a/src/main/native-chat/transcript-incremental-reader.ts b/src/main/native-chat/transcript-incremental-reader.ts index 1dd60f58292..ed718bcc471 100644 --- a/src/main/native-chat/transcript-incremental-reader.ts +++ b/src/main/native-chat/transcript-incremental-reader.ts @@ -16,6 +16,16 @@ export type IncrementalTranscriptState = { droppingOversizedRecord: boolean } +export function createIncrementalTranscriptState(): IncrementalTranscriptState { + return { + offset: 0, + pendingChunks: [], + pendingStart: 0, + pendingBytes: 0, + droppingOversizedRecord: false + } +} + export function resetIncrementalTranscriptState(state: IncrementalTranscriptState): void { state.offset = 0 state.pendingChunks.length = 0 diff --git a/src/main/native-chat/transcript-unavailable-snapshot.ts b/src/main/native-chat/transcript-unavailable-snapshot.ts new file mode 100644 index 00000000000..293bb012061 --- /dev/null +++ b/src/main/native-chat/transcript-unavailable-snapshot.ts @@ -0,0 +1,14 @@ +import type { SubscribeNativeChatTranscriptArgs } from './transcript-watch-contract' + +type InitialSnapshotCallback = NonNullable + +export function emitTranscriptUnavailableSnapshot( + onInitialSnapshot: InitialSnapshotCallback | undefined, + message = 'Transcript unavailable' +): boolean { + if (!onInitialSnapshot) { + return false + } + onInitialSnapshot([], false, 0, message) + return true +} diff --git a/src/main/native-chat/transcript-watch-engine-wsl-lifecycle.test.ts b/src/main/native-chat/transcript-watch-engine-wsl-lifecycle.test.ts new file mode 100644 index 00000000000..51569737e72 --- /dev/null +++ b/src/main/native-chat/transcript-watch-engine-wsl-lifecycle.test.ts @@ -0,0 +1,173 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFsPromisesModule from 'node:fs/promises' + +const UNC_PATH = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex\\sessions\\rollout.jsonl' +const EMPTY_STATS = { size: 0, mtimeMs: 1, ctimeMs: 1, ino: 1, dev: 1 } + +const mocks = vi.hoisted(() => ({ + bind: vi.fn(), + dispose: vi.fn(), + filterRunning: vi.fn(), + invalidate: vi.fn(), + observe: vi.fn(), + observation: undefined as ((running: boolean) => Promise | void) | undefined, + rebindNeeded: true, + stat: vi.fn() +})) + +vi.mock('../wsl-running-path-filter', () => ({ + filterPathsToRunningWslDistrosAsync: mocks.filterRunning +})) +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + stat: mocks.stat +})) +vi.mock('./transcript-native-watcher', () => ({ + createTranscriptNativeWatcher: () => ({ + bind: mocks.bind, + dispose: mocks.dispose, + invalidate: mocks.invalidate, + needsRebind: () => mocks.rebindNeeded + }) +})) +vi.mock('./wsl-transcript-running-observer', () => ({ + observeWslTranscriptRunningState: mocks.observe +})) + +import { installTranscriptWatcher } from './transcript-watch-engine' + +describe('installed WSL transcript watcher lifecycle', () => { + beforeEach(() => { + vi.useFakeTimers() + mocks.rebindNeeded = true + mocks.bind.mockReset().mockImplementation(() => { + mocks.rebindNeeded = false + return true + }) + mocks.dispose.mockReset() + mocks.filterRunning.mockReset().mockResolvedValue([UNC_PATH]) + mocks.invalidate.mockReset().mockImplementation(() => { + mocks.rebindNeeded = true + }) + mocks.observation = undefined + mocks.observe.mockReset().mockImplementation((_path, onRunning, onStopped) => { + mocks.observation = (running) => (running ? onRunning() : onStopped()) + return vi.fn() + }) + mocks.stat.mockReset().mockResolvedValue(EMPTY_STATS) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('settles the guarded initial drain when the distro stops after install', async () => { + const onInitialSnapshot = vi.fn() + const subscription = await installTranscriptWatcher(UNC_PATH, () => null, { + agent: 'codex', + sessionId: 'wsl-session', + reconciliationIntervalMs: 100, + onAppend: () => {}, + onInitialSnapshot + }) + expect(subscription).not.toBeNull() + + mocks.filterRunning.mockResolvedValue([]) + const statsAfterInstall = mocks.stat.mock.calls.length + await vi.advanceTimersByTimeAsync(50) + + expect(mocks.stat).toHaveBeenCalledTimes(statsAfterInstall) + expect(onInitialSnapshot).toHaveBeenCalledWith([], false, 0, 'Transcript unavailable') + subscription?.unsubscribe() + }) + + it('does not settle after unsubscribe wins a delayed running probe', async () => { + const onInitialSnapshot = vi.fn() + const subscription = await installTranscriptWatcher(UNC_PATH, () => null, { + agent: 'codex', + sessionId: 'wsl-session', + onAppend: () => {}, + onInitialSnapshot + }) + let finishProbe: (() => void) | undefined + mocks.filterRunning.mockImplementationOnce( + () => + new Promise((resolve) => { + finishProbe = () => resolve([]) + }) + ) + + const drain = vi.advanceTimersByTimeAsync(50) + await vi.waitFor(() => expect(finishProbe).toBeDefined()) + subscription?.unsubscribe() + finishProbe?.() + await drain + + expect(onInitialSnapshot).not.toHaveBeenCalled() + }) + + it('suspends observation while stopped and resumes after an explicit start', async () => { + const subscription = await installTranscriptWatcher(UNC_PATH, () => null, { + agent: 'codex', + sessionId: 'wsl-session', + reconciliationIntervalMs: 100, + onAppend: () => {} + }) + await vi.advanceTimersByTimeAsync(50) + const statsBeforeStop = mocks.stat.mock.calls.length + + mocks.observation?.(false) + await vi.advanceTimersByTimeAsync(100) + expect(mocks.stat).toHaveBeenCalledTimes(statsBeforeStop) + + mocks.filterRunning.mockResolvedValue([UNC_PATH]) + mocks.observation?.(true) + await vi.advanceTimersByTimeAsync(0) + + expect(mocks.stat.mock.calls.length).toBeGreaterThan(statsBeforeStop) + expect(mocks.bind).not.toHaveBeenCalled() + subscription?.unsubscribe() + }) + + it('returns the running reconciliation promise to the shared observer', async () => { + const subscription = await installTranscriptWatcher(UNC_PATH, () => null, { + agent: 'codex', + sessionId: 'wsl-session', + onAppend: () => {} + }) + await vi.advanceTimersByTimeAsync(50) + let finishStat: (() => void) | undefined + mocks.stat.mockImplementationOnce( + () => + new Promise((resolve) => { + finishStat = () => resolve(EMPTY_STATS) + }) + ) + + const reconciliation = mocks.observation?.(true) + expect(reconciliation).toBeInstanceOf(Promise) + await vi.waitFor(() => expect(finishStat).toBeDefined()) + finishStat?.() + await reconciliation + subscription?.unsubscribe() + }) + + it('defers a failed observed UNC probe to the next shared observation', async () => { + const subscription = await installTranscriptWatcher(UNC_PATH, () => null, { + agent: 'codex', + sessionId: 'wsl-session', + onAppend: () => {} + }) + await vi.advanceTimersByTimeAsync(50) + mocks.filterRunning.mockClear().mockResolvedValue([]) + mocks.stat.mockClear().mockRejectedValueOnce(new Error('distro stopped')) + + await mocks.observation?.(true) + + expect(mocks.stat).toHaveBeenCalledTimes(1) + expect(mocks.filterRunning).not.toHaveBeenCalled() + await mocks.observation?.(false) + expect(mocks.stat).toHaveBeenCalledTimes(1) + subscription?.unsubscribe() + }) +}) diff --git a/src/main/native-chat/transcript-watch-engine.ts b/src/main/native-chat/transcript-watch-engine.ts index bbc8a52635c..b0ebbeaebfc 100644 --- a/src/main/native-chat/transcript-watch-engine.ts +++ b/src/main/native-chat/transcript-watch-engine.ts @@ -6,70 +6,47 @@ import { type TranscriptFileVersion } from './transcript-file-version' import { + createIncrementalTranscriptState, readIncrementalTranscriptMessages, - resetIncrementalTranscriptState, - type IncrementalTranscriptState + resetIncrementalTranscriptState } from './transcript-incremental-reader' -import { createTranscriptNativeWatcher } from './transcript-native-watcher' import { readNativeChatTranscriptTailFile } from './transcript-tail-reader' +import { emitTranscriptUnavailableSnapshot } from './transcript-unavailable-snapshot' +import { transcriptWatcherPathIsInstallable } from './transcript-watcher-install-probe' import { nativeChatTurnLifecycleDecoderForAgent } from './transcript-turn-lifecycle' import type { NativeChatTranscriptSubscription, SubscribeNativeChatTranscriptArgs } from './transcript-watch-contract' import { createTranscriptWatchScheduler } from './transcript-watch-scheduler' -import { wslGatedStat } from './wsl-transcript-fs-access' import { WslTranscriptFsError } from './wsl-transcript-fs-gate' +import { + createRunningGuardedTranscriptNativeWatcher, + isWslTranscriptWatcherPath, + transcriptWatcherPathIsRunning +} from './wsl-transcript-watcher-running-guard' +import { observeWslTranscriptRunningState } from './wsl-transcript-running-observer' +import { trackActiveNativeChatWatcher } from './transcript-watcher-count' -const ROTATION_RETRY_MS = 25 -const MAX_ROTATION_RETRY_MS = 2_000 -let activeWatcherCount = 0 - -export function getActiveNativeChatWatcherCount(): number { - return activeWatcherCount -} - -/** - * Install the live-tail engine on an already-resolved file path. Returns null - * when the file doesn't exist yet, so the caller falls back to resolve-polling. - * A failed native watch still installs a reconciliation-only subscription: some - * remote filesystems allow stat/read while rejecting fs.watch entirely. - */ +/** Install a live tail, or return null when the resolved file is not readable yet. */ export async function installTranscriptWatcher( filePath: string, decode: (line: string, fallbackId: string) => NativeChatMessage | null, args: SubscribeNativeChatTranscriptArgs, - /** Cancels the install probe so an unsubscribe during it detaches the gate - * waiter immediately instead of at the 30s deadline. */ signal?: AbortSignal ): Promise { - try { - await wslGatedStat(filePath, 'exact', signal) - } catch (error) { - // Why: "not flushed yet" degrades to resolve-polling, but a stalled distro - // must reach the caller so it can surface a retryable message instead of - // stranding the client at `loading`. - if (error instanceof WslTranscriptFsError) { - throw error - } + const isWslPath = isWslTranscriptWatcherPath(filePath) + if (!(await transcriptWatcherPathIsInstallable(filePath, signal))) { return null } const { onAppend, onInitialSnapshot, onReplace, initialLimit } = args const decodeLifecycle = nativeChatTurnLifecycleDecoderForAgent(args.agent) - const state: IncrementalTranscriptState = { - offset: 0, - pendingChunks: [], - pendingStart: 0, - pendingBytes: 0, - droppingOversizedRecord: false - } + const state = createIncrementalTranscriptState() let watchedVersion: TranscriptFileVersion | null = null let watchedBoundary = '' - let initialDrain = true - // Guards the one-time error snapshot emitted when the initial drain throws, so - // a persistently-failing retry loop can't spam the subscriber with error frames. - let initialErrorEmitted = false + let initialDrain = true, + initialErrorEmitted = false let closed = false // Why: every gated call on the drain path must detach the moment we // unsubscribe, instead of holding a waiter until its 30s deadline, and an @@ -84,10 +61,7 @@ export async function installTranscriptWatcher( if (closed) { return } - const retryDelay = Math.min( - ROTATION_RETRY_MS * 2 ** Math.min(rotationRetryCount, 7), - MAX_ROTATION_RETRY_MS - ) + const retryDelay = Math.min(25 * 2 ** Math.min(rotationRetryCount, 7), 2_000) if (scheduler.scheduleRetry(retryDelay)) { rotationRetryCount += 1 } @@ -133,7 +107,9 @@ export async function installTranscriptWatcher( rotationRetryCount = 0 return } - scheduleRotationRetry() + if (!isWslPath) { + scheduleRotationRetry() + } } async function drainOnce(): Promise { @@ -246,10 +222,16 @@ export async function installTranscriptWatcher( await finishSuccessfulDrain(current) } - async function drain(): Promise { + async function drain(runningChecked = false): Promise { if (closed) { return } + if (isWslPath && !runningChecked && !(await transcriptWatcherPathIsRunning(filePath))) { + nativeWatcher.invalidate() + initialErrorEmitted ||= + !closed && initialDrain && emitTranscriptUnavailableSnapshot(onInitialSnapshot) + return + } if (reading) { pendingReadRequested = true return @@ -266,16 +248,16 @@ export async function installTranscriptWatcher( // A still-pending initial drain also surfaces one error snapshot so a // watching client isn't stranded at 'loading' when the read keeps // throwing; initialDrain stays true so a recovered read can still win. - if (!closed && initialDrain && onInitialSnapshot && !initialErrorEmitted) { - initialErrorEmitted = true - onInitialSnapshot( - [], - false, - 0, + initialErrorEmitted ||= + !closed && + initialDrain && + emitTranscriptUnavailableSnapshot( + onInitialSnapshot, error instanceof WslTranscriptFsError ? error.message : 'Transcript unavailable' ) + if (!isWslPath) { + scheduleRotationRetry() } - scheduleRotationRetry() break } } while (pendingReadRequested && !closed) @@ -284,7 +266,7 @@ export async function installTranscriptWatcher( } } - async function reconcile(): Promise { + async function reconcileKnownRunning(): Promise { if (closed) { return } @@ -296,12 +278,11 @@ export async function installTranscriptWatcher( const versionChanged = watchedVersion === null || transcriptFileVersionChanged(current, watchedVersion) if (versionChanged || current.size !== state.offset || nativeWatcher.needsRebind()) { - await drain() + await drain(true) } } catch { - // Why: a missing/replaced path needs the existing capped rotation retry, - // even when fs.watch stayed silent about the transition. - await drain() + // WSL retries wait for the next shared running-state observation. + await (isWslPath ? undefined : drain()) } } @@ -309,17 +290,26 @@ export async function installTranscriptWatcher( debounceMs: args.debounceMs, reconciliationIntervalMs: args.reconciliationIntervalMs, drain: () => void drain(), - reconcile + reconcile: reconcileKnownRunning }) - const nativeWatcher = createTranscriptNativeWatcher( + const nativeWatcher = createRunningGuardedTranscriptNativeWatcher( filePath, () => scheduler.scheduleEventDrain(), scheduleRotationRetry ) nativeWatcher.bind() - activeWatcherCount++ - scheduler.startReconciliation() + const stopWslObservation = isWslPath + ? observeWslTranscriptRunningState( + filePath, + () => reconcileKnownRunning(), + () => nativeWatcher.invalidate() + ) + : () => {} + trackActiveNativeChatWatcher(1) + if (!isWslPath) { + scheduler.startReconciliation() + } scheduler.scheduleEventDrain() return { @@ -331,8 +321,9 @@ export async function installTranscriptWatcher( closed = true gateAbort.abort(new Error('Native Chat transcript watcher unsubscribed')) scheduler.dispose() + stopWslObservation() nativeWatcher.dispose() - activeWatcherCount-- + trackActiveNativeChatWatcher(-1) } } } diff --git a/src/main/native-chat/transcript-watch-error.test.ts b/src/main/native-chat/transcript-watch-error.test.ts index 2d3b0828664..655d53c1cbe 100644 --- a/src/main/native-chat/transcript-watch-error.test.ts +++ b/src/main/native-chat/transcript-watch-error.test.ts @@ -5,8 +5,10 @@ import { appendFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' +import type * as TranscriptTailReader from './transcript-tail-reader' -const { watchers, watchCallbacks, watchMock } = vi.hoisted(() => ({ +const { tailReaderState, watchers, watchCallbacks, watchMock } = vi.hoisted(() => ({ + tailReaderState: { failure: null as Error | null }, watchers: [] as (EventEmitter & { close: ReturnType })[], watchCallbacks: [] as ((event: string, filename: string | Buffer | null) => void)[], watchMock: vi.fn() @@ -23,6 +25,21 @@ vi.mock('node:fs', async () => { return { ...actual, watch: watchMock } }) +vi.mock('./transcript-tail-reader', async () => { + const actual = await vi.importActual('./transcript-tail-reader') + return { + ...actual, + readNativeChatTranscriptTailFile: ( + ...args: Parameters + ) => { + if (tailReaderState.failure) { + return Promise.reject(tailReaderState.failure) + } + return actual.readNativeChatTranscriptTailFile(...args) + } + } +}) + import { subscribeNativeChatTranscript } from './transcript-watch' const roots: string[] = [] @@ -31,6 +48,7 @@ afterEach(async () => { watchers.length = 0 watchCallbacks.length = 0 watchMock.mockClear() + tailReaderState.failure = null await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) }) @@ -98,11 +116,9 @@ describe('native chat transcript watcher errors', () => { it('surfaces an error snapshot when the initial drain throws', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-initial-error-')) roots.push(root) - // A directory sitting at the transcript path: it exists (so install does not - // defer to the not-yet-flushed resolve poll, #8401) but every tail read - // throws EISDIR — a persistent real read error, not a missing file. const filePath = join(root, 'transcript.jsonl') - await mkdir(filePath) + await writeFile(filePath, '') + tailReaderState.failure = new Error('deterministic read failure') const onInitialSnapshot = vi.fn() const onAppend = vi.fn() const subscription = await subscribeNativeChatTranscript({ @@ -129,10 +145,9 @@ describe('native chat transcript watcher errors', () => { it('still wins with a real initial snapshot once the transcript becomes readable', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-initial-recover-')) roots.push(root) - // Same unreadable-directory setup as above; the error frame must not be - // terminal once the path is replaced with a readable transcript. const filePath = join(root, 'transcript.jsonl') - await mkdir(filePath) + await writeFile(filePath, '') + tailReaderState.failure = new Error('deterministic read failure') const onInitialSnapshot = vi.fn() const subscription = await subscribeNativeChatTranscript({ agent: 'claude', @@ -149,7 +164,7 @@ describe('native chat transcript watcher errors', () => { // initialDrain stays true after the error, so a recovered read delivers the // real snapshot instead of stranding the client on the error frame. - await rm(filePath, { recursive: true, force: true }) + tailReaderState.failure = null await writeFile(filePath, claudeLine('u-recovered', 'user', 'back')) watchCallbacks[0]!('change', 'transcript.jsonl') await vi.waitFor(() => diff --git a/src/main/native-chat/transcript-watch-resolve-poll.test.ts b/src/main/native-chat/transcript-watch-resolve-poll.test.ts index e5ec941e5e9..dfdd8e9f892 100644 --- a/src/main/native-chat/transcript-watch-resolve-poll.test.ts +++ b/src/main/native-chat/transcript-watch-resolve-poll.test.ts @@ -1,9 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as HostReadableTranscriptPathModule from './host-readable-transcript-path' - const mocks = vi.hoisted(() => ({ install: vi.fn(), + observe: vi.fn(), + observation: undefined as + | ((runningDistros: readonly string[]) => Promise | void) + | undefined, resolve: vi.fn(), + stopObservation: vi.fn(), toHostReadable: vi.fn() })) @@ -14,9 +18,12 @@ vi.mock('./transcript-watch-engine', () => ({ getActiveNativeChatWatcherCount: vi.fn(() => 0), installTranscriptWatcher: mocks.install })) -vi.mock('./host-readable-transcript-path', async (importOriginal) => ({ - ...(await importOriginal()), - toHostReadableTranscriptPath: mocks.toHostReadable +vi.mock('./host-readable-transcript-path', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, toHostReadableTranscriptPath: mocks.toHostReadable } +}) +vi.mock('./wsl-transcript-running-observer', () => ({ + observeRunningWslDistros: mocks.observe })) import { subscribeNativeChatTranscript } from './transcript-watch' @@ -33,6 +40,12 @@ describe('native chat transcript resolve polling', () => { vi.useFakeTimers() mocks.install.mockReset().mockReturnValue(null) mocks.resolve.mockReset().mockResolvedValue(null) + mocks.observation = undefined + mocks.stopObservation.mockReset() + mocks.observe.mockReset().mockImplementation((callback) => { + mocks.observation = callback + return mocks.stopObservation + }) mocks.toHostReadable.mockReset().mockResolvedValue(null) // Why: a POSIX exact path is a WSL guest path on win32 and is deliberately // never installed raw there, so pin the platform instead of inheriting the @@ -68,9 +81,7 @@ describe('native chat transcript resolve polling', () => { expect(mocks.install).toHaveBeenCalledTimes(callsAfterUnsubscribe) }) - it('retries the WSL translation on the slow cadence, never installing the raw guest path', async () => { - // Why: each translation probes the UNC twin per distro over the 9P - // share; doing it every fast tick would hammer the main process (#10326). + it('retries WSL translation from shared observations, never installing the raw guest path', async () => { setPlatform('win32') const subscription = await subscribeNativeChatTranscript({ agent: 'codex', @@ -80,13 +91,13 @@ describe('native chat transcript resolve polling', () => { onAppend: () => {} }) - await vi.advanceTimersByTimeAsync(100) + await mocks.observation?.(['Ubuntu']) expect(mocks.toHostReadable).toHaveBeenCalledTimes(1) expect(mocks.install.mock.calls.some(([filePath]) => String(filePath).startsWith('/'))).toBe( false ) - await vi.advanceTimersByTimeAsync(5_100) + await mocks.observation?.(['Ubuntu']) expect(mocks.toHostReadable).toHaveBeenCalledTimes(2) subscription.unsubscribe() @@ -95,7 +106,9 @@ describe('native chat transcript resolve polling', () => { it('installs the translated UNC path once the WSL transcript becomes readable', async () => { setPlatform('win32') const unc = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex\\sessions\\rollout-session-id.jsonl' + const engine = { unsubscribe: vi.fn(), watching: true } mocks.toHostReadable.mockResolvedValue(unc) + mocks.install.mockResolvedValue(engine) const subscription = await subscribeNativeChatTranscript({ agent: 'codex', @@ -105,10 +118,11 @@ describe('native chat transcript resolve polling', () => { onAppend: () => {} }) - await vi.advanceTimersByTimeAsync(100) + await mocks.observation?.(['Ubuntu']) expect(mocks.install.mock.calls.some(([filePath]) => filePath === unc)).toBe(true) - // Memoized: a successful translation is not re-probed on later ticks. + // The resolve observer hands ownership to the installed watcher. expect(mocks.toHostReadable).toHaveBeenCalledTimes(1) + expect(mocks.stopObservation).toHaveBeenCalledOnce() subscription.unsubscribe() }) @@ -179,10 +193,12 @@ describe('native chat transcript resolve polling', () => { onAppend: () => {} }) - await vi.advanceTimersByTimeAsync(10) + const observation = mocks.observation?.(['Ubuntu']) + await vi.waitFor(() => expect(receivedSignal).toBeDefined()) expect(receivedSignal?.aborted).toBe(false) subscription.unsubscribe() expect(receivedSignal?.aborted).toBe(true) + await observation }) it('does not install after initial resolution is cancelled', async () => { diff --git a/src/main/native-chat/transcript-watch-wsl-exact-path.test.ts b/src/main/native-chat/transcript-watch-wsl-exact-path.test.ts index 814addff83c..0137d0de47e 100644 --- a/src/main/native-chat/transcript-watch-wsl-exact-path.test.ts +++ b/src/main/native-chat/transcript-watch-wsl-exact-path.test.ts @@ -1,5 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as NodeFsPromisesModule from 'node:fs/promises' +import type * as WslRunningPathFilterModule from '../wsl-running-path-filter' +import { parseWslUncPath } from '../../shared/wsl-paths' const UBUNTU_HOME = '\\\\wsl.localhost\\Ubuntu\\home\\ada' const ROLLOUT_LINUX = @@ -8,7 +10,10 @@ const ROLLOUT_UNC = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.local\\share\\orca\\codex-runtime-home\\home\\sessions\\2026\\07\\24\\rollout-wsl.jsonl' const mocks = vi.hoisted(() => ({ + filterPathsToRunningWslDistrosAsync: vi.fn(), + getWslHomeAsync: vi.fn(), install: vi.fn(), + listRunningWslDistrosAsync: vi.fn(), resolve: vi.fn() })) @@ -20,8 +25,13 @@ vi.mock('./transcript-watch-engine', () => ({ installTranscriptWatcher: mocks.install })) vi.mock('../wsl', () => ({ - listWslDistrosAsync: vi.fn(async () => ['Ubuntu']), - getWslHomeAsync: vi.fn(async () => UBUNTU_HOME) + getWslHomeAsync: mocks.getWslHomeAsync, + listRunningWslDistrosAsync: mocks.listRunningWslDistrosAsync, + listRunningWslHomeDirsAsync: vi.fn(async () => [UBUNTU_HOME]) +})) +vi.mock('../wsl-running-path-filter', async (importOriginal) => ({ + ...(await importOriginal()), + filterPathsToRunningWslDistrosAsync: mocks.filterPathsToRunningWslDistrosAsync })) vi.mock('node:fs/promises', async (importOriginal) => { const actual = await importOriginal() @@ -37,6 +47,8 @@ vi.mock('node:fs/promises', async (importOriginal) => { import { resetHostReadableTranscriptPathCacheForTests } from './host-readable-transcript-path' import { subscribeNativeChatTranscript } from './transcript-watch' +import { WslTranscriptFsError } from './wsl-transcript-fs-gate' +import { resetWslTranscriptRunningObserverForTests } from './wsl-transcript-running-observer' const realPlatform = process.platform @@ -48,11 +60,25 @@ describe('exact hook path install on a Windows host with WSL (#10326)', () => { beforeEach(() => { vi.useFakeTimers() resetHostReadableTranscriptPathCacheForTests() + mocks.filterPathsToRunningWslDistrosAsync + .mockReset() + .mockImplementation(async (paths: readonly string[]) => { + const running = new Set( + (await mocks.listRunningWslDistrosAsync()).map((distro) => distro.toLowerCase()) + ) + return paths.filter((path) => { + const parsed = parseWslUncPath(path) + return !parsed || running.has(parsed.distro.toLowerCase()) + }) + }) + mocks.getWslHomeAsync.mockReset().mockResolvedValue(UBUNTU_HOME) mocks.install.mockReset().mockReturnValue(null) + mocks.listRunningWslDistrosAsync.mockReset().mockResolvedValue(['Ubuntu']) mocks.resolve.mockReset().mockResolvedValue(null) }) afterEach(() => { + resetWslTranscriptRunningObserverForTests() vi.useRealTimers() setPlatform(realPlatform) }) @@ -67,7 +93,7 @@ describe('exact hook path install on a Windows host with WSL (#10326)', () => { onAppend: () => {} }) - await vi.advanceTimersByTimeAsync(50) + await vi.advanceTimersByTimeAsync(2_100) expect(mocks.install).toHaveBeenCalledWith( ROLLOUT_UNC, expect.anything(), @@ -97,4 +123,139 @@ describe('exact hook path install on a Windows host with WSL (#10326)', () => { ) subscription.unsubscribe() }) + + it('validates an exact UNC path without probing distro homes', async () => { + setPlatform('win32') + const subscription = await subscribeNativeChatTranscript({ + agent: 'codex', + sessionId: 'wsl-sess', + transcriptPath: ROLLOUT_UNC, + resolvePollIntervalMs: 10, + onAppend: () => {} + }) + + await vi.advanceTimersByTimeAsync(2_100) + expect(mocks.install).toHaveBeenCalledWith( + ROLLOUT_UNC, + expect.anything(), + expect.anything(), + expect.any(AbortSignal) + ) + expect(mocks.getWslHomeAsync).not.toHaveBeenCalled() + subscription.unsubscribe() + }) + + it('does not install an already-UNC path after its distro stops', async () => { + setPlatform('win32') + mocks.listRunningWslDistrosAsync.mockResolvedValue([]) + const subscription = await subscribeNativeChatTranscript({ + agent: 'codex', + sessionId: 'wsl-sess', + transcriptPath: ROLLOUT_UNC, + resolvePollIntervalMs: 10, + onAppend: () => {} + }) + + await vi.advanceTimersByTimeAsync(2_100) + expect(mocks.listRunningWslDistrosAsync).toHaveBeenCalledTimes(1) + expect(mocks.install).not.toHaveBeenCalled() + subscription.unsubscribe() + }) + + it('does not run broad id fallback for an unresolved exact WSL path', async () => { + setPlatform('win32') + mocks.listRunningWslDistrosAsync.mockResolvedValue([]) + const subscription = await subscribeNativeChatTranscript({ + agent: 'codex', + sessionId: 'wsl-sess', + transcriptPath: ROLLOUT_UNC, + resolvePollIntervalMs: 10, + onAppend: () => {} + }) + + expect(mocks.resolve).toHaveBeenCalledTimes(1) + mocks.resolve.mockClear() + await vi.advanceTimersByTimeAsync(6_100) + + expect(mocks.listRunningWslDistrosAsync).toHaveBeenCalledTimes(3) + expect(mocks.resolve).not.toHaveBeenCalled() + expect(mocks.getWslHomeAsync).not.toHaveBeenCalled() + subscription.unsubscribe() + }) + + it('revalidates an absent UNC transcript before retrying after the distro stops', async () => { + setPlatform('win32') + mocks.listRunningWslDistrosAsync.mockResolvedValueOnce(['Ubuntu']).mockResolvedValue([]) + const subscription = await subscribeNativeChatTranscript({ + agent: 'codex', + sessionId: 'wsl-sess', + transcriptPath: ROLLOUT_UNC, + resolvePollIntervalMs: 10, + onAppend: () => {} + }) + + await vi.advanceTimersByTimeAsync(2_100) + expect(mocks.install).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(2_100) + expect(mocks.listRunningWslDistrosAsync).toHaveBeenCalledTimes(2) + expect(mocks.install).toHaveBeenCalledTimes(1) + subscription.unsubscribe() + }) + + it('revalidates an UNC transcript after a gated install failure', async () => { + setPlatform('win32') + mocks.listRunningWslDistrosAsync.mockResolvedValueOnce(['Ubuntu']).mockResolvedValue([]) + mocks.install.mockRejectedValueOnce(new WslTranscriptFsError('timeout', 'stalled')) + const subscription = await subscribeNativeChatTranscript({ + agent: 'codex', + sessionId: 'wsl-sess', + transcriptPath: ROLLOUT_UNC, + resolvePollIntervalMs: 10, + onAppend: () => {} + }) + + await vi.advanceTimersByTimeAsync(2_100) + expect(mocks.install).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(2_100) + expect(mocks.listRunningWslDistrosAsync).toHaveBeenCalledTimes(2) + expect(mocks.install).toHaveBeenCalledTimes(1) + subscription.unsubscribe() + }) + + it('shares one probe and skips history scans across staggered unresolved paths', async () => { + setPlatform('win32') + const subscriptions = await Promise.all( + [0, 1].map((index) => + subscribeNativeChatTranscript({ + agent: 'codex', + sessionId: `wsl-sess-${index}`, + transcriptPath: ROLLOUT_UNC, + onAppend: () => {} + }) + ) + ) + await vi.advanceTimersByTimeAsync(1_000) + subscriptions.push( + ...(await Promise.all( + [2, 3].map((index) => + subscribeNativeChatTranscript({ + agent: 'codex', + sessionId: `wsl-sess-${index}`, + transcriptPath: ROLLOUT_UNC, + onAppend: () => {} + }) + ) + )) + ) + mocks.resolve.mockClear() + + await vi.advanceTimersByTimeAsync(1_000) + + expect(mocks.listRunningWslDistrosAsync).toHaveBeenCalledTimes(1) + expect(mocks.resolve).not.toHaveBeenCalled() + expect(mocks.install).toHaveBeenCalledTimes(4) + for (const subscription of subscriptions) { + subscription.unsubscribe() + } + }) }) diff --git a/src/main/native-chat/transcript-watch-wsl-stall.test.ts b/src/main/native-chat/transcript-watch-wsl-stall.test.ts index 3c508151016..b1c46f3f51b 100644 --- a/src/main/native-chat/transcript-watch-wsl-stall.test.ts +++ b/src/main/native-chat/transcript-watch-wsl-stall.test.ts @@ -11,6 +11,9 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('./session-file-resolver', () => ({ resolveSessionFilePath: mocks.resolve })) +vi.mock('../wsl-running-path-filter', () => ({ + filterPathsToRunningWslDistrosAsync: vi.fn(async (paths: readonly string[]) => [...paths]) +})) vi.mock('./transcript-native-watcher', () => ({ createTranscriptNativeWatcher: () => ({ bind: () => true, diff --git a/src/main/native-chat/transcript-watch.ts b/src/main/native-chat/transcript-watch.ts index a104bd35d3b..97e7b75ec61 100644 --- a/src/main/native-chat/transcript-watch.ts +++ b/src/main/native-chat/transcript-watch.ts @@ -1,8 +1,9 @@ import { extname } from 'node:path' import type { NativeChatMessage } from '../../shared/native-chat-types' import { - needsWslHostTranslation, - toHostReadableTranscriptPath + needsWslHostResolution, + toHostReadableTranscriptPath, + type WslTranscriptResolutionSnapshot } from './host-readable-transcript-path' import { resolveSessionFilePath } from './session-file-resolver' import { installTranscriptWatcher } from './transcript-watch-engine' @@ -12,9 +13,10 @@ import type { } from './transcript-watch-contract' import { nativeChatLineDecoderForAgent } from './transcript-tail-reader' import { WslTranscriptFsError, wslTranscriptFsRefusal } from './wsl-transcript-fs-gate' +import { observeRunningWslDistros } from './wsl-transcript-running-observer' export { readNativeChatTranscriptTail } from './transcript-tail-reader' -export { getActiveNativeChatWatcherCount } from './transcript-watch-engine' +export { getActiveNativeChatWatcherCount } from './transcript-watcher-count' export type { NativeChatTranscriptSubscription, SubscribeNativeChatTranscriptArgs @@ -80,11 +82,11 @@ function subscribeViaResolvePoll( let delay = args.resolvePollIntervalMs ?? INITIAL_RESOLVE_POLL_MS let lastFallbackResolveAt = Date.now() const exactPath = exactTranscriptPath(args) + const exactPathNeedsWslResolution = exactPath !== null && needsWslHostResolution(exactPath) // Why: WSL hooks report guest Linux paths the Windows host cannot open; the // UNC twin is resolved lazily (the distro may still be cold) and memoized so // the exact-path install doesn't wait on the slower id-glob (#10326). let hostReadableExactPath: string | null = null - let lastWslTranslateAt = 0 // Latches only once a frame was actually emitted, so a subscriber without the // callback can't suppress it for a later one. let gateErrorEmitted = false @@ -92,6 +94,7 @@ function subscribeViaResolvePoll( let settled = false let settleTimer: ReturnType | null = null const resolveController = new AbortController() + let stopWslObservation = (): void => {} function stopSettleTimer(): void { if (settleTimer) { @@ -119,7 +122,7 @@ function subscribeViaResolvePoll( } function scheduleAttempt(): void { - if (closed) { + if (closed || exactPathNeedsWslResolution) { return } const untilFallbackResolve = exactPath @@ -142,26 +145,25 @@ function subscribeViaResolvePoll( } } - async function runAttempt(): Promise { - if (closed) { + async function runAttempt(wslSnapshot?: WslTranscriptResolutionSnapshot): Promise { + if (closed || installed) { return } let result: NativeChatTranscriptSubscription | null + const now = Date.now() + const fallbackDue = !exactPath || now - lastFallbackResolveAt >= FALLBACK_RESOLVE_POLL_MS try { if (exactPath && !hostReadableExactPath) { - if (!needsWslHostTranslation(exactPath)) { + if (!exactPathNeedsWslResolution) { // Non-WSL paths stay raw: installTranscriptWatcher already handles a // not-yet-created file, so don't spend an extra probe per tick. hostReadableExactPath = exactPath - } else if (Date.now() - lastWslTranslateAt >= FALLBACK_RESOLVE_POLL_MS) { - // Why: translating probes the UNC twin per distro over the 9P - // share, and the guest file usually appears well after the hook does, - // so retry on the slow cadence rather than every fast tick. The raw - // guest path is never installed on Windows — it would resolve against - // the current drive (`C:\home\…`) and bind chat to a look-alike file. - lastWslTranslateAt = Date.now() + } else if (wslSnapshot) { + // The raw guest path is never installed on Windows — it would resolve + // against the current drive (`C:\home\…`) and bind a look-alike file. hostReadableExactPath = await toHostReadableTranscriptPath(exactPath, { - signal: resolveController.signal + signal: resolveController.signal, + wslSnapshot }) } } @@ -172,14 +174,18 @@ function subscribeViaResolvePoll( resolveController.signal ) : null - if ( - !result && - (!exactPath || Date.now() - lastFallbackResolveAt >= FALLBACK_RESOLVE_POLL_MS) - ) { - lastFallbackResolveAt = Date.now() + if (!result && exactPathNeedsWslResolution) { + // A distro may stop after resolution; never retry a stale UNC root. + hostReadableExactPath = null + } + if (!result && fallbackDue && !exactPathNeedsWslResolution) { + lastFallbackResolveAt = now result = await attemptInstall(args, decode, resolveController.signal) } } catch (error) { + if (exactPathNeedsWslResolution) { + hostReadableExactPath = null + } // Why: a transient resolve failure (EACCES/EIO during the glob) must not // kill the poll loop with an unhandled rejection — retry like a miss. A // stalled WSL distro would otherwise poll silently forever, leaving the @@ -203,13 +209,21 @@ function subscribeViaResolvePoll( } if (result) { installed = result + stopWslObservation() + stopWslObservation = () => {} stopSettleTimer() return } scheduleAttempt() } - scheduleAttempt() + if (exactPathNeedsWslResolution) { + stopWslObservation = observeRunningWslDistros((runningDistros) => + runAttempt({ runningDistros: [...runningDistros] }) + ) + } else { + scheduleAttempt() + } return { watching: true, @@ -219,6 +233,8 @@ function subscribeViaResolvePoll( } closed = true resolveController.abort() + stopWslObservation() + stopWslObservation = () => {} stopSettleTimer() if (pollTimer) { clearTimeout(pollTimer) diff --git a/src/main/native-chat/transcript-watcher-count.ts b/src/main/native-chat/transcript-watcher-count.ts new file mode 100644 index 00000000000..03424917f0f --- /dev/null +++ b/src/main/native-chat/transcript-watcher-count.ts @@ -0,0 +1,5 @@ +let activeWatcherCount = 0 + +export const getActiveNativeChatWatcherCount = (): number => activeWatcherCount +export const trackActiveNativeChatWatcher = (delta: 1 | -1): void => + void (activeWatcherCount += delta) diff --git a/src/main/native-chat/transcript-watcher-install-probe.ts b/src/main/native-chat/transcript-watcher-install-probe.ts new file mode 100644 index 00000000000..2d3cf4858c6 --- /dev/null +++ b/src/main/native-chat/transcript-watcher-install-probe.ts @@ -0,0 +1,21 @@ +import { wslGatedStat } from './wsl-transcript-fs-access' +import { WslTranscriptFsError } from './wsl-transcript-fs-gate' +import { transcriptWatcherPathIsRunning } from './wsl-transcript-watcher-running-guard' + +export async function transcriptWatcherPathIsInstallable( + filePath: string, + signal?: AbortSignal +): Promise { + if (!(await transcriptWatcherPathIsRunning(filePath))) { + return false + } + try { + await wslGatedStat(filePath, 'exact', signal) + return true + } catch (error) { + if (error instanceof WslTranscriptFsError) { + throw error + } + return false + } +} diff --git a/src/main/native-chat/wsl-transcript-running-observer.test.ts b/src/main/native-chat/wsl-transcript-running-observer.test.ts new file mode 100644 index 00000000000..fc111698e70 --- /dev/null +++ b/src/main/native-chat/wsl-transcript-running-observer.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ listRunning: vi.fn() })) + +vi.mock('../wsl', () => ({ listRunningWslDistrosAsync: mocks.listRunning })) + +import { + observeWslTranscriptRunningState, + resetWslTranscriptRunningObserverForTests +} from './wsl-transcript-running-observer' + +describe('WSL transcript running observer', () => { + beforeEach(() => { + vi.useFakeTimers() + mocks.listRunning.mockReset().mockResolvedValue(['Ubuntu']) + }) + + afterEach(() => { + resetWslTranscriptRunningObserverForTests() + vi.useRealTimers() + }) + + it('shares one running-distro probe across staggered watchers', async () => { + const ubuntu = vi.fn() + const debian = vi.fn() + const stopUbuntu = observeWslTranscriptRunningState( + '\\\\wsl.localhost\\Ubuntu\\home\\ada\\a.jsonl', + () => ubuntu(true), + () => ubuntu(false) + ) + await vi.advanceTimersByTimeAsync(1_000) + const stopDebian = observeWslTranscriptRunningState( + '\\\\wsl.localhost\\Debian\\home\\ada\\b.jsonl', + () => debian(true), + () => debian(false) + ) + await vi.advanceTimersByTimeAsync(1_000) + + expect(mocks.listRunning).toHaveBeenCalledTimes(1) + expect(ubuntu).toHaveBeenCalledWith(true) + expect(debian).toHaveBeenCalledWith(false) + + await vi.advanceTimersByTimeAsync(2_000) + expect(mocks.listRunning).toHaveBeenCalledTimes(2) + stopUbuntu() + stopDebian() + }) + + it('coalesces slow subscription callbacks and retains the latest observation', async () => { + let finishFirst: (() => void) | undefined + const callback = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishFirst = resolve + }) + ) + .mockResolvedValue(undefined) + const stop = observeWslTranscriptRunningState( + '\\\\wsl.localhost\\Ubuntu\\home\\ada\\a.jsonl', + callback, + callback + ) + + await vi.advanceTimersByTimeAsync(2_000) + expect(callback).toHaveBeenCalledTimes(1) + + mocks.listRunning.mockResolvedValue([]) + await vi.advanceTimersByTimeAsync(6_000) + expect(mocks.listRunning).toHaveBeenCalledTimes(4) + expect(callback).toHaveBeenCalledTimes(1) + + finishFirst?.() + await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(2)) + stop() + }) +}) diff --git a/src/main/native-chat/wsl-transcript-running-observer.ts b/src/main/native-chat/wsl-transcript-running-observer.ts new file mode 100644 index 00000000000..6da593952d4 --- /dev/null +++ b/src/main/native-chat/wsl-transcript-running-observer.ts @@ -0,0 +1,108 @@ +import { listRunningWslDistrosAsync } from '../wsl' +import { filterPathsToWslDistros } from '../wsl-running-path-filter' + +const OBSERVATION_INTERVAL_MS = 2_000 +type RunningDistrosCallback = (runningDistros: readonly string[]) => Promise | void +type RunningDistrosSubscription = { + active: boolean + callback: RunningDistrosCallback + inFlight: boolean + pending: string[] | null +} + +const subscriptions = new Map() +let nextSubscriptionId = 0 +let timer: ReturnType | null = null + +function notify(subscription: RunningDistrosSubscription, runningDistros: string[]): void { + if (!subscription.active) { + return + } + if (subscription.inFlight) { + subscription.pending = runningDistros + return + } + subscription.inFlight = true + void (async () => { + let next: string[] | null = runningDistros + while (subscription.active && next) { + subscription.pending = null + try { + await subscription.callback(next) + } catch { + // A subscriber owns its retry/error policy; one failure must not stop observation. + } + next = subscription.pending + } + subscription.inFlight = false + })() +} + +async function observe(): Promise { + timer = null + if (subscriptions.size === 0) { + return + } + try { + const runningDistros = await listRunningWslDistrosAsync() + for (const subscription of subscriptions.values()) { + notify(subscription, runningDistros) + } + } catch { + // A transient list failure is an unknown state; retry without touching UNC paths. + } finally { + armObservation() + } +} + +function armObservation(): void { + if (timer || subscriptions.size === 0) { + return + } + timer = setTimeout(() => void observe(), OBSERVATION_INTERVAL_MS) + timer.unref?.() +} + +export function observeWslTranscriptRunningState( + path: string, + onRunning: () => Promise | void, + onStopped: () => Promise | void +): () => void { + return observeRunningWslDistros((runningDistros) => + filterPathsToWslDistros([path], runningDistros).length > 0 ? onRunning() : onStopped() + ) +} + +export function observeRunningWslDistros(callback: RunningDistrosCallback): () => void { + const id = ++nextSubscriptionId + const subscription: RunningDistrosSubscription = { + active: true, + callback, + inFlight: false, + pending: null + } + subscriptions.set(id, subscription) + armObservation() + return () => { + subscription.active = false + subscription.pending = null + subscriptions.delete(id) + if (subscriptions.size === 0 && timer) { + clearTimeout(timer) + timer = null + } + } +} + +export function resetWslTranscriptRunningObserverForTests(): void { + for (const subscription of subscriptions.values()) { + subscription.active = false + subscription.pending = null + } + subscriptions.clear() + if (timer) { + clearTimeout(timer) + timer = null + } + nextSubscriptionId = 0 +} diff --git a/src/main/native-chat/wsl-transcript-watcher-running-guard.ts b/src/main/native-chat/wsl-transcript-watcher-running-guard.ts new file mode 100644 index 00000000000..50d98689744 --- /dev/null +++ b/src/main/native-chat/wsl-transcript-watcher-running-guard.ts @@ -0,0 +1,46 @@ +import { isWslUncPath } from '../../shared/wsl-paths' +import { filterPathsToRunningWslDistrosAsync } from '../wsl-running-path-filter' +import { + createTranscriptNativeWatcher, + type TranscriptNativeWatcher +} from './transcript-native-watcher' + +export function isWslTranscriptWatcherPath(filePath: string): boolean { + return isWslUncPath(filePath) +} + +export async function transcriptWatcherPathIsRunning(filePath: string): Promise { + return ( + !isWslTranscriptWatcherPath(filePath) || + (await filterPathsToRunningWslDistrosAsync([filePath])).length > 0 + ) +} + +export function createRunningGuardedTranscriptNativeWatcher( + filePath: string, + onEvent: () => void, + onRetry: () => void +): TranscriptNativeWatcher { + const isWslPath = isWslTranscriptWatcherPath(filePath) + if (isWslPath) { + return { + bind: () => false, + dispose: () => {}, + invalidate: () => {}, + needsRebind: () => false + } + } + let watcher: TranscriptNativeWatcher + watcher = createTranscriptNativeWatcher( + filePath, + () => { + if (!watcher.needsRebind()) { + onEvent() + } + }, + () => { + onRetry() + } + ) + return watcher +} diff --git a/src/main/wsl-running-distro-cache.ts b/src/main/wsl-running-distro-cache.ts new file mode 100644 index 00000000000..436fbe419cb --- /dev/null +++ b/src/main/wsl-running-distro-cache.ts @@ -0,0 +1,64 @@ +import { wslDistroListRetryDelayMs } from './wsl-distro-retry' + +// Why: `listRunningWslDistrosAsync` polls on a 2s timer for as long as any WSL transcript +// tab is open (see wsl-transcript-running-observer.ts). Without a last-known-good fallback +// and backoff mirroring the full distro-list cache in wsl.ts, a persistently broken +// wsl.exe would make every WSL session vanish app-wide with no signal distinguishing +// "discovery broken" from "distro stopped", and would re-spawn wsl.exe every 2s forever. +let cache: string[] | null = null +let retryAfterMs = 0 +let failureStreak = 0 +let inFlightProbe: Promise | null = null + +function armRetryAfterFailure(): void { + const now = Date.now() + // Concurrent completions belong to the retry window already armed by the first result. + if (now < retryAfterMs) { + return + } + failureStreak += 1 + retryAfterMs = now + wslDistroListRetryDelayMs(failureStreak) +} + +/** + * Runs `probe` unless another probe is already in flight (shares its result) or a prior + * failure's retry window is still open (falls back to the last-known-good list instead). + * A successful probe — including a genuine empty result, i.e. no distros running — is + * authoritative and replaces the cache; only a probe *failure* (thrown/rejected) falls + * back to the cache and arms backoff. Bounds wsl.exe spawns under both IPC fan-out and a + * broken/degraded host. + */ +export function resolveRunningWslDistros(probe: () => Promise): Promise { + if (inFlightProbe) { + return inFlightProbe + } + if (Date.now() < retryAfterMs) { + return Promise.resolve(cache ?? []) + } + const result = probe() + .then((distros) => { + cache = distros + retryAfterMs = 0 + failureStreak = 0 + return cache + }) + .catch((error: unknown) => { + armRetryAfterFailure() + console.warn('[wsl] running-distro probe failed; falling back to last-known-good list', error) + return cache ?? [] + }) + .finally(() => { + if (inFlightProbe === result) { + inFlightProbe = null + } + }) + inFlightProbe = result + return result +} + +export function _resetRunningWslDistroCacheForTests(): void { + cache = null + retryAfterMs = 0 + failureStreak = 0 + inFlightProbe = null +} diff --git a/src/main/wsl-running-distros.test.ts b/src/main/wsl-running-distros.test.ts new file mode 100644 index 00000000000..049c2f2e379 --- /dev/null +++ b/src/main/wsl-running-distros.test.ts @@ -0,0 +1,210 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type * as childProcess from 'node:child_process' + +const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() })) + +vi.mock('child_process', async (importOriginal) => ({ + ...(await importOriginal()), + execFile: execFileMock +})) + +import { + _resetWslCachesForTests, + listRunningWslDistrosAsync, + listRunningWslHomeDirsAsync +} from './wsl' +import { filterPathsToRunningWslDistrosAsync } from './wsl-running-path-filter' +import { + observeWslTranscriptRunningState, + resetWslTranscriptRunningObserverForTests +} from './native-chat/wsl-transcript-running-observer' + +async function withPlatform(value: NodeJS.Platform, fn: () => Promise): Promise { + const original = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value }) + try { + return await fn() + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: original }) + } +} + +describe('running WSL distro discovery', () => { + afterEach(() => { + execFileMock.mockReset() + _resetWslCachesForTests() + resetWslTranscriptRunningObserverForTests() + vi.useRealTimers() + }) + + it('lists only running user distros without starting them', async () => { + execFileMock.mockImplementation((_command, _args, _options, callback) => { + callback(null, 'Ubuntu\u0000\nDocker-Desktop\u0000\n') + }) + + await withPlatform('win32', async () => { + await expect(listRunningWslDistrosAsync()).resolves.toEqual(['Ubuntu']) + expect(execFileMock).toHaveBeenCalledWith( + 'wsl.exe', + ['--list', '--running', '--quiet'], + expect.objectContaining({ + env: expect.objectContaining({ WSL_UTF8: '1' }), + timeout: 5000, + windowsHide: true + }), + expect.any(Function) + ) + }) + }) + + it('fails closed when running-distro discovery fails', async () => { + execFileMock.mockImplementation((_command, _args, _options, callback) => { + callback(new Error('wsl unavailable'), '') + }) + + await withPlatform('win32', async () => { + await expect(listRunningWslDistrosAsync()).resolves.toEqual([]) + }) + }) + + it('resolves homes only for the running distro set', async () => { + execFileMock.mockImplementation((_command, args, _options, callback) => { + callback(null, args.includes('--running') ? 'Ubuntu\n' : '/home/ada\n') + }) + + await withPlatform('win32', async () => { + await expect(listRunningWslHomeDirsAsync()).resolves.toEqual([ + '\\\\wsl.localhost\\Ubuntu\\home\\ada' + ]) + expect(execFileMock.mock.calls.map(([, args]) => args)).toEqual([ + ['--list', '--running', '--quiet'], + ['-d', 'Ubuntu', '--exec', 'bash', '-c', 'echo $HOME'] + ]) + }) + }) + + it('single-flights concurrent probes without caching the result', async () => { + let finishProbe: ((output: string) => void) | undefined + execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { + finishProbe = (output) => callback(null, output) + }) + + await withPlatform('win32', async () => { + const concurrent = [ + listRunningWslDistrosAsync(), + listRunningWslDistrosAsync(), + listRunningWslDistrosAsync() + ] + expect(execFileMock).toHaveBeenCalledTimes(1) + finishProbe?.('Ubuntu\n') + await expect(Promise.all(concurrent)).resolves.toEqual([['Ubuntu'], ['Ubuntu'], ['Ubuntu']]) + + execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { + callback(null, '') + }) + await expect(listRunningWslDistrosAsync()).resolves.toEqual([]) + expect(execFileMock).toHaveBeenCalledTimes(2) + }) + }) + + it('single-flights cold HOME probes across concurrent callers', async () => { + let finishList: ((output: string) => void) | undefined + let finishHome: ((output: string) => void) | undefined + execFileMock.mockImplementation((_command, args, _options, callback) => { + if (args.includes('--running')) { + finishList = (output) => callback(null, output) + } else { + finishHome = (output) => callback(null, output) + } + }) + + await withPlatform('win32', async () => { + const concurrent = [ + listRunningWslHomeDirsAsync(), + listRunningWslHomeDirsAsync(), + listRunningWslHomeDirsAsync() + ] + finishList?.('Ubuntu\n') + await vi.waitFor(() => expect(execFileMock).toHaveBeenCalledTimes(2)) + finishHome?.('/home/ada\n') + + await expect(Promise.all(concurrent)).resolves.toEqual([ + ['\\\\wsl.localhost\\Ubuntu\\home\\ada'], + ['\\\\wsl.localhost\\Ubuntu\\home\\ada'], + ['\\\\wsl.localhost\\Ubuntu\\home\\ada'] + ]) + expect( + execFileMock.mock.calls.filter(([, args]) => args.includes('echo $HOME')) + ).toHaveLength(1) + }) + }) + + it('filters stopped-distro UNC paths while preserving host paths', async () => { + execFileMock.mockImplementation((_command, _args, _options, callback) => { + callback(null, 'Ubuntu\n') + }) + + await withPlatform('win32', async () => { + await expect( + filterPathsToRunningWslDistrosAsync([ + 'C:\\Users\\ada\\codex-home', + '\\\\wsl.localhost\\Ubuntu\\home\\ada', + '\\\\wsl.localhost\\Debian\\home\\other' + ]) + ).resolves.toEqual(['C:\\Users\\ada\\codex-home', '\\\\wsl.localhost\\Ubuntu\\home\\ada']) + }) + }) + + it('does not probe WSL on non-Windows hosts', async () => { + await withPlatform('linux', async () => { + await expect(listRunningWslDistrosAsync()).resolves.toEqual([]) + expect(execFileMock).not.toHaveBeenCalled() + }) + }) + + // Consumer-level: what a live transcript watcher sees when wsl.exe stays broken across + // a whole polling session, not just a single failed call. + it('keeps reporting a session running through a sustained wsl.exe outage, without unbounded spawns', async () => { + let spawnCount = 0 + execFileMock.mockImplementation((_command, _args, _options, callback) => { + spawnCount += 1 + callback(null, 'Ubuntu\n') + }) + + await withPlatform('win32', async () => { + // Seed a real last-known-good answer before the outage starts. + await expect(listRunningWslDistrosAsync()).resolves.toEqual(['Ubuntu']) + expect(spawnCount).toBe(1) + + // wsl.exe now fails on every call — a persistent, not transient, break. + execFileMock.mockImplementation((_command, _args, _options, callback) => { + spawnCount += 1 + callback(new Error('wsl unavailable'), '') + }) + + vi.useFakeTimers() + const observedStates: boolean[] = [] + const stop = observeWslTranscriptRunningState( + '\\\\wsl.localhost\\Ubuntu\\home\\ada\\a.jsonl', + () => { + observedStates.push(true) + }, + () => { + observedStates.push(false) + } + ) + + // 30 minutes of the 2s transcript-watcher poll (~900 ticks) against a broken wsl.exe. + await vi.advanceTimersByTimeAsync(30 * 60_000) + stop() + + // A live session must never be reported as stopped just because discovery is broken — + // that would make every open WSL transcript vanish out from under the user. + expect(observedStates.length).toBeGreaterThan(0) + expect(observedStates.every((state) => state === true)).toBe(true) + + // Backoff must keep the real wsl.exe spawn count far below one per 2s poll tick. + expect(spawnCount).toBeLessThan(15) + }) + }) +}) diff --git a/src/main/wsl-running-path-filter.ts b/src/main/wsl-running-path-filter.ts new file mode 100644 index 00000000000..0ba6694b82f --- /dev/null +++ b/src/main/wsl-running-path-filter.ts @@ -0,0 +1,23 @@ +import { parseWslUncPath } from '../shared/wsl-paths' +import { listRunningWslDistrosAsync } from './wsl' + +export function filterPathsToWslDistros( + paths: readonly string[], + distros: readonly string[] +): string[] { + const allowed = new Set(distros.map((distro) => distro.toLowerCase())) + return paths.filter((candidate) => { + const parsed = parseWslUncPath(candidate) + return !parsed || allowed.has(parsed.distro.toLowerCase()) + }) +} + +/** Keep host paths and WSL paths whose distro is running now. */ +export async function filterPathsToRunningWslDistrosAsync( + paths: readonly string[] +): Promise { + if (process.platform !== 'win32') { + return [...paths] + } + return filterPathsToWslDistros(paths, await listRunningWslDistrosAsync()) +} diff --git a/src/main/wsl.ts b/src/main/wsl.ts index e09fbe02e4b..be20f2b96ee 100644 --- a/src/main/wsl.ts +++ b/src/main/wsl.ts @@ -7,6 +7,10 @@ import { _setWslAvailabilityCacheForTests, dropStaleWslAvailabilityFailure } from './wsl-availability' +import { + _resetRunningWslDistroCacheForTests, + resolveRunningWslDistros +} from './wsl-running-distro-cache' // Why re-exported rather than defined here: the relay bundle needs the path // conversion without this module's distro-probing subprocess graph. @@ -122,6 +126,7 @@ export function wslUncDirectoryExistsAsync(uncPath: string): Promise() +const wslHomeProbeCache = new Map>() let wslDistroCache: string[] | null = null // Why: a wsl.exe failure must stay retryable (a transient error would // otherwise hide every distro until restart), but repeated failures cannot @@ -231,6 +236,20 @@ export async function listWslDistrosAsync(): Promise { } } +/** Running user distros only — see `resolveRunningWslDistros` for the fallback/backoff and + * single-flight contract shared by every caller. */ +export async function listRunningWslDistrosAsync(): Promise { + if (process.platform !== 'win32') { + return [] + } + return resolveRunningWslDistros(() => + execFileUtf8('wsl.exe', ['--list', '--running', '--quiet'], { + ...process.env, + WSL_UTF8: '1' + }).then((output) => filterUserWslDistros(parseWslDistros(output))) + ) +} + export function hasCachedWslDistros(): boolean { return wslDistroCache !== null } @@ -290,31 +309,48 @@ export async function getWslHomeAsync(distro: string): Promise { if (wslHomeCache.has(distro)) { return wslHomeCache.get(distro)! } - - try { - const home = ( - await execFileUtf8('wsl.exe', ['-d', distro, '--exec', 'bash', '-c', 'echo $HOME']) - ).trim() - - if (!home || !home.startsWith('/')) { - return null - } - - const uncPath = toWindowsWslPath(home, distro) - wslHomeCache.set(distro, uncPath) - return uncPath - } catch { - return null + const inflight = wslHomeProbeCache.get(distro) + if (inflight) { + return inflight } + + const probe = execFileUtf8('wsl.exe', ['-d', distro, '--exec', 'bash', '-c', 'echo $HOME']) + .then((output) => { + const home = output.trim() + if (!home || !home.startsWith('/')) { + return null + } + const uncPath = toWindowsWslPath(home, distro) + wslHomeCache.set(distro, uncPath) + return uncPath + }) + .catch(() => null) + .finally(() => { + if (wslHomeProbeCache.get(distro) === probe) { + wslHomeProbeCache.delete(distro) + } + }) + wslHomeProbeCache.set(distro, probe) + return probe +} + +/** UNC home roots for distros that are running at discovery time. */ +export async function listRunningWslHomeDirsAsync(): Promise { + const homes = await Promise.all( + (await listRunningWslDistrosAsync()).map((distro) => getWslHomeAsync(distro)) + ) + return homes.filter((home): home is string => Boolean(home)) } export function _resetWslCachesForTests(): void { wslHomeCache.clear() + wslHomeProbeCache.clear() wslDistroCache = null wslDistroListRetryAfterMs = 0 wslDistroListEmptyStreak = 0 wslDistroProbeSequence = 0 wslDistroCacheSequence = 0 + _resetRunningWslDistroCacheForTests() _resetWslAvailabilityCacheForTests() } @@ -339,12 +375,12 @@ export function _setWslCachesForTests(args: { } } -function execFileUtf8(command: string, args: string[]): Promise { +function execFileUtf8(command: string, args: string[], env?: NodeJS.ProcessEnv): Promise { return new Promise((resolve, reject) => { execFile( command, args, - { encoding: 'utf-8', timeout: 5000, windowsHide: true }, + { encoding: 'utf-8', env, timeout: 5000, windowsHide: true }, (error, stdout) => { if (error) { reject(error)