mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 500ms–5s 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 () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user