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>
This commit is contained in:
OrcaWin
2026-08-30 01:49:05 -07:00
committed by GitHub
co-authored by OrcaWin Neil
parent 58a52a8ce3
commit e741ff1318
30 changed files with 1386 additions and 212 deletions
+1
View File
@@ -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",
+6 -23
View File
@@ -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<boolean> {
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(
+32 -3
View File
@@ -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()
})
})
+12 -8
View File
@@ -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<string[]> {
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
@@ -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',
+2 -2
View File
@@ -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', () => ({
@@ -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([])
})
})
@@ -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<WslTranscriptResolutionSnapshot> {
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<string[]> {
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<boolean>
signal?: AbortSignal
/** Each installed WSL distro's `$HOME` as a Windows UNC path. */
listWslHomeDirs?: () => Promise<string[]>
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<bool
}
}
// Why: resolveSessionFilePath runs on a 500ms5s poll loop. listWslDistrosAsync
// caches, but getWslHomeAsync does NOT cache failures, so a cold/stopped distro
// would re-spawn wsl.exe on every tick. Cache the composed answer here instead.
// Test/caller-provided home loaders are cached across resolve ticks. Production
// discovery is revalidated separately so a stale UNC root cannot restart WSL.
const WSL_HOME_DIRS_EMPTY_RETRY_MS = 30_000
// Why: a distro that was booting when we first probed resolves to no $HOME and
// would otherwise be excluded for the whole session. Both branches expire so it
@@ -81,10 +119,11 @@ export function configureHostReadableTranscriptPathSources(options: {
}
async function defaultListWslHomeDirs(): Promise<string[]> {
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<string[]>): Promise<string[]> {
return load ? wslHomeDirs(load) : defaultListWslHomeDirs()
}
async function wslHomeDirs(load: () => Promise<string[]>): Promise<string[]> {
@@ -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<HostReadableTranscriptPathDeps, 'platform' | 'listWslHomeDirs'> = {}
deps: Pick<HostReadableTranscriptPathDeps, 'platform' | 'listWslHomeDirs' | 'wslSnapshot'> = {}
): Promise<string[]> {
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'))
}
@@ -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<typeof WslModule>()),
listRunningWslHomeDirsAsync: vi.fn(async () => [])
}))
vi.mock('../wsl-running-path-filter', async (importOriginal) => ({
...(await importOriginal<typeof WslRunningPathFilterModule>()),
filterPathsToRunningWslDistrosAsync: vi.fn(async (paths: readonly string[]) => [...paths])
}))
const scanned = vi.hoisted(() => ({ dirs: [] as string[] }))
@@ -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<typeof WslRunningPathFilterModule>()),
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 () => {
@@ -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<typeof WslRunningPathFilterModule>()),
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 () => {
+26 -4
View File
@@ -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
)
}
@@ -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
@@ -0,0 +1,14 @@
import type { SubscribeNativeChatTranscriptArgs } from './transcript-watch-contract'
type InitialSnapshotCallback = NonNullable<SubscribeNativeChatTranscriptArgs['onInitialSnapshot']>
export function emitTranscriptUnavailableSnapshot(
onInitialSnapshot: InitialSnapshotCallback | undefined,
message = 'Transcript unavailable'
): boolean {
if (!onInitialSnapshot) {
return false
}
onInitialSnapshot([], false, 0, message)
return true
}
@@ -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> | 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<typeof NodeFsPromisesModule>()),
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<string[]>((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()
})
})
+54 -63
View File
@@ -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<NativeChatTranscriptSubscription | null> {
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<void> {
@@ -246,10 +222,16 @@ export async function installTranscriptWatcher(
await finishSuccessfulDrain(current)
}
async function drain(): Promise<void> {
async function drain(runningChecked = false): Promise<void> {
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<void> {
async function reconcileKnownRunning(): Promise<void> {
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)
}
}
}
@@ -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<typeof vi.fn> })[],
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<typeof TranscriptTailReader>('./transcript-tail-reader')
return {
...actual,
readNativeChatTranscriptTailFile: (
...args: Parameters<typeof actual.readNativeChatTranscriptTailFile>
) => {
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(() =>
@@ -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> | 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<typeof HostReadableTranscriptPathModule>()),
toHostReadableTranscriptPath: mocks.toHostReadable
vi.mock('./host-readable-transcript-path', async (importOriginal) => {
const actual = await importOriginal<typeof HostReadableTranscriptPathModule>()
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 () => {
@@ -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<typeof WslRunningPathFilterModule>()),
filterPathsToRunningWslDistrosAsync: mocks.filterPathsToRunningWslDistrosAsync
}))
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof NodeFsPromisesModule>()
@@ -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()
}
})
})
@@ -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,
+38 -22
View File
@@ -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<typeof setTimeout> | 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<void> {
if (closed) {
async function runAttempt(wslSnapshot?: WslTranscriptResolutionSnapshot): Promise<void> {
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)
@@ -0,0 +1,5 @@
let activeWatcherCount = 0
export const getActiveNativeChatWatcherCount = (): number => activeWatcherCount
export const trackActiveNativeChatWatcher = (delta: 1 | -1): void =>
void (activeWatcherCount += delta)
@@ -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<boolean> {
if (!(await transcriptWatcherPathIsRunning(filePath))) {
return false
}
try {
await wslGatedStat(filePath, 'exact', signal)
return true
} catch (error) {
if (error instanceof WslTranscriptFsError) {
throw error
}
return false
}
}
@@ -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<void>((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()
})
})
@@ -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> | void
type RunningDistrosSubscription = {
active: boolean
callback: RunningDistrosCallback
inFlight: boolean
pending: string[] | null
}
const subscriptions = new Map<number, RunningDistrosSubscription>()
let nextSubscriptionId = 0
let timer: ReturnType<typeof setTimeout> | 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<void> {
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> | void,
onStopped: () => Promise<void> | 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
}
@@ -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<boolean> {
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
}
+64
View File
@@ -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<string[]> | 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<string[]>): Promise<string[]> {
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
}
+210
View File
@@ -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<typeof childProcess>()),
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<T>(value: NodeJS.Platform, fn: () => Promise<T>): Promise<T> {
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)
})
})
})
+23
View File
@@ -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<string[]> {
if (process.platform !== 'win32') {
return [...paths]
}
return filterPathsToWslDistros(paths, await listRunningWslDistrosAsync())
}
+53 -17
View File
@@ -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<boolean | n
// ─── WSL home directory resolution ──────────────────────────────────
const wslHomeCache = new Map<string, string>()
const wslHomeProbeCache = new Map<string, Promise<string | null>>()
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<string[]> {
}
}
/** Running user distros only — see `resolveRunningWslDistros` for the fallback/backoff and
* single-flight contract shared by every caller. */
export async function listRunningWslDistrosAsync(): Promise<string[]> {
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<string | null> {
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<string[]> {
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<string> {
function execFileUtf8(command: string, args: string[], env?: NodeJS.ProcessEnv): Promise<string> {
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)