From d6d99b6a347d760c8250ac80162136b93d336434 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 8 Aug 2026 03:43:34 -0700 Subject: [PATCH] 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. --- .../host-readable-transcript-path.test.ts | 31 +++++++++ .../host-readable-transcript-path.ts | 13 ++-- .../session-file-resolver-wsl.test.ts | 30 +++++++- src/main/native-chat/session-file-resolver.ts | 34 +++++++-- .../transcript-watch-resolve-poll.test.ts | 69 ++++++++++++++++++- src/main/native-chat/transcript-watch.ts | 19 +++-- 6 files changed, 174 insertions(+), 22 deletions(-) diff --git a/src/main/native-chat/host-readable-transcript-path.test.ts b/src/main/native-chat/host-readable-transcript-path.test.ts index 6e0ec5edaac..97513c78838 100644 --- a/src/main/native-chat/host-readable-transcript-path.test.ts +++ b/src/main/native-chat/host-readable-transcript-path.test.ts @@ -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>() + .mockResolvedValueOnce([UBUNTU_HOME]) + .mockResolvedValue([UBUNTU_HOME, DEBIAN_HOME]) + + const debianRollout = '\\\\wsl.localhost\\Debian\\home\\other\\x.jsonl' + const call = (): Promise => + 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', () => { diff --git a/src/main/native-chat/host-readable-transcript-path.ts b/src/main/native-chat/host-readable-transcript-path.ts index c45d20c6721..c9ed265dc7b 100644 --- a/src/main/native-chat/host-readable-transcript-path.ts +++ b/src/main/native-chat/host-readable-transcript-path.ts @@ -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 | null = null @@ -51,10 +56,7 @@ async function defaultListWslHomeDirs(): Promise { } async function wslHomeDirs(load: () => Promise): Promise { - 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): Promise { .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 }) diff --git a/src/main/native-chat/session-file-resolver-wsl.test.ts b/src/main/native-chat/session-file-resolver-wsl.test.ts index cd810d061eb..6195fca93ca 100644 --- a/src/main/native-chat/session-file-resolver-wsl.test.ts +++ b/src/main/native-chat/session-file-resolver-wsl.test.ts @@ -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() 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', { diff --git a/src/main/native-chat/session-file-resolver.ts b/src/main/native-chat/session-file-resolver.ts index 49bd43b20d9..15eb094bbbb 100644 --- a/src/main/native-chat/session-file-resolver.ts +++ b/src/main/native-chat/session-file-resolver.ts @@ -25,14 +25,12 @@ function claudeProjectsDir(): string { // `/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 { +// 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 ): Promise { // Codex rollout file names embed the session id (rollout--.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 { for (const sessionsDir of sessionsDirs) { if (!existsSync(sessionsDir)) { continue diff --git a/src/main/native-chat/transcript-watch-resolve-poll.test.ts b/src/main/native-chat/transcript-watch-resolve-poll.test.ts index be4e60dede3..3dd33659656 100644 --- a/src/main/native-chat/transcript-watch-resolve-poll.test.ts +++ b/src/main/native-chat/transcript-watch-resolve-poll.test.ts @@ -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()), + 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', diff --git a/src/main/native-chat/transcript-watch.ts b/src/main/native-chat/transcript-watch.ts index b35f1eb8c34..cc02c70c36d 100644 --- a/src/main/native-chat/transcript-watch.ts +++ b/src/main/native-chat/transcript-watch.ts @@ -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)