fix(wsl): close the session scanner's post-stop boot gaps (#35729)

* fix(wsl): close the session scanner's post-stop boot gaps

An OpenCode/ZCode WSL reader that exits notes its distro may have
stopped, so a respawn needs a settled running read. The Devin
sessions.db opens only on a running list from the transcript lease.
Queued transcript reads re-admit right before they run, and reads on
open 9P handles re-admit too. WSL roots and extra Codex homes come
from a confirmed running list (E9).

* fix(wsl): a failed running-list probe keeps WSL sessions and delete roots

Background discovery keeps confirmed-only WSL roots, but an unconfirmed
probe now carries over the last confirmed WSL rows (marked partial)
instead of dropping them. Path checks for user acts (delete, subagents,
transcript lookup) use last-known-good roots, since checking a path
against a root reads nothing.
This commit is contained in:
Neil
2026-10-29 00:22:47 -07:00
committed by GitHub
parent f2c97a1ced
commit 25f4b45dc4
11 changed files with 498 additions and 55 deletions
@@ -0,0 +1,48 @@
import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types'
import { isWslUncPath } from '../../shared/wsl-paths'
// The WSL rows of the last scan whose WSL roots a confirmed running list named, per scope key.
const lastConfirmedWslSessions = new Map<string, AiVaultSession[]>()
const isWslSession = (session: AiVaultSession): boolean => isWslUncPath(session.filePath)
/**
* A failed running-list probe says nothing about WSL: keep the last confirmed WSL rows, marked
* partial, rather than letting every WSL session vanish until the probe recovers.
*/
export function carryOverUnconfirmedWslSessions(
scopeKey: string,
result: AiVaultListResult,
wslConfirmed: boolean
): AiVaultListResult {
if (wslConfirmed) {
lastConfirmedWslSessions.set(scopeKey, result.sessions.filter(isWslSession))
return result
}
const present = new Set(result.sessions.map((session) => session.id))
const carried = (lastConfirmedWslSessions.get(scopeKey) ?? []).filter(
(session) => !present.has(session.id)
)
if (carried.length === 0) {
return result
}
return {
...result,
sessions: [...result.sessions, ...carried].sort((a, b) =>
b.modifiedAt.localeCompare(a.modifiedAt)
),
issues: [
...result.issues,
{
agent: carried[0]?.agent ?? 'codex',
kind: 'notice',
path: '',
message: 'WSL could not be checked; its sessions are from the last scan.'
}
]
}
}
export function resetAiVaultWslSessionCarryover(): void {
lastConfirmedWslSessions.clear()
}
+107 -9
View File
@@ -14,13 +14,17 @@ const {
filterPathsToRunningWslDistrosAsync,
getCachedWslDistros,
hasCachedWslDistros,
listRunningWslHomeDirsUnlessStopped,
readRunningWslHomeDirsUnlessStopped,
listLastKnownRunningWslHomeDirs,
scanAiVaultSessionsInService
} = vi.hoisted(() => ({
filterPathsToRunningWslDistrosAsync: vi.fn(async (paths: readonly string[]) => [...paths]),
getCachedWslDistros: vi.fn((): string[] | null => null),
hasCachedWslDistros: vi.fn(() => false),
listRunningWslHomeDirsUnlessStopped: vi.fn().mockResolvedValue([]),
readRunningWslHomeDirsUnlessStopped: vi.fn(
async (): Promise<{ homes: string[]; confirmed: boolean }> => ({ homes: [], confirmed: true })
),
listLastKnownRunningWslHomeDirs: vi.fn(async (): Promise<string[]> => []),
scanAiVaultSessionsInService: vi.fn()
}))
@@ -32,7 +36,10 @@ vi.mock('../wsl', () => ({
getCachedWslDistros,
hasCachedWslDistros
}))
vi.mock('../wsl/wsl-home-unless-stopped', () => ({ listRunningWslHomeDirsUnlessStopped }))
vi.mock('../wsl/wsl-home-unless-stopped', () => ({
readRunningWslHomeDirsUnlessStopped,
listLastKnownRunningWslHomeDirs
}))
vi.mock('../wsl-running-path-filter', () => ({ filterPathsToRunningWslDistrosAsync }))
import {
@@ -72,7 +79,10 @@ describe('invalidateAiVaultSessionListCache generation guard', () => {
filterPathsToRunningWslDistrosAsync.mockClear()
getCachedWslDistros.mockReset().mockReturnValue(null)
hasCachedWslDistros.mockReset().mockReturnValue(false)
listRunningWslHomeDirsUnlessStopped.mockReset().mockResolvedValue([])
readRunningWslHomeDirsUnlessStopped
.mockReset()
.mockResolvedValue({ homes: [], confirmed: true })
listLastKnownRunningWslHomeDirs.mockReset().mockResolvedValue([])
scanAiVaultSessionsInService.mockReset()
})
afterEach(() => {
@@ -152,7 +162,7 @@ describe('invalidateAiVaultSessionListCache generation guard', () => {
expect(cached.scannedAt).toBe('scan-A')
expect(cached.scanId).toBe(first.scanId)
expect(scanAiVaultSessionsInService).toHaveBeenCalledTimes(1)
expect(listRunningWslHomeDirsUnlessStopped).toHaveBeenCalledTimes(1)
expect(readRunningWslHomeDirsUnlessStopped).toHaveBeenCalledTimes(1)
})
it('skips running-distro discovery once a probe has reported no installed WSL distro', async () => {
@@ -160,22 +170,110 @@ describe('invalidateAiVaultSessionListCache generation guard', () => {
getCachedWslDistros.mockReturnValue([])
await expect(getAiVaultWslHomeDirs()).resolves.toEqual([])
expect(listRunningWslHomeDirsUnlessStopped).not.toHaveBeenCalled()
expect(listLastKnownRunningWslHomeDirs).not.toHaveBeenCalled()
})
it('still discovers running distros before any distro probe has succeeded', async () => {
hasCachedWslDistros.mockReturnValue(false)
listRunningWslHomeDirsUnlessStopped.mockResolvedValue(['\\\\wsl.localhost\\Ubuntu\\home\\ada'])
listLastKnownRunningWslHomeDirs.mockResolvedValue(['\\\\wsl.localhost\\Ubuntu\\home\\ada'])
await expect(getAiVaultWslHomeDirs()).resolves.toEqual(['\\\\wsl.localhost\\Ubuntu\\home\\ada'])
expect(listRunningWslHomeDirsUnlessStopped).toHaveBeenCalledTimes(1)
expect(listLastKnownRunningWslHomeDirs).toHaveBeenCalledTimes(1)
})
it('skips WSL home discovery off Windows', async () => {
platform = 'linux'
await expect(getAiVaultWslHomeDirs()).resolves.toEqual([])
expect(listRunningWslHomeDirsUnlessStopped).not.toHaveBeenCalled()
expect(listLastKnownRunningWslHomeDirs).not.toHaveBeenCalled()
})
})
const WSL_HOME = '\\\\wsl.localhost\\Ubuntu\\home\\ada'
function session(id: string, filePath: string, modifiedAt: string): AiVaultSession {
return {
id,
executionHostId: 'local',
agent: 'claude',
sessionId: id,
title: id,
cwd: null,
branch: null,
model: null,
filePath,
codexHome: null,
createdAt: null,
updatedAt: null,
modifiedAt,
messageCount: 1,
totalTokens: 0,
previewMessages: [],
queuedMessageCount: 0,
subagentTranscriptCount: 0,
resumeCommand: '',
subagent: null
}
}
// #35729 review: a failed running-list probe is not a stopped distro; WSL history must not vanish.
describe('WSL sessions while the running list cannot be confirmed', () => {
beforeEach(() => {
platform = 'win32'
vi.spyOn(process, 'platform', 'get').mockImplementation(() => platform)
resetAiVaultSessionListCacheForTests()
resetLocalAiVaultScanProgressForTests()
hasCachedWslDistros.mockReset().mockReturnValue(false)
scanAiVaultSessionsInService.mockReset()
})
afterEach(() => {
resetAiVaultSessionListCacheForTests()
resetLocalAiVaultScanProgressForTests()
vi.restoreAllMocks()
})
it('keeps the last confirmed WSL rows, marked partial, and keeps the delete roots', async () => {
const native = session('native', '/Users/ada/.claude/projects/p/native.jsonl', '2026-01-02')
const wsl = session('wsl', `${WSL_HOME}\\.claude\\projects\\p\\wsl.jsonl`, '2026-01-03')
readRunningWslHomeDirsUnlessStopped.mockResolvedValue({ homes: [WSL_HOME], confirmed: true })
scanAiVaultSessionsInService.mockResolvedValueOnce({
sessions: [wsl, native],
issues: [],
scannedAt: 'confirmed'
})
await listAiVaultSessions({ force: true })
// The next probe fails: the scanner gets no WSL roots and finds only native history.
readRunningWslHomeDirsUnlessStopped.mockResolvedValue({ homes: [], confirmed: false })
listLastKnownRunningWslHomeDirs.mockResolvedValue([WSL_HOME])
scanAiVaultSessionsInService.mockResolvedValueOnce({
sessions: [native],
issues: [],
scannedAt: 'unconfirmed'
})
const unconfirmed = await listAiVaultSessions({ force: true })
expect(scanAiVaultSessionsInService.mock.calls[1]?.[0]).toMatchObject({ wslHomeDirs: [] })
expect(unconfirmed.sessions.map((row) => row.id)).toEqual(['wsl', 'native'])
expect(unconfirmed.issues).toEqual([expect.objectContaining({ kind: 'notice' })])
// Checking a delete or subagent path against a root reads nothing, so it keeps last-known-good.
await expect(getAiVaultWslHomeDirs()).resolves.toEqual([WSL_HOME])
})
it('never carries a WSL row back after a delete invalidated the list', async () => {
const wsl = session('wsl', `${WSL_HOME}\\.claude\\projects\\p\\wsl.jsonl`, '2026-01-03')
readRunningWslHomeDirsUnlessStopped.mockResolvedValue({ homes: [WSL_HOME], confirmed: true })
scanAiVaultSessionsInService.mockResolvedValueOnce({
sessions: [wsl],
issues: [],
scannedAt: 'confirmed'
})
await listAiVaultSessions({ force: true })
invalidateAiVaultSessionListCache()
readRunningWslHomeDirsUnlessStopped.mockResolvedValue({ homes: [], confirmed: false })
scanAiVaultSessionsInService.mockResolvedValueOnce(scanResult('unconfirmed'))
await expect(listAiVaultSessions({ force: true })).resolves.toMatchObject({ sessions: [] })
})
})
+77 -31
View File
@@ -1,3 +1,4 @@
import { isWslUncPath } from '../../shared/wsl-paths'
import { join } from 'node:path'
import { claudeProfileHistoryDirs } from '../claude-accounts/claude-profile-installed-router'
import {
@@ -6,7 +7,14 @@ import {
scanAiVaultSessionsInBackground
} from './session-scanner-background'
import { getCachedWslDistros, hasCachedWslDistros } from '../wsl'
import { listRunningWslHomeDirsUnlessStopped } from '../wsl/wsl-home-unless-stopped'
import {
listLastKnownRunningWslHomeDirs,
readRunningWslHomeDirsUnlessStopped
} from '../wsl/wsl-home-unless-stopped'
import {
carryOverUnconfirmedWslSessions,
resetAiVaultWslSessionCarryover
} from './ai-vault-wsl-session-carryover'
import { filterPathsToRunningWslDistrosAsync } from '../wsl-running-path-filter'
import {
isAiVaultScanCancelledError,
@@ -68,30 +76,48 @@ export function configureAiVaultSessionSources(next: AiVaultSessionSources): voi
sources = next
}
type LocalAiVaultScanRoots = Required<
Pick<AiVaultScanOptions, 'additionalCodexSessionsDirs' | 'wslHomeDirs'>
> &
Pick<
AiVaultScanOptions,
| 'executionHostId'
| 'wslOpenCodeReaders'
| 'claudeProfileProjectsDirs'
| 'opencodeAccountDataDirs'
>
/** Resolve fresh WSL roots for both listing and indexing; distros can start or stop between scans. */
export async function localAiVaultScanRoots(): Promise<
Required<Pick<AiVaultScanOptions, 'additionalCodexSessionsDirs' | 'wslHomeDirs'>> &
Pick<
AiVaultScanOptions,
| 'executionHostId'
| 'wslOpenCodeReaders'
| 'claudeProfileProjectsDirs'
| 'opencodeAccountDataDirs'
>
> {
const [additionalCodexHomes, wslHomeDirs] = await Promise.all([
filterPathsToRunningWslDistrosAsync(configuredAdditionalCodexHomePaths()),
getAiVaultWslHomeDirs()
export async function localAiVaultScanRoots(): Promise<LocalAiVaultScanRoots> {
return (await resolveLocalAiVaultScanRoots()).roots
}
/** `wslConfirmed` is false when no confirmed running list was read: WSL rows are then unknown, not gone. */
async function resolveLocalAiVaultScanRoots(): Promise<{
roots: LocalAiVaultScanRoots
wslConfirmed: boolean
}> {
const [additionalCodexHomes, wsl] = await Promise.all([
// Confirmed only: a stale list could name a distro that stopped; a failed probe drops WSL homes.
filterPathsToRunningWslDistrosAsync(configuredAdditionalCodexHomePaths(), {
requireConfirmed: true
}).catch(() => configuredAdditionalCodexHomePaths().filter((home) => !isWslUncPath(home))),
scanWslHomeDirs()
])
return {
additionalCodexSessionsDirs: additionalCodexHomes.map((homePath) => join(homePath, 'sessions')),
wslHomeDirs,
// Scanner workers have no account router; supply profile roots from this process.
claudeProfileProjectsDirs: claudeProfileHistoryDirs('projects'),
opencodeAccountDataDirs: managedOpenCodeAccountDataDirs(),
wslOpenCodeReaders: await prepareOpenCodeWslReaders(wslHomeDirs),
// Runtime-addressed callers restamp host-local results at the RPC edge instead of rescanning.
executionHostId: LOCAL_EXECUTION_HOST_ID
wslConfirmed: wsl.confirmed,
roots: {
additionalCodexSessionsDirs: additionalCodexHomes.map((homePath) =>
join(homePath, 'sessions')
),
wslHomeDirs: wsl.homes,
// Scanner workers have no account router; supply profile roots from this process.
claudeProfileProjectsDirs: claudeProfileHistoryDirs('projects'),
opencodeAccountDataDirs: managedOpenCodeAccountDataDirs(),
wslOpenCodeReaders: await prepareOpenCodeWslReaders(wsl.homes),
// Runtime-addressed callers restamp host-local results at the RPC edge instead of rescanning.
executionHostId: LOCAL_EXECUTION_HOST_ID
}
}
}
@@ -139,18 +165,23 @@ export async function listAiVaultSessions(
throw new Error('Local scan progress was not created.')
}
const scope = await resolveAiVaultScopeRealPaths(args?.scopePaths ?? [])
const { roots, wslConfirmed } = await resolveLocalAiVaultScanRoots()
const scanned = await scanAiVaultSessionsInBackground(
{
includeAntigravityIdeSessions: args?.includeAntigravityIdeSessions,
limit: args?.limit,
unlimited: args?.unlimited,
scopePaths: args?.scopePaths ? scope.scanScopePaths : undefined,
...(await localAiVaultScanRoots())
...roots
},
scanSignal,
scanProgress
)
const result = { ...attachAiVaultScopeCwdAliases(scanned, scope), scanId }
const result = carryOverUnconfirmedWslSessions(
key,
{ ...attachAiVaultScopeCwdAliases(scanned, scope), scanId },
wslConfirmed
)
// Return pre-invalidation results to their caller, but never repopulate the cache with them.
if (!scanSignal.aborted && startGeneration === cacheGeneration) {
const current = cachedList
@@ -173,16 +204,29 @@ export async function listAiVaultSessions(
})
}
// Subagent transcript validation shares these WSL-aware roots with discovery.
/** Trust cached absence of any distro; otherwise a confirmed running list (E9). */
async function scanWslHomeDirs(): Promise<{ homes: string[]; confirmed: boolean }> {
if (
process.platform !== 'win32' ||
(hasCachedWslDistros() && getCachedWslDistros()?.length === 0)
) {
return { homes: [], confirmed: true }
}
return readRunningWslHomeDirsUnlessStopped()
}
/**
* WSL roots for checking a path a user act names (delete, subagents, transcript lookup). Checking
* reads nothing, so a failed probe must not narrow them: it keeps the last-known-good list.
*/
export async function getAiVaultWslHomeDirs(): Promise<string[]> {
if (process.platform !== 'win32') {
if (
process.platform !== 'win32' ||
(hasCachedWslDistros() && getCachedWslDistros()?.length === 0)
) {
return []
}
// Trust only cached absence; a failed or new probe must not narrow WSL roots used by deletion and subagent validation.
if (hasCachedWslDistros() && getCachedWslDistros()?.length === 0) {
return []
}
return listRunningWslHomeDirsUnlessStopped()
return listLastKnownRunningWslHomeDirs()
}
export function aiVaultSessionListCacheGeneration(): number {
@@ -193,6 +237,8 @@ export function aiVaultSessionListCacheGeneration(): number {
export function invalidateAiVaultSessionListCache(): void {
cacheGeneration++
cachedList = null
// A deleted WSL session must not come back as a carried-over row.
resetAiVaultWslSessionCarryover()
}
export function resetAiVaultSessionListCacheForTests(): void {
@@ -1,3 +1,6 @@
import { parseWslUncPath } from '../../shared/wsl-paths'
import { wasWslDistroLastSeenRunningSettled } from '../wsl-background-distro-gate'
import { WSL_TRANSCRIPT_RUNNING_LEASE_MS } from '../native-chat/wsl-transcript-running-admission'
import { AsyncLocalStorage } from 'node:async_hooks'
import { dirname, join } from 'node:path'
import { wslGatedStat } from '../native-chat/wsl-transcript-fs-access'
@@ -95,6 +98,15 @@ export function devinSessionsIndexForSidecar(sidecar: SessionSidecarObservation
indexCache.set(dbPath, cached)
return { index: cached.index, unreadable: false }
}
// Opening the database over 9P boots a stopped distro; this read is synchronous, so it trusts only
// a running list from the transcript lease (the sidecar stat just before took one).
const distro = process.platform === 'win32' ? parseWslUncPath(dbPath)?.distro : undefined
if (
distro &&
!wasWslDistroLastSeenRunningSettled(distro, { maxAgeMs: WSL_TRANSCRIPT_RUNNING_LEASE_MS })
) {
return { index: null, unreadable: true }
}
try {
const index = readDevinSessionsIndex(dbPath)
if (indexCache.size >= INDEX_CACHE_LIMIT) {
@@ -19,6 +19,8 @@ export type OpenCodeSqliteProcessOptions = {
requestTimeoutMs?: number
idleTeardownMs?: number
beforeSpawn?: (signal: AbortSignal) => Promise<void>
/** The child exited without being retired, e.g. its WSL distro stopped under it. */
onUnexpectedExit?: () => void
}
function createProcessTransport(options: OpenCodeSqliteProcessOptions): WorkerRequestTransport {
@@ -68,6 +70,7 @@ function createProcessTransport(options: OpenCodeSqliteProcessOptions): WorkerRe
child.stderr.on('error', fault)
child.on('exit', (code) => {
if (!retired) {
options.onUnexpectedExit?.()
events.emit('exit', code ?? 1)
}
})
@@ -238,4 +238,38 @@ describe('OpenCode SQLite process retirement', () => {
noisy.dispose()
}
})
// A WSL reader dies with its distro; the client reports that so the respawn re-checks the distro.
it('reports an unexpected child exit, but not its own retirement', async () => {
const onUnexpectedExit = vi.fn()
const dying = createOpenCodeSqliteProcessClient({
executable,
args: ['-e', 'process.exit(3)'],
onUnexpectedExit
})
try {
await dying.list({ dbPaths: [dbPath], limit: 1, issues: [] })
await vi.waitFor(() => expect(onUnexpectedExit).toHaveBeenCalled())
} finally {
dying.dispose()
}
const spawn = vi.spyOn(processRunner, 'spawnProcess')
const retiredExit = vi.fn()
const healthy = createOpenCodeSqliteProcessClient({
executable,
args: [entry],
onUnexpectedExit: retiredExit
})
try {
await healthy.list({ dbPaths: [dbPath], limit: 1, issues: [] })
const child = spawn.mock.results[0]?.value
const exited = new Promise((resolve) => child?.once('exit', resolve))
healthy.dispose()
await exited
expect(retiredExit).not.toHaveBeenCalled()
} finally {
spawn.mockRestore()
}
})
})
@@ -5,6 +5,7 @@ import { parseWslUncPath } from '../../shared/wsl-paths'
import { resolveWslExecutablePath } from '../wsl/wsl-executable-path'
import { resolveWslInteropSpawnCwd } from '../wsl-interop-spawn-directory'
import { admitWslTranscriptPath } from '../native-chat/wsl-transcript-running-admission'
import { noteWslDistroMayHaveStopped } from '../wsl/wsl-unc-access-gate'
import { createOpenCodeSqliteProcessClient } from './session-scanner-opencode-sqlite-process-client'
import type { OpenCodeSqliteWorkerClient } from './session-scanner-opencode-sqlite-worker-client'
import type { OpenCodeWslRuntime } from './session-scanner-opencode-wsl-runtime'
@@ -73,7 +74,9 @@ export async function openCodeWslClient(
async beforeSpawn(spawnSignal) {
// A `--exec` of a stopped distro boots it; every respawn asks again.
await waitForPromiseWithSignal(admitWslTranscriptPath(dbPath), spawnSignal)
}
},
// The reader dying may mean its distro stopped: a respawn must not trust an older running read.
onUnexpectedExit: () => noteWslDistroMayHaveStopped(distro)
})
clients.set(key, client)
}
@@ -0,0 +1,122 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ProcessResult } from '@orca/process-host/process-spec'
import type { OpenCodeSqliteProcessOptions } from './session-scanner-opencode-sqlite-process-client'
const mocks = vi.hoisted(() => ({
runProcess: vi.fn<(spec: { args?: string[] }) => Promise<ProcessResult>>(),
running: { distros: ['Ubuntu'] },
create: vi.fn((_options: OpenCodeSqliteProcessOptions) => ({ dispose: vi.fn() })),
readDb: vi.fn(() => new Map())
}))
vi.mock('@orca/process-host', () => ({ runProcess: mocks.runProcess }))
vi.mock('./session-scanner-opencode-sqlite-process-client', () => ({
createOpenCodeSqliteProcessClient: mocks.create
}))
vi.mock('./session-scanner-opencode-sqlite-open', async (importOriginal) => ({
...(await importOriginal<object>()),
readOpenCodeDatabase: mocks.readDb
}))
vi.mock('../wsl/wsl-executable-path', () => ({ resolveWslExecutablePath: () => 'wsl.exe' }))
vi.mock('../wsl-interop-spawn-directory', () => ({ resolveWslInteropSpawnCwd: () => 'C:\\' }))
import { _resetWslCachesForTests } from '../wsl'
import { _resetWslBackgroundDistroGateForTests } from '../wsl-background-distro-gate'
import { WSL_TRANSCRIPT_RUNNING_LEASE_MS } from '../native-chat/wsl-transcript-running-admission'
import {
configureOpenCodeWslReaders,
openCodeWslClient
} from './session-scanner-opencode-wsl-client'
import {
devinSessionsIndexForSidecar,
resetDevinSessionsIndexCacheForTests
} from './session-scanner-devin-db'
import { listRunningWslHomeDirsUnlessStopped } from '../wsl/wsl-home-unless-stopped'
import { clearWslHomeCache, rememberWslHome } from '../wsl-home-cache'
import { _resetRunningWslDistroCacheForTests } from '../wsl-running-distro-cache'
import { localAiVaultScanRoots } from './cached-session-list'
const DB = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.opencode\\opencode.db'
const DEVIN_DB = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.devin\\sessions.db'
let originalPlatform: NodeJS.Platform
beforeEach(() => {
originalPlatform = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
mocks.running.distros = ['Ubuntu']
mocks.runProcess.mockReset().mockImplementation(async (spec) => {
if (!spec.args?.includes('--running')) {
return { code: 1, signal: null, stdout: '', stderr: 'unexpected', timedOut: false }
}
const stdout = mocks.running.distros.map((distro) => `${distro}\n`).join('')
return { code: 0, signal: null, stdout, stderr: '', timedOut: false }
})
mocks.create.mockClear()
mocks.readDb.mockClear()
resetDevinSessionsIndexCacheForTests()
})
afterEach(() => {
configureOpenCodeWslReaders([])
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
_resetWslCachesForTests()
_resetWslBackgroundDistroGateForTests()
_resetRunningWslDistroCacheForTests()
clearWslHomeCache()
vi.useRealTimers()
})
const listProbes = (): number =>
mocks.runProcess.mock.calls.filter(([spec]) => spec.args?.includes('--running')).length
describe('session scanner after a `wsl --terminate`', () => {
// verify-18 (a): the reader died with its distro, and its respawn inside the lease booted it.
it('re-reads the running list before respawning an OpenCode reader that exited', async () => {
configureOpenCodeWslReaders([
{ distro: 'Ubuntu', executable: '/usr/bin/node', readerPath: '/r.cjs' }
])
await openCodeWslClient('Ubuntu', DB)
const options = mocks.create.mock.calls[0]?.[0]
await options?.beforeSpawn?.(new AbortController().signal)
expect(listProbes()).toBe(1)
mocks.running.distros = []
options?.onUnexpectedExit?.()
await expect(options?.beforeSpawn?.(new AbortController().signal)).rejects.toThrow(
'is not running'
)
expect(listProbes()).toBe(2)
})
it('opens a WSL Devin sessions.db only on a running list from the transcript lease', async () => {
vi.useFakeTimers({ toFake: ['performance', 'Date', 'setTimeout'] })
const sidecar = { path: DEVIN_DB, mtimeMs: 1, sizeBytes: 2 }
await listRunningWslHomeDirsUnlessStopped()
expect(devinSessionsIndexForSidecar(sidecar)).toMatchObject({ unreadable: false })
expect(mocks.readDb).toHaveBeenCalledOnce()
resetDevinSessionsIndexCacheForTests()
vi.advanceTimersByTime(WSL_TRANSCRIPT_RUNNING_LEASE_MS + 1)
expect(devinSessionsIndexForSidecar(sidecar)).toEqual({ index: null, unreadable: true })
expect(mocks.readDb).toHaveBeenCalledOnce()
})
it('lists no WSL root from a stale list when the running list cannot be confirmed', async () => {
const home = '\\\\wsl.localhost\\Ubuntu\\home\\ada'
rememberWslHome('Ubuntu', home)
await expect(listRunningWslHomeDirsUnlessStopped()).resolves.toEqual([home])
// The probe now fails; the last-known-good list still names Ubuntu, whose home is cached.
mocks.runProcess.mockResolvedValue({
code: 1,
signal: null,
stdout: '',
stderr: 'wsl failed',
timedOut: false
})
await expect(listRunningWslHomeDirsUnlessStopped()).resolves.toEqual([])
const roots = await localAiVaultScanRoots()
expect(roots.wslHomeDirs).toEqual([])
})
})
@@ -45,12 +45,15 @@ function runReusableFsOperation<T>(
? admitWslTranscriptPath(request.path).then(() =>
runWslTranscriptFsTask(
{ operation: request.operation, path: request.path, priority, signal },
(taskSignal) =>
runWslTranscriptFsProcess<T>(
async (taskSignal) => {
// Queued work runs later: ask again right before the touch.
await admitWslTranscriptPath(request.path)
return runWslTranscriptFsProcess<T>(
request,
taskSignal,
wslTranscriptFsLaneKey(request.path, priority)
)
}
)
)
: localTask()
@@ -144,8 +147,10 @@ async function openAdmittedWslTranscriptFile(
dedupe: false,
onAbandonedResult: (handle) => void closeTranscriptHandle(handle, path)
},
(taskSignal) =>
openWslTranscriptFsProcess(path, taskSignal, wslTranscriptFsLaneKey(path, priority))
async (taskSignal) => {
await admitWslTranscriptPath(path)
return openWslTranscriptFsProcess(path, taskSignal, wslTranscriptFsLaneKey(path, priority))
}
)
}
@@ -171,6 +176,8 @@ export function readTranscriptFile(
return runWslTranscriptFsTask(
{ operation: 'read', path, priority, signal, dedupe: false },
async (taskSignal) => {
// An open 9P handle still reconnects (and boots) a distro that stopped since the open.
await admitWslTranscriptPath(path)
if (!isWslTranscriptFsProcessHandle(handle)) {
return (handle as FileHandle).read(buffer, offset, length, position)
}
@@ -3,19 +3,27 @@ import type { ProcessResult } from '@orca/process-host/process-spec'
const mocks = vi.hoisted(() => ({
runProcess: vi.fn(),
runFsProcess: vi.fn(async () => [])
runFsProcess: vi.fn(async (..._args: unknown[]): Promise<unknown> => []),
readFsProcess: vi.fn(async (..._args: unknown[]) => Buffer.from('x'))
}))
vi.mock('@orca/process-host', () => ({ runProcess: mocks.runProcess }))
vi.mock('./wsl-transcript-fs-process-dispatch', async (importOriginal) => ({
...(await importOriginal<object>()),
runWslTranscriptFsProcess: mocks.runFsProcess
runWslTranscriptFsProcess: mocks.runFsProcess,
openWslTranscriptFsProcess: async () => ({ wslTranscriptFsProcessHandle: 1 }),
readWslTranscriptFsProcess: mocks.readFsProcess,
isWslTranscriptFsProcessHandle: () => true,
closeWslTranscriptFsProcess: async () => {}
}))
import { _resetWslCachesForTests } from '../wsl'
import { _resetWslBackgroundDistroGateForTests } from '../wsl-background-distro-gate'
import {
_resetWslBackgroundDistroGateForTests,
noteWslDistroMayHaveStopped
} from '../wsl-background-distro-gate'
import { runAsWslUserAct } from '../wsl/wsl-unc-access-gate'
import { wslGatedReaddir } from './wsl-transcript-fs-access'
import { openTranscriptFile, readTranscriptFile, wslGatedReaddir } from './wsl-transcript-fs-access'
import { resetWslTranscriptFsGateForTests, WslTranscriptFsError } from './wsl-transcript-fs-gate'
import { WSL_TRANSCRIPT_RUNNING_LEASE_MS } from './wsl-transcript-running-admission'
@@ -35,7 +43,8 @@ beforeEach(() => {
originalPlatform = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
mocks.runProcess.mockReset()
mocks.runFsProcess.mockClear()
mocks.runFsProcess.mockReset().mockResolvedValue([])
mocks.readFsProcess.mockClear()
})
afterEach(() => {
@@ -86,4 +95,40 @@ describe('transcript reads never boot a stopped WSL distro (E9)', () => {
).resolves.toEqual([])
expect(mocks.runFsProcess).toHaveBeenCalledOnce()
})
// Admission at queue time is not enough: the distro can stop while the task waits for a slot.
it('asks again when a queued read finally runs, refusing it after a stop', async () => {
mocks.runProcess.mockResolvedValue(runningList('Ubuntu'))
const release: (() => void)[] = []
mocks.runFsProcess.mockImplementation(
() => new Promise((resolve) => release.push(() => resolve([])))
)
// One task per distro lane runs at a time; the next one waits in the queue.
const busy = wslGatedReaddir(`${SESSIONS}\\a`, 'scan')
await vi.waitFor(() => expect(mocks.runFsProcess).toHaveBeenCalledTimes(1))
const queued = wslGatedReaddir(`${SESSIONS}\\b`, 'scan')
await new Promise((resolve) => setImmediate(resolve))
noteWslDistroMayHaveStopped('Ubuntu')
mocks.runProcess.mockResolvedValue(runningList())
release[0]?.()
await busy
await expect(queued).rejects.toThrow('is not running')
expect(mocks.runFsProcess).toHaveBeenCalledTimes(1)
})
it('never reads an already-open handle of a distro that stopped since the open', async () => {
mocks.runProcess.mockResolvedValue(runningList('Ubuntu'))
const file = `${SESSIONS}\\s.jsonl`
const handle = await openTranscriptFile(file, 'exact')
noteWslDistroMayHaveStopped('Ubuntu')
mocks.runProcess.mockResolvedValue(runningList())
await expect(
readTranscriptFile(handle, file, Buffer.alloc(1), 0, 1, 0, 'exact')
).rejects.toThrow('is not running')
expect(mocks.readFsProcess).not.toHaveBeenCalled()
})
})
+30 -5
View File
@@ -1,4 +1,5 @@
import { getWslHomeAsync, listRunningWslDistrosAsync } from '../wsl'
import { readConfirmedRunningWslDistros } from '../wsl-background-distro-gate'
import { getCachedWslHome } from '../wsl-home-cache'
import { blockedWslDistroForPath } from './wsl-unc-access-gate'
@@ -10,10 +11,34 @@ export async function wslHomeUnlessStopped(distro: string): Promise<string | nul
return getWslHomeAsync(distro)
}
/** UNC homes of running distros, never probing one that stopped since the list (E9). */
export async function listRunningWslHomeDirsUnlessStopped(): Promise<string[]> {
const homes = await Promise.all(
(await listRunningWslDistrosAsync()).map((distro) => wslHomeUnlessStopped(distro))
)
async function homesOf(distros: readonly string[]): Promise<string[]> {
const homes = await Promise.all(distros.map((distro) => wslHomeUnlessStopped(distro)))
return homes.filter((home): home is string => Boolean(home))
}
/**
* UNC homes of distros a fresh running list confirms, for background scans (E9). A failed probe
* lists none and says so (`confirmed: false`): a stale "running" answer plus a cached home would
* hand a stopped distro's root to the scanner.
*/
export async function readRunningWslHomeDirsUnlessStopped(): Promise<{
homes: string[]
confirmed: boolean
}> {
const read = await readConfirmedRunningWslDistros()
return read.status === 'confirmed'
? { homes: await homesOf(read.distros), confirmed: true }
: { homes: [], confirmed: false }
}
export async function listRunningWslHomeDirsUnlessStopped(): Promise<string[]> {
return (await readRunningWslHomeDirsUnlessStopped()).homes
}
/**
* Homes for checking a path a user act names (delete, subagents, a transcript lookup): checking a
* path against a root reads nothing, so a failed probe keeps the last-known-good list.
*/
export async function listLastKnownRunningWslHomeDirs(): Promise<string[]> {
return homesOf(await listRunningWslDistrosAsync())
}