mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
fix(wsl): session history reads never boot a stopped WSL distro (E9) (#35367)
* fix(wsl): session history reads never boot a stopped WSL distro (E9) Transcript and session-history reads touched \\wsl.localhost shares, and ran `wsl.exe --exec` probes, for any distro the root list named. Those touches are the OpenCode reader, its setup, HOME and realpath probes, and the session scanner and search index. Nothing re-checked that the distro still ran. A `wsl --terminate` mid-scan, or a list taken while the distro was still stopping, let background work boot it again. Every UNC transcript touch (stat, readdir, open, access, readFile) and every scanner `--exec` now asks a settled running read first. That read is no older than 5 s, because the scanner process hears of no stop by itself. A user act on the distro still passes. * fix(wsl): opening a chat starts its stopped distro; scanner hears main's stop notes A user-opened chat, a history page and an AI Vault resume start the transcript's stopped WSL distro once, as a user act, so its history shows; watch and scan reads stay background. Main forwards WSL stop and wake notes to the session-scanner process so a terminate main saw is honoured there at once. * fix(wsl): type the scanner note channel structurally; test the resume wake Keeps node:child_process out of the new scanner-notes module (the process-host import check) and adds a test that fails if an AI Vault resume stops starting the session's WSL distro.
This commit is contained in:
@@ -14,13 +14,13 @@ const {
|
||||
filterPathsToRunningWslDistrosAsync,
|
||||
getCachedWslDistros,
|
||||
hasCachedWslDistros,
|
||||
listRunningWslHomeDirsAsync,
|
||||
listRunningWslHomeDirsUnlessStopped,
|
||||
scanAiVaultSessionsInService
|
||||
} = vi.hoisted(() => ({
|
||||
filterPathsToRunningWslDistrosAsync: vi.fn(async (paths: readonly string[]) => [...paths]),
|
||||
getCachedWslDistros: vi.fn((): string[] | null => null),
|
||||
hasCachedWslDistros: vi.fn(() => false),
|
||||
listRunningWslHomeDirsAsync: vi.fn().mockResolvedValue([]),
|
||||
listRunningWslHomeDirsUnlessStopped: vi.fn().mockResolvedValue([]),
|
||||
scanAiVaultSessionsInService: vi.fn()
|
||||
}))
|
||||
|
||||
@@ -30,9 +30,9 @@ vi.mock('./session-scanner-service-spawn', async (importOriginal) => ({
|
||||
}))
|
||||
vi.mock('../wsl', () => ({
|
||||
getCachedWslDistros,
|
||||
hasCachedWslDistros,
|
||||
listRunningWslHomeDirsAsync
|
||||
hasCachedWslDistros
|
||||
}))
|
||||
vi.mock('../wsl/wsl-home-unless-stopped', () => ({ listRunningWslHomeDirsUnlessStopped }))
|
||||
vi.mock('../wsl-running-path-filter', () => ({ filterPathsToRunningWslDistrosAsync }))
|
||||
|
||||
import {
|
||||
@@ -72,7 +72,7 @@ describe('invalidateAiVaultSessionListCache generation guard', () => {
|
||||
filterPathsToRunningWslDistrosAsync.mockClear()
|
||||
getCachedWslDistros.mockReset().mockReturnValue(null)
|
||||
hasCachedWslDistros.mockReset().mockReturnValue(false)
|
||||
listRunningWslHomeDirsAsync.mockReset().mockResolvedValue([])
|
||||
listRunningWslHomeDirsUnlessStopped.mockReset().mockResolvedValue([])
|
||||
scanAiVaultSessionsInService.mockReset()
|
||||
})
|
||||
afterEach(() => {
|
||||
@@ -152,7 +152,7 @@ describe('invalidateAiVaultSessionListCache generation guard', () => {
|
||||
expect(cached.scannedAt).toBe('scan-A')
|
||||
expect(cached.scanId).toBe(first.scanId)
|
||||
expect(scanAiVaultSessionsInService).toHaveBeenCalledTimes(1)
|
||||
expect(listRunningWslHomeDirsAsync).toHaveBeenCalledTimes(1)
|
||||
expect(listRunningWslHomeDirsUnlessStopped).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('skips running-distro discovery once a probe has reported no installed WSL distro', async () => {
|
||||
@@ -160,22 +160,22 @@ describe('invalidateAiVaultSessionListCache generation guard', () => {
|
||||
getCachedWslDistros.mockReturnValue([])
|
||||
|
||||
await expect(getAiVaultWslHomeDirs()).resolves.toEqual([])
|
||||
expect(listRunningWslHomeDirsAsync).not.toHaveBeenCalled()
|
||||
expect(listRunningWslHomeDirsUnlessStopped).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still discovers running distros before any distro probe has succeeded', async () => {
|
||||
hasCachedWslDistros.mockReturnValue(false)
|
||||
listRunningWslHomeDirsAsync.mockResolvedValue(['\\\\wsl.localhost\\Ubuntu\\home\\ada'])
|
||||
listRunningWslHomeDirsUnlessStopped.mockResolvedValue(['\\\\wsl.localhost\\Ubuntu\\home\\ada'])
|
||||
|
||||
await expect(getAiVaultWslHomeDirs()).resolves.toEqual(['\\\\wsl.localhost\\Ubuntu\\home\\ada'])
|
||||
expect(listRunningWslHomeDirsAsync).toHaveBeenCalledTimes(1)
|
||||
expect(listRunningWslHomeDirsUnlessStopped).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('skips WSL home discovery off Windows', async () => {
|
||||
platform = 'linux'
|
||||
|
||||
await expect(getAiVaultWslHomeDirs()).resolves.toEqual([])
|
||||
expect(listRunningWslHomeDirsAsync).not.toHaveBeenCalled()
|
||||
expect(listRunningWslHomeDirsUnlessStopped).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
resetAiVaultScannerBackgroundForTests,
|
||||
scanAiVaultSessionsInBackground
|
||||
} from './session-scanner-background'
|
||||
import { getCachedWslDistros, hasCachedWslDistros, listRunningWslHomeDirsAsync } from '../wsl'
|
||||
import { getCachedWslDistros, hasCachedWslDistros } from '../wsl'
|
||||
import { listRunningWslHomeDirsUnlessStopped } from '../wsl/wsl-home-unless-stopped'
|
||||
import { filterPathsToRunningWslDistrosAsync } from '../wsl-running-path-filter'
|
||||
import {
|
||||
isAiVaultScanCancelledError,
|
||||
@@ -181,7 +182,7 @@ export async function getAiVaultWslHomeDirs(): Promise<string[]> {
|
||||
if (hasCachedWslDistros() && getCachedWslDistros()?.length === 0) {
|
||||
return []
|
||||
}
|
||||
return listRunningWslHomeDirsAsync()
|
||||
return listRunningWslHomeDirsUnlessStopped()
|
||||
}
|
||||
|
||||
export function aiVaultSessionListCacheGeneration(): number {
|
||||
|
||||
@@ -9,7 +9,9 @@ import type * as preparationModule from './opencode-wsl-runtime-preparation'
|
||||
const mocks = vi.hoisted(() => ({
|
||||
exists: vi.fn(() => true),
|
||||
run: vi.fn(),
|
||||
running: vi.fn(async (paths: readonly string[]) => [...paths]),
|
||||
running: vi.fn(async (paths: readonly string[], _options?: { requireConfirmed?: boolean }) => [
|
||||
...paths
|
||||
]),
|
||||
download: vi.fn(async () => 'C:\\cache\\node-v24.21.0-linux-arm64-musl.tar.gz'),
|
||||
bundles: vi.fn((platform: string) => [`C:/relay/${platform}`])
|
||||
}))
|
||||
@@ -21,8 +23,13 @@ vi.mock('../../shared/app-environment', () => ({
|
||||
})
|
||||
}))
|
||||
vi.mock('../wsl/wsl-runner', () => ({ runWslProcess: mocks.run }))
|
||||
vi.mock('../wsl-running-path-filter', () => ({
|
||||
filterPathsToRunningWslDistrosAsync: mocks.running
|
||||
// The admission gate asks the running list for the path's distro; `running` stands in for it.
|
||||
vi.mock('../native-chat/wsl-transcript-running-admission', () => ({
|
||||
admitWslTranscriptPath: async (path: string) => {
|
||||
if ((await mocks.running([path], { requireConfirmed: true })).length === 0) {
|
||||
throw new Error('WSL distro is not running.')
|
||||
}
|
||||
}
|
||||
}))
|
||||
vi.mock('../node-runtime/pinned-runtime-materializer', () => ({
|
||||
materializeNodeRuntimeArchive: mocks.download
|
||||
|
||||
@@ -20,7 +20,7 @@ import { REMOTE_NODE_RUNTIME_READY } from '../ssh/orcad-remote-node-runtime-repo
|
||||
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import { runWslProcess, type WslSpec } from '../wsl/wsl-runner'
|
||||
import { describeWslProcessFailure } from '../wsl/wsl-process-failure'
|
||||
import { filterPathsToRunningWslDistrosAsync } from '../wsl-running-path-filter'
|
||||
import { admitWslTranscriptPath } from '../native-chat/wsl-transcript-running-admission'
|
||||
import type { OpenCodeWslRuntime } from './session-scanner-opencode-wsl-runtime'
|
||||
|
||||
const preparation = new Map<string, { value: OpenCodeWslRuntime; expires: number }>()
|
||||
@@ -81,15 +81,8 @@ async function prepare(distro: string): Promise<OpenCodeWslRuntime> {
|
||||
const signal = AbortSignal.timeout(PREPARATION_TIMEOUT_MS)
|
||||
const run = async (spec: WslSpec, timeoutMs = 15_000): Promise<string> => {
|
||||
signal.throwIfAborted()
|
||||
const running = await waitForPromiseWithSignal(
|
||||
filterPathsToRunningWslDistrosAsync([toWindowsWslUncPath('/', distro)], {
|
||||
requireConfirmed: true
|
||||
}),
|
||||
signal
|
||||
)
|
||||
if (running.length === 0) {
|
||||
throw new Error(`WSL distro ${distro} is not running. Start it to read its history.`)
|
||||
}
|
||||
// Each `--exec` would boot a distro that stopped since the roots were listed.
|
||||
await waitForPromiseWithSignal(admitWslTranscriptPath(toWindowsWslUncPath('/', distro)), signal)
|
||||
signal.throwIfAborted()
|
||||
const result = await runWslProcess({
|
||||
...spec,
|
||||
|
||||
@@ -3,11 +3,18 @@ import { createAccumulator, finalizeSession } from './session-scanner-accumulato
|
||||
import type { OpenCodeSqliteProcessOptions } from './session-scanner-opencode-sqlite-process-client'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
running: vi.fn(async (paths: readonly string[]) => [...paths]),
|
||||
running: vi.fn(async (paths: readonly string[], _options?: { requireConfirmed?: boolean }) => [
|
||||
...paths
|
||||
]),
|
||||
create: vi.fn((_options: OpenCodeSqliteProcessOptions) => ({ dispose: vi.fn() }))
|
||||
}))
|
||||
vi.mock('../wsl-running-path-filter', () => ({
|
||||
filterPathsToRunningWslDistrosAsync: mocks.running
|
||||
// The admission gate asks the running list for the path's distro; `running` stands in for it.
|
||||
vi.mock('../native-chat/wsl-transcript-running-admission', () => ({
|
||||
admitWslTranscriptPath: async (path: string) => {
|
||||
if ((await mocks.running([path], { requireConfirmed: true })).length === 0) {
|
||||
throw new Error('WSL distro is not running.')
|
||||
}
|
||||
}
|
||||
}))
|
||||
vi.mock('./session-scanner-opencode-sqlite-process-client', () => ({
|
||||
createOpenCodeSqliteProcessClient: mocks.create
|
||||
|
||||
@@ -4,7 +4,7 @@ import { buildWslExecArgs } from '../../shared/wsl-login-shell-command'
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
import { resolveWslExecutablePath } from '../wsl/wsl-executable-path'
|
||||
import { resolveWslInteropSpawnCwd } from '../wsl-interop-spawn-directory'
|
||||
import { filterPathsToRunningWslDistrosAsync } from '../wsl-running-path-filter'
|
||||
import { admitWslTranscriptPath } from '../native-chat/wsl-transcript-running-admission'
|
||||
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'
|
||||
@@ -62,13 +62,8 @@ export async function openCodeWslClient(
|
||||
cwd: resolveWslInteropSpawnCwd(),
|
||||
env: { ...buildRelayAiVaultServiceEnv(), WSL_UTF8: '1' },
|
||||
async beforeSpawn(spawnSignal) {
|
||||
const running = await waitForPromiseWithSignal(
|
||||
filterPathsToRunningWslDistrosAsync([dbPath], { requireConfirmed: true }),
|
||||
spawnSignal
|
||||
)
|
||||
if (running.length === 0) {
|
||||
throw new Error(`WSL distro ${distro} is not running. Start it to read its history.`)
|
||||
}
|
||||
// A `--exec` of a stopped distro boots it; every respawn asks again.
|
||||
await waitForPromiseWithSignal(admitWslTranscriptPath(dbPath), spawnSignal)
|
||||
}
|
||||
})
|
||||
clients.set(key, client)
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
type AiVaultServiceRequestBody,
|
||||
type AiVaultServiceResultValue
|
||||
} from './session-scanner-service-protocol'
|
||||
import { forwardWslDistroNotesToChild } from './session-scanner-service-wsl-notes'
|
||||
|
||||
export class AiVaultScannerServiceClient {
|
||||
private child: ChildProcess | null = null
|
||||
@@ -48,6 +49,8 @@ export class AiVaultScannerServiceClient {
|
||||
private readonly sessionSearch = new AiVaultServiceSessionSearchHold()
|
||||
private disposed = false
|
||||
|
||||
private readonly stopForwardingWslNotes = forwardWslDistroNotesToChild(() => this.child)
|
||||
|
||||
constructor(private readonly options: AiVaultServiceClientOptions) {}
|
||||
|
||||
request<T>(
|
||||
@@ -115,6 +118,7 @@ export class AiVaultScannerServiceClient {
|
||||
return
|
||||
}
|
||||
this.disposed = true
|
||||
this.stopForwardingWslNotes()
|
||||
this.restartPolicy.dispose()
|
||||
this.idleRetirement.clear()
|
||||
const error = new Error('AI Vault service client was disposed.')
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { readAiVaultSessionTitlesFromFiles } from './session-title-file-reader'
|
||||
import { resolveHostReadableAiVaultTitleRequests } from './session-title-request-paths'
|
||||
import { listLocalAiVaultSubagentSessions } from './session-subagent-reader'
|
||||
import { noteWslDistroMayHaveStopped, noteWslDistroWoken } from '../wsl-background-distro-gate'
|
||||
|
||||
if (!process.send) {
|
||||
throw new Error('AI Vault service requires a parent IPC channel.')
|
||||
@@ -238,6 +239,14 @@ process.on('message', (raw: AiVaultServiceParentMessage) => {
|
||||
void shutdown()
|
||||
return
|
||||
}
|
||||
if (raw?.type === 'wslDistroNote' && typeof raw.distro === 'string') {
|
||||
if (raw.kind === 'stopped') {
|
||||
noteWslDistroMayHaveStopped(raw.distro)
|
||||
} else {
|
||||
noteWslDistroWoken(raw.distro)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (isAiVaultServiceRequest(raw)) {
|
||||
queueRequest(raw)
|
||||
}
|
||||
|
||||
@@ -105,6 +105,8 @@ export type AiVaultServiceParentMessage =
|
||||
roots: SessionSearchScanRoots | null
|
||||
}
|
||||
| { type: 'shutdown' }
|
||||
// Main's WSL stop and wake notes, so the child's transcript gate never trusts a stale running read.
|
||||
| { type: 'wslDistroNote'; kind: 'stopped' | 'woken'; distro: string }
|
||||
|
||||
export type AiVaultServiceResultValue =
|
||||
| {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
_resetWslBackgroundDistroGateForTests,
|
||||
noteWslDistroMayHaveStopped,
|
||||
noteWslDistroWoken
|
||||
} from '../wsl-background-distro-gate'
|
||||
import { AiVaultScannerServiceClient } from './session-scanner-service-client'
|
||||
import {
|
||||
AiVaultServiceTestChild,
|
||||
readyAiVaultServiceChild
|
||||
} from './session-scanner-service-test-child'
|
||||
|
||||
afterEach(() => {
|
||||
_resetWslBackgroundDistroGateForTests()
|
||||
})
|
||||
|
||||
// verify-18 (a): a `wsl --terminate` main saw must reach the scanner before its running lease would.
|
||||
describe('the session scanner hears main WSL stop and wake notes', () => {
|
||||
it('forwards notes to a live child and stops after dispose', async () => {
|
||||
const child = new AiVaultServiceTestChild()
|
||||
const client = new AiVaultScannerServiceClient({
|
||||
processFactory: () => child.asChildProcess(),
|
||||
init: () => ({ sessionParseCache: null, sessionSearch: null })
|
||||
})
|
||||
void client.request({ type: 'request', operation: 'titles', requests: [] }).catch(() => {})
|
||||
readyAiVaultServiceChild(child)
|
||||
await Promise.resolve()
|
||||
|
||||
noteWslDistroMayHaveStopped('Ubuntu')
|
||||
noteWslDistroWoken('Debian')
|
||||
expect(child.sent).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ type: 'wslDistroNote', kind: 'stopped', distro: 'Ubuntu' },
|
||||
{ type: 'wslDistroNote', kind: 'woken', distro: 'Debian' }
|
||||
])
|
||||
)
|
||||
|
||||
client.dispose()
|
||||
const sentBefore = child.sent.length
|
||||
noteWslDistroMayHaveStopped('Ubuntu')
|
||||
expect(child.sent).toHaveLength(sentBefore)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import { onWslDistroGateNote } from '../wsl-background-distro-gate'
|
||||
|
||||
type WslNoteChannel = {
|
||||
send(message: unknown, callback: (error: Error | null) => void): boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The child's transcript gate cannot hear main's WSL stop and wake notes; without them it trusts a
|
||||
* running read for its whole lease. Returns the unsubscribe.
|
||||
*/
|
||||
export function forwardWslDistroNotesToChild(child: () => WslNoteChannel | null): () => void {
|
||||
return onWslDistroGateNote((note) =>
|
||||
child()?.send({ type: 'wslDistroNote', ...note }, () => undefined)
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
import { prepareAiVaultSessionResume } from './ai-vault-resume'
|
||||
|
||||
vi.mock('electron', () => ({ ipcMain: { handle: vi.fn() } }))
|
||||
const wakeMock = vi.hoisted(() => vi.fn(async (_distro: string | null) => {}))
|
||||
vi.mock('../native-chat/native-chat-open-wake', () => ({
|
||||
wakeNativeChatTranscriptDistro: wakeMock
|
||||
}))
|
||||
afterEach(() => setStructuredAgentSessionHost(null))
|
||||
|
||||
it('refuses the native owner while preserving forks and other execution hosts', async () => {
|
||||
@@ -46,3 +50,30 @@ it('refuses the native owner while preserving forks and other execution hosts',
|
||||
})
|
||||
expect(prepareSessionResume).toHaveBeenCalledWith(wsl)
|
||||
})
|
||||
|
||||
// Resuming is a user act: its stopped distro must start before the transcript's gated reads.
|
||||
it('starts the session file distro before preparing a WSL resume', async () => {
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
try {
|
||||
const prepareSessionResume = vi.fn(async () => {
|
||||
expect(wakeMock).toHaveBeenCalledWith('Ubuntu')
|
||||
return { useRealCodexHome: false }
|
||||
})
|
||||
await prepareAiVaultSessionResume(
|
||||
{
|
||||
agent: 'claude',
|
||||
sessionId: 'sess-1',
|
||||
filePath: String.raw`\\wsl.localhost\Ubuntu\home\qa\session.jsonl`,
|
||||
codexHome: null,
|
||||
executionHostId: 'local'
|
||||
},
|
||||
{ prepareSessionResume }
|
||||
)
|
||||
expect(prepareSessionResume).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -11,6 +11,8 @@ import { isWslUncPath } from '../../shared/wsl-paths'
|
||||
import { assertLegacyAiVaultResumeAllowed } from '../ai-vault/structured-session-ownership'
|
||||
import { tryEnsureStructuredAgentSessionHostForResume } from '../runtime/structured-agent-session-host-refusal'
|
||||
import { copyStructuredChatConversation } from '../ai-vault/structured-chat-conversation-copy'
|
||||
import { wakeNativeChatTranscriptDistro } from '../native-chat/native-chat-open-wake'
|
||||
import { wslDistroForPath } from '../wsl/wsl-unc-access-gate'
|
||||
|
||||
export type AiVaultResumeHandlerOptions = {
|
||||
ensureStructuredSessionOwnership?: () => Promise<void>
|
||||
@@ -57,6 +59,8 @@ export async function prepareAiVaultSessionResume(
|
||||
if (executionHost?.kind === 'ssh') {
|
||||
return { useRealCodexHome: false }
|
||||
}
|
||||
// Resuming a session is a user act: start its stopped distro before the gated transcript reads.
|
||||
await wakeNativeChatTranscriptDistro(wslDistroForPath(args.filePath))
|
||||
if (!isWslUncPath(args.filePath)) {
|
||||
if (options.ensureStructuredSessionOwnership) {
|
||||
await (args.codexHome === null && !args.fork
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import {
|
||||
access,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
open,
|
||||
readdir,
|
||||
readFile,
|
||||
rm,
|
||||
stat,
|
||||
writeFile
|
||||
} from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ProcessResult } from '@orca/process-host/process-spec'
|
||||
import type { WslTranscriptFsReusableProcessCall } from '../native-chat/wsl-transcript-fs-process-protocol'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
handlers: new Map<string, (_event: unknown, args?: unknown) => unknown>(),
|
||||
listeners: new Map<string, (_event: unknown, args?: unknown) => unknown>(),
|
||||
runProcess: vi.fn<(spec: { args?: string[] }) => Promise<ProcessResult>>(),
|
||||
fsCalls: new Array<string>(),
|
||||
running: false,
|
||||
home: ''
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: (channel: string, handler: (_event: unknown, args?: unknown) => unknown) =>
|
||||
mocks.handlers.set(channel, handler),
|
||||
on: (channel: string, listener: (_event: unknown, args?: unknown) => unknown) =>
|
||||
mocks.listeners.set(channel, listener)
|
||||
}
|
||||
}))
|
||||
vi.mock('@orca/process-host', () => ({ runProcess: mocks.runProcess }))
|
||||
|
||||
const GUEST_HOME = '\\\\wsl.localhost\\Ubuntu\\home\\ada'
|
||||
const ROOT = '\\\\wsl.localhost\\Ubuntu\\'
|
||||
const TRANSCRIPT = `${GUEST_HOME}\\.claude\\projects\\-repo\\sess-wsl.jsonl`
|
||||
|
||||
// The distro's 9P share, backed by a temp dir: any touch of it starts the distro, as on Windows.
|
||||
function onShare(path: string): string {
|
||||
mocks.fsCalls.push(path)
|
||||
mocks.running = true
|
||||
return path === ROOT ? mocks.home : join(mocks.home, ...path.slice(GUEST_HOME.length).split('\\'))
|
||||
}
|
||||
|
||||
vi.mock('../native-chat/wsl-transcript-fs-process-dispatch', async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
runWslTranscriptFsProcess: async (request: WslTranscriptFsReusableProcessCall) => {
|
||||
const path = onShare(request.path)
|
||||
switch (request.operation) {
|
||||
case 'access':
|
||||
await access(path)
|
||||
return true
|
||||
case 'stat':
|
||||
return stat(path)
|
||||
case 'lstat':
|
||||
return lstat(path)
|
||||
case 'readdir':
|
||||
return readdir(path, { withFileTypes: true })
|
||||
case 'readfile':
|
||||
return readFile(path, request.encoding)
|
||||
}
|
||||
},
|
||||
openWslTranscriptFsProcess: async (path: string) => open(onShare(path), 'r')
|
||||
}))
|
||||
|
||||
import { _resetWslCachesForTests } from '../wsl'
|
||||
import { _resetWslBackgroundDistroGateForTests } from '../wsl-background-distro-gate'
|
||||
import { resetWslTranscriptFsGateForTests } from '../native-chat/wsl-transcript-fs-gate'
|
||||
import { readNativeChatTranscriptTail } from '../native-chat/transcript-watch'
|
||||
import {
|
||||
clearNativeChatSubscriptions,
|
||||
clearNativeChatTranscriptCache,
|
||||
registerNativeChatHandlers
|
||||
} from './native-chat'
|
||||
|
||||
let originalPlatform: NodeJS.Platform
|
||||
|
||||
beforeEach(async () => {
|
||||
originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
mocks.home = await mkdtemp(join(tmpdir(), 'orca-wsl-open-wake-'))
|
||||
const dir = join(mocks.home, '.claude', 'projects', '-repo')
|
||||
await mkdir(dir, { recursive: true })
|
||||
await writeFile(
|
||||
join(dir, 'sess-wsl.jsonl'),
|
||||
`${JSON.stringify({
|
||||
type: 'user',
|
||||
uuid: 'u-1',
|
||||
timestamp: '2026-06-01T10:00:00.000Z',
|
||||
message: { role: 'user', content: 'Hi from WSL' }
|
||||
})}\n`
|
||||
)
|
||||
mocks.running = false
|
||||
mocks.fsCalls = []
|
||||
mocks.runProcess.mockReset().mockImplementation(async (spec) => ({
|
||||
code: 0,
|
||||
signal: null,
|
||||
stdout: spec.args?.includes('--running') && mocks.running ? 'Ubuntu\n' : '',
|
||||
stderr: '',
|
||||
timedOut: false
|
||||
}))
|
||||
clearNativeChatTranscriptCache()
|
||||
mocks.handlers.clear()
|
||||
registerNativeChatHandlers()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
clearNativeChatSubscriptions()
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
_resetWslCachesForTests()
|
||||
_resetWslBackgroundDistroGateForTests()
|
||||
resetWslTranscriptFsGateForTests()
|
||||
await rm(mocks.home, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
// verify-18 review: opening a chat whose transcript is in a stopped distro is a user act.
|
||||
describe('a user-opened chat on a stopped WSL distro', () => {
|
||||
it('starts the distro and shows its history', async () => {
|
||||
const result = await mocks.handlers.get('nativeChat:readSession')!(
|
||||
{},
|
||||
{ agent: 'claude', sessionId: 'sess-wsl', transcriptPath: TRANSCRIPT }
|
||||
)
|
||||
|
||||
expect(mocks.fsCalls[0]).toBe(ROOT)
|
||||
expect(result).toMatchObject({ messages: [expect.objectContaining({ role: 'user' })] })
|
||||
})
|
||||
|
||||
it('starts the distro when a chat pane subscribes, and paints its history', async () => {
|
||||
const events = new EventEmitter()
|
||||
const send = vi.fn<(channel: string, payload: unknown) => void>()
|
||||
const sender = {
|
||||
id: 1,
|
||||
isDestroyed: () => false,
|
||||
on: (event: string, callback: () => void) => void events.on(event, callback),
|
||||
removeListener: (event: string, callback: () => void) =>
|
||||
void events.removeListener(event, callback),
|
||||
send
|
||||
}
|
||||
mocks.listeners.get('nativeChat:subscribe')!(
|
||||
{ sender },
|
||||
{
|
||||
subscriptionId: 'pane-1',
|
||||
agent: 'claude',
|
||||
sessionId: 'sess-wsl',
|
||||
transcriptPath: TRANSCRIPT
|
||||
}
|
||||
)
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
'nativeChat:appended',
|
||||
expect.objectContaining({
|
||||
frame: expect.objectContaining({
|
||||
type: 'snapshot',
|
||||
messages: [expect.objectContaining({ role: 'user' })]
|
||||
})
|
||||
})
|
||||
)
|
||||
)
|
||||
expect(mocks.fsCalls[0]).toBe(ROOT)
|
||||
})
|
||||
|
||||
it('still refuses the same read when nobody opened the chat', async () => {
|
||||
const result = await readNativeChatTranscriptTail({
|
||||
agent: 'claude',
|
||||
sessionId: 'sess-wsl',
|
||||
transcriptPath: TRANSCRIPT,
|
||||
limit: 20
|
||||
})
|
||||
|
||||
expect(mocks.fsCalls).toEqual([])
|
||||
expect(mocks.running).toBe(false)
|
||||
expect(result).not.toHaveProperty('messages')
|
||||
})
|
||||
})
|
||||
+11
-12
@@ -14,9 +14,8 @@ import {
|
||||
type SubscribeNativeChatTranscriptArgs
|
||||
} from '../native-chat/transcript-watch'
|
||||
import { abortWhenRendererGone } from './renderer-lifetime-abort'
|
||||
import { agentHookServer } from '../agent-hooks/server'
|
||||
import { nativeChatTranscriptPathOnExecutionHost } from '../native-chat/ssh-transcript-path'
|
||||
import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
|
||||
import { openNativeChatTranscript } from '../native-chat/native-chat-open-wake'
|
||||
|
||||
export { clearNativeChatTranscriptCache }
|
||||
|
||||
@@ -34,14 +33,13 @@ export type NativeChatReadSessionArgs = {
|
||||
async function readSession(args: NativeChatReadSessionArgs): Promise<ReadTranscriptResult> {
|
||||
const { agent, sessionId } = args
|
||||
const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DESKTOP_READ_WINDOW
|
||||
// Showing or paging a chat's history is a user act on its WSL distro.
|
||||
const { transcriptPath, waking } = openNativeChatTranscript(sessionId, args.transcriptPath)
|
||||
await waking
|
||||
return readNativeChatTranscriptTail({
|
||||
agent,
|
||||
sessionId,
|
||||
transcriptPath: nativeChatTranscriptPathOnExecutionHost(
|
||||
agentHookServer.getStatusSnapshot(),
|
||||
sessionId,
|
||||
args.transcriptPath
|
||||
),
|
||||
transcriptPath,
|
||||
limit,
|
||||
sourceOrder: true
|
||||
})
|
||||
@@ -188,14 +186,12 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg
|
||||
const canPublish = (): boolean =>
|
||||
!sender.isDestroyed() && !rendererSignal.aborted && !pending.controller.signal.aborted
|
||||
|
||||
// Opening a chat is a user act on its WSL distro; the watch that follows stays background.
|
||||
const opened = openNativeChatTranscript(sessionId, transcriptPath)
|
||||
const subscribeArgs: SubscribeNativeChatTranscriptArgs = {
|
||||
agent,
|
||||
sessionId,
|
||||
transcriptPath: nativeChatTranscriptPathOnExecutionHost(
|
||||
agentHookServer.getStatusSnapshot(),
|
||||
sessionId,
|
||||
transcriptPath
|
||||
),
|
||||
transcriptPath: opened.transcriptPath,
|
||||
initialLimit: limit,
|
||||
sourceOrder: true,
|
||||
onTranscriptPending: () => {
|
||||
@@ -257,6 +253,9 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg
|
||||
}
|
||||
let subscription: NativeChatTranscriptSubscription
|
||||
try {
|
||||
if (opened.waking) {
|
||||
await opened.waking
|
||||
}
|
||||
subscription = await subscribeNativeChatTranscript(subscribeArgs, pending.controller.signal)
|
||||
} catch {
|
||||
takePendingSubscription(sender.id, subscriptionId, pending)
|
||||
|
||||
@@ -6,6 +6,15 @@ const wslMocks = vi.hoisted(() => ({
|
||||
resolveWslGuestRealDirAsync: vi.fn()
|
||||
}))
|
||||
|
||||
// These cases cover root and path policy; the E9 gate has its own suite.
|
||||
vi.mock('./wsl-transcript-running-admission', () => ({ admitWslTranscriptPath: async () => {} }))
|
||||
vi.mock('../wsl/wsl-home-unless-stopped', async () => {
|
||||
const wsl = await import('../wsl')
|
||||
return {
|
||||
wslHomeUnlessStopped: (distro: string) => wsl.getWslHomeAsync(distro),
|
||||
listRunningWslHomeDirsUnlessStopped: () => wsl.listRunningWslHomeDirsAsync()
|
||||
}
|
||||
})
|
||||
vi.mock('../wsl', () => ({
|
||||
listRunningWslHomeDirsAsync: wslMocks.listRunningWslHomeDirsAsync,
|
||||
resolveWslGuestRealDirAsync: wslMocks.resolveWslGuestRealDirAsync
|
||||
|
||||
@@ -6,7 +6,12 @@ import {
|
||||
wslHomeSessionDirs,
|
||||
type SessionHomeAgent
|
||||
} from '../ai-vault/session-scanner-roots'
|
||||
import { getWslHomeAsync, listRunningWslHomeDirsAsync, resolveWslGuestRealDirAsync } from '../wsl'
|
||||
import { resolveWslGuestRealDirAsync } from '../wsl'
|
||||
import {
|
||||
listRunningWslHomeDirsUnlessStopped,
|
||||
wslHomeUnlessStopped
|
||||
} from '../wsl/wsl-home-unless-stopped'
|
||||
import { admitWslTranscriptPath } from './wsl-transcript-running-admission'
|
||||
import {
|
||||
filterPathsToRunningWslDistrosAsync,
|
||||
filterPathsToWslDistros
|
||||
@@ -48,7 +53,9 @@ async function snapshotHomeDirs(snapshot: WslTranscriptResolutionSnapshot): Prom
|
||||
if (snapshot.homeDirs) {
|
||||
return snapshot.homeDirs
|
||||
}
|
||||
const homes = await Promise.all(snapshot.runningDistros.map((distro) => getWslHomeAsync(distro)))
|
||||
const homes = await Promise.all(
|
||||
snapshot.runningDistros.map((distro) => wslHomeUnlessStopped(distro))
|
||||
)
|
||||
return homes.filter((home): home is string => home !== null)
|
||||
}
|
||||
|
||||
@@ -107,7 +114,16 @@ export function hostAdditionalCodexHomePaths(): string[] {
|
||||
}
|
||||
|
||||
async function defaultListWslHomeDirs(): Promise<string[]> {
|
||||
return listRunningWslHomeDirsAsync()
|
||||
return listRunningWslHomeDirsUnlessStopped()
|
||||
}
|
||||
|
||||
// `realpath` runs through `wsl.exe --exec`, which would boot a stopped distro.
|
||||
async function resolveGuestRealDirUnlessStopped(
|
||||
distro: string,
|
||||
guestDir: string
|
||||
): Promise<string | null> {
|
||||
await admitWslTranscriptPath(toWindowsWslPath('/', distro))
|
||||
return resolveWslGuestRealDirAsync(distro, guestDir)
|
||||
}
|
||||
|
||||
function resolveWslHomeDirs(load?: () => Promise<string[]>): Promise<string[]> {
|
||||
@@ -151,7 +167,7 @@ async function guestSymlinkTarget(
|
||||
const key = `${distro}\0${dir}`
|
||||
let entry = guestRealDirs.get(key)
|
||||
if (!entry || Date.now() >= entry.expiresAt) {
|
||||
const resolve = deps.resolveGuestRealDir ?? resolveWslGuestRealDirAsync
|
||||
const resolve = deps.resolveGuestRealDir ?? resolveGuestRealDirUnlessStopped
|
||||
entry = {
|
||||
realDir: resolve(distro, dir).catch(() => null),
|
||||
expiresAt: Date.now() + GUEST_REAL_DIR_TTL_MS
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { AgentStatusIpcPayload } from '../../shared/agent-status-types'
|
||||
import {
|
||||
isWslHookRelayConnectionId,
|
||||
WSL_HOOK_RELAY_CONNECTION_PREFIX
|
||||
} from '../../shared/wsl-hook-relay-contract'
|
||||
import { toWindowsWslPath } from '../../shared/wsl-paths'
|
||||
import {
|
||||
blockedWslDistroForPath,
|
||||
runAsWslUserAct,
|
||||
wslDistroForPath
|
||||
} from '../wsl/wsl-unc-access-gate'
|
||||
import { wslGatedAccess } from './wsl-transcript-fs-access'
|
||||
import { WSL_TRANSCRIPT_RUNNING_LEASE_MS } from './wsl-transcript-running-admission'
|
||||
import { agentHookServer } from '../agent-hooks/server'
|
||||
import { nativeChatTranscriptPathOnExecutionHost } from './ssh-transcript-path'
|
||||
|
||||
type HookRow = Pick<AgentStatusIpcPayload, 'connectionId' | 'providerSession'>
|
||||
|
||||
/** The distro an opened chat's transcript lives in: its UNC path, else the WSL relay that reported it. */
|
||||
export function nativeChatTranscriptDistro(
|
||||
statusRows: readonly HookRow[],
|
||||
sessionId: string,
|
||||
transcriptPath: string | undefined
|
||||
): string | null {
|
||||
const fromPath = transcriptPath ? wslDistroForPath(transcriptPath) : null
|
||||
if (fromPath || process.platform !== 'win32') {
|
||||
return fromPath
|
||||
}
|
||||
const relay = statusRows.find(
|
||||
(row) => row.providerSession?.id === sessionId && isWslHookRelayConnectionId(row.connectionId)
|
||||
)?.connectionId
|
||||
return relay ? relay.slice(WSL_HOOK_RELAY_CONNECTION_PREFIX.length) || null : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Opening or paging a chat is a user act: start its stopped distro so the gated transcript reads
|
||||
* that follow can run (E9). Later watch and poll reads stay background and never start it.
|
||||
*/
|
||||
export async function wakeNativeChatTranscriptDistro(distro: string | null): Promise<void> {
|
||||
if (
|
||||
!distro ||
|
||||
!(await blockedWslDistroForPath('', distro, { leaseMs: WSL_TRANSCRIPT_RUNNING_LEASE_MS }))
|
||||
) {
|
||||
return
|
||||
}
|
||||
// The read that follows reports any failure; this touch only has to start the distro.
|
||||
await runAsWslUserAct(distro, () => wslGatedAccess(toWindowsWslPath('/', distro), 'exact')).catch(
|
||||
() => {}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The host transcript path of a chat the user opened, and the start of its stopped WSL distro.
|
||||
* `waking` is null without a distro, so other chats open without an extra await.
|
||||
*/
|
||||
export function openNativeChatTranscript(
|
||||
sessionId: string,
|
||||
requestedPath: string | undefined
|
||||
): { transcriptPath: string | undefined; waking: Promise<void> | null } {
|
||||
const statusRows = agentHookServer.getStatusSnapshot()
|
||||
const transcriptPath = nativeChatTranscriptPathOnExecutionHost(
|
||||
statusRows,
|
||||
sessionId,
|
||||
requestedPath
|
||||
)
|
||||
const distro = nativeChatTranscriptDistro(statusRows, sessionId, transcriptPath)
|
||||
return { transcriptPath, waking: distro ? wakeNativeChatTranscriptDistro(distro) : null }
|
||||
}
|
||||
@@ -29,6 +29,15 @@ vi.mock('./wsl-transcript-fs-gate', async (importOriginal) => ({
|
||||
vi.mock('../ai-vault/session-scanner-discovery', () => ({
|
||||
walkSessionFiles: mocks.walk
|
||||
}))
|
||||
// These cases cover root and path policy; the E9 gate has its own suite.
|
||||
vi.mock('./wsl-transcript-running-admission', () => ({ admitWslTranscriptPath: async () => {} }))
|
||||
vi.mock('../wsl/wsl-home-unless-stopped', async () => {
|
||||
const wsl = await import('../wsl')
|
||||
return {
|
||||
wslHomeUnlessStopped: (distro: string) => wsl.getWslHomeAsync(distro),
|
||||
listRunningWslHomeDirsUnlessStopped: () => wsl.listRunningWslHomeDirsAsync()
|
||||
}
|
||||
})
|
||||
vi.mock('../wsl', () => ({
|
||||
getWslHomeAsync: vi.fn(async () => '\\\\wsl.localhost\\Ubuntu\\home\\ada'),
|
||||
listRunningWslDistrosAsync: vi.fn(async () => ['Ubuntu']),
|
||||
|
||||
@@ -9,6 +9,15 @@ const ROLLOUT_UNC =
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\ada\\.local\\share\\orca\\codex-runtime-home\\home\\sessions\\2026\\07\\24\\rollout-wsl-sess.jsonl'
|
||||
const DEBIAN_ROLLOUT_UNC = ROLLOUT_UNC.replace('Ubuntu', 'Debian')
|
||||
|
||||
// These cases cover root and path policy; the E9 gate has its own suite.
|
||||
vi.mock('./wsl-transcript-running-admission', () => ({ admitWslTranscriptPath: async () => {} }))
|
||||
vi.mock('../wsl/wsl-home-unless-stopped', async () => {
|
||||
const wsl = await import('../wsl')
|
||||
return {
|
||||
wslHomeUnlessStopped: (distro: string) => wsl.getWslHomeAsync(distro),
|
||||
listRunningWslHomeDirsUnlessStopped: () => wsl.listRunningWslHomeDirsAsync()
|
||||
}
|
||||
})
|
||||
vi.mock('../wsl', () => ({
|
||||
listWslDistrosAsync: vi.fn(async () => ['Ubuntu', 'Debian']),
|
||||
listRunningWslDistrosAsync: vi.fn(async () => ['Ubuntu', 'Debian']),
|
||||
|
||||
@@ -24,6 +24,15 @@ vi.mock('./transcript-watch-engine', () => ({
|
||||
getActiveNativeChatWatcherCount: vi.fn(() => 0),
|
||||
installTranscriptWatcher: mocks.install
|
||||
}))
|
||||
// These cases cover root and path policy; the E9 gate has its own suite.
|
||||
vi.mock('./wsl-transcript-running-admission', () => ({ admitWslTranscriptPath: async () => {} }))
|
||||
vi.mock('../wsl/wsl-home-unless-stopped', async () => {
|
||||
const wsl = await import('../wsl')
|
||||
return {
|
||||
wslHomeUnlessStopped: (distro: string) => wsl.getWslHomeAsync(distro),
|
||||
listRunningWslHomeDirsUnlessStopped: () => wsl.listRunningWslHomeDirsAsync()
|
||||
}
|
||||
})
|
||||
vi.mock('../wsl', () => ({
|
||||
getWslHomeAsync: mocks.getWslHomeAsync,
|
||||
listRunningWslDistrosAsync: mocks.listRunningWslDistrosAsync,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from './wsl-transcript-fs-process-dispatch'
|
||||
import type { WslTranscriptFsReusableProcessCall } from './wsl-transcript-fs-process-protocol'
|
||||
import { wslTranscriptFsLaneKey } from './wsl-transcript-fs-route'
|
||||
import { admitWslTranscriptPath } from './wsl-transcript-running-admission'
|
||||
import {
|
||||
readSshTranscript,
|
||||
statSshTranscript,
|
||||
@@ -41,14 +42,16 @@ function runReusableFsOperation<T>(
|
||||
localTask: () => Promise<T>
|
||||
): Promise<T> {
|
||||
return isWslUncPath(request.path)
|
||||
? runWslTranscriptFsTask(
|
||||
{ operation: request.operation, path: request.path, priority, signal },
|
||||
(taskSignal) =>
|
||||
runWslTranscriptFsProcess<T>(
|
||||
request,
|
||||
taskSignal,
|
||||
wslTranscriptFsLaneKey(request.path, priority)
|
||||
)
|
||||
? admitWslTranscriptPath(request.path).then(() =>
|
||||
runWslTranscriptFsTask(
|
||||
{ operation: request.operation, path: request.path, priority, signal },
|
||||
(taskSignal) =>
|
||||
runWslTranscriptFsProcess<T>(
|
||||
request,
|
||||
taskSignal,
|
||||
wslTranscriptFsLaneKey(request.path, priority)
|
||||
)
|
||||
)
|
||||
)
|
||||
: localTask()
|
||||
}
|
||||
@@ -123,6 +126,15 @@ export function openTranscriptFile(
|
||||
if (!isWslUncPath(path)) {
|
||||
return open(path, 'r')
|
||||
}
|
||||
return openAdmittedWslTranscriptFile(path, priority, signal)
|
||||
}
|
||||
|
||||
async function openAdmittedWslTranscriptFile(
|
||||
path: string,
|
||||
priority: WslTranscriptFsTaskPriority,
|
||||
signal?: AbortSignal
|
||||
): Promise<TranscriptFileHandle> {
|
||||
await admitWslTranscriptPath(path)
|
||||
return runWslTranscriptFsTask<TranscriptFileHandle>(
|
||||
{
|
||||
operation: 'open',
|
||||
|
||||
@@ -2,6 +2,7 @@ export const WSL_TRANSCRIPT_FS_SLOW_MESSAGE =
|
||||
'WSL transcript files are temporarily unavailable because filesystem access is taking too long. Try again shortly or restart Orca if the issue continues.'
|
||||
export const WSL_TRANSCRIPT_FS_CAPACITY_MESSAGE =
|
||||
'WSL transcript discovery is temporarily unavailable because too many filesystem requests are already waiting. Try again shortly or restart Orca if the issue continues.'
|
||||
const WSL_TRANSCRIPT_FS_DISTRO_NOT_RUNNING_PREFIX = 'WSL transcript files are unavailable because'
|
||||
const WSL_TRANSCRIPT_FS_PROCESS_FAILURE_PREFIX =
|
||||
'WSL transcript files are temporarily unavailable because the filesystem helper process failed'
|
||||
|
||||
@@ -38,9 +39,23 @@ export function wslTranscriptFsProcessFailureError(detail: unknown): WslTranscri
|
||||
)
|
||||
}
|
||||
|
||||
/** Reading would boot the distro (E9), or WSL couldn't say it runs; nothing touched its files. */
|
||||
export function wslTranscriptFsDistroNotRunningError(
|
||||
distro: string,
|
||||
state: 'stopped' | 'unverifiable'
|
||||
): WslTranscriptFsError {
|
||||
return new WslTranscriptFsError(
|
||||
'unavailable',
|
||||
state === 'stopped'
|
||||
? `${WSL_TRANSCRIPT_FS_DISTRO_NOT_RUNNING_PREFIX} WSL distro '${distro}' is not running. Open a terminal in it to start it.`
|
||||
: `${WSL_TRANSCRIPT_FS_DISTRO_NOT_RUNNING_PREFIX} WSL didn't answer whether '${distro}' is running. Try again shortly.`
|
||||
)
|
||||
}
|
||||
|
||||
export function isWslTranscriptFsRefusalMessage(message: string): boolean {
|
||||
return (
|
||||
message === WSL_TRANSCRIPT_FS_SLOW_MESSAGE ||
|
||||
message.startsWith(WSL_TRANSCRIPT_FS_DISTRO_NOT_RUNNING_PREFIX) ||
|
||||
message === WSL_TRANSCRIPT_FS_CAPACITY_MESSAGE ||
|
||||
message.startsWith(WSL_TRANSCRIPT_FS_PROCESS_FAILURE_PREFIX)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ProcessResult } from '@orca/process-host/process-spec'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
runProcess: vi.fn(),
|
||||
runFsProcess: vi.fn(async () => [])
|
||||
}))
|
||||
|
||||
vi.mock('@orca/process-host', () => ({ runProcess: mocks.runProcess }))
|
||||
vi.mock('./wsl-transcript-fs-process-dispatch', async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
runWslTranscriptFsProcess: mocks.runFsProcess
|
||||
}))
|
||||
|
||||
import { _resetWslCachesForTests } from '../wsl'
|
||||
import { _resetWslBackgroundDistroGateForTests } from '../wsl-background-distro-gate'
|
||||
import { runAsWslUserAct } from '../wsl/wsl-unc-access-gate'
|
||||
import { 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'
|
||||
|
||||
const SESSIONS = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.claude\\projects'
|
||||
|
||||
function runningList(...distros: string[]): ProcessResult {
|
||||
const stdout = distros.map((distro) => `${distro}\n`).join('')
|
||||
return { code: 0, signal: null, stdout, stderr: '', timedOut: false }
|
||||
}
|
||||
|
||||
const listProbes = (): number =>
|
||||
mocks.runProcess.mock.calls.filter(([spec]) => spec.args?.includes('--running')).length
|
||||
|
||||
let originalPlatform: NodeJS.Platform
|
||||
|
||||
beforeEach(() => {
|
||||
originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
mocks.runProcess.mockReset()
|
||||
mocks.runFsProcess.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
_resetWslCachesForTests()
|
||||
_resetWslBackgroundDistroGateForTests()
|
||||
resetWslTranscriptFsGateForTests()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('transcript reads never boot a stopped WSL distro (E9)', () => {
|
||||
it('refuses a background read of a stopped distro without touching its files', async () => {
|
||||
mocks.runProcess.mockResolvedValue(runningList())
|
||||
|
||||
const read = wslGatedReaddir(SESSIONS, 'scan')
|
||||
|
||||
await expect(read).rejects.toBeInstanceOf(WslTranscriptFsError)
|
||||
await expect(read).rejects.toThrow("WSL distro 'Ubuntu' is not running")
|
||||
expect(mocks.runFsProcess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads a running distro', async () => {
|
||||
mocks.runProcess.mockResolvedValue(runningList('Ubuntu'))
|
||||
|
||||
await expect(wslGatedReaddir(SESSIONS, 'scan')).resolves.toEqual([])
|
||||
expect(mocks.runFsProcess).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('re-checks a running read older than its short lease, as after a `wsl --terminate`', async () => {
|
||||
vi.useFakeTimers({ toFake: ['performance', 'Date', 'setTimeout'] })
|
||||
mocks.runProcess.mockResolvedValue(runningList('Ubuntu'))
|
||||
await wslGatedReaddir(SESSIONS, 'scan')
|
||||
|
||||
// The distro is terminated; nothing tells this process. The next read must ask again.
|
||||
mocks.runProcess.mockResolvedValue(runningList())
|
||||
vi.advanceTimersByTime(WSL_TRANSCRIPT_RUNNING_LEASE_MS + 1)
|
||||
|
||||
await expect(wslGatedReaddir(SESSIONS, 'scan')).rejects.toThrow('is not running')
|
||||
expect(listProbes()).toBe(2)
|
||||
expect(mocks.runFsProcess).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it("lets a user act on the distro read it, as the user's own touch may start it", async () => {
|
||||
mocks.runProcess.mockResolvedValue(runningList())
|
||||
|
||||
await expect(
|
||||
runAsWslUserAct('Ubuntu', () => wslGatedReaddir(SESSIONS, 'exact'))
|
||||
).resolves.toEqual([])
|
||||
expect(mocks.runFsProcess).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { blockedWslDistroForPath } from '../wsl/wsl-unc-access-gate'
|
||||
import { wslTranscriptFsDistroNotRunningError } from './wsl-transcript-fs-error'
|
||||
|
||||
/**
|
||||
* Nothing tells a transcript reader (main or the session scanner process) that a distro stopped,
|
||||
* so a running read is trusted only this long: about one `wsl --list --running` per 5 s of reads.
|
||||
*/
|
||||
export const WSL_TRANSCRIPT_RUNNING_LEASE_MS = 5_000
|
||||
|
||||
/**
|
||||
* Any 9P touch of `\\wsl.localhost\<distro>`, or a `wsl.exe --exec` there, boots a stopped distro.
|
||||
* Transcript reads are background work, so they refuse unless it runs (E9); a user act passes.
|
||||
*/
|
||||
export async function admitWslTranscriptPath(path: string): Promise<void> {
|
||||
const blocked = await blockedWslDistroForPath(path, null, {
|
||||
leaseMs: WSL_TRANSCRIPT_RUNNING_LEASE_MS
|
||||
})
|
||||
if (blocked) {
|
||||
throw wslTranscriptFsDistroNotRunningError(blocked.distro, blocked.state)
|
||||
}
|
||||
}
|
||||
@@ -49,14 +49,38 @@ export async function readConfirmedRunningWslDistros(
|
||||
}
|
||||
}
|
||||
|
||||
export type WslDistroGateNote = { kind: 'stopped' | 'woken'; distro: string }
|
||||
const noteListeners = new Set<(note: WslDistroGateNote) => void>()
|
||||
|
||||
function ensureStopSubscription(): void {
|
||||
unsubscribeStops ??= onRunningWslDistrosStopped((stopped) => {
|
||||
for (const candidate of stopped) {
|
||||
noteWslDistroMayHaveStopped(candidate)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Stop and wake notes, for a utility process (the session scanner) whose own gate never hears them. */
|
||||
export function onWslDistroGateNote(listener: (note: WslDistroGateNote) => void): () => void {
|
||||
ensureStopSubscription()
|
||||
noteListeners.add(listener)
|
||||
return () => noteListeners.delete(listener)
|
||||
}
|
||||
|
||||
/** A link into the distro died, or a read saw it stop: later running reads must start after it settles. */
|
||||
export function noteWslDistroMayHaveStopped(distro: string): void {
|
||||
mayHaveStoppedAt.set(distroKey(distro), performance.now())
|
||||
for (const listener of noteListeners) {
|
||||
listener({ kind: 'stopped', distro })
|
||||
}
|
||||
}
|
||||
|
||||
/** A user act just touched the distro, which starts it: an older 'stopped' read no longer answers. */
|
||||
export function noteWslDistroWoken(distro: string): void {
|
||||
wokenAt.set(distroKey(distro), performance.now())
|
||||
for (const listener of noteListeners) {
|
||||
listener({ kind: 'woken', distro })
|
||||
}
|
||||
}
|
||||
|
||||
/** A wsl.exe launch starts its distro: if the last read said stopped, that read no longer answers. */
|
||||
@@ -85,11 +109,7 @@ export async function settledWslRunningReadAnchor(
|
||||
distro: string,
|
||||
startedAfter: number | undefined
|
||||
): Promise<number | undefined> {
|
||||
unsubscribeStops ??= onRunningWslDistrosStopped((stopped) => {
|
||||
for (const candidate of stopped) {
|
||||
noteWslDistroMayHaveStopped(candidate)
|
||||
}
|
||||
})
|
||||
ensureStopSubscription()
|
||||
const floor = settledRunningFloor(distro)
|
||||
const anchor = floor === -Infinity ? startedAfter : Math.max(startedAfter ?? -Infinity, floor)
|
||||
// Timers truncate to whole ms on a ms clock and can wake ~2ms short; sleep until the anchor has passed.
|
||||
@@ -260,4 +280,5 @@ export function _resetWslBackgroundDistroGateForTests(): void {
|
||||
wokenAt.clear()
|
||||
unsubscribeStops?.()
|
||||
unsubscribeStops = null
|
||||
noteListeners.clear()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getWslHomeAsync } from '../wsl'
|
||||
import { getWslHomeAsync, listRunningWslDistrosAsync } from '../wsl'
|
||||
import { getCachedWslHome } from '../wsl-home-cache'
|
||||
import { blockedWslDistroForPath } from './wsl-unc-access-gate'
|
||||
|
||||
@@ -9,3 +9,11 @@ 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))
|
||||
)
|
||||
return homes.filter((home): home is string => Boolean(home))
|
||||
}
|
||||
|
||||
@@ -58,14 +58,15 @@ export function wslDistroForPath(path: string, wslDistro?: string | null): strin
|
||||
*/
|
||||
export async function blockedWslDistroForPath(
|
||||
path: string,
|
||||
wslDistro?: string | null
|
||||
wslDistro?: string | null,
|
||||
options: { leaseMs?: number } = {}
|
||||
): Promise<BlockedWslDistro | null> {
|
||||
const distro = wslDistroForPath(path, wslDistro)
|
||||
if (!distro || isWslUserActFor(distro)) {
|
||||
return null
|
||||
}
|
||||
const state = await readWslDistroRunningState(distro, {
|
||||
startedAfter: performance.now() - WSL_UNC_RUNNING_LEASE_MS,
|
||||
startedAfter: performance.now() - (options.leaseMs ?? WSL_UNC_RUNNING_LEASE_MS),
|
||||
retryUnverifiable: true
|
||||
})
|
||||
return state === 'running' ? null : { distro, state }
|
||||
|
||||
Reference in New Issue
Block a user