diff --git a/src/main/codex/wsl-codex-session-bridge-index.test.ts b/src/main/codex/wsl-codex-session-bridge-index.test.ts index 6e9241a9559..3b37ff58985 100644 --- a/src/main/codex/wsl-codex-session-bridge-index.test.ts +++ b/src/main/codex/wsl-codex-session-bridge-index.test.ts @@ -11,6 +11,8 @@ const { runWslProcessMock, stopping } = vi.hoisted(() => ({ stopping: { value: false } })) +// The E9 running check has its own cases; these cover the chain once the distro runs. +vi.mock('../wsl/wsl-unc-access-gate', () => ({ assertWslUncPathRunning: async () => {} })) vi.mock('../wsl/wsl-runner', () => ({ runWslProcess: runWslProcessMock })) @@ -225,4 +227,91 @@ describe('startWslCodexSessionBridgeInBackground', () => { expect(openStateDb).not.toHaveBeenCalled() expect(runWslProcessMock).not.toHaveBeenCalled() }) + + it('links nothing in a distro that stopped after the launch that started the chain (E9)', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const openStateDb = vi.fn(async () => true) + await startWslCodexSessionBridgeInBackground(TARGET, { + openStateDb, + healPending: vi.fn(), + requireRunning: async () => { + throw new Error("WSL distro 'Ubuntu' is not running.") + } + }) + + expect(openStateDb).not.toHaveBeenCalled() + expect(runWslProcessMock).not.toHaveBeenCalled() + warn.mockRestore() + }) + + it('ends the index heal before its next batch once the distro stops (E9)', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + runWslProcessMock + .mockResolvedValueOnce(bridgeResult([NEWER])) + .mockResolvedValue(markerClearResult) + let running = true + let stoppedBeforeNextBatch: boolean | undefined + const healPending = vi.fn( + async (_home, _pending, options = {}) => { + running = false + await options + .afterBatch?.([{ threadId: '22222222-2222-4222-8222-222222222222', outcome: 'healed' }]) + .catch(() => {}) + stoppedBeforeNextBatch = options.shouldStop?.() + return { + outcome: 'stopped' as const, + healedThreads: 1, + missingThreads: 0, + failedThreads: 0 + } + } + ) + + await startWslCodexSessionBridgeInBackground(TARGET, { + openStateDb: vi.fn(async () => true), + healPending, + requireRunning: async () => { + if (!running) { + throw new Error("WSL distro 'Ubuntu' is not running.") + } + } + }) + + expect(stoppedBeforeNextBatch).toBe(true) + // Only the link step ran; the marker clear after the stop never spawned. + expect(runWslProcessMock).toHaveBeenCalledTimes(1) + warn.mockRestore() + }) + + it('ends the index heal when a running-list probe sees the distro stop (E9)', async () => { + runWslProcessMock + .mockResolvedValueOnce(bridgeResult([NEWER])) + .mockResolvedValue(markerClearResult) + let emitStop: (stopped: string[]) => void = () => {} + let stopped: boolean | undefined + const healPending = vi.fn( + async (_home, _pending, options = {}) => { + emitStop(['ubuntu']) + stopped = options.shouldStop?.() + return { + outcome: 'stopped' as const, + healedThreads: 0, + missingThreads: 0, + failedThreads: 0 + } + } + ) + + await startWslCodexSessionBridgeInBackground(TARGET, { + openStateDb: vi.fn(async () => true), + healPending, + requireRunning: async () => {}, + subscribeStops: (listener) => { + emitStop = (distros) => listener(distros, []) + return () => {} + } + }) + + expect(stopped).toBe(true) + }) }) diff --git a/src/main/codex/wsl-codex-session-bridge.test.ts b/src/main/codex/wsl-codex-session-bridge.test.ts index 8b680bd1e3f..4997f79d40c 100644 --- a/src/main/codex/wsl-codex-session-bridge.test.ts +++ b/src/main/codex/wsl-codex-session-bridge.test.ts @@ -8,6 +8,8 @@ const { runWslProcessMock } = vi.hoisted(() => ({ runWslProcessMock: vi.fn() })) +// The E9 running check has its own cases; these cover the chain once the distro runs. +vi.mock('../wsl/wsl-unc-access-gate', () => ({ assertWslUncPathRunning: async () => {} })) vi.mock('../wsl/wsl-runner', () => ({ runWslProcess: runWslProcessMock })) diff --git a/src/main/codex/wsl-codex-session-bridge.ts b/src/main/codex/wsl-codex-session-bridge.ts index 4cf6f64ca12..48feace5a56 100644 --- a/src/main/codex/wsl-codex-session-bridge.ts +++ b/src/main/codex/wsl-codex-session-bridge.ts @@ -2,6 +2,8 @@ import { posix as pathPosix } from 'node:path' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import { parseWslUncPath } from '../../shared/wsl-paths' import { runWslProcess } from '../wsl/wsl-runner' +import { assertWslUncPathRunning } from '../wsl/wsl-unc-access-gate' +import { onRunningWslDistrosStopped } from '../wsl-running-distro-cache' import { isCodexAccountSessionBridgeStopping } from './codex-account-session-bridge' import { createCodexAccountStateDb, @@ -57,11 +59,16 @@ const WSL_SESSION_BRIDGE_MAX_OUTPUT_BYTES = 64 * 1024 * 1024 type WslBridgeDependencies = { openStateDb: typeof createCodexAccountStateDb healPending: typeof healPendingCodexAccountThreads + /** Throws unless the distro runs; each step outlives the launch that started the chain (E9). */ + requireRunning: (distro: string) => Promise + subscribeStops: typeof onRunningWslDistrosStopped } const defaultWslBridgeDependencies: WslBridgeDependencies = { openStateDb: createCodexAccountStateDb, - healPending: healPendingCodexAccountThreads + healPending: healPendingCodexAccountThreads, + requireRunning: (distro) => assertWslUncPathRunning('', distro), + subscribeStops: onRunningWslDistrosStopped } const emptySummary: WslCodexSessionBridgeSummary = { @@ -119,7 +126,10 @@ async function bridgeAndIndexWslCodexSessions( const buildInvocation = (_codexHomePath: string, timeoutMs: number) => buildWslHealInvocation(target.distro, paths.managedHomePath, timeoutMs) const homeKey = normalizeRuntimePathForComparison(target.managedCodexHomePath) + // Every wsl.exe step below re-checks: the user may stop the distro mid-chain, and a step would boot it. + const requireRunning = (): Promise => dependencies.requireRunning(target.distro) if (!homesWithCompleteIndex.has(homeKey)) { + await requireRunning() // Why: linking before Codex's first open makes that open index everything, // and every other Codex on the home gives up after 30s (#20669). const complete = await dependencies.openStateDb(target.managedCodexHomePath, { @@ -134,6 +144,7 @@ async function bridgeAndIndexWslCodexSessions( if (isCodexAccountSessionBridgeStopping()) { return } + await requireRunning() const summary = await syncWslCodexSessionsIntoManagedHome(target) const pendingThreads = new Map() const markerByThreadId = new Map() @@ -149,6 +160,7 @@ async function bridgeAndIndexWslCodexSessions( } if (markersWithoutThread.length > 0) { // Why: no thread id means thread/read can never settle it, so it would be reported forever. + await requireRunning() await clearIndexPendingMarkers(target.distro, paths, markersWithoutThread).catch( (error: unknown) => { console.warn('[codex-session-bridge] Could not clear WSL index markers:', error) @@ -156,12 +168,29 @@ async function bridgeAndIndexWslCodexSessions( ) } if (pendingThreads.size > 0 && !isCodexAccountSessionBridgeStopping()) { - await dependencies.healPending(target.managedCodexHomePath, pendingThreads, { - buildInvocation, - shouldStop: isCodexAccountSessionBridgeStopping, - afterBatch: (settled) => - clearSettledIndexPendingMarkers(target.distro, paths, settled, markerByThreadId) + await requireRunning() + // Batches spawn synchronously from here on; a confirmed stop ends the heal before the next one. + const distroKey = target.distro.toLowerCase() + let distroStopped = false + const unsubscribe = dependencies.subscribeStops((stopped) => { + distroStopped ||= stopped.some((distro) => distro.toLowerCase() === distroKey) }) + try { + await dependencies.healPending(target.managedCodexHomePath, pendingThreads, { + buildInvocation, + shouldStop: () => distroStopped || isCodexAccountSessionBridgeStopping(), + afterBatch: async (settled) => { + // Also the check before the next batch: a refused read ends the heal there. + await requireRunning().catch((error: unknown) => { + distroStopped = true + throw error + }) + await clearSettledIndexPendingMarkers(target.distro, paths, settled, markerByThreadId) + } + }) + } finally { + unsubscribe() + } } if (summary.bridgeFailed) { throw new Error(`WSL codex session bridge could not link every session for ${target.distro}`) diff --git a/src/main/git/runner-wsl-direct-read.test.ts b/src/main/git/runner-wsl-direct-read.test.ts index 7b5ea88d154..3bfb793bd23 100644 --- a/src/main/git/runner-wsl-direct-read.test.ts +++ b/src/main/git/runner-wsl-direct-read.test.ts @@ -308,7 +308,13 @@ describe('WSL direct Git reads', () => { return command } const terminator = createMockChild() - queueMicrotask(() => terminator.emit('close', 0, null)) + queueMicrotask(() => { + // The kill first confirms the distro still runs, so it cannot boot a stopped one (E9). + if (args?.includes('--running')) { + terminator.stdout.emit('data', Buffer.from(`${DISTRO}\n`)) + } + terminator.emit('close', 0, null) + }) return terminator }) const controller = new AbortController() @@ -326,8 +332,10 @@ describe('WSL direct Git reads', () => { await expect(pending).rejects.toMatchObject({ name: 'AbortError' }) expect(command.kill).not.toHaveBeenCalled() - expect(spawnMock.mock.calls[1]?.[1]?.join(' ')).toContain('kill -TERM') - expect(spawnMock.mock.calls[1]?.[1]).toContain('4321') + const kill = spawnMock.mock.calls.find(([, args]) => + String(args?.join(' ')).includes('kill -TERM') + ) + expect(kill?.[1]).toContain('4321') }) }) diff --git a/src/main/git/wsl-process-group-termination.test.ts b/src/main/git/wsl-process-group-termination.test.ts index 790083786f3..857ec4e766e 100644 --- a/src/main/git/wsl-process-group-termination.test.ts +++ b/src/main/git/wsl-process-group-termination.test.ts @@ -1,9 +1,17 @@ import type { ChildProcess } from 'node:child_process' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as BackgroundDistroGate from '../wsl-background-distro-gate' -const { runWslProcessMock } = vi.hoisted(() => ({ runWslProcessMock: vi.fn() })) +const { runWslProcessMock, readWslDistroRunningStateMock } = vi.hoisted(() => ({ + runWslProcessMock: vi.fn(), + readWslDistroRunningStateMock: vi.fn() +})) vi.mock('../wsl/wsl-runner', () => ({ runWslProcess: runWslProcessMock })) +vi.mock('../wsl-background-distro-gate', async (importOriginal) => ({ + ...(await importOriginal()), + readWslDistroRunningState: readWslDistroRunningStateMock +})) import { createWslProcessGroupTermination } from './wsl-process-group-termination' @@ -11,6 +19,8 @@ describe('WSL process-group termination', () => { beforeEach(() => { runWslProcessMock.mockReset() runWslProcessMock.mockResolvedValue({ code: 0, timedOut: false }) + // Windows CI runs the real gate; these cases kill in a running distro. + readWslDistroRunningStateMock.mockReset().mockResolvedValue('running') }) it('wraps the guest command in a reported process group', () => { @@ -92,4 +102,45 @@ describe('WSL process-group termination', () => { await expect(termination.signal({} as ChildProcess)).resolves.toBe(true) expect(runWslProcessMock.mock.calls[0]?.[0]?.args).toEqual(['4321']) }) + + describe('on Windows (E9)', () => { + const originalPlatform = process.platform + + beforeEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + }) + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the guest barrier never reads the host child. + const child = {} as ChildProcess + + function reportedGroup(): ReturnType { + const termination = createWslProcessGroupTermination('Ubuntu') + const wrapped = termination.wrapGuestArgs(['git', 'fetch']).join(' ') + const marker = wrapped.match(/(__ORCA_WSL_PROCESS_GROUP_[0-9a-f-]+__=)/)?.[1] + termination.observeStderr?.(Buffer.from(`${marker}4321\n`)) + return termination + } + + it('treats a group in a stopped distro as gone instead of booting it to kill', async () => { + readWslDistroRunningStateMock.mockResolvedValue('stopped') + await expect(reportedGroup().force(child)).resolves.toBe(true) + expect(runWslProcessMock).not.toHaveBeenCalled() + }) + + it('claims nothing when WSL cannot say whether the distro runs', async () => { + readWslDistroRunningStateMock.mockResolvedValue('unverifiable') + await expect(reportedGroup().signal(child)).resolves.toBe(false) + expect(runWslProcessMock).not.toHaveBeenCalled() + }) + + it('kills in a running distro as before', async () => { + readWslDistroRunningStateMock.mockResolvedValue('running') + await expect(reportedGroup().signal(child)).resolves.toBe(true) + expect(runWslProcessMock).toHaveBeenCalledOnce() + }) + }) }) diff --git a/src/main/git/wsl-process-group-termination.ts b/src/main/git/wsl-process-group-termination.ts index 8a696cdb600..61d0c834cbc 100644 --- a/src/main/git/wsl-process-group-termination.ts +++ b/src/main/git/wsl-process-group-termination.ts @@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto' import type { ProcessTerminationBarrier } from '@orca/process-host/process-spec' import { quotePosixShell } from '../../shared/shell-argument-quoting' import { runWslProcess } from '../wsl/wsl-runner' +import { blockedWslDistroForPath } from '../wsl/wsl-unc-access-gate' const GUEST_TERMINATION_ATTEMPTS = 40 const GUEST_TERMINATION_INTERVAL_SECONDS = '0.025' @@ -32,6 +33,12 @@ export function createWslProcessGroupTermination(distro: string): WslProcessGrou if (processGroupId === null) { return false } + // A cancel or timeout can land after the distro stopped; a kill would boot it again (E9). + // A confirmed stop already ended the group, while an unanswered read proves nothing. + const blocked = await blockedWslDistroForPath('', distro) + if (blocked) { + return blocked.state === 'stopped' + } const script = [ '_orca_group=$1', `kill -${signal} "-$_orca_group" 2>/dev/null || :`, diff --git a/src/main/wsl/wsl-guest-process-table.test.ts b/src/main/wsl/wsl-guest-process-table.test.ts new file mode 100644 index 00000000000..1f4e9302105 --- /dev/null +++ b/src/main/wsl/wsl-guest-process-table.test.ts @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as BackgroundDistroGate from '../wsl-background-distro-gate' + +const mocks = vi.hoisted(() => ({ + runWslProcess: vi.fn(), + readWslDistroRunningState: vi.fn() +})) +vi.mock('./wsl-runner', () => ({ runWslProcess: mocks.runWslProcess })) +vi.mock('../wsl-background-distro-gate', async (importOriginal) => ({ + ...(await importOriginal()), + readWslDistroRunningState: mocks.readWslDistroRunningState +})) + +import { readWslGuestProcessTable } from './wsl-guest-process-table' +import { runAsWslUserAct } from './wsl-unc-access-gate' + +describe('readWslGuestProcessTable never boots a stopped distro (E9)', () => { + const originalPlatform = process.platform + + beforeEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + mocks.runWslProcess.mockReset().mockResolvedValue({ + environmentResolved: true, + code: 0, + stdout: '', + stderr: '', + timedOut: false + }) + mocks.readWslDistroRunningState.mockReset().mockResolvedValue('stopped') + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + }) + + it('reports an empty table for a stopped distro without listing it', async () => { + await expect(readWslGuestProcessTable('Ubuntu')).resolves.toEqual([]) + expect(mocks.runWslProcess).not.toHaveBeenCalled() + }) + + it('reports an unreadable table when WSL cannot say whether the distro runs', async () => { + mocks.readWslDistroRunningState.mockResolvedValue('unverifiable') + await expect(readWslGuestProcessTable('Ubuntu')).resolves.toBeNull() + expect(mocks.runWslProcess).not.toHaveBeenCalled() + }) + + it('lists a running distro, and a user move may list a stopped one', async () => { + mocks.readWslDistroRunningState.mockResolvedValue('running') + await readWslGuestProcessTable('Ubuntu') + expect(mocks.runWslProcess).toHaveBeenCalledTimes(1) + + mocks.readWslDistroRunningState.mockResolvedValue('stopped') + await runAsWslUserAct('Debian', () => readWslGuestProcessTable('Debian')) + expect(mocks.runWslProcess).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/wsl/wsl-guest-process-table.ts b/src/main/wsl/wsl-guest-process-table.ts index df837b26fa9..654d00f8146 100644 --- a/src/main/wsl/wsl-guest-process-table.ts +++ b/src/main/wsl/wsl-guest-process-table.ts @@ -8,6 +8,7 @@ import { type WslGuestProcessRow } from '../persistence/moving-wsl-projects/wsl-move-guest-foreground' import { runWslProcess } from './wsl-runner' +import { blockedWslDistroForPath } from './wsl-unc-access-gate' const readsInFlight = new Map>() @@ -19,7 +20,18 @@ export function readWslGuestProcessTable(distro: string): Promise readsInFlight.delete(key)) + readsInFlight.set(key, read) + return read +} + +async function readRunningGuestProcessTable(distro: string): Promise { + // An automatic move must not boot the distro to list it (E9); a stopped one runs nothing. + const blocked = await blockedWslDistroForPath('', distro) + if (blocked) { + return blocked.state === 'stopped' ? [] : null + } + return runWslProcess({ distro, script: WSL_GUEST_PANE_PROCESS_SCRIPT, loginPath: 'none', @@ -32,7 +44,4 @@ export function readWslGuestProcessTable(distro: string): Promise null) - .finally(() => readsInFlight.delete(key)) - readsInFlight.set(key, read) - return read } diff --git a/src/main/wsl/wsl-host-connector.test.ts b/src/main/wsl/wsl-host-connector.test.ts index 16e14bc7e0f..ace018279c8 100644 --- a/src/main/wsl/wsl-host-connector.test.ts +++ b/src/main/wsl/wsl-host-connector.test.ts @@ -4,7 +4,7 @@ import type { WslRunningDistrosRead } from '../wsl-background-distro-gate' import type * as TunnelIdentity from '../managed-server/lifecycle/orcad-managed-tunnel-identity' const mocks = vi.hoisted(() => ({ - readRunning: vi.fn<() => Promise>(), + readRunning: vi.fn<(distro: string) => Promise>(), connect: vi.fn(), stopServer: vi.fn(async () => ({ stopped: true })), verify: vi.fn(), @@ -22,7 +22,8 @@ vi.mock('./wsl-orcad-connection', async (importOriginal) => ({ } })) vi.mock('../wsl-background-distro-gate', () => ({ - readConfirmedRunningWslDistros: mocks.readRunning, + // Settled per distro: a list taken while it stops still names it (E9). + readSettledConfirmedRunningWslDistros: mocks.readRunning, listConfirmedRunningWslDistros: vi.fn(async () => []) })) vi.mock('../managed-server/lifecycle/orcad-managed-tunnel-identity', async (importOriginal) => ({ @@ -57,6 +58,7 @@ it('uses owned readiness without discovery and coalesces cold cleanup without wa await expect(passive).resolves.toBe(false) await expect(removal).resolves.toEqual({ stopped: true }) expect(mocks.readRunning).toHaveBeenCalledTimes(1) + expect(mocks.readRunning).toHaveBeenCalledWith('Ubuntu') mocks.readRunning.mockResolvedValueOnce({ status: 'unavailable' }) await expect(connector.stopServer('Ubuntu', 'wsl:ubuntu')).resolves.toMatchObject({ stopped: false @@ -120,6 +122,11 @@ it('a no-boot call joins a booting connect but stays no-boot itself (E9)', async 'Ubuntu', expect.objectContaining({ requireRunning: expect.any(Function) }) ) + // That check is a settled read, so a distro still stopping counts as stopped. + const [, ensureOptions] = mocks.managerEnsure.mock.lastCall ?? [] + mocks.readRunning.mockClear().mockResolvedValueOnce({ status: 'confirmed', distros: ['Ubuntu'] }) + await expect(ensureOptions?.requireRunning('Ubuntu')).resolves.toBe(true) + expect(mocks.readRunning).toHaveBeenCalledWith('Ubuntu') finish() await first }) diff --git a/src/main/wsl/wsl-host-connector.ts b/src/main/wsl/wsl-host-connector.ts index d883675a0ef..4f29c70d440 100644 --- a/src/main/wsl/wsl-host-connector.ts +++ b/src/main/wsl/wsl-host-connector.ts @@ -1,7 +1,7 @@ import { WslOrcadBridgeActivity } from './wsl-orcad-bridge-activity' import { listenWslOrcadStdioBridge } from './wsl-orcad-stdio-bridge' import { createWslOrcadPairingRecovery } from './wsl-orcad-pairing-repair' -import { readConfirmedRunningWslDistros } from '../wsl-background-distro-gate' +import { readSettledConfirmedRunningWslDistros } from '../wsl-background-distro-gate' import { parsePairingCode } from '../../shared/pairing' import { pairedRuntimeConnectionFromOffer } from '../../shared/paired-runtime-connection' import { verifyOrcadPairingIdentity } from '../managed-server/lifecycle/orcad-managed-tunnel-identity' @@ -100,9 +100,10 @@ export function createWslHostConnector(options: { string, { promise: Promise; noBoot: boolean; intent: boolean } >() - // Read right before a no-boot reconnect spawns anything in the distro (E9). + // Read right before a no-boot reconnect spawns anything in the distro (E9); settled, since a + // list taken while the distro stops still names it. const requireRunning = async (distro: string): Promise => - confirmsRunning(await readConfirmedRunningWslDistros(), wslDistroHolderKey(distro)) + confirmsRunning(await readSettledConfirmedRunningWslDistros(distro), wslDistroHolderKey(distro)) const coldProbes = new Map< string, { @@ -117,7 +118,7 @@ export function createWslHostConnector(options: { connection: WslOrcadConnection | null ): Promise => { // Bridge dialing spawns wsl.exe; passive checks must first prove the distro runs. - const running = await readConfirmedRunningWslDistros() + const running = await readSettledConfirmedRunningWslDistros(distro) if (running.status === 'unavailable') { return 'unavailable' } diff --git a/src/main/wsl/wsl-orcad-liveness-state.ts b/src/main/wsl/wsl-orcad-liveness-state.ts index d06ccd2e79e..67b34293468 100644 --- a/src/main/wsl/wsl-orcad-liveness-state.ts +++ b/src/main/wsl/wsl-orcad-liveness-state.ts @@ -19,10 +19,13 @@ export type WslOrcadLivenessDependencies = { WslOrcadConnectionManager, 'ensure' | 'get' | 'publish' | 'close' | 'retireListener' > - /** `afterExit`: answer only from a probe started once that exit's settle window has passed. */ - readRunning: (afterExit?: { + /** + * A settled read for `distro`: never a list probed while it may still be stopping (E9). With + * `exitedAt`, also only a probe started once that exit's settle window has passed. + */ + readRunning: (target: { distro: string - exitedAt: number | null + exitedAt?: number | null }) => Promise readIdentity: (distro: string, orcadPid: number | null) => Promise openWatch: (connection: WslOrcadConnection) => Promise diff --git a/src/main/wsl/wsl-orcad-liveness-supervisor.test.ts b/src/main/wsl/wsl-orcad-liveness-supervisor.test.ts index b4e9828ab43..5f5d10620c5 100644 --- a/src/main/wsl/wsl-orcad-liveness-supervisor.test.ts +++ b/src/main/wsl/wsl-orcad-liveness-supervisor.test.ts @@ -73,7 +73,9 @@ function harness(options: { visible?: boolean } = {}) { return connection } ) - const readRunning = vi.fn(async () => state.running) + const readRunning = vi.fn( + async (_target: { distro: string; exitedAt?: number | null }) => state.running + ) const requireRunning = vi.fn(async (_distro: string) => true) const readIdentity = vi.fn(async () => state.identity) const collectCensus = vi.fn(async () => CENSUS) @@ -343,6 +345,10 @@ describe('WslOrcadLivenessSupervisor', () => { for (const [, opts] of recoveryCalls) { expect(opts).toMatchObject({ requireRunning: h.requireRunning }) } + // Its backoff gate reads too: every read names the distro, so the wiring can settle it. + for (const [target] of h.readRunning.mock.calls) { + expect(target).toMatchObject({ distro: 'Ubuntu' }) + } }) it('reports ready itself when a user connect supersedes a recovery', async () => { @@ -427,6 +433,15 @@ describe('WslOrcadLivenessSupervisor', () => { expect(h.readRunning).toHaveBeenNthCalledWith(2, { distro: 'Ubuntu', exitedAt }) }) + it('asks for a settled read of its own distro on every check, not a bare list (E9)', async () => { + const h = harness() + await h.supervisor.connect('Ubuntu') + h.lastWatch().exit(1) + await h.settle() + // The wiring answers a distro-scoped read only from a probe past that distro's stop settle. + expect(h.readRunning).toHaveBeenNthCalledWith(1, { distro: 'Ubuntu' }) + }) + it('waits for a still-listed distro to settle after a failed exit before probing inside it', async () => { const h = harness() await h.supervisor.connect('Ubuntu') diff --git a/src/main/wsl/wsl-orcad-liveness-supervisor.ts b/src/main/wsl/wsl-orcad-liveness-supervisor.ts index b98a06cf1ab..ed7e663656e 100644 --- a/src/main/wsl/wsl-orcad-liveness-supervisor.ts +++ b/src/main/wsl/wsl-orcad-liveness-supervisor.ts @@ -219,7 +219,7 @@ export class WslOrcadLivenessSupervisor { private async run(entry: Supervised, trigger: Trigger): Promise { const generation = entry.generation - let running = await this.deps.readRunning() + let running = await this.deps.readRunning({ distro: entry.distro }) if (entry.generation !== generation) { return } @@ -325,7 +325,7 @@ export class WslOrcadLivenessSupervisor { ensure: (options) => this.deps.manager.ensure(entry.distro, { ...options, requireRunning }), collectCensus: this.deps.collectCensus, arm: (connection) => this.arm(entry, connection), - readRunning: this.deps.readRunning + readRunning: () => this.deps.readRunning({ distro: entry.distro }) }, reason ) diff --git a/src/main/wsl/wsl-orcad-liveness-wiring.test.ts b/src/main/wsl/wsl-orcad-liveness-wiring.test.ts new file mode 100644 index 00000000000..a932a5707b1 --- /dev/null +++ b/src/main/wsl/wsl-orcad-liveness-wiring.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import type { WslOrcadLivenessDependencies } from './wsl-orcad-liveness-state' + +const mocks = vi.hoisted(() => { + const captured: { deps: WslOrcadLivenessDependencies | null } = { deps: null } + return { readSettled: vi.fn(), captured } +}) +vi.mock('../wsl-background-distro-gate', () => ({ + deferUntilWslDistroRunning: vi.fn(), + readSettledConfirmedRunningWslDistros: mocks.readSettled +})) +vi.mock('./wsl-orcad-liveness-supervisor', () => ({ + WslOrcadLivenessSupervisor: class { + constructor(deps: WslOrcadLivenessDependencies) { + mocks.captured.deps = deps + } + } +})) + +import { createWslOrcadLivenessSupervisor } from './wsl-orcad-liveness-wiring' +import { EXIT_SETTLE_MS } from './wsl-orcad-liveness-evidence' + +beforeEach(() => { + mocks.readSettled.mockReset().mockResolvedValue({ status: 'confirmed', distros: ['Ubuntu'] }) + createWslOrcadLivenessSupervisor({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the captured deps under test never touch these. + manager: {} as never, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the captured deps under test never touch these. + bridgeActivity: {} as never, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the captured deps under test never touch these. + holder: {} as never, + hasUserVisibleWork: () => false + }) +}) + +it('reads a settled running list for heartbeats and recovery, not one probed mid-stop (E9)', async () => { + await mocks.captured.deps?.readRunning({ distro: 'Ubuntu' }) + expect(mocks.readSettled).toHaveBeenLastCalledWith('Ubuntu', undefined) + + await mocks.captured.deps?.readRunning({ distro: 'Ubuntu', exitedAt: 1_000 }) + expect(mocks.readSettled).toHaveBeenLastCalledWith('Ubuntu', 1_000 + EXIT_SETTLE_MS) +}) + +it('checks a no-boot reconnect against a settled read of its own distro (E9)', async () => { + await expect(mocks.captured.deps?.requireRunning?.('Ubuntu')).resolves.toBe(true) + expect(mocks.readSettled).toHaveBeenLastCalledWith('Ubuntu') + + mocks.readSettled.mockResolvedValue({ status: 'confirmed', distros: [] }) + await expect(mocks.captured.deps?.requireRunning?.('Ubuntu')).resolves.toBe(false) +}) diff --git a/src/main/wsl/wsl-orcad-liveness-wiring.ts b/src/main/wsl/wsl-orcad-liveness-wiring.ts index 1aecf19a686..09a91a229b4 100644 --- a/src/main/wsl/wsl-orcad-liveness-wiring.ts +++ b/src/main/wsl/wsl-orcad-liveness-wiring.ts @@ -13,7 +13,6 @@ import { requestOrcadTerminalCensus } from '../ssh/orcad-terminal-census-client' import { subscribeSystemPowerLifecycle } from '../system-power-lifecycle' import { deferUntilWslDistroRunning, - readConfirmedRunningWslDistros, readSettledConfirmedRunningWslDistros } from '../wsl-background-distro-gate' import type { WslDistroHolder } from './wsl-distro-holder' @@ -48,13 +47,11 @@ export function createWslOrcadLivenessSupervisor(options: { }): WslOrcadLivenessSupervisor { return new WslOrcadLivenessSupervisor({ manager: options.manager, - readRunning: (afterExit) => - afterExit - ? readSettledConfirmedRunningWslDistros( - afterExit.distro, - afterExit.exitedAt === null ? undefined : afterExit.exitedAt + EXIT_SETTLE_MS - ) - : readConfirmedRunningWslDistros(), + readRunning: ({ distro, exitedAt }) => + readSettledConfirmedRunningWslDistros( + distro, + exitedAt === null || exitedAt === undefined ? undefined : exitedAt + EXIT_SETTLE_MS + ), readIdentity: (distro, orcadPid) => readWslGuestIdentity(transportFor(distro), orcadPid), openWatch: (connection) => probeThenWatchWslOrcadBridges( @@ -70,7 +67,10 @@ export function createWslOrcadLivenessSupervisor(options: { hasUserVisibleWork: options.hasUserVisibleWork, deferUntilRunning: deferUntilWslDistroRunning, requireRunning: async (distro) => - confirmsRunning(await readConfirmedRunningWslDistros(), wslDistroHolderKey(distro)), + confirmsRunning( + await readSettledConfirmedRunningWslDistros(distro), + wslDistroHolderKey(distro) + ), ensureHolder: (distro) => options.holder.ensureHeld(distro), subscribePower: subscribeSystemPowerLifecycle })