mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 18:47:40 +00:00
fix(wsl): flag-on liveness and post-launch WSL steps use settled running reads (E9) (#35433)
L1: the orcad liveness supervisor's heartbeat/verify reads, its recovery gate, the no-boot reconnect check and the connector's passive probe now take a settled read of their own distro, so a list probed while the distro is still stopping cannot admit a holder spawn, identity read or reconnect that would boot it again. L2: a Git cancel/timeout's guest kill, the WSL move's process table and each step of the Codex session bridge chain check the distro first. A confirmed stop counts as the group gone / an empty table / the end of the chain; an unanswered read proves nothing and spawns nothing.
This commit is contained in:
@@ -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<typeof healPendingCodexAccountThreads>(
|
||||
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<typeof healPendingCodexAccountThreads>(
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
|
||||
@@ -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<void>
|
||||
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<void> => 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<string, string>()
|
||||
const markerByThreadId = new Map<string, string>()
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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<typeof BackgroundDistroGate>()),
|
||||
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<typeof createWslProcessGroupTermination> {
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 || :`,
|
||||
|
||||
@@ -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<typeof BackgroundDistroGate>()),
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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<string, Promise<WslGuestProcessRow[] | null>>()
|
||||
|
||||
@@ -19,7 +20,18 @@ export function readWslGuestProcessTable(distro: string): Promise<WslGuestProces
|
||||
if (inFlight) {
|
||||
return inFlight
|
||||
}
|
||||
const read = runWslProcess({
|
||||
const read = readRunningGuestProcessTable(distro).finally(() => readsInFlight.delete(key))
|
||||
readsInFlight.set(key, read)
|
||||
return read
|
||||
}
|
||||
|
||||
async function readRunningGuestProcessTable(distro: string): Promise<WslGuestProcessRow[] | null> {
|
||||
// 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<WslGuestProces
|
||||
: parseWslGuestPaneProcesses(result.stdout)
|
||||
)
|
||||
.catch(() => null)
|
||||
.finally(() => readsInFlight.delete(key))
|
||||
readsInFlight.set(key, read)
|
||||
return read
|
||||
}
|
||||
|
||||
@@ -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<WslRunningDistrosRead>>(),
|
||||
readRunning: vi.fn<(distro: string) => Promise<WslRunningDistrosRead>>(),
|
||||
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
|
||||
})
|
||||
|
||||
@@ -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<WslOrcadConnection>; 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<boolean> =>
|
||||
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<Reachability> => {
|
||||
// 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'
|
||||
}
|
||||
|
||||
@@ -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<WslRunningDistrosRead>
|
||||
readIdentity: (distro: string, orcadPid: number | null) => Promise<WslGuestIdentity | null>
|
||||
openWatch: (connection: WslOrcadConnection) => Promise<WslOrcadWatch>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -219,7 +219,7 @@ export class WslOrcadLivenessSupervisor {
|
||||
|
||||
private async run(entry: Supervised, trigger: Trigger): Promise<void> {
|
||||
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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user