diff --git a/src/main/ai-vault/cached-session-list.test.ts b/src/main/ai-vault/cached-session-list.test.ts index a5b3f0c907f..9840874a0e3 100644 --- a/src/main/ai-vault/cached-session-list.test.ts +++ b/src/main/ai-vault/cached-session-list.test.ts @@ -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() }) }) diff --git a/src/main/ai-vault/cached-session-list.ts b/src/main/ai-vault/cached-session-list.ts index e1ac800f5b8..23296760e23 100644 --- a/src/main/ai-vault/cached-session-list.ts +++ b/src/main/ai-vault/cached-session-list.ts @@ -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 { if (hasCachedWslDistros() && getCachedWslDistros()?.length === 0) { return [] } - return listRunningWslHomeDirsAsync() + return listRunningWslHomeDirsUnlessStopped() } export function aiVaultSessionListCacheGeneration(): number { diff --git a/src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts b/src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts index 780adfaf05c..2bd490fe023 100644 --- a/src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts +++ b/src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts @@ -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 diff --git a/src/main/ai-vault/opencode-wsl-runtime-preparation.ts b/src/main/ai-vault/opencode-wsl-runtime-preparation.ts index 36847613265..06fce266468 100644 --- a/src/main/ai-vault/opencode-wsl-runtime-preparation.ts +++ b/src/main/ai-vault/opencode-wsl-runtime-preparation.ts @@ -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() @@ -81,15 +81,8 @@ async function prepare(distro: string): Promise { const signal = AbortSignal.timeout(PREPARATION_TIMEOUT_MS) const run = async (spec: WslSpec, timeoutMs = 15_000): Promise => { 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, diff --git a/src/main/ai-vault/session-scanner-opencode-wsl-client.test.ts b/src/main/ai-vault/session-scanner-opencode-wsl-client.test.ts index 2d0e52bfc81..dc583bd5cc1 100644 --- a/src/main/ai-vault/session-scanner-opencode-wsl-client.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-wsl-client.test.ts @@ -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 diff --git a/src/main/ai-vault/session-scanner-opencode-wsl-client.ts b/src/main/ai-vault/session-scanner-opencode-wsl-client.ts index 1c93c2616a6..7a13b05a951 100644 --- a/src/main/ai-vault/session-scanner-opencode-wsl-client.ts +++ b/src/main/ai-vault/session-scanner-opencode-wsl-client.ts @@ -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) diff --git a/src/main/ai-vault/session-scanner-service-client.ts b/src/main/ai-vault/session-scanner-service-client.ts index 089a34951e2..e374e33b8c8 100644 --- a/src/main/ai-vault/session-scanner-service-client.ts +++ b/src/main/ai-vault/session-scanner-service-client.ts @@ -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( @@ -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.') diff --git a/src/main/ai-vault/session-scanner-service-entry.ts b/src/main/ai-vault/session-scanner-service-entry.ts index 0ae3b39b68d..e23e621b6cc 100644 --- a/src/main/ai-vault/session-scanner-service-entry.ts +++ b/src/main/ai-vault/session-scanner-service-entry.ts @@ -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) } diff --git a/src/main/ai-vault/session-scanner-service-protocol.ts b/src/main/ai-vault/session-scanner-service-protocol.ts index 56ea8d94b04..6feeabf9275 100644 --- a/src/main/ai-vault/session-scanner-service-protocol.ts +++ b/src/main/ai-vault/session-scanner-service-protocol.ts @@ -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 = | { diff --git a/src/main/ai-vault/session-scanner-service-wsl-notes.test.ts b/src/main/ai-vault/session-scanner-service-wsl-notes.test.ts new file mode 100644 index 00000000000..4f93bfd7c45 --- /dev/null +++ b/src/main/ai-vault/session-scanner-service-wsl-notes.test.ts @@ -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) + }) +}) diff --git a/src/main/ai-vault/session-scanner-service-wsl-notes.ts b/src/main/ai-vault/session-scanner-service-wsl-notes.ts new file mode 100644 index 00000000000..2215579a751 --- /dev/null +++ b/src/main/ai-vault/session-scanner-service-wsl-notes.ts @@ -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) + ) +} diff --git a/src/main/ipc/ai-vault-resume.test.ts b/src/main/ipc/ai-vault-resume.test.ts index de1952a298a..524c2d16573 100644 --- a/src/main/ipc/ai-vault-resume.test.ts +++ b/src/main/ipc/ai-vault-resume.test.ts @@ -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) + } + } +}) diff --git a/src/main/ipc/ai-vault-resume.ts b/src/main/ipc/ai-vault-resume.ts index ba455ecf69a..7dbd841adff 100644 --- a/src/main/ipc/ai-vault-resume.ts +++ b/src/main/ipc/ai-vault-resume.ts @@ -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 @@ -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 diff --git a/src/main/ipc/native-chat-wsl-open-wake.test.ts b/src/main/ipc/native-chat-wsl-open-wake.test.ts new file mode 100644 index 00000000000..b7f39b00c58 --- /dev/null +++ b/src/main/ipc/native-chat-wsl-open-wake.test.ts @@ -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 unknown>(), + listeners: new Map unknown>(), + runProcess: vi.fn<(spec: { args?: string[] }) => Promise>(), + fsCalls: new Array(), + 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()), + 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') + }) +}) diff --git a/src/main/ipc/native-chat.ts b/src/main/ipc/native-chat.ts index 1429f634127..e3776903165 100644 --- a/src/main/ipc/native-chat.ts +++ b/src/main/ipc/native-chat.ts @@ -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 { 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) diff --git a/src/main/native-chat/host-readable-transcript-path.test.ts b/src/main/native-chat/host-readable-transcript-path.test.ts index e4285f572a3..9cee7e6871e 100644 --- a/src/main/native-chat/host-readable-transcript-path.test.ts +++ b/src/main/native-chat/host-readable-transcript-path.test.ts @@ -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 diff --git a/src/main/native-chat/host-readable-transcript-path.ts b/src/main/native-chat/host-readable-transcript-path.ts index d120a489a0b..620a0179d55 100644 --- a/src/main/native-chat/host-readable-transcript-path.ts +++ b/src/main/native-chat/host-readable-transcript-path.ts @@ -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 { - return listRunningWslHomeDirsAsync() + return listRunningWslHomeDirsUnlessStopped() +} + +// `realpath` runs through `wsl.exe --exec`, which would boot a stopped distro. +async function resolveGuestRealDirUnlessStopped( + distro: string, + guestDir: string +): Promise { + await admitWslTranscriptPath(toWindowsWslPath('/', distro)) + return resolveWslGuestRealDirAsync(distro, guestDir) } function resolveWslHomeDirs(load?: () => Promise): Promise { @@ -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 diff --git a/src/main/native-chat/native-chat-open-wake.ts b/src/main/native-chat/native-chat-open-wake.ts new file mode 100644 index 00000000000..883aebb7c12 --- /dev/null +++ b/src/main/native-chat/native-chat-open-wake.ts @@ -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 + +/** 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 { + 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 | null } { + const statusRows = agentHookServer.getStatusSnapshot() + const transcriptPath = nativeChatTranscriptPathOnExecutionHost( + statusRows, + sessionId, + requestedPath + ) + const distro = nativeChatTranscriptDistro(statusRows, sessionId, transcriptPath) + return { transcriptPath, waking: distro ? wakeNativeChatTranscriptDistro(distro) : null } +} diff --git a/src/main/native-chat/session-file-resolver-wsl-scan-gate.test.ts b/src/main/native-chat/session-file-resolver-wsl-scan-gate.test.ts index 875aabaab23..72b4d7246b1 100644 --- a/src/main/native-chat/session-file-resolver-wsl-scan-gate.test.ts +++ b/src/main/native-chat/session-file-resolver-wsl-scan-gate.test.ts @@ -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']), diff --git a/src/main/native-chat/session-file-resolver-wsl.test.ts b/src/main/native-chat/session-file-resolver-wsl.test.ts index fc19fb7f414..3c404670b76 100644 --- a/src/main/native-chat/session-file-resolver-wsl.test.ts +++ b/src/main/native-chat/session-file-resolver-wsl.test.ts @@ -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']), diff --git a/src/main/native-chat/transcript-watch-wsl-exact-path.test.ts b/src/main/native-chat/transcript-watch-wsl-exact-path.test.ts index 0137d0de47e..72e26ce7cb0 100644 --- a/src/main/native-chat/transcript-watch-wsl-exact-path.test.ts +++ b/src/main/native-chat/transcript-watch-wsl-exact-path.test.ts @@ -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, diff --git a/src/main/native-chat/wsl-transcript-fs-access.ts b/src/main/native-chat/wsl-transcript-fs-access.ts index 6114921015b..b8195bb53a2 100644 --- a/src/main/native-chat/wsl-transcript-fs-access.ts +++ b/src/main/native-chat/wsl-transcript-fs-access.ts @@ -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( localTask: () => Promise ): Promise { return isWslUncPath(request.path) - ? runWslTranscriptFsTask( - { operation: request.operation, path: request.path, priority, signal }, - (taskSignal) => - runWslTranscriptFsProcess( - request, - taskSignal, - wslTranscriptFsLaneKey(request.path, priority) - ) + ? admitWslTranscriptPath(request.path).then(() => + runWslTranscriptFsTask( + { operation: request.operation, path: request.path, priority, signal }, + (taskSignal) => + runWslTranscriptFsProcess( + 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 { + await admitWslTranscriptPath(path) return runWslTranscriptFsTask( { operation: 'open', diff --git a/src/main/native-chat/wsl-transcript-fs-error.ts b/src/main/native-chat/wsl-transcript-fs-error.ts index 0301aae6ff1..657e81e3817 100644 --- a/src/main/native-chat/wsl-transcript-fs-error.ts +++ b/src/main/native-chat/wsl-transcript-fs-error.ts @@ -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) ) diff --git a/src/main/native-chat/wsl-transcript-running-admission.test.ts b/src/main/native-chat/wsl-transcript-running-admission.test.ts new file mode 100644 index 00000000000..4adecf360b5 --- /dev/null +++ b/src/main/native-chat/wsl-transcript-running-admission.test.ts @@ -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()), + 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() + }) +}) diff --git a/src/main/native-chat/wsl-transcript-running-admission.ts b/src/main/native-chat/wsl-transcript-running-admission.ts new file mode 100644 index 00000000000..3601a06e909 --- /dev/null +++ b/src/main/native-chat/wsl-transcript-running-admission.ts @@ -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\`, 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 { + const blocked = await blockedWslDistroForPath(path, null, { + leaseMs: WSL_TRANSCRIPT_RUNNING_LEASE_MS + }) + if (blocked) { + throw wslTranscriptFsDistroNotRunningError(blocked.distro, blocked.state) + } +} diff --git a/src/main/wsl-background-distro-gate.ts b/src/main/wsl-background-distro-gate.ts index 506a74d4262..c20fb63709e 100644 --- a/src/main/wsl-background-distro-gate.ts +++ b/src/main/wsl-background-distro-gate.ts @@ -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 { - 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() } diff --git a/src/main/wsl/wsl-home-unless-stopped.ts b/src/main/wsl/wsl-home-unless-stopped.ts index d9023cfa0dc..9e8c5d4f17f 100644 --- a/src/main/wsl/wsl-home-unless-stopped.ts +++ b/src/main/wsl/wsl-home-unless-stopped.ts @@ -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 { + const homes = await Promise.all( + (await listRunningWslDistrosAsync()).map((distro) => wslHomeUnlessStopped(distro)) + ) + return homes.filter((home): home is string => Boolean(home)) +} diff --git a/src/main/wsl/wsl-unc-access-gate.ts b/src/main/wsl/wsl-unc-access-gate.ts index 479f94e0cbc..9a93dbe314c 100644 --- a/src/main/wsl/wsl-unc-access-gate.ts +++ b/src/main/wsl/wsl-unc-access-gate.ts @@ -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 { 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 }