From 6aabc76e49cdc951b220e42469dfd2e84d7ed0da Mon Sep 17 00:00:00 2001 From: me2seeks Date: Sun, 6 Sep 2026 23:02:51 +0800 Subject: [PATCH 1/2] fix(desktop): let the launch-owner guard retire the owned Host on quit Desktop quit unconditionally released the candidate launch barrier in RuntimeHostDesktopManager.#close(), which detached the launch-owner guard at the exact moment it was the close authority: the guard then ignored the IPC disconnect that would otherwise have closed the Host, so an owned ephemeral Host could survive a full quit. Quit no longer drives retirement. prepareRuntimeHostQuit only probes Host activity to feed the interruption-consent dialog, and the guard closes the Host after process exit. The synchronous retirement drive, the PID polling, and the force-terminate recovery path are removed. The Host reports upgradeBlockingActivity in host.diagnostics.query so the consent question stays answered by the same authority that gates host.upgrade.prepare; the predicate takes a selfCommands argument so the query path does not mistake one in-flight command from another client for idle. A guard-triggered close now records the retirement reason instead of exiting like a crash. The probe's accepted trade is documented: during a reconnect gap a busy Host reads as not_owned and quit does not ask, because the guard still closes it truthfully after exit. forceTerminateRegisteredRuntimeHost and its WithDependencies variant are deleted as their only caller left with forceTerminateOwnedLocalHost. Closes #4730. Generated-by: Maka --- .../main-process-diagnostics.test.ts | 1 + .../runtime-host-desktop-manager.test.ts | 116 +++++++++--------- .../__tests__/runtime-host-quit-copy.test.ts | 56 ++------- .../main/__tests__/runtime-host-quit.test.ts | 92 ++++++-------- apps/desktop/src/main/runtime-host-boot.ts | 7 -- .../src/main/runtime-host-desktop-manager.ts | 98 +++++---------- .../src/main/runtime-host-quit-copy.ts | 82 +------------ apps/desktop/src/main/runtime-host-quit.ts | 83 ++----------- .../src/__tests__/host-kernel.test.ts | 23 ++++ .../src/__tests__/protocol.test.ts | 39 ++++++ .../registered-host-termination.test.ts | 90 +------------- packages/runtime-host/src/candidate-entry.ts | 5 +- packages/runtime-host/src/client/index.ts | 2 - .../src/client/registered-host-termination.ts | 82 ------------- .../runtime-host/src/protocol/host-status.ts | 16 +++ packages/runtime-host/src/protocol/index.ts | 8 +- .../runtime-host/src/server/host-kernel.ts | 15 ++- 17 files changed, 257 insertions(+), 558 deletions(-) diff --git a/apps/desktop/src/main/__tests__/main-process-diagnostics.test.ts b/apps/desktop/src/main/__tests__/main-process-diagnostics.test.ts index b96c0114f4..434bd94710 100644 --- a/apps/desktop/src/main/__tests__/main-process-diagnostics.test.ts +++ b/apps/desktop/src/main/__tests__/main-process-diagnostics.test.ts @@ -61,6 +61,7 @@ const runtimeHostDiagnostics = { connections: 1, activeOperations: 1, activeResidencies: 0, + upgradeBlockingActivity: true, residencies: [], protocolVersion: 0, compatibilityEpoch: 16, diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts index 0d1855d4f5..383d1e1da4 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts @@ -261,8 +261,7 @@ test('does not treat an in-flight replacement as retired after admission times o retirement, (error: unknown) => error instanceof DesktopLocalHostRetirementError && - error.facts.pid === undefined && - !error.facts.forceTerminationAvailable, + error.facts.pid === undefined, ); releaseReconnect(); @@ -309,10 +308,56 @@ test('retires the owned ephemeral Host before Desktop quit', async () => { 'wait:42', ]); await owner.close(); - assert.equal(events.at(-1), 'release-launches'); + assert.ok(!events.includes('release-launches')); assert.ok(!events.includes('resume-launches')); }); +test('probes owned Host activity for the quit consent dialog without retiring it', async () => { + const active = candidateHarness({ upgradeBlockingActivity: true }); + const owner = await startRuntimeHostDesktopManager( + {} as DesktopRuntimeHostCandidateStartInput, + { startCandidate: async () => ready(active.candidate) }, + ); + + assert.deepEqual(await owner.probeOwnedLocalHostActivity(), { kind: 'active_tasks' }); + assert.equal(active.prepareRetirementCalls, 0); + await owner.close(); +}); + +test('probe treats a missing activity field as clear and never retires', async () => { + const current = candidateHarness(); + const owner = await startRuntimeHostDesktopManager( + {} as DesktopRuntimeHostCandidateStartInput, + { startCandidate: async () => ready(current.candidate) }, + ); + + assert.deepEqual(await owner.probeOwnedLocalHostActivity(), { kind: 'clear' }); + assert.equal(current.prepareRetirementCalls, 0); + await owner.close(); +}); + +test('probe reports not_owned for a Host this Desktop does not own', async () => { + const external = candidateHarness({ ownership: 'external' }); + const owner = await startRuntimeHostDesktopManager( + {} as DesktopRuntimeHostCandidateStartInput, + { startCandidate: async () => ready(external.candidate) }, + ); + + assert.deepEqual(await owner.probeOwnedLocalHostActivity(), { kind: 'not_owned' }); + await owner.close(); +}); + +test('probe failure never blocks quit', async () => { + const wedged = candidateHarness({ diagnosticsError: new Error('connection lost') }); + const owner = await startRuntimeHostDesktopManager( + {} as DesktopRuntimeHostCandidateStartInput, + { startCandidate: async () => ready(wedged.candidate) }, + ); + + assert.deepEqual(await owner.probeOwnedLocalHostActivity(), { kind: 'clear' }); + await owner.close(); +}); + test('does not retire the local Host twice when an update handoff triggers quit', async () => { const current = candidateHarness({ disconnectOnPrepare: true }); const waitedFor: number[] = []; @@ -462,7 +507,7 @@ test('retires unadopted candidates before draining the tracked Host', async () = if (retirement.kind === 'retired') retirement.resume(); assert.equal(events.at(-1), 'resume-launches'); await owner.close(); - assert.equal(events.at(-1), 'release-launches'); + assert.ok(!events.includes('release-launches')); }); test('resumes candidate launches when active tasks block the update', async () => { @@ -487,7 +532,7 @@ test('resumes candidate launches when active tasks block the update', async () = }); assert.deepEqual(events, ['pause', 'retire', 'resume']); await owner.close(); - assert.equal(events.at(-1), 'release'); + assert.ok(!events.includes('release')); }); test('preserves Host facts when authorized retirement is refused', async () => { @@ -512,57 +557,6 @@ test('preserves Host facts when authorized retirement is refused', async () => { await owner.close(); }); -test('fences replacement launches while force-terminating the exact failed retirement', async () => { - const events: string[] = []; - const current = candidateHarness({ - ownedProcess: { - pid: 42, - exited: new Promise(() => {}), - }, - }); - const owner = await startRuntimeHostDesktopManager({ - rootPath: '/test-root', - candidateLaunchBarrier: { - connect: async () => assert.fail('mocked candidate startup bypasses the barrier'), - pause: () => events.push('pause'), - retireExcept: async (pid: number) => { - events.push(`retire:${pid}`); - }, - resume: () => events.push('resume'), - release: () => events.push('release'), - }, - } as unknown as DesktopRuntimeHostCandidateStartInput, { - startCandidate: async () => ready(current.candidate), - forceTerminateHost: async (identity, stillOwnsProcess) => { - assert.deepEqual(identity, { - rootPath: '/test-root', - rootId: 'test-host', - hostEpoch: 'test-host-epoch', - pid: 42, - }); - assert.equal(stillOwnsProcess(), true); - events.push('terminate'); - return true; - }, - }); - - assert.equal( - await owner.forceTerminateOwnedLocalHost({ - hostId: 'test-host', - hostEpoch: 'test-host-epoch', - lifecycleMode: 'ephemeral', - rootPath: '/test-root', - pid: 42, - forceTerminationAvailable: true, - }), - true, - ); - assert.deepEqual(events, ['pause', 'retire:42', 'terminate']); - assert.equal((await owner.retireOwnedLocalHost('refuse_active_work')).kind, 'retired'); - await owner.close(); - assert.equal(events.at(-1), 'release'); -}); - test('resumes candidate launches when candidate retirement fails', async () => { const events: string[] = []; const current = candidateHarness(); @@ -1861,6 +1855,8 @@ function candidateHarness( delayDisconnect?: boolean; disconnectOnPrepare?: boolean; activeTasks?: boolean | 'always'; + upgradeBlockingActivity?: boolean; + diagnosticsError?: Error; ownership?: 'owned_ephemeral' | 'supervised' | 'external'; ownedProcess?: RuntimeHostSpawnedProcess; hostId?: string; @@ -1895,7 +1891,13 @@ function candidateHarness( return lifecycleState; }, async queryHostDiagnostics() { - return { pid: 42 }; + if (options.diagnosticsError) throw options.diagnosticsError; + return { + pid: 42, + ...(options.upgradeBlockingActivity === undefined + ? {} + : { upgradeBlockingActivity: options.upgradeBlockingActivity }), + }; }, async prepareHostRetirement(mode: string) { prepareRetirementCalls += 1; diff --git a/apps/desktop/src/main/__tests__/runtime-host-quit-copy.test.ts b/apps/desktop/src/main/__tests__/runtime-host-quit-copy.test.ts index 1c03be70d7..1afc8c5df1 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-quit-copy.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-quit-copy.test.ts @@ -19,53 +19,21 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { DesktopLocalHostRetirementError } from '../runtime-host-desktop-manager.js'; -import { - buildRuntimeHostActiveQuitDialog, - buildRuntimeHostQuitFailureDialog, -} from '../runtime-host-quit-copy.js'; +import { buildRuntimeHostActiveQuitDialog } from '../runtime-host-quit-copy.js'; -const failure = new DesktopLocalHostRetirementError( - { - hostId: 'root-id', - hostEpoch: 'host-epoch', - lifecycleMode: 'ephemeral', - rootPath: '/state/root', - pid: 4242, - forceTerminationAvailable: true, - }, - { cause: new Error('writer release timed out') }, -); -const manualFailure = new DesktopLocalHostRetirementError( - { ...failure.facts, forceTerminationAvailable: false }, - { cause: failure.cause }, -); - -for (const locale of ['en', 'zh-CN'] as const) { - test(`quit failure copy exposes actionable Host facts in ${locale}`, () => { - const dialog = buildRuntimeHostQuitFailureDialog(manualFailure, locale); - - assert.match(dialog.options.detail ?? '', /4242/); - assert.match(dialog.options.detail ?? '', /host-epoch/); - assert.match(dialog.options.detail ?? '', /\/state\/root/); - assert.match(dialog.options.detail ?? '', /writer release timed out/); - }); -} - -test('manual recovery copy names a cross-platform process-management concept', () => { - const english = buildRuntimeHostQuitFailureDialog(manualFailure, 'en').options.detail ?? ''; - const chinese = buildRuntimeHostQuitFailureDialog(manualFailure, 'zh-CN').options.detail ?? ''; +test('quit dialog defaults to preserving background work', () => { + const active = buildRuntimeHostActiveQuitDialog('en'); - assert.match(english, /operating system's process-management tool/); - assert.match(chinese, /操作系统的进程管理工具/); - assert.doesNotMatch(`${english}\n${chinese}`, /Activity Monitor|Task Manager|活动监视器|任务管理器/); + assert.equal(active.decisions[active.options.defaultId ?? -1], 'cancel'); + assert.deepEqual(active.decisions, ['quit', 'cancel']); }); -test('quit dialogs default to preserving background work', () => { - const active = buildRuntimeHostActiveQuitDialog('en'); - const recovery = buildRuntimeHostQuitFailureDialog(failure, 'en'); +test('quit dialog copy promises durable recovery in every locale', () => { + const english = buildRuntimeHostActiveQuitDialog('en').options.detail ?? ''; + const chinese = buildRuntimeHostActiveQuitDialog('zh-CN').options.detail ?? ''; + const traditional = buildRuntimeHostActiveQuitDialog('zh-TW').options.detail ?? ''; - assert.equal(active.decisions[active.options.defaultId ?? -1], 'cancel'); - assert.equal(recovery.decisions[recovery.options.defaultId ?? -1], 'cancel'); - assert.deepEqual(recovery.decisions, ['retry', 'force', 'cancel']); + assert.match(english, /durable state/); + assert.match(chinese, /持久状态/); + assert.match(traditional, /持久狀態/); }); diff --git a/apps/desktop/src/main/__tests__/runtime-host-quit.test.ts b/apps/desktop/src/main/__tests__/runtime-host-quit.test.ts index 61f1814bd1..40ba69ab94 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-quit.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-quit.test.ts @@ -19,86 +19,64 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import type { RuntimeHostRetirementMode } from '@maka/runtime-host/client'; -import { DesktopLocalHostRetirementError } from '../runtime-host-desktop-manager.js'; import { prepareRuntimeHostQuit } from '../runtime-host-quit.js'; -test('background work requires consent before interruption', async () => { - const modes: RuntimeHostRetirementMode[] = []; +test('quit proceeds without consent when no owned Host is probed', async () => { + const probes: string[] = []; const owner = { - retireOwnedLocalHost: async (mode: RuntimeHostRetirementMode) => { - modes.push(mode); - return mode === 'refuse_active_work' - ? ({ kind: 'active_tasks' } as const) - : ({ kind: 'retired', resume: () => {} } as const); + probeOwnedLocalHostActivity: async () => { + probes.push('probe'); + return { kind: 'not_owned' } as const; }, - forceTerminateOwnedLocalHost: async () => assert.fail('force termination is not expected'), }; - const recoverFailure = async () => assert.fail('recovery is not expected'); assert.equal( await prepareRuntimeHostQuit(owner, { - confirmInterrupt: async () => false, - recoverFailure, + confirmInterrupt: async () => assert.fail('consent is not expected without an owned Host'), }), - 'cancelled', + 'ready', ); - assert.deepEqual(modes, ['refuse_active_work']); + assert.deepEqual(probes, ['probe']); +}); + +test('quit proceeds without consent when the owned Host is clear', async () => { + const owner = { + probeOwnedLocalHostActivity: async () => ({ kind: 'clear' }) as const, + }; assert.equal( await prepareRuntimeHostQuit(owner, { - confirmInterrupt: async () => true, - recoverFailure, + confirmInterrupt: async () => assert.fail('consent is not expected when idle'), }), 'ready', ); - assert.deepEqual(modes, [ - 'refuse_active_work', - 'refuse_active_work', - 'interrupt_active_work', - ]); }); -test('failed force termination stays inside the quit recovery decision', async () => { - const retirement = new DesktopLocalHostRetirementError( - { - hostId: 'root-id', - hostEpoch: 'host-epoch', - lifecycleMode: 'ephemeral', - rootPath: '/state/root', - pid: 4242, - forceTerminationAvailable: true, - }, - { cause: new Error('graceful retirement timed out') }, - ); - const recovery: Array<{ canForceTerminate: boolean; cause: string | undefined }> = []; +test('background work requires consent before quitting', async () => { + const probes: string[] = []; const owner = { - retireOwnedLocalHost: async () => Promise.reject(retirement), - forceTerminateOwnedLocalHost: async () => { - throw new Error('process access denied'); + probeOwnedLocalHostActivity: async () => { + probes.push('probe'); + return { kind: 'active_tasks' } as const; }, }; assert.equal( - await prepareRuntimeHostQuit(owner, { - confirmInterrupt: async () => assert.fail('active-work consent is not expected'), - recoverFailure: async (error) => { - const canForceTerminate = - error instanceof DesktopLocalHostRetirementError && - error.facts.forceTerminationAvailable; - recovery.push({ - canForceTerminate, - cause: error instanceof Error && error.cause instanceof Error - ? error.cause.message - : undefined, - }); - return canForceTerminate ? 'force' : 'cancel'; - }, - }), + await prepareRuntimeHostQuit(owner, { confirmInterrupt: async () => false }), 'cancelled', ); - assert.deepEqual(recovery, [ - { canForceTerminate: true, cause: 'graceful retirement timed out' }, - { canForceTerminate: false, cause: 'process access denied' }, - ]); + assert.equal( + await prepareRuntimeHostQuit(owner, { confirmInterrupt: async () => true }), + 'ready', + ); + assert.deepEqual(probes, ['probe', 'probe']); +}); + +test('quit proceeds without an owner', async () => { + assert.equal( + await prepareRuntimeHostQuit(undefined, { + confirmInterrupt: async () => assert.fail('consent is not expected without an owner'), + }), + 'ready', + ); }); diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index eb92df4e95..db1b353f0c 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -195,7 +195,6 @@ import { } from "./runtime-host-startup-recovery.js"; import { buildRuntimeHostActiveQuitDialog, - buildRuntimeHostQuitFailureDialog, } from "./runtime-host-quit-copy.js"; import { prepareRuntimeHostQuit } from "./runtime-host-quit.js"; import { createRuntimeHostUpgradePrompts } from "./runtime-host-upgrade-dialog.js"; @@ -2003,12 +2002,6 @@ async function prepareRuntimeHostDesktopQuit(): Promise<'ready' | 'cancelled'> { const { response } = await showDesktopMessageBox(dialog.options, { locale }); return dialog.decisions[response] === 'quit'; }, - recoverFailure: async (error) => { - const locale = await desktopLocale.resolve(); - const dialog = buildRuntimeHostQuitFailureDialog(error, locale); - const { response } = await showDesktopMessageBox(dialog.options, { locale }); - return dialog.decisions[response] ?? 'cancel'; - }, }); if (preparation === 'ready') mainWindowController.browserWindow()?.destroy(); return preparation; diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts index ceb0475eac..a053213e32 100644 --- a/apps/desktop/src/main/runtime-host-desktop-manager.ts +++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts @@ -22,7 +22,6 @@ import type { BotIncomingMessage } from '@maka/runtime/bots'; import { abortable, forceTerminateObservedRegisteredRuntimeHost, - forceTerminateRegisteredRuntimeHost, RuntimeHostOperationError, RuntimeHostPermanentReconnectError, RuntimeHostPeerError, @@ -100,7 +99,7 @@ export interface RuntimeHostDesktopManager { runManagedLocalHostChange(change: () => Promise): Promise; setDefaultProfile(profileId: string): void; retireOwnedLocalHost(mode: RuntimeHostRetirementMode): Promise; - forceTerminateOwnedLocalHost(facts: DesktopLocalHostRetirementFacts): Promise; + probeOwnedLocalHostActivity(): Promise; close(): Promise; } @@ -139,6 +138,17 @@ export type DesktopLocalHostRetirement = | { readonly kind: 'not_owned' } | { readonly kind: 'retired'; resume(): void }; +/** + * Read-only answer to "would quitting interrupt active work right now". Quit + * never drives retirement — the launch-owner guard closes an owned ephemeral + * Host when the Desktop process exits — so the probe only feeds the + * interruption-consent dialog. + */ +export type DesktopLocalHostActivityProbe = + | { readonly kind: 'active_tasks' } + | { readonly kind: 'clear' } + | { readonly kind: 'not_owned' }; + interface DesktopLocalHostRetirementTask { readonly mode: RuntimeHostRetirementMode; readonly result: Promise; @@ -150,7 +160,6 @@ export interface DesktopLocalHostRetirementFacts { readonly lifecycleMode: 'ephemeral'; readonly rootPath: string; readonly pid?: number; - readonly forceTerminationAvailable: boolean; } export class DesktopLocalHostRetirementError extends Error { @@ -250,7 +259,6 @@ export async function startRuntimeHostDesktopManager( onFatalError?: (error: Error, target: ResolvedRuntimeHostProfile) => void; upgradePrompts?: RuntimeHostUpgradePrompts; waitForHostExit?: (pid: number) => Promise; - forceTerminateHost?: typeof forceTerminateRegisteredRuntimeHost; forceTerminateObservedHost?: typeof forceTerminateObservedRegisteredRuntimeHost; waitForHostRetirement?: ( registration: HostRegistration, @@ -275,7 +283,6 @@ export async function startRuntimeHostDesktopManager( options.onFatalError ?? ((error) => console.error('[runtime-host] reconnect failed:', error)), options.upgradePrompts, options.waitForHostExit ?? waitForProcessExit, - options.forceTerminateHost ?? forceTerminateRegisteredRuntimeHost, options.forceTerminateObservedHost ?? forceTerminateObservedRegisteredRuntimeHost, options.waitForHostRetirement ?? waitForProcessRetirement, options.resolveLocalHostReplacement, @@ -315,7 +322,6 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { ) => void, private readonly upgradePrompts: RuntimeHostUpgradePrompts | undefined, private readonly waitForHostExit: (pid: number) => Promise, - private readonly forceTerminateHost: typeof forceTerminateRegisteredRuntimeHost, private readonly forceTerminateObservedHost: typeof forceTerminateObservedRegisteredRuntimeHost, private readonly waitForHostRetirement: ( registration: HostRegistration, @@ -777,58 +783,25 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { return result; } - async forceTerminateOwnedLocalHost( - facts: DesktopLocalHostRetirementFacts, - ): Promise { - if (this.#localHostRetirement) return true; + async probeOwnedLocalHostActivity(): Promise { const target = this.#targets.get(LOCAL_RUNTIME_HOST_PROFILE.id); - const last = target?.lastCandidate; - const ownedProcess = last?.ownedProcess; - if ( - !facts.forceTerminationAvailable || - !last || - last.hostId !== facts.hostId || - last.hostEpoch !== facts.hostEpoch || - last.ownership !== 'owned_ephemeral' || - facts.pid === undefined || - !ownedProcess || - ownedProcess.pid !== facts.pid || - ownedProcess.state === 'unknown' - ) { - return false; + // During a reconnect gap `lifecycle.current` is undefined, so a busy Host + // reads as not_owned and quit will not ask. That is the accepted trade: + // quit never waits on the Host, and the launch-owner guard still closes + // the owned Host truthfully after process exit, interrupting or settling + // its work under the retirement contract. + const candidate = target?.lifecycle?.current; + if (!candidate || candidate.hostOwnership !== 'owned_ephemeral') { + return { kind: 'not_owned' }; } - const stillOwnsProcess = () => - !this.#closed && - target?.lastCandidate === last && - last.ownedProcess === ownedProcess && - ownedProcess.state === 'running'; - const barrier = this.#baseInput.candidateLaunchBarrier; - let paused = false; - let retained = false; try { - barrier?.pause(); - paused = barrier !== undefined; - await barrier?.retireExcept(facts.pid); - const terminated = - ownedProcess.state === 'exited' || - await this.forceTerminateHost( - { - rootPath: facts.rootPath, - rootId: facts.hostId, - hostEpoch: facts.hostEpoch, - pid: facts.pid, - }, - stillOwnsProcess, - ); - if (!terminated && (target.lastCandidate !== last || ownedProcess.state !== 'exited')) { - return false; - } - target.lastCandidate = undefined; - this.#completeLocalHostRetirement(() => barrier?.resume()); - retained = true; - return true; - } finally { - if (paused && !retained) barrier?.resume(); + const diagnostics = await candidate.client.queryHostDiagnostics(); + return { kind: diagnostics.upgradeBlockingActivity === true ? 'active_tasks' : 'clear' }; + } catch { + // A Host that cannot answer a probe is still closed by its launch-owner + // guard when this process exits; diagnostics must never hold quit + // hostage. + return { kind: 'clear' }; } } @@ -892,14 +865,6 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { target.lastCandidate = undefined; return this.#completeLocalHostRetirement(resume); } catch (error) { - const last = target.lastCandidate; - const forceTerminationAvailable = - hostPid !== undefined && - last?.hostId === quiescence.current.client.hostId && - last.hostEpoch === quiescence.current.client.hostEpoch && - last.ownership === 'owned_ephemeral' && - last.ownedProcess?.pid === hostPid && - last.ownedProcess.state === 'running'; resume(); throw new DesktopLocalHostRetirementError( { @@ -908,7 +873,6 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { lifecycleMode: 'ephemeral', rootPath: this.#baseInput.rootPath, ...(hostPid === undefined ? {} : { pid: hostPid }), - forceTerminationAvailable, }, { cause: error }, ); @@ -949,7 +913,6 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { ...(last.ownedProcess?.state === 'running' ? { pid: last.ownedProcess.pid } : {}), - forceTerminationAvailable: last.ownedProcess?.state === 'running', }, { cause: cause instanceof Error ? cause : new Error(String(cause)) }, ); @@ -988,7 +951,10 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { const results = await Promise.allSettled( [...this.#targets.values()].map((target) => this.#removeTarget(target)), ); - this.#baseInput.candidateLaunchBarrier?.release(); + // Owned candidates are deliberately not released here: a launcher that + // exits without releasing them is their close authority, so keeping the + // launch-owner guard armed is what retires an owned ephemeral Host on + // quit. this.#ipcMain.close(); const failures = results.filter( (result): result is PromiseRejectedResult => result.status === 'rejected', diff --git a/apps/desktop/src/main/runtime-host-quit-copy.ts b/apps/desktop/src/main/runtime-host-quit-copy.ts index e53cf84b89..9b316e77a6 100644 --- a/apps/desktop/src/main/runtime-host-quit-copy.ts +++ b/apps/desktop/src/main/runtime-host-quit-copy.ts @@ -19,8 +19,6 @@ import type { UiLocale } from '@maka/core/ui-locale'; import type { MessageBoxOptions } from 'electron'; -import { DesktopLocalHostRetirementError } from './runtime-host-desktop-manager.js'; -import type { RuntimeHostQuitFailureDecision } from './runtime-host-quit.js'; export interface RuntimeHostQuitDialog { readonly options: MessageBoxOptions; @@ -48,97 +46,29 @@ export function buildRuntimeHostActiveQuitDialog( }; } -export function buildRuntimeHostQuitFailureDialog( - error: unknown, - locale: UiLocale, -): RuntimeHostQuitDialog { - const retirement = error instanceof DesktopLocalHostRetirementError ? error : undefined; - const canForceTerminate = retirement?.facts.forceTerminationAvailable === true; - const copy = COPY[locale]; - const details: string[] = [copy.detail]; - if (retirement) { - details.push(`State Root: ${retirement.facts.rootPath}`); - details.push(`Host epoch: ${retirement.facts.hostEpoch}`); - if (retirement.facts.pid !== undefined) { - details.push(copy.process(retirement.facts.pid)); - details.push(canForceTerminate ? copy.forceWarning : copy.manual); - } - } - const cause = error instanceof Error && error.cause instanceof Error - ? error.cause.message - : error instanceof Error - ? error.message - : String(error); - details.push(`${copy.cause}: ${cause}`); - const decisions: RuntimeHostQuitFailureDecision[] = canForceTerminate - ? ['retry', 'force', 'cancel'] - : ['retry', 'cancel']; - return { - options: { - type: 'error', - title: copy.title, - message: copy.message, - detail: details.join('\n'), - buttons: canForceTerminate - ? [copy.retry, copy.forceQuit, copy.keepRunning] - : [copy.retry, copy.keepRunning], - defaultId: decisions.length - 1, - cancelId: decisions.length - 1, - noLink: true, - }, - decisions, - }; -} - const COPY = { en: { activeTitle: 'Maka is still working', activeMessage: 'Background work is still running.', activeDetail: - 'Quitting now stops the Runtime Host and may interrupt active executions or scheduled background work.', + 'Quitting now stops the Runtime Host and may interrupt active executions or scheduled background work. It resumes from its durable state the next time a Runtime Host runs.', stopAndQuit: 'Stop Work and Quit', keepRunning: 'Keep Maka Running', - title: 'Unable to quit Maka safely', - message: 'The local Runtime Host could not stop safely. Maka is still running.', - detail: 'Quit was cancelled. Try again, or inspect diagnostics if the problem persists.', - process: (pid: number) => `Runtime Host process PID: ${pid}`, - manual: - "If retry still fails, confirm that no execution must be preserved before stopping this PID with the operating system's process-management tool.", - forceWarning: 'Force quitting can discard in-flight external work that has not settled.', - cause: 'Cause', - retry: 'Retry Quit', - forceQuit: 'Force Quit Maka', }, 'zh-CN': { activeTitle: 'Maka 正在后台工作', activeMessage: '仍有后台工作正在运行。', - activeDetail: '现在退出会停止 Runtime Host,并可能中断正在执行或等待运行的后台任务。', + activeDetail: + '现在退出会停止 Runtime Host,并可能中断正在执行或等待运行的后台任务。任务会在下次 Runtime Host 运行时从持久状态恢复。', stopAndQuit: '停止任务并退出', keepRunning: '继续运行 Maka', - title: '无法安全退出 Maka', - message: '本地 Runtime Host 未能安全停止,Maka 仍在运行。', - detail: '退出已取消。请重试;如果问题持续存在,请查看诊断信息。', - process: (pid: number) => `Runtime Host 进程 PID:${pid}`, - manual: '如果重试仍然失败,请先确认没有需要保留的执行,再通过操作系统的进程管理工具停止该 PID。', - forceWarning: '强制退出可能丢弃尚未完成的外部工作。', - cause: '原因', - retry: '重试退出', - forceQuit: '强制退出 Maka', }, 'zh-TW': { activeTitle: 'Maka 正在背景工作', activeMessage: '仍有背景工作正在執行。', - activeDetail: '現在退出會停止 Runtime Host,並可能中斷正在執行或等待執行的背景工作。', - stopAndQuit: '停止工作並退出', + activeDetail: + '現在結束會停止 Runtime Host,並可能中斷正在執行或等待執行的背景工作。工作會在下次 Runtime Host 執行時從持久狀態恢復。', + stopAndQuit: '停止工作並結束', keepRunning: '繼續執行 Maka', - title: '無法安全退出 Maka', - message: '本地 Runtime Host 未能安全停止,Maka 仍在執行。', - detail: '退出已取消。請重試;如果問題持續存在,請檢視診斷資訊。', - process: (pid: number) => `Runtime Host 程序 PID:${pid}`, - manual: '如果重試仍然失敗,請先確認沒有需要保留的執行,再透過作業系統的程序管理工具停止該 PID。', - forceWarning: '強制退出可能丟棄尚未完成的外部工作。', - cause: '原因', - retry: '重試退出', - forceQuit: '強制退出 Maka', }, } as const; diff --git a/apps/desktop/src/main/runtime-host-quit.ts b/apps/desktop/src/main/runtime-host-quit.ts index b45529e1a7..d297b36d79 100644 --- a/apps/desktop/src/main/runtime-host-quit.ts +++ b/apps/desktop/src/main/runtime-host-quit.ts @@ -17,85 +17,26 @@ * under the License. */ -import { - DesktopLocalHostRetirementError, - type RuntimeHostDesktopManager, -} from './runtime-host-desktop-manager.js'; +import type { RuntimeHostDesktopManager } from './runtime-host-desktop-manager.js'; -type RetirementOwner = Pick< - RuntimeHostDesktopManager, - 'retireOwnedLocalHost' | 'forceTerminateOwnedLocalHost' ->; - -export type RuntimeHostQuitFailureDecision = 'retry' | 'force' | 'cancel'; +type ActivityProbeOwner = Pick; export interface RuntimeHostQuitPrompts { confirmInterrupt(): Promise; - recoverFailure(error: unknown): Promise; } +/** + * Quit never drives retirement: the launch-owner guard closes an owned + * ephemeral Host once the Desktop process exits. The probe only feeds the + * interruption-consent dialog, and a Host that cannot answer it is still + * closed by the guard — quit is never held hostage to the Host. + */ export async function prepareRuntimeHostQuit( - owner: RetirementOwner | undefined, + owner: ActivityProbeOwner | undefined, prompts: RuntimeHostQuitPrompts, ): Promise<'ready' | 'cancelled'> { if (!owner) return 'ready'; - for (;;) { - try { - const guarded = await owner.retireOwnedLocalHost('refuse_active_work'); - if (guarded.kind !== 'active_tasks') return 'ready'; - if (!(await prompts.confirmInterrupt())) return 'cancelled'; - const authorized = await owner.retireOwnedLocalHost('interrupt_active_work'); - if (authorized.kind === 'active_tasks') { - throw new Error('Runtime Host refused authorized quit retirement'); - } - return 'ready'; - } catch (error) { - const recovery = await recoverRuntimeHostQuit(owner, prompts, error); - if (recovery !== 'retry') return recovery; - } - } -} - -async function recoverRuntimeHostQuit( - owner: RetirementOwner, - prompts: RuntimeHostQuitPrompts, - error: unknown, -): Promise<'ready' | 'retry' | 'cancelled'> { - let currentError = error; - for (;;) { - const retirement = forceTerminableRetirement(currentError); - const decision = await prompts.recoverFailure(currentError); - if (decision === 'cancel') return 'cancelled'; - if (decision === 'retry') return 'retry'; - if (!retirement) throw new Error('Force termination was selected without Host authority'); - try { - if (await owner.forceTerminateOwnedLocalHost(retirement.facts)) return 'ready'; - currentError = forceTerminationError( - retirement, - new Error('The Runtime Host identity changed or forced termination failed'), - ); - } catch (cause) { - currentError = forceTerminationError(retirement, cause); - } - } -} - -function forceTerminableRetirement( - error: unknown, -): DesktopLocalHostRetirementError | undefined { - return error instanceof DesktopLocalHostRetirementError && - error.facts.pid !== undefined && - error.facts.forceTerminationAvailable - ? error - : undefined; -} - -function forceTerminationError( - retirement: DesktopLocalHostRetirementError, - cause: unknown, -): DesktopLocalHostRetirementError { - return new DesktopLocalHostRetirementError( - { ...retirement.facts, forceTerminationAvailable: false }, - { cause: cause instanceof Error ? cause : new Error(String(cause)) }, - ); + const probe = await owner.probeOwnedLocalHostActivity(); + if (probe.kind !== 'active_tasks') return 'ready'; + return (await prompts.confirmInterrupt()) ? 'ready' : 'cancelled'; } diff --git a/packages/runtime-host/src/__tests__/host-kernel.test.ts b/packages/runtime-host/src/__tests__/host-kernel.test.ts index acdd7edd28..2c25043b0a 100644 --- a/packages/runtime-host/src/__tests__/host-kernel.test.ts +++ b/packages/runtime-host/src/__tests__/host-kernel.test.ts @@ -809,6 +809,7 @@ describe('non-serving Runtime Host kernel', () => { assert.equal(diagnostics.platform, process.platform); assert.equal(diagnostics.protocolVersion, RUNTIME_HOST_PROTOCOL_VERSION); assert.equal(diagnostics.compatibilityEpoch, RUNTIME_HOST_COMPATIBILITY_EPOCH); + assert.equal(diagnostics.upgradeBlockingActivity, false); assert.ok(Array.isArray(diagnostics.logs)); await connected.connection.close(); await winner.host.closed; @@ -1089,6 +1090,11 @@ describe('non-serving Runtime Host kernel', () => { { kind: 'active_tasks' }, ); assert.equal(host.state, 'ready'); + assert.equal( + (await replacement.connection.request('host.diagnostics.query', {})) + .upgradeBlockingActivity, + true, + ); await lateClient.connection.close(); assert.deepEqual( @@ -1103,6 +1109,23 @@ describe('non-serving Runtime Host kernel', () => { }); }); + test('closing with a retirement reason reports a retirement shutdown', async () => { + await withHostPaths(async (paths) => { + const capability = await resolveStorageRoot({ path: paths.root, kind: 'interactive' }); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + const host = await RuntimeHostKernel.start({ + owner, + lifecycleMode: 'service', + composition: KERNEL_COMPOSITION, + }); + + assert.equal(host.shutdownReason, undefined); + await host.close({ reason: 'retirement' }); + assert.equal(host.shutdownReason, 'retirement'); + }); + }); + test('an explicit generation takeover drains only the exact unobserved ephemeral Host', async () => { await withHostPaths(async (paths) => { const candidate = await startTestRuntimeHostCandidate(paths, { diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index 46eca23e2c..b025966e9d 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -2225,6 +2225,7 @@ describe('Runtime Host bootstrap protocol', () => { connections: 1, activeOperations: 0, activeResidencies: 0, + upgradeBlockingActivity: true, protocolVersion: 0, compatibilityEpoch: 9, pid: 42, @@ -2238,6 +2239,44 @@ describe('Runtime Host bootstrap protocol', () => { ); }); + test('decodes the required upgrade blocking activity fact in diagnostics', () => { + const base = { + hostEpoch: 'epoch-1', + compositionId: 'maka.interactive', + compositionRevision: '1', + compositionModules: ['interactive'], + residencies: [], + state: 'ready', + connections: 1, + activeOperations: 0, + activeResidencies: 0, + upgradeBlockingActivity: false, + protocolVersion: 0, + compatibilityEpoch: 9, + pid: 42, + processUptimeSeconds: 1, + nodeVersion: '22.0.0', + platform: 'linux', + arch: 'x64', + osRelease: '6.6.0', + logs: [], + }; + const spec = HOST_BOOTSTRAP_OPERATION_SPECS['host.diagnostics.query']; + + assert.deepEqual(spec.decodeOutput(base), { ...base }); + assert.deepEqual(spec.decodeOutput({ ...base, upgradeBlockingActivity: true }), { + ...base, + upgradeBlockingActivity: true, + }); + assert.throws( + () => spec.decodeOutput({ ...base, upgradeBlockingActivity: 'yes' }), + isInvalidFrame, + ); + const missing = { ...base } as Record; + delete missing.upgradeBlockingActivity; + assert.throws(() => spec.decodeOutput(missing), isInvalidFrame); + }); + test('rejects terminal snapshots with fields from another terminal variant', () => { assert.throws( () => diff --git a/packages/runtime-host/src/__tests__/registered-host-termination.test.ts b/packages/runtime-host/src/__tests__/registered-host-termination.test.ts index cd95dce806..3f3970b28c 100644 --- a/packages/runtime-host/src/__tests__/registered-host-termination.test.ts +++ b/packages/runtime-host/src/__tests__/registered-host-termination.test.ts @@ -26,10 +26,7 @@ import { prepareStorageRootControlDirectory, resolveStorageRoot, } from '@maka/storage/root-authority'; -import { - forceTerminateObservedRegisteredRuntimeHostWithDependencies, - forceTerminateRegisteredRuntimeHostWithDependencies, -} from '../client/registered-host-termination.js'; +import { forceTerminateObservedRegisteredRuntimeHostWithDependencies } from '../client/registered-host-termination.js'; import { readHostRegistration, writeHostRegistration } from '../control/registration.js'; import { RUNTIME_HOST_COMPATIBILITY_EPOCH, @@ -38,91 +35,6 @@ import { type HostRegistration, } from '../protocol/index.js'; -test('owned forced termination remains bound to the registered Host identity', async (t) => { - const rootPath = await mkdtemp(join(tmpdir(), 'maka-host-termination-')); - t.after(() => rm(rootPath, { recursive: true, force: true })); - const capability = await resolveStorageRoot({ path: rootPath, kind: 'interactive' }); - const { controlDirectory } = await prepareStorageRootControlDirectory(capability); - const identity = { - rootPath, - rootId: capability.rootId, - hostEpoch: 'expected-epoch', - pid: 4242, - }; - const registration: HostRegistration = { - kind: 'maka-runtime-host', - schemaVersion: RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION, - rootId: capability.rootId, - hostEpoch: identity.hostEpoch, - endpoint: join(rootPath, 'runtime-host.sock'), - protocolMin: RUNTIME_HOST_PROTOCOL_VERSION, - protocolMax: RUNTIME_HOST_PROTOCOL_VERSION, - compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH, - compositionId: 'maka.interactive', - compositionRevision: 'test', - lifecycleMode: 'ephemeral', - state: 'ready', - pid: identity.pid, - createdAt: new Date(0).toISOString(), - }; - let alive = true; - let terminated = 0; - let replaceBeforeSignal = false; - let stillOwnsProcess = true; - let releaseOwnershipBeforeSignal = false; - const dependencies = { - isProcessAlive: () => alive, - settleMs: 0, - terminateProcess: async (options: { beforeSignal?: () => boolean | Promise }) => { - if (replaceBeforeSignal) { - await writeHostRegistration(controlDirectory, { ...registration, hostEpoch: 'successor' }); - } - if (releaseOwnershipBeforeSignal) stillOwnsProcess = false; - if (options.beforeSignal && !(await options.beforeSignal())) return false; - terminated += 1; - alive = false; - return true; - }, - }; - - await writeHostRegistration(controlDirectory, registration); - replaceBeforeSignal = true; - assert.equal( - await forceTerminateRegisteredRuntimeHostWithDependencies( - identity, - () => stillOwnsProcess, - dependencies, - ), - false, - ); - assert.equal(terminated, 0); - - await writeHostRegistration(controlDirectory, registration); - replaceBeforeSignal = false; - releaseOwnershipBeforeSignal = true; - assert.equal( - await forceTerminateRegisteredRuntimeHostWithDependencies( - identity, - () => stillOwnsProcess, - dependencies, - ), - false, - ); - assert.equal(terminated, 0); - - stillOwnsProcess = true; - releaseOwnershipBeforeSignal = false; - assert.equal( - await forceTerminateRegisteredRuntimeHostWithDependencies( - identity, - () => stillOwnsProcess, - dependencies, - ), - true, - ); - assert.equal(terminated, 1); -}); - test('observed forced termination remains bound to the exact process instance', async (t) => { const rootPath = await mkdtemp(join(tmpdir(), 'maka-host-termination-')); t.after(() => rm(rootPath, { recursive: true, force: true })); diff --git a/packages/runtime-host/src/candidate-entry.ts b/packages/runtime-host/src/candidate-entry.ts index c3e6854c72..61612b5ce0 100644 --- a/packages/runtime-host/src/candidate-entry.ts +++ b/packages/runtime-host/src/candidate-entry.ts @@ -102,7 +102,10 @@ export async function runExecutionCandidateEntry( process.exit(2); } - launchOwnerGuard?.bind(() => result.host.close()); + // A launcher that disappears without releasing this Host (Desktop quit, + // launcher crash) is an intentional retirement of an owned ephemeral Host, + // not a crash — closing with the retirement reason keeps the exit truthful. + launchOwnerGuard?.bind(() => result.host.close({ reason: 'retirement' })); const stopWatch = hooks.onWon?.(result.host); try { await runRuntimeHostProcessLifecycle(result.host); diff --git a/packages/runtime-host/src/client/index.ts b/packages/runtime-host/src/client/index.ts index c5e727f15d..62bda5e1d8 100644 --- a/packages/runtime-host/src/client/index.ts +++ b/packages/runtime-host/src/client/index.ts @@ -46,10 +46,8 @@ export { } from './host-retirement.js'; export { forceTerminateObservedRegisteredRuntimeHost, - forceTerminateRegisteredRuntimeHost, type ObservedRegisteredRuntimeHost, type ObservedRegisteredRuntimeHostTerminationAuthority, - type RegisteredRuntimeHostIdentity, } from './registered-host-termination.js'; export type { RuntimeHostProcessIdentity } from './process-identity.js'; export { diff --git a/packages/runtime-host/src/client/registered-host-termination.ts b/packages/runtime-host/src/client/registered-host-termination.ts index 0025c80f69..12e4ad17a5 100644 --- a/packages/runtime-host/src/client/registered-host-termination.ts +++ b/packages/runtime-host/src/client/registered-host-termination.ts @@ -17,7 +17,6 @@ * under the License. */ -import { terminateProcessTree } from '@maka/runtime/process-tree-terminator'; import { prepareStorageRootControlDirectory, resolveStorageRoot, @@ -31,22 +30,11 @@ import { const TERMINATION_SETTLE_MS = 2_000; -export interface RegisteredRuntimeHostIdentity { - readonly rootPath: string; - readonly rootId: string; - readonly hostEpoch: string; - readonly pid: number; -} - interface RuntimeHostExitDependencies { readonly isProcessAlive: (pid: number) => boolean; readonly settleMs: number; } -interface RegisteredRuntimeHostTerminationDependencies extends RuntimeHostExitDependencies { - readonly terminateProcess: typeof terminateProcessTree; -} - export interface ObservedRegisteredRuntimeHostTerminationAuthority { readonly processIdentity: RuntimeHostProcessIdentity; readonly isCurrent: () => boolean; @@ -63,12 +51,6 @@ interface ObservedRegisteredRuntimeHostTerminationDependencies extends RuntimeHo readonly signalProcess: (pid: number) => boolean; } -const defaultDependencies: RegisteredRuntimeHostTerminationDependencies = { - terminateProcess: terminateProcessTree, - isProcessAlive, - settleMs: TERMINATION_SETTLE_MS, -}; - const defaultObservedDependencies: ObservedRegisteredRuntimeHostTerminationDependencies = { isProcessAlive, readProcessIdentity: readRuntimeHostProcessIdentity, @@ -82,58 +64,6 @@ const defaultObservedDependencies: ObservedRegisteredRuntimeHostTerminationDepen * the expected State Root. Callers must reserve this for explicit recovery * and keep their authorization current until the signal is sent. */ -export function forceTerminateRegisteredRuntimeHost( - identity: RegisteredRuntimeHostIdentity, - stillOwnsProcess: () => boolean, -): Promise { - return forceTerminateRegisteredRuntimeHostWithDependencies( - identity, - stillOwnsProcess, - defaultDependencies, - ); -} - -export async function forceTerminateRegisteredRuntimeHostWithDependencies( - identity: RegisteredRuntimeHostIdentity, - stillOwnsProcess: () => boolean, - dependencies: RegisteredRuntimeHostTerminationDependencies, -): Promise { - if (!stillOwnsProcess()) return false; - const capability = await resolveStorageRoot({ path: identity.rootPath, kind: 'interactive' }); - if (capability.rootId !== identity.rootId) return false; - const { controlDirectory } = await prepareStorageRootControlDirectory(capability); - const registered = await readHostRegistration(controlDirectory); - if (!registered) return true; - if (!matchesIdentity(registered, identity)) return false; - if (!dependencies.isProcessAlive(identity.pid)) return true; - - let signalTarget: HostRegistration | undefined = registered; - const signaled = await dependencies.terminateProcess({ - pid: identity.pid, - signal: 'SIGKILL', - hasExited: () => !dependencies.isProcessAlive(identity.pid), - beforeSignal: async () => { - // This runs after asynchronous process-tree discovery and immediately - // before the OS signal, so neither a successor nor a reused PID can - // inherit stale intent. - signalTarget = await readHostRegistration(controlDirectory); - return matchesIdentity(signalTarget, identity) && stillOwnsProcess(); - }, - fallback: () => { - try { - process.kill(identity.pid, 'SIGKILL'); - return true; - } catch { - return false; - } - }, - }); - if (!signalTarget) return true; - if (!matchesIdentity(signalTarget, identity)) return false; - if (!signaled && dependencies.isProcessAlive(identity.pid)) return false; - return waitForExit(identity.pid, dependencies); -} - /** * Stops an ephemeral Host that Desktop did not launch in this process. Unlike * the owned-process path above, its authority is limited to the exact root PID @@ -197,18 +127,6 @@ function matchesObservedRegistration( ); } -function matchesIdentity( - registration: HostRegistration | undefined, - identity: RegisteredRuntimeHostIdentity, -): boolean { - return ( - registration?.rootId === identity.rootId && - registration.hostEpoch === identity.hostEpoch && - registration.pid === identity.pid && - registration.lifecycleMode === 'ephemeral' - ); -} - async function waitForExit( pid: number, dependencies: RuntimeHostExitDependencies, diff --git a/packages/runtime-host/src/protocol/host-status.ts b/packages/runtime-host/src/protocol/host-status.ts index 5ec5bd51fd..a4506b6df2 100644 --- a/packages/runtime-host/src/protocol/host-status.ts +++ b/packages/runtime-host/src/protocol/host-status.ts @@ -80,6 +80,13 @@ export type HostPeerEndpoint = SignedPeerReachabilityLeaseV1; export interface HostDiagnosticsResult extends HostStatusResult { compositionModules: readonly string[]; residencies: readonly { label: string; count: number }[]; + /** + * The Host's authoritative answer to "would a maintenance drain interrupt + * active work right now", computed by the same authority that gates + * `host.upgrade.prepare`. Required: the epoch gate already refuses + * mixed-version peers, so there is no wire case where it is absent. + */ + upgradeBlockingActivity: boolean; protocolVersion: number; compatibilityEpoch: number; pid: number; @@ -151,6 +158,7 @@ function decodeHostDiagnosticsResult(value: unknown): HostDiagnosticsResult { 'activeOperations', 'activeResidencies', ...(valueRecord.peerEndpoint === undefined ? [] : ['peerEndpoint']), + 'upgradeBlockingActivity', 'compositionModules', 'residencies', 'protocolVersion', @@ -174,6 +182,7 @@ function decodeHostDiagnosticsResult(value: unknown): HostDiagnosticsResult { } return { ...decodeHostStatusFields(record), + upgradeBlockingActivity: requireUpgradeBlockingActivity(record.upgradeBlockingActivity), compositionModules: record.compositionModules.map((moduleId) => requireString(moduleId, 'Runtime Host composition module id', 64), ), @@ -202,6 +211,13 @@ function decodeHostDiagnosticsResult(value: unknown): HostDiagnosticsResult { }; } +function requireUpgradeBlockingActivity(value: unknown): boolean { + if (typeof value !== 'boolean') { + throw invalidProtocolFrame('Invalid Runtime Host upgrade blocking activity'); + } + return value; +} + export function decodeHostActivitySnapshot(value: unknown): HostActivitySnapshot { const record = requireExactRecord(value, 'Runtime Host activity', [ 'connections', diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 6e38c7504b..4ec1d4da53 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -101,7 +101,12 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 120 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 121 as const; +// 121: Host diagnostics report `upgradeBlockingActivity`, the Host's +// authoritative activity answer for maintenance probes, computed by the same +// authority that gates `host.upgrade.prepare`. Older Clients reject the +// unknown key when decoding diagnostics, so the pair must refuse each other +// at the handshake. // 120: WorkHub admits named resume proposals with an explicit resumesActionId // and returns a transient resume outcome. Older peers cannot decode this action. // 119: Session Guest principals expose optional display names and an owner-only @@ -117,6 +122,7 @@ export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 120 as const; // 113: Client Capability tool schemas add `patternProperties` and draft-07 tuple // `additionalItems`; validation and projection share one per-keyword shape table. // Older peers reject these keywords and fail the handshake. + // 112: Owners can query the Host execution environment through an extensible, // bounded resource-envelope contract. Older Hosts do not implement the query. // 111: Client Capability tool schemas may use draft-07 tuple additionalItems. diff --git a/packages/runtime-host/src/server/host-kernel.ts b/packages/runtime-host/src/server/host-kernel.ts index db232e2e2f..ab407eed55 100644 --- a/packages/runtime-host/src/server/host-kernel.ts +++ b/packages/runtime-host/src/server/host-kernel.ts @@ -334,7 +334,8 @@ export class RuntimeHostKernel { return this.#options.composition.descriptor; } - close(): Promise { + close(input?: { readonly reason?: 'retirement' }): Promise { + this.#shutdownReason ??= input?.reason; this.#requestDrain(); return this.closed; } @@ -693,6 +694,7 @@ export class RuntimeHostKernel { ok: true, result: { ...this.#statusSnapshot(), + upgradeBlockingActivity: this.#hasUpgradeBlockingActivity(0), compositionModules: this.#composition?.moduleIds ?? [], residencies: this.#residencies.snapshot(), protocolVersion: RUNTIME_HOST_PROTOCOL_VERSION, @@ -722,7 +724,7 @@ export class RuntimeHostKernel { }, }; } - if (!input.allowInterruptActiveTasks && this.#hasUpgradeBlockingActivity()) { + if (!input.allowInterruptActiveTasks && this.#hasUpgradeBlockingActivity(1)) { return { ok: true, result: { kind: 'active_tasks' } }; } this.#shutdownReason = 'retirement'; @@ -874,12 +876,15 @@ export class RuntimeHostKernel { }; } - #hasUpgradeBlockingActivity(): boolean { + #hasUpgradeBlockingActivity(selfCommands: 0 | 1): boolean { // The request's own accepted transport is expected. Any other live // connection arrived after discovery or remained attached and therefore - // requires explicit interruption authority before retirement. + // requires explicit interruption authority before retirement. Callers + // pass how many of the in-flight commands are their own: the + // `host.upgrade.prepare` command counts itself, while the diagnostics + // query path runs outside the command counter. if (this.#acceptedTransports.size > 1) return true; - if (this.#activeCommandOperations > 1) return true; + if (this.#activeCommandOperations > selfCommands) return true; return this.#residencies.drainCount > 0; } From a6996cfa3bdaf959fd7a6134e238c45a023c5e9c Mon Sep 17 00:00:00 2001 From: me2seeks Date: Sun, 6 Sep 2026 23:35:18 +0800 Subject: [PATCH 2/2] test(desktop): keep the quit probe fixtures on the valid wire payload The diagnostics fixture now always carries the required upgradeBlockingActivity field, and the idle-case probe test uses an explicit idle report instead of a payload shape the production decoder would reject. Generated-by: Maka --- .../__tests__/runtime-host-desktop-manager.test.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts index 383d1e1da4..0bcb2c7de6 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts @@ -324,8 +324,8 @@ test('probes owned Host activity for the quit consent dialog without retiring it await owner.close(); }); -test('probe treats a missing activity field as clear and never retires', async () => { - const current = candidateHarness(); +test('probe treats an idle activity report as clear and never retires', async () => { + const current = candidateHarness({ upgradeBlockingActivity: false }); const owner = await startRuntimeHostDesktopManager( {} as DesktopRuntimeHostCandidateStartInput, { startCandidate: async () => ready(current.candidate) }, @@ -1892,12 +1892,9 @@ function candidateHarness( }, async queryHostDiagnostics() { if (options.diagnosticsError) throw options.diagnosticsError; - return { - pid: 42, - ...(options.upgradeBlockingActivity === undefined - ? {} - : { upgradeBlockingActivity: options.upgradeBlockingActivity }), - }; + // Mirrors the production decoder contract: the field is required on + // the wire, so the harness always returns a valid payload. + return { pid: 42, upgradeBlockingActivity: options.upgradeBlockingActivity ?? false }; }, async prepareHostRetirement(mode: string) { prepareRetirementCalls += 1;