From 851407f8ca3f15b903eac85d5ccdb99f3d596700 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Fri, 4 Sep 2026 17:15:44 +0800 Subject: [PATCH] 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, and a guard-triggered close now records the retirement reason instead of exiting like a crash. Closes #4730. Generated-by: Maka --- .../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 | 93 ++++---------- .../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 | 38 ++++++ packages/runtime-host/src/candidate-entry.ts | 5 +- .../runtime-host/src/protocol/host-status.ts | 20 +++ packages/runtime-host/src/protocol/index.ts | 7 +- .../runtime-host/src/server/host-kernel.ts | 4 +- 13 files changed, 245 insertions(+), 381 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 b2392b6b5b..33875af344 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 @@ -260,8 +260,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(); @@ -308,10 +307,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[] = []; @@ -461,7 +506,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 () => { @@ -486,7 +531,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 () => { @@ -511,57 +556,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(); @@ -1812,6 +1806,8 @@ function candidateHarness( delayDisconnect?: boolean; disconnectOnPrepare?: boolean; activeTasks?: boolean | 'always'; + upgradeBlockingActivity?: boolean; + diagnosticsError?: Error; ownership?: 'owned_ephemeral' | 'supervised' | 'external'; ownedProcess?: RuntimeHostSpawnedProcess; hostId?: string; @@ -1846,7 +1842,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 7caee1cf13..a7d1cb3cdd 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"; @@ -1984,12 +1983,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 36d8eca62d..7bd93ad89b 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, RuntimeHostRequestInterruptedError, @@ -98,7 +97,7 @@ export interface RuntimeHostDesktopManager { runManagedLocalHostChange(change: () => Promise): Promise; setDefaultProfile(profileId: string): void; retireOwnedLocalHost(mode: RuntimeHostRetirementMode): Promise; - forceTerminateOwnedLocalHost(facts: DesktopLocalHostRetirementFacts): Promise; + probeOwnedLocalHostActivity(): Promise; close(): Promise; } @@ -136,6 +135,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; @@ -147,7 +157,6 @@ export interface DesktopLocalHostRetirementFacts { readonly lifecycleMode: 'ephemeral'; readonly rootPath: string; readonly pid?: number; - readonly forceTerminationAvailable: boolean; } export class DesktopLocalHostRetirementError extends Error { @@ -247,7 +256,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, @@ -272,7 +280,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, @@ -312,7 +319,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, @@ -762,58 +768,20 @@ 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; + 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' }; } } @@ -877,14 +845,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( { @@ -893,7 +853,6 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { lifecycleMode: 'ephemeral', rootPath: this.#baseInput.rootPath, ...(hostPid === undefined ? {} : { pid: hostPid }), - forceTerminationAvailable, }, { cause: error }, ); @@ -934,7 +893,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)) }, ); @@ -973,7 +931,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 9ac1cfde4f..a88abc6258 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -2232,6 +2232,44 @@ describe('Runtime Host bootstrap protocol', () => { ); }); + test('decodes the optional 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, + 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, + ); + assert.throws( + () => spec.decodeOutput({ ...base, upgradeBlockingActivity: undefined }), + isInvalidFrame, + ); + }); + test('rejects terminal snapshots with fields from another terminal variant', () => { assert.throws( () => 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/protocol/host-status.ts b/packages/runtime-host/src/protocol/host-status.ts index 5ec5bd51fd..54ef94ae9e 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`. Absent from older Hosts; readers must treat a + * missing value as unknown rather than as idle. + */ + upgradeBlockingActivity?: boolean; protocolVersion: number; compatibilityEpoch: number; pid: number; @@ -151,6 +158,7 @@ function decodeHostDiagnosticsResult(value: unknown): HostDiagnosticsResult { 'activeOperations', 'activeResidencies', ...(valueRecord.peerEndpoint === undefined ? [] : ['peerEndpoint']), + ...(valueRecord.upgradeBlockingActivity === undefined ? [] : ['upgradeBlockingActivity']), 'compositionModules', 'residencies', 'protocolVersion', @@ -174,6 +182,11 @@ function decodeHostDiagnosticsResult(value: unknown): HostDiagnosticsResult { } return { ...decodeHostStatusFields(record), + ...(record.upgradeBlockingActivity === undefined + ? {} + : { + upgradeBlockingActivity: requireUpgradeBlockingActivity(record.upgradeBlockingActivity), + }), compositionModules: record.compositionModules.map((moduleId) => requireString(moduleId, 'Runtime Host composition module id', 64), ), @@ -202,6 +215,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 b58467c6ee..ce3f6109e5 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 = 112 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 113 as const; +// 113: Host diagnostics may 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. // 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 de8c1d4acf..2160a208dc 100644 --- a/packages/runtime-host/src/server/host-kernel.ts +++ b/packages/runtime-host/src/server/host-kernel.ts @@ -333,7 +333,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; } @@ -692,6 +693,7 @@ export class RuntimeHostKernel { ok: true, result: { ...this.#statusSnapshot(), + upgradeBlockingActivity: this.#hasUpgradeBlockingActivity(), compositionModules: this.#composition?.moduleIds ?? [], residencies: this.#residencies.snapshot(), protocolVersion: RUNTIME_HOST_PROTOCOL_VERSION,