fix(native-chat): stop the WSL transcript probe firing every poll tick

Three follow-ups from review of the WSL Codex transcript fix:

- The UNC translation was retried on every fast resolve-poll tick (measured 10
  sync UNC stats per 100ms) because only a successful result was memoized. Gate
  the retry to the slow fallback cadence.
- A non-empty WSL home list was cached for the process lifetime, so a distro
  that was still booting during the first probe stayed excluded forever. Expire
  both branches; getWslHomeAsync caches successes, so a refresh only re-spawns
  wsl.exe for the distros that actually failed.
- codexSessionsDirs enumerated every distro's home eagerly, waking distros the
  user left stopped even for native-Windows panes. Make the WSL roots a lazy
  tier consulted only after the host's own Codex roots miss.

The resolve-poll suite became platform-dependent and only passed off Windows;
pin the platform and add explicit win32 coverage.
This commit is contained in:
OrcaWin
2026-08-08 03:43:34 -07:00
parent b7df31adea
commit d6d99b6a34
6 changed files with 174 additions and 22 deletions
@@ -135,6 +135,37 @@ describe('toHostReadableTranscriptPath', () => {
await wslCodexSessionsDirs({ platform: 'win32', listWslHomeDirs })
expect(listWslHomeDirs).toHaveBeenCalledTimes(1)
})
it('re-probes after the TTL so a distro that was booting is not excluded forever', async () => {
// Why: getWslHomeAsync returns null for a distro whose 5s $HOME probe timed
// out on a cold boot. Latching that partial list for the process lifetime
// would leave that distro's transcripts permanently unresolvable (#10326).
vi.useFakeTimers()
try {
const listWslHomeDirs = vi
.fn<() => Promise<string[]>>()
.mockResolvedValueOnce([UBUNTU_HOME])
.mockResolvedValue([UBUNTU_HOME, DEBIAN_HOME])
const debianRollout = '\\\\wsl.localhost\\Debian\\home\\other\\x.jsonl'
const call = (): Promise<string | null> =>
toHostReadableTranscriptPath('/home/other/x.jsonl', {
platform: 'win32',
pathExists: (candidate) => candidate === debianRollout,
listWslHomeDirs
})
await expect(call()).resolves.toBeNull()
await expect(call()).resolves.toBeNull()
expect(listWslHomeDirs).toHaveBeenCalledTimes(1)
vi.setSystemTime(Date.now() + 6 * 60_000)
await expect(call()).resolves.toBe(debianRollout)
expect(listWslHomeDirs).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
}
})
})
describe('wslCodexSessionsDirs', () => {
@@ -39,6 +39,11 @@ export type HostReadableTranscriptPathDeps = {
// 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.
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
// is retried; getWslHomeAsync caches successes, so a refresh only re-spawns
// wsl.exe for the distros that actually failed.
const WSL_HOME_DIRS_TTL_MS = 5 * 60_000
let cachedWslHomeDirs: string[] | null = null
let cachedWslHomeDirsExpiresAt = 0
let inflightWslHomeDirs: Promise<string[]> | null = null
@@ -51,10 +56,7 @@ async function defaultListWslHomeDirs(): Promise<string[]> {
}
async function wslHomeDirs(load: () => Promise<string[]>): Promise<string[]> {
if (
cachedWslHomeDirs &&
(cachedWslHomeDirs.length > 0 || Date.now() < cachedWslHomeDirsExpiresAt)
) {
if (cachedWslHomeDirs && Date.now() < cachedWslHomeDirsExpiresAt) {
return cachedWslHomeDirs
}
if (inflightWslHomeDirs) {
@@ -64,7 +66,8 @@ async function wslHomeDirs(load: () => Promise<string[]>): Promise<string[]> {
.catch(() => [] as string[])
.then((dirs) => {
cachedWslHomeDirs = dirs
cachedWslHomeDirsExpiresAt = Date.now() + WSL_HOME_DIRS_EMPTY_RETRY_MS
cachedWslHomeDirsExpiresAt =
Date.now() + (dirs.length > 0 ? WSL_HOME_DIRS_TTL_MS : WSL_HOME_DIRS_EMPTY_RETRY_MS)
inflightWslHomeDirs = null
return dirs
})
@@ -15,24 +15,31 @@ vi.mock('../wsl', () => ({
// Only WSL UNC paths are readable; the guest Linux path is not (as on a real
// Windows host, where it would misresolve against the current drive).
const fsState = vi.hoisted(() => ({ existsAll: false }))
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof NodeFsModule>()
return {
...actual,
existsSync: (path: string) => path.startsWith('\\\\wsl.localhost\\') || actual.existsSync(path)
existsSync: (path: string) =>
fsState.existsAll || path.startsWith('\\\\wsl.localhost\\') || actual.existsSync(path)
}
})
const scanned = vi.hoisted(() => ({ dirs: [] as string[] }))
const HOST_ROLLOUT = 'C:\\host\\sessions\\rollout-wsl-sess.jsonl'
const scanned = vi.hoisted(() => ({ dirs: [] as string[], hostRootHasRollout: false }))
vi.mock('../ai-vault/session-scanner-discovery', () => ({
walkSessionFiles: async (dir: string) => {
scanned.dirs.push(dir)
return []
const isWslRoot = dir.startsWith('\\\\wsl.localhost\\')
return scanned.hostRootHasRollout && !isWslRoot
? ['C:\\host\\sessions\\rollout-wsl-sess.jsonl']
: []
}
}))
import { resetHostReadableTranscriptPathCacheForTests } from './host-readable-transcript-path'
import { resolveSessionFilePath } from './session-file-resolver'
import { getWslHomeAsync, listWslDistrosAsync } from '../wsl'
const realPlatform = process.platform
@@ -42,7 +49,11 @@ function setPlatform(platform: NodeJS.Platform): void {
beforeEach(() => {
resetHostReadableTranscriptPathCacheForTests()
vi.mocked(getWslHomeAsync).mockClear()
vi.mocked(listWslDistrosAsync).mockClear()
scanned.dirs = []
scanned.hostRootHasRollout = false
fsState.existsAll = false
setPlatform('win32')
})
@@ -65,6 +76,19 @@ describe('resolveSessionFilePath on a Windows host with WSL', () => {
expect(scanned.dirs).toContain(`${UBUNTU_HOME}\\.codex\\sessions`)
})
it('does not enumerate WSL distros when a host Codex root already has the rollout', async () => {
// Why: listing WSL homes spawns wsl.exe per distro, which boots distros the
// user deliberately left stopped. It must stay a last resort.
fsState.existsAll = true
scanned.hostRootHasRollout = true
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()
})
it('leaves the guest path alone on non-Windows hosts', async () => {
setPlatform('darwin')
const resolved = await resolveSessionFilePath('codex', 'wsl-sess', {
+27 -7
View File
@@ -25,14 +25,12 @@ function claudeProjectsDir(): string {
// `<managed home>/sessions`, NOT `~/.codex/sessions`. Search the managed home
// first (that's where this main process's Codex sessions actually live), then
// fall back to CODEX_HOME/~/.codex so a non-Orca Codex transcript still resolves.
// On Windows also search each WSL distro's managed + system Codex homes last, so
// a WSL-hosted session still resolves when the hook path is Linux-only (#10326).
// Duplicates are filtered so a managed-home symlink to ~/.codex isn't scanned twice.
async function codexSessionsDirs(): Promise<string[]> {
// WSL roots are a separate lazy tier — see resolveCodexSessionFile.
function codexSessionsDirs(): string[] {
const candidates = [
join(getOrcaManagedCodexHomePath(), 'sessions'),
join(process.env.CODEX_HOME?.trim() || join(homedir(), '.codex'), 'sessions'),
...(await wslCodexSessionsDirs())
join(process.env.CODEX_HOME?.trim() || join(homedir(), '.codex'), 'sessions')
]
return candidates.filter((dir, index) => candidates.indexOf(dir) === index)
}
@@ -96,9 +94,13 @@ export async function resolveSessionFilePath(
return resolveClaudeSessionFile(trimmedId, options.claudeProjectsDir ?? claudeProjectsDir())
}
if (transcriptAgent === 'codex') {
const overrideDirs = options.codexSessionsDirs
return resolveCodexSessionFile(
trimmedId,
options.codexSessionsDirs ?? (await codexSessionsDirs())
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
)
}
if (transcriptAgent === 'grok') {
@@ -121,11 +123,29 @@ async function resolveClaudeSessionFile(
async function resolveCodexSessionFile(
sessionId: string,
sessionsDirs: string[]
sessionsDirs: string[],
loadFallbackDirs?: () => Promise<string[]>
): Promise<string | null> {
// Codex rollout file names embed the session id (rollout-<ts>-<id>.jsonl), so
// match the id as a suffix of the file's base name rather than an exact name.
// Search each candidate root (managed home first) and stop at the first match.
const hit = await findCodexRolloutInDirs(sessionId, sessionsDirs)
if (hit) {
return hit
}
if (!loadFallbackDirs) {
return null
}
// Why: a WSL-hosted session's rollout only exists inside the distro, so fall
// back to each distro's Codex homes when the host's own roots came up empty (#10326).
const fallbackDirs = (await loadFallbackDirs()).filter((dir) => !sessionsDirs.includes(dir))
return findCodexRolloutInDirs(sessionId, fallbackDirs)
}
async function findCodexRolloutInDirs(
sessionId: string,
sessionsDirs: string[]
): Promise<string | null> {
for (const sessionsDir of sessionsDirs) {
if (!existsSync(sessionsDir)) {
continue
@@ -1,8 +1,10 @@
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(),
resolve: vi.fn()
resolve: vi.fn(),
toHostReadable: vi.fn()
}))
vi.mock('./session-file-resolver', () => ({
@@ -12,17 +14,35 @@ 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
}))
import { subscribeNativeChatTranscript } from './transcript-watch'
const realPlatform = process.platform
function setPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', { value: platform, configurable: true })
}
describe('native chat transcript resolve polling', () => {
beforeEach(() => {
vi.useFakeTimers()
mocks.install.mockReset().mockReturnValue(null)
mocks.resolve.mockReset().mockResolvedValue(null)
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
// host's — otherwise these cases only hold on non-Windows machines.
setPlatform('linux')
})
afterEach(() => vi.useRealTimers())
afterEach(() => {
setPlatform(realPlatform)
vi.useRealTimers()
})
it('fast-probes an exact hook path without repeatedly scanning the session tree', async () => {
const subscription = await subscribeNativeChatTranscript({
@@ -47,6 +67,51 @@ 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 sync-stats the UNC twin per distro over the 9P
// share; doing it every fast tick would hammer the main process (#10326).
setPlatform('win32')
const subscription = await subscribeNativeChatTranscript({
agent: 'codex',
sessionId: 'session-id',
transcriptPath: '/home/ada/.codex/sessions/rollout-session-id.jsonl',
resolvePollIntervalMs: 10,
onAppend: () => {}
})
await vi.advanceTimersByTimeAsync(100)
expect(mocks.toHostReadable).toHaveBeenCalledTimes(1)
expect(mocks.install.mock.calls.some(([filePath]) => String(filePath).startsWith('/'))).toBe(
false
)
await vi.advanceTimersByTimeAsync(5_100)
expect(mocks.toHostReadable).toHaveBeenCalledTimes(2)
subscription.unsubscribe()
})
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'
mocks.toHostReadable.mockResolvedValue(unc)
const subscription = await subscribeNativeChatTranscript({
agent: 'codex',
sessionId: 'session-id',
transcriptPath: '/home/ada/.codex/sessions/rollout-session-id.jsonl',
resolvePollIntervalMs: 10,
onAppend: () => {}
})
await vi.advanceTimersByTimeAsync(100)
expect(mocks.install.mock.calls.some(([filePath]) => filePath === unc)).toBe(true)
// Memoized: a successful translation is not re-probed on later ticks.
expect(mocks.toHostReadable).toHaveBeenCalledTimes(1)
subscription.unsubscribe()
})
it('keeps resolving on every retry when no exact hook path is available', async () => {
const subscription = await subscribeNativeChatTranscript({
agent: 'claude',
+14 -5
View File
@@ -68,6 +68,7 @@ function subscribeViaResolvePoll(
// 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
function scheduleAttempt(): void {
if (closed) {
@@ -100,11 +101,19 @@ function subscribeViaResolvePoll(
let result: NativeChatTranscriptSubscription | null
try {
if (exactPath && !hostReadableExactPath) {
// Non-WSL paths stay raw: installTranscriptWatcher already handles a
// not-yet-created file, so don't spend an extra probe per tick.
hostReadableExactPath = needsWslHostTranslation(exactPath)
? await toHostReadableTranscriptPath(exactPath)
: exactPath
if (!needsWslHostTranslation(exactPath)) {
// 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 sync-stats 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()
hostReadableExactPath = await toHostReadableTranscriptPath(exactPath)
}
}
result = hostReadableExactPath
? await attemptInstall({ ...args, filePath: hostReadableExactPath }, decode)