From 6568e887ae2e2d52fc40325004df2c8da19fa0da Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:09:13 -0700 Subject: [PATCH] fix(wsl): show a stopped WSL host within a second of its stop, never exec-ing into it (#35823) After a watch or holder exit, a distro the running list still names is usually stopping. Liveness now re-reads only the running list (never boots) once a second for up to 5 s before any identity exec, and publishes distro-stopped as soon as the list drops it. An exit that arrives during a recovery backoff ends that recovery so the exit re-checks at once (E9). --- src/main/wsl/wsl-orcad-liveness-state.ts | 7 ++ .../wsl/wsl-orcad-liveness-stopping-poll.ts | 64 +++++++++++++++ .../wsl/wsl-orcad-liveness-supervisor.test.ts | 78 +++++++++++++++++-- src/main/wsl/wsl-orcad-liveness-supervisor.ts | 15 ++-- .../wsl/wsl-orcad-liveness-wiring.test.ts | 9 +++ src/main/wsl/wsl-orcad-liveness-wiring.ts | 11 ++- 6 files changed, 166 insertions(+), 18 deletions(-) create mode 100644 src/main/wsl/wsl-orcad-liveness-stopping-poll.ts diff --git a/src/main/wsl/wsl-orcad-liveness-state.ts b/src/main/wsl/wsl-orcad-liveness-state.ts index 67b34293468..359bb77604c 100644 --- a/src/main/wsl/wsl-orcad-liveness-state.ts +++ b/src/main/wsl/wsl-orcad-liveness-state.ts @@ -26,6 +26,8 @@ export type WslOrcadLivenessDependencies = { readRunning: (target: { distro: string exitedAt?: number | null + /** Only a probe started at or after this instant (monotonic) may answer; a cached list won't. */ + probedAfter?: number }) => Promise readIdentity: (distro: string, orcadPid: number | null) => Promise openWatch: (connection: WslOrcadConnection) => Promise @@ -125,5 +127,10 @@ export function isWatchOrHolderExit(trigger: Trigger): boolean { export function noteSupervisedExit(entry: Supervised, trigger: Trigger, nowMs: number): void { if (isWatchOrHolderExit(trigger)) { entry.lastExitMs = nowMs + // An exit during a recovery backoff ends that recovery, so the exit re-reads the list now. + if (entry.busy && entry.wakeWait) { + entry.generation += 1 + entry.wakeWait() + } } } diff --git a/src/main/wsl/wsl-orcad-liveness-stopping-poll.ts b/src/main/wsl/wsl-orcad-liveness-stopping-poll.ts new file mode 100644 index 00000000000..d53caf29e55 --- /dev/null +++ b/src/main/wsl/wsl-orcad-liveness-stopping-poll.ts @@ -0,0 +1,64 @@ +import type { WslRunningDistrosRead } from '../wsl-background-distro-gate' +import { confirmsRunning, EXIT_SETTLE_MS } from './wsl-orcad-liveness-evidence' +import type { Supervised, WslOrcadLivenessDependencies } from './wsl-orcad-liveness-state' + +export const WSL_ORCAD_STOPPING_POLL_MS = 1_000 +export const WSL_ORCAD_STOPPING_WINDOW_MS = 5_000 + +/** + * After a watch or holder exit, a distro the list still names is usually stopping: read only the + * running list (never boots) until it drops the distro or the window ends, before any exec into + * it. An exec while it stops is slow to fail and, landing after the stop, boots it (E9). + */ +export async function readRunningWhileDistroMayBeStopping(args: { + distro: string + key: string + exitedAt: number | null + running: WslRunningDistrosRead + now: () => number + schedule: Schedule + readRunning: WslOrcadLivenessDependencies['readRunning'] + current: () => boolean +}): Promise { + let running = args.running + const deadline = args.now() + WSL_ORCAD_STOPPING_WINDOW_MS + while (confirmsRunning(running, args.key) && args.now() < deadline) { + await new Promise((resolve) => args.schedule(WSL_ORCAD_STOPPING_POLL_MS, resolve)) + if (!args.current()) { + break + } + running = await args.readRunning({ + distro: args.distro, + exitedAt: args.exitedAt, + probedAfter: args.now() + }) + } + return running +} + +type Schedule = (delayMs: number, task: () => void) => unknown + +/** After an exit: wait out the settle window, re-read the list, then watch it while it still names the distro. */ +export async function settleExitedDistroRunning( + entry: Pick, + deps: Pick, + current: () => boolean, + now: () => number, + schedule: Schedule +): Promise { + await new Promise((resolve) => schedule(EXIT_SETTLE_MS, resolve)) + const settled = await deps.readRunning({ distro: entry.distro, exitedAt: entry.lastExitMs }) + if (!current()) { + return settled + } + return readRunningWhileDistroMayBeStopping({ + distro: entry.distro, + key: entry.key, + exitedAt: entry.lastExitMs, + running: settled, + now, + schedule, + readRunning: deps.readRunning, + current + }) +} diff --git a/src/main/wsl/wsl-orcad-liveness-supervisor.test.ts b/src/main/wsl/wsl-orcad-liveness-supervisor.test.ts index 5f5d10620c5..7054fa7daf1 100644 --- a/src/main/wsl/wsl-orcad-liveness-supervisor.test.ts +++ b/src/main/wsl/wsl-orcad-liveness-supervisor.test.ts @@ -11,6 +11,10 @@ import { WSL_ORCAD_IDLE_VERIFY_MS } from './wsl-orcad-liveness-monitor' import { WslDistroNotRunningError } from './wsl-orcad-connection-errors' import { WslOrcadLivenessSupervisor } from './wsl-orcad-liveness-supervisor' import { WSL_ORCAD_RECONNECT_DELAYS_MS } from './wsl-orcad-reconnect-backoff' +import { + WSL_ORCAD_STOPPING_POLL_MS, + WSL_ORCAD_STOPPING_WINDOW_MS +} from './wsl-orcad-liveness-stopping-poll' import type { WslOrcadWatch } from './wsl-orcad-watch' const CENSUS: OrcadTerminalCensus = { @@ -308,7 +312,8 @@ describe('WslOrcadLivenessSupervisor', () => { await h.supervisor.connect('Ubuntu') h.state.identity = { distroInstance: 'boot:1', orcad: null } h.lastWatch().exit(0) - await h.advance(5_000) + // The distro stays listed: the list is watched through the stopping window before any exec. + await h.advance(5_000 + WSL_ORCAD_STOPPING_WINDOW_MS) const states = h.statuses.map((status) => status.state === 'reconnecting' ? `${status.state}:${status.reason}` : status.state ) @@ -356,8 +361,8 @@ describe('WslOrcadLivenessSupervisor', () => { await h.supervisor.connect('Ubuntu') h.state.identity = { distroInstance: 'boot:1', orcad: null } h.lastWatch().exit(0) - // A clean exit is settled (and the list re-read) before the verdict, as a failed one is. - await h.advance(EXIT_SETTLE_MS) + // A clean exit is settled (and the list watched) before the verdict, as a failed one is. + await h.advance(EXIT_SETTLE_MS + WSL_ORCAD_STOPPING_WINDOW_MS) expect(h.statuses.at(-1)).toMatchObject({ state: 'reconnecting' }) await h.supervisor.connect('Ubuntu') expect(h.statuses.at(-1)).toEqual({ state: 'ready', census: null }) @@ -397,7 +402,7 @@ describe('WslOrcadLivenessSupervisor', () => { const h = harness() await h.supervisor.connect('Ubuntu') h.lastWatch().exit(0) - await h.advance(EXIT_SETTLE_MS) + await h.advance(EXIT_SETTLE_MS + WSL_ORCAD_STOPPING_WINDOW_MS) expect(h.statuses).toEqual([]) expect(h.watches).toHaveLength(2) expect(h.ensure).toHaveBeenCalledTimes(1) @@ -535,7 +540,7 @@ describe('WslOrcadLivenessSupervisor', () => { await h.supervisor.connect('Ubuntu') // A watch riding the desktop's sockets reports no exit code. h.lastWatch().exit(null) - await h.advance(5_000) + await h.advance(5_000 + WSL_ORCAD_STOPPING_WINDOW_MS) expect(h.readIdentity).toHaveBeenCalledTimes(2) expect(h.statuses.filter((status) => status.state !== 'ready')).toEqual([]) expect(h.ensure).toHaveBeenCalledTimes(1) @@ -610,4 +615,67 @@ describe('WslOrcadLivenessSupervisor', () => { await h.advance(1_000) expect(h.statuses.map((status) => status.state)).toEqual(['distro-stopped']) }) + + describe('after an exit, while the distro may still be stopping (E9)', () => { + const STOPPED: WslRunningDistrosRead = { status: 'confirmed', distros: [] } + + it('publishes distro-stopped within a poll of the list dropping it, never exec-ing into it', async () => { + const h = harness() + await h.supervisor.connect('Ubuntu') + const identityReads = h.readIdentity.mock.calls.length + h.lastWatch().exit(null) + await h.advance(EXIT_SETTLE_MS + 2_000) + // Still listed mid-window: only the list was read, nothing ran inside the distro. + expect(h.readIdentity).toHaveBeenCalledTimes(identityReads) + expect(h.statuses).toEqual([]) + + h.state.running = STOPPED + await h.advance(WSL_ORCAD_STOPPING_POLL_MS) + expect(h.statuses).toEqual([ + { state: 'distro-stopped', terminals: 'exited', recovering: false } + ]) + expect(h.readIdentity).toHaveBeenCalledTimes(identityReads) + }) + + it('falls back to the identity check once the window ends with the distro still listed', async () => { + const h = harness() + await h.supervisor.connect('Ubuntu') + const identityReads = h.readIdentity.mock.calls.length + h.supervisor.noteHolderExit('Ubuntu', { code: 1 }) + await h.advance(EXIT_SETTLE_MS + WSL_ORCAD_STOPPING_WINDOW_MS - WSL_ORCAD_STOPPING_POLL_MS) + expect(h.readIdentity).toHaveBeenCalledTimes(identityReads) + await h.advance(WSL_ORCAD_STOPPING_POLL_MS) + expect(h.readIdentity).toHaveBeenCalledTimes(identityReads + 1) + expect(h.statuses).toEqual([]) + }) + + it('adds no running-list reads to heartbeats while the distro runs', async () => { + const h = harness() + await h.supervisor.connect('Ubuntu') + await h.advance(WSL_ORCAD_HEARTBEAT_MS * 3) + expect(h.readRunning).toHaveBeenCalledTimes(3) + }) + + it('ends a recovery backoff when the distro then stops, rather than wait it out', async () => { + const h = harness() + await h.supervisor.connect('Ubuntu') + // orcad crashed and keeps failing to relaunch: recovery sits in a long backoff. + h.state.identity = { distroInstance: 'boot:1', orcad: null } + h.state.ensureFails = 100 + h.lastWatch().exit(0) + await h.advance(EXIT_SETTLE_MS + WSL_ORCAD_STOPPING_WINDOW_MS + 8_000) + const waiting = h.statuses.at(-1) + expect(waiting).toMatchObject({ state: 'reconnecting' }) + expect(waiting && 'retryInMs' in waiting ? waiting.retryInMs : 0).toBeGreaterThan(2_000) + + h.state.running = STOPPED + h.supervisor.noteHolderExit('Ubuntu', { code: 1 }) + await h.advance(EXIT_SETTLE_MS + 100) + expect(h.statuses.at(-1)).toEqual({ + state: 'distro-stopped', + terminals: 'exited', + recovering: false + }) + }) + }) }) diff --git a/src/main/wsl/wsl-orcad-liveness-supervisor.ts b/src/main/wsl/wsl-orcad-liveness-supervisor.ts index ed7e663656e..bee8063ef7d 100644 --- a/src/main/wsl/wsl-orcad-liveness-supervisor.ts +++ b/src/main/wsl/wsl-orcad-liveness-supervisor.ts @@ -1,14 +1,11 @@ /** D2c/C6 classify watch exits against fresh running lists and guest incarnation, publishing through onChange. Boot only for visible demand; background work waits for running evidence. Use monotonic timers and reverify after resume. */ import type { WslOrcadConnection } from './wsl-orcad-connection' +import { settleExitedDistroRunning } from './wsl-orcad-liveness-stopping-poll' import { WslDistroNotRunningError } from './wsl-orcad-connection-errors' import type { WslOrcadConnectIntent, WslOrcadConnectionStatus } from './wsl-orcad-connection-status' import { wslDistroHolderKey } from './wsl-distro-holder-record' import { recoverWslOrcad, type WslOrcadRecoveryReason } from './wsl-orcad-liveness-recovery' -import { - confirmsRunning, - EXIT_SETTLE_MS, - gatherWslLivenessEvidence -} from './wsl-orcad-liveness-evidence' +import { confirmsRunning, gatherWslLivenessEvidence } from './wsl-orcad-liveness-evidence' import { attachSupervisedWatch, cancellableWait, @@ -224,10 +221,10 @@ export class WslOrcadLivenessSupervisor { return } if (isWatchOrHolderExit(trigger) && confirmsRunning(running, entry.key)) { - // Stopping distros can still appear running; wait before exec accidentally boots them again. - await new Promise((resolve) => this.schedule(EXIT_SETTLE_MS, resolve)) - running = await this.deps.readRunning({ distro: entry.distro, exitedAt: entry.lastExitMs }) - if (entry.generation !== generation) { + // Stopping distros can still appear running; exec into one could boot it again (E9). + const current = (): boolean => entry.generation === generation + running = await settleExitedDistroRunning(entry, this.deps, current, this.now, this.schedule) + if (!current()) { return } } diff --git a/src/main/wsl/wsl-orcad-liveness-wiring.test.ts b/src/main/wsl/wsl-orcad-liveness-wiring.test.ts index a932a5707b1..924f8c62052 100644 --- a/src/main/wsl/wsl-orcad-liveness-wiring.test.ts +++ b/src/main/wsl/wsl-orcad-liveness-wiring.test.ts @@ -48,3 +48,12 @@ it('checks a no-boot reconnect against a settled read of its own distro (E9)', a mocks.readSettled.mockResolvedValue({ status: 'confirmed', distros: [] }) await expect(mocks.captured.deps?.requireRunning?.('Ubuntu')).resolves.toBe(false) }) + +it('makes each stopping-window poll take a fresh probe, never the list read before it', async () => { + await mocks.captured.deps?.readRunning({ distro: 'Ubuntu', exitedAt: 1_000, probedAfter: 4_000 }) + expect(mocks.readSettled).toHaveBeenLastCalledWith('Ubuntu', 4_000) + + // A poll is never older than the exit's settle window. + await mocks.captured.deps?.readRunning({ distro: 'Ubuntu', exitedAt: 1_000, probedAfter: 1_200 }) + expect(mocks.readSettled).toHaveBeenLastCalledWith('Ubuntu', 1_000 + EXIT_SETTLE_MS) +}) diff --git a/src/main/wsl/wsl-orcad-liveness-wiring.ts b/src/main/wsl/wsl-orcad-liveness-wiring.ts index 7ac163e15f0..b13352254d6 100644 --- a/src/main/wsl/wsl-orcad-liveness-wiring.ts +++ b/src/main/wsl/wsl-orcad-liveness-wiring.ts @@ -47,11 +47,14 @@ export function createWslOrcadLivenessSupervisor(options: { }): WslOrcadLivenessSupervisor { return new WslOrcadLivenessSupervisor({ manager: options.manager, - readRunning: ({ distro, exitedAt }) => - readSettledConfirmedRunningWslDistros( - distro, + readRunning: ({ distro, exitedAt, probedAfter }) => { + const settled = exitedAt === null || exitedAt === undefined ? undefined : exitedAt + EXIT_SETTLE_MS - ), + return readSettledConfirmedRunningWslDistros( + distro, + probedAfter === undefined ? settled : Math.max(settled ?? -Infinity, probedAfter) + ) + }, readIdentity: (distro, orcadPid) => readWslGuestIdentity(transportFor(distro), orcadPid), openWatch: (connection) => probeThenWatchWslOrcadBridges(