diff --git a/apps/desktop/src/main/__tests__/app-quit-coordinator.test.ts b/apps/desktop/src/main/__tests__/app-quit-coordinator.test.ts index a5b249259f..e3bab13b5e 100644 --- a/apps/desktop/src/main/__tests__/app-quit-coordinator.test.ts +++ b/apps/desktop/src/main/__tests__/app-quit-coordinator.test.ts @@ -26,8 +26,10 @@ describe('app quit coordinator', () => { let resumeQuitCount = 0; let preventedCount = 0; const coordinator = createAppQuitCoordinator({ + prepareToQuit: async () => {}, cleanup: async () => {}, focusOrCreateWindow: () => {}, + onPreparationError: () => {}, onCleanupError: () => {}, onWindowCreationError: () => {}, resumeQuit: () => { @@ -48,7 +50,7 @@ describe('app quit coordinator', () => { assert.equal(resumeQuitCount, 0); assert.equal(preventedCount, 2); - await new Promise((resolve) => setImmediate(resolve)); + await flushQuitCoordinator(); assert.equal(resumeQuitCount, 1); }); @@ -62,6 +64,7 @@ describe('app quit coordinator', () => { releaseCleanup = resolve; }); const coordinator = createAppQuitCoordinator({ + prepareToQuit: async () => {}, cleanup: async () => { cleanupCount += 1; await cleanupPending; @@ -69,6 +72,7 @@ describe('app quit coordinator', () => { focusOrCreateWindow: () => { focusOrCreateCount += 1; }, + onPreparationError: () => {}, onCleanupError: () => {}, onWindowCreationError: () => {}, resumeQuit: () => { @@ -84,6 +88,7 @@ describe('app quit coordinator', () => { coordinator.handleBeforeQuit(event); coordinator.handleBeforeQuit(event); + await flushQuitCoordinator(); assert.equal(cleanupCount, 1); assert.equal(preventedCount, 2); assert.equal(resumeQuitCount, 0); @@ -91,7 +96,7 @@ describe('app quit coordinator', () => { releaseCleanup(); await cleanupPending; await Promise.resolve(); - await new Promise((resolve) => setImmediate(resolve)); + await flushQuitCoordinator(); assert.equal(resumeQuitCount, 1); @@ -108,11 +113,13 @@ describe('app quit coordinator', () => { let focusOrCreateCount = 0; let windowCreationSignal: AbortSignal | undefined; const coordinator = createAppQuitCoordinator({ + prepareToQuit: async () => {}, cleanup: () => new Promise(() => {}), focusOrCreateWindow: (signal) => { focusOrCreateCount += 1; windowCreationSignal = signal; }, + onPreparationError: () => {}, onCleanupError: () => {}, onWindowCreationError: () => {}, resumeQuit: () => {}, @@ -130,10 +137,12 @@ describe('app quit coordinator', () => { const failure = new Error('window load failed'); const reportedErrors: unknown[] = []; const coordinator = createAppQuitCoordinator({ + prepareToQuit: async () => {}, cleanup: async () => {}, focusOrCreateWindow: async () => { throw failure; }, + onPreparationError: () => {}, onCleanupError: () => {}, onWindowCreationError: (error) => reportedErrors.push(error), resumeQuit: () => {}, @@ -146,18 +155,62 @@ describe('app quit coordinator', () => { assert.deepEqual(reportedErrors, [failure]); }); + it('cancels quit without closing resources when Host retirement preparation fails', async () => { + const preparationError = new Error('retirement failed'); + const reportedErrors: unknown[] = []; + let preparationCount = 0; + let cleanupCount = 0; + let focusOrCreateCount = 0; + let resumeQuitCount = 0; + const coordinator = createAppQuitCoordinator({ + prepareToQuit: async () => { + preparationCount += 1; + if (preparationCount === 1) throw preparationError; + }, + cleanup: async () => { + cleanupCount += 1; + }, + focusOrCreateWindow: () => { + focusOrCreateCount += 1; + }, + onPreparationError: (error) => reportedErrors.push(error), + onCleanupError: () => {}, + onWindowCreationError: () => {}, + resumeQuit: () => { + resumeQuitCount += 1; + }, + }); + + coordinator.handleBeforeQuit({ preventDefault: () => {} }); + await flushQuitCoordinator(); + + assert.deepEqual(reportedErrors, [preparationError]); + assert.equal(cleanupCount, 0); + assert.equal(resumeQuitCount, 0); + assert.equal(focusOrCreateCount, 1); + + coordinator.handleBeforeQuit({ preventDefault: () => {} }); + await flushQuitCoordinator(); + + assert.equal(preparationCount, 2); + assert.equal(cleanupCount, 1); + assert.equal(resumeQuitCount, 1); + }); + it('reports cleanup failure without leaking an unhandled rejection', async () => { const cleanupError = new Error('close failed'); const reportedErrors: unknown[] = []; let focusOrCreateCount = 0; let resumeQuitCount = 0; const deps = { + prepareToQuit: async () => {}, cleanup: async () => { throw cleanupError; }, focusOrCreateWindow: () => { focusOrCreateCount += 1; }, + onPreparationError: () => {}, onCleanupError: (error: unknown) => { reportedErrors.push(error); }, @@ -169,8 +222,7 @@ describe('app quit coordinator', () => { const coordinator = createAppQuitCoordinator(deps); coordinator.handleBeforeQuit({ preventDefault: () => {} }); - await Promise.resolve(); - await new Promise((resolve) => setImmediate(resolve)); + await flushQuitCoordinator(); let secondQuitPrevented = false; coordinator.focusOrCreateWindow(); coordinator.handleBeforeQuit({ @@ -185,3 +237,8 @@ describe('app quit coordinator', () => { assert.equal(secondQuitPrevented, false); }); }); + +async function flushQuitCoordinator(): Promise { + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); +} 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 219d099a7a..4f9cdef921 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 @@ -35,6 +35,7 @@ import type { DesktopRuntimeHostCandidateStartResult, } from '../runtime-host-desktop-candidate.js'; import { + DesktopLocalHostRetirementError, RuntimeHostPairingFinalizationInterruptedError, RuntimeHostUpgradeCancelledError, startRuntimeHostDesktopManager, @@ -128,18 +129,144 @@ test('quiesces reconnect and waits for the Host process before update install', }, }); - const preparation = await owner.prepareForUpdate(false); - assert.equal(preparation.kind, 'prepared'); - assert.equal(current.prepareUpgradeCalls, 1); - assert.deepEqual(current.prepareUpgradeAuthorities, [false]); + const retirement = await owner.retireOwnedLocalHost('refuse_active_work'); + assert.equal(retirement.kind, 'retired'); + assert.equal(current.prepareRetirementCalls, 1); + assert.deepEqual(current.retirementModes, ['refuse_active_work']); assert.equal(waitedForPid, 42); assert.equal(starts, 1); - if (preparation.kind === 'prepared') preparation.rollback(); + if (retirement.kind === 'retired') retirement.resume(); await reconnected; assert.equal(starts, 2); await owner.close(); }); +test('retires the owned ephemeral Host before Desktop quit', async () => { + const events: string[] = []; + const current = candidateHarness({ + activeTasks: true, + disconnectOnPrepare: true, + onPrepare: () => events.push('prepare-host'), + }); + const owner = await startRuntimeHostDesktopManager({ + candidateLaunchBarrier: { + connect: async () => assert.fail('mocked candidate startup bypasses the barrier'), + pause: () => events.push('pause-launches'), + retireExcept: async (pid: number) => { + events.push(`retire-except:${pid}`); + }, + resume: () => events.push('resume-launches'), + release: () => events.push('release-launches'), + }, + } as unknown as DesktopRuntimeHostCandidateStartInput, { + startCandidate: async () => ready(current.candidate), + waitForHostExit: async (pid) => { + events.push(`wait:${pid}`); + }, + }); + + await owner.retireOwnedLocalHost('interrupt_active_work'); + + assert.deepEqual(current.retirementModes, ['interrupt_active_work']); + assert.deepEqual(events, [ + 'pause-launches', + 'retire-except:42', + 'prepare-host', + 'wait:42', + ]); + await owner.close(); + assert.equal(events.at(-1), 'release-launches'); + assert.ok(!events.includes('resume-launches')); +}); + +test('does not retire the local Host twice when an update handoff triggers quit', async () => { + const current = candidateHarness({ disconnectOnPrepare: true }); + const waitedFor: number[] = []; + const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { + startCandidate: async () => ready(current.candidate), + waitForHostExit: async (pid) => { + waitedFor.push(pid); + }, + }); + + const update = await owner.retireOwnedLocalHost('refuse_active_work'); + assert.equal(update.kind, 'retired'); + await owner.retireOwnedLocalHost('interrupt_active_work'); + + assert.equal(current.prepareRetirementCalls, 1); + assert.deepEqual(waitedFor, [42]); + await owner.close(); +}); + +test('coalesces concurrent retirement intents onto one exact Host request', async () => { + const current = candidateHarness({ disconnectOnPrepare: true }); + let releaseExitWait!: () => void; + let reportExitWait!: () => void; + const exitWaitStarted = new Promise((resolve) => { + reportExitWait = resolve; + }); + const exitWait = new Promise((resolve) => { + releaseExitWait = resolve; + }); + const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { + startCandidate: async () => ready(current.candidate), + waitForHostExit: async () => { + reportExitWait(); + await exitWait; + }, + }); + + const update = owner.retireOwnedLocalHost('refuse_active_work'); + await exitWaitStarted; + const quit = owner.retireOwnedLocalHost('interrupt_active_work'); + releaseExitWait(); + assert.deepEqual( + (await Promise.all([update, quit])).map(({ kind }) => kind), + ['retired', 'retired'], + ); + + assert.equal(current.prepareRetirementCalls, 1); + assert.deepEqual(current.retirementModes, ['refuse_active_work']); + await owner.close(); +}); + +test('reissues a concurrent strong retirement when weak retirement is refused', async () => { + let reportWeakPrepare!: () => void; + let releaseWeakPrepare!: () => void; + const weakPrepareStarted = new Promise((resolve) => { + reportWeakPrepare = resolve; + }); + const weakPrepareGate = new Promise((resolve) => { + releaseWeakPrepare = resolve; + }); + const current = candidateHarness({ + activeTasks: true, + disconnectOnPrepare: true, + onPrepare: async (mode) => { + if (mode !== 'refuse_active_work') return; + reportWeakPrepare(); + await weakPrepareGate; + }, + }); + const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { + startCandidate: async () => ready(current.candidate), + waitForHostExit: async () => {}, + }); + + const update = owner.retireOwnedLocalHost('refuse_active_work'); + await weakPrepareStarted; + const quit = owner.retireOwnedLocalHost('interrupt_active_work'); + releaseWeakPrepare(); + + assert.deepEqual(await update, { kind: 'active_tasks' }); + assert.equal((await quit).kind, 'retired'); + assert.deepEqual(current.retirementModes, [ + 'refuse_active_work', + 'interrupt_active_work', + ]); + await owner.close(); +}); + test('retires unadopted candidates before draining the tracked Host', async () => { const events: string[] = []; const current = candidateHarness({ @@ -163,15 +290,15 @@ test('retires unadopted candidates before draining the tracked Host', async () = }, }); - const preparation = await owner.prepareForUpdate(false); - assert.equal(preparation.kind, 'prepared'); + const retirement = await owner.retireOwnedLocalHost('refuse_active_work'); + assert.equal(retirement.kind, 'retired'); assert.deepEqual(events, [ 'pause-launches', 'retire-except:42', 'prepare-host', 'wait:42', ]); - if (preparation.kind === 'prepared') preparation.rollback(); + if (retirement.kind === 'retired') retirement.resume(); assert.equal(events.at(-1), 'resume-launches'); await owner.close(); assert.equal(events.at(-1), 'release-launches'); @@ -194,12 +321,36 @@ test('resumes candidate launches when active tasks block the update', async () = startCandidate: async () => ready(current.candidate), }); - assert.deepEqual(await owner.prepareForUpdate(false), { kind: 'active_tasks' }); + assert.deepEqual(await owner.retireOwnedLocalHost('refuse_active_work'), { + kind: 'active_tasks', + }); assert.deepEqual(events, ['pause', 'retire', 'resume']); await owner.close(); assert.equal(events.at(-1), 'release'); }); +test('preserves Host facts when authorized retirement is refused', async () => { + const current = candidateHarness({ activeTasks: 'always' }); + const owner = await startRuntimeHostDesktopManager({ + rootPath: '/test-root', + } as DesktopRuntimeHostCandidateStartInput, { + startCandidate: async () => ready(current.candidate), + }); + + await assert.rejects( + owner.retireOwnedLocalHost('interrupt_active_work'), + (error: unknown) => + error instanceof DesktopLocalHostRetirementError && + error.facts.hostId === 'test-host' && + error.facts.hostEpoch === 'test-host-epoch' && + error.facts.rootPath === '/test-root' && + error.facts.pid === 42 && + error.cause instanceof Error && + error.cause.message === 'Runtime Host refused authorized retirement', + ); + await owner.close(); +}); + test('resumes candidate launches when candidate retirement fails', async () => { const events: string[] = []; const current = candidateHarness(); @@ -218,7 +369,14 @@ test('resumes candidate launches when candidate retirement fails', async () => { startCandidate: async () => ready(current.candidate), }); - await assert.rejects(owner.prepareForUpdate(false), /retirement failed/); + await assert.rejects( + owner.retireOwnedLocalHost('refuse_active_work'), + (error: unknown) => + error instanceof DesktopLocalHostRetirementError && + error.facts.pid === 42 && + error.cause instanceof Error && + error.cause.message === 'retirement failed', + ); assert.deepEqual(events, ['pause', 'retire', 'resume']); await owner.handleBotIncomingMessage({ text: 'still connected' } as BotIncomingMessage); assert.equal(current.botMessages, 1); @@ -235,12 +393,14 @@ test('keeps active-task confirmation bound to the current Host', async () => { }, }); - assert.deepEqual(await owner.prepareForUpdate(false), { kind: 'active_tasks' }); + assert.deepEqual(await owner.retireOwnedLocalHost('refuse_active_work'), { + kind: 'active_tasks', + }); await owner.handleBotIncomingMessage({ text: 'still connected' } as BotIncomingMessage); assert.equal(current.botMessages, 1); - const authorized = await owner.prepareForUpdate(true); - assert.equal(authorized.kind, 'prepared'); - assert.deepEqual(current.prepareUpgradeAuthorities, [false, true]); + const authorized = await owner.retireOwnedLocalHost('interrupt_active_work'); + assert.equal(authorized.kind, 'retired'); + assert.deepEqual(current.retirementModes, ['refuse_active_work', 'interrupt_active_work']); assert.deepEqual(waitedFor, [42]); await owner.close(); }); @@ -253,10 +413,9 @@ for (const lifecycleMode of ['service', 'remote'] as const) { waitForHostExit: async () => assert.fail(`${lifecycleMode} Host exit must not be awaited`), }); - const preparation = await owner.prepareForUpdate(false); - assert.equal(preparation.kind, 'prepared'); - assert.equal(current.prepareUpgradeCalls, 0); - if (preparation.kind === 'prepared') preparation.rollback(); + const retirement = await owner.retireOwnedLocalHost('refuse_active_work'); + assert.equal(retirement.kind, 'not_owned'); + assert.equal(current.prepareRetirementCalls, 0); await owner.handleBotIncomingMessage({ text: 'still connected' } as BotIncomingMessage); assert.equal(current.botMessages, 1); await owner.close(); @@ -901,13 +1060,13 @@ function candidateHarness( options: { delayDisconnect?: boolean; disconnectOnPrepare?: boolean; - activeTasks?: boolean; + activeTasks?: boolean | 'always'; lifecycleMode?: 'ephemeral' | 'service' | 'remote'; hostId?: string; hostEpoch?: string; finalizeFailures?: Error[]; disconnectOnFinalizeFailure?: boolean; - onPrepare?: () => void; + onPrepare?: (mode: string) => unknown | Promise; } = {}, ) { let resolveClosed: (() => void) | undefined; @@ -918,13 +1077,14 @@ function candidateHarness( let botMessages = 0; const stoppedSessions: string[] = []; let lifecycleState: 'ready' | 'unavailable' = 'ready'; - let prepareUpgradeCalls = 0; + let prepareRetirementCalls = 0; let finalizeCalls = 0; const finalizeTimeouts: number[] = []; - const prepareUpgradeAuthorities: boolean[] = []; + const retirementModes: string[] = []; const candidate = { closed, hostLifecycleMode: options.lifecycleMode ?? 'ephemeral', + hostPid: 42, client: { hostId: options.hostId ?? 'test-host', hostEpoch: options.hostEpoch ?? 'test-host-epoch', @@ -934,11 +1094,14 @@ function candidateHarness( async queryHostDiagnostics() { return { pid: 42 }; }, - async prepareHostUpgrade(allowInterruptActiveTasks: boolean) { - options.onPrepare?.(); - prepareUpgradeCalls += 1; - prepareUpgradeAuthorities.push(allowInterruptActiveTasks); - if (options.activeTasks && !allowInterruptActiveTasks) { + async prepareHostRetirement(mode: string) { + prepareRetirementCalls += 1; + retirementModes.push(mode); + await options.onPrepare?.(mode); + if ( + (options.activeTasks && mode === 'refuse_active_work') || + options.activeTasks === 'always' + ) { return { kind: 'active_tasks' as const }; } if (options.disconnectOnPrepare) { @@ -991,11 +1154,11 @@ function candidateHarness( get stoppedSessions() { return stoppedSessions; }, - get prepareUpgradeCalls() { - return prepareUpgradeCalls; + get prepareRetirementCalls() { + return prepareRetirementCalls; }, - get prepareUpgradeAuthorities() { - return prepareUpgradeAuthorities; + get retirementModes() { + return retirementModes; }, get finalizeCalls() { return finalizeCalls; 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 new file mode 100644 index 0000000000..bf1aab2f04 --- /dev/null +++ b/apps/desktop/src/main/__tests__/runtime-host-quit-copy.test.ts @@ -0,0 +1,54 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { DesktopLocalHostRetirementError } from '../runtime-host-desktop-manager.js'; +import { buildRuntimeHostQuitFailureDialog } from '../runtime-host-quit-copy.js'; + +const failure = new DesktopLocalHostRetirementError( + { + hostId: 'root-id', + hostEpoch: 'host-epoch', + lifecycleMode: 'ephemeral', + rootPath: '/state/root', + pid: 4242, + }, + { cause: new Error('writer release timed out') }, +); + +for (const locale of ['en', 'zh'] as const) { + test(`quit failure copy exposes actionable Host facts in ${locale}`, () => { + const dialog = buildRuntimeHostQuitFailureDialog(failure, locale); + + assert.match(dialog.detail ?? '', /4242/); + assert.match(dialog.detail ?? '', /host-epoch/); + assert.match(dialog.detail ?? '', /\/state\/root/); + assert.match(dialog.detail ?? '', /writer release timed out/); + }); +} + +test('manual recovery copy names a cross-platform process-management concept', () => { + const english = buildRuntimeHostQuitFailureDialog(failure, 'en').detail ?? ''; + const chinese = buildRuntimeHostQuitFailureDialog(failure, 'zh').detail ?? ''; + + assert.match(english, /operating system's process-management tool/); + assert.match(chinese, /操作系统的进程管理工具/); + assert.doesNotMatch(`${english}\n${chinese}`, /Activity Monitor|Task Manager|活动监视器|任务管理器/); +}); diff --git a/apps/desktop/src/main/app-quit-coordinator.ts b/apps/desktop/src/main/app-quit-coordinator.ts index 6aef75305a..e6c997da77 100644 --- a/apps/desktop/src/main/app-quit-coordinator.ts +++ b/apps/desktop/src/main/app-quit-coordinator.ts @@ -27,35 +27,39 @@ export interface AppQuitCoordinator { } export interface AppQuitCoordinatorDeps { + prepareToQuit(): Promise; cleanup(): Promise; focusOrCreateWindow(signal: AbortSignal): void | Promise; + onPreparationError(error: unknown): void; onCleanupError(error: unknown): void; onWindowCreationError(error: unknown): void; resumeQuit(): void; } -type AppQuitPhase = 'running' | 'cleaning' | 'ready-to-exit'; +type AppQuitPhase = 'running' | 'preparing' | 'cleaning' | 'ready-to-exit'; export function createAppQuitCoordinator(deps: AppQuitCoordinatorDeps): AppQuitCoordinator { let phase: AppQuitPhase = 'running'; - const windowCreationAbort = new AbortController(); + let windowCreationAbort = new AbortController(); + + const focusOrCreateWindow = (): void => { + if (phase !== 'running') return; + try { + void Promise.resolve(deps.focusOrCreateWindow(windowCreationAbort.signal)).catch( + deps.onWindowCreationError, + ); + } catch (error) { + deps.onWindowCreationError(error); + } + }; return { - focusOrCreateWindow(): void { - if (phase !== 'running') return; - try { - void Promise.resolve(deps.focusOrCreateWindow(windowCreationAbort.signal)).catch( - deps.onWindowCreationError, - ); - } catch (error) { - deps.onWindowCreationError(error); - } - }, + focusOrCreateWindow, handleBeforeQuit(event): void { if (phase === 'ready-to-exit') return; event.preventDefault(); - if (phase === 'cleaning') return; - phase = 'cleaning'; + if (phase !== 'running') return; + phase = 'preparing'; windowCreationAbort.abort(); const finishCleanup = () => { // `before-quit` was cancelled inside Electron's native quit transaction. @@ -68,10 +72,25 @@ export function createAppQuitCoordinator(deps: AppQuitCoordinatorDeps): AppQuitC deps.resumeQuit(); }); }; - void deps.cleanup().then(finishCleanup, (error) => { - deps.onCleanupError(error); - finishCleanup(); - }); + void Promise.resolve() + .then(() => deps.prepareToQuit()) + .then( + () => { + phase = 'cleaning'; + return Promise.resolve() + .then(() => deps.cleanup()) + .then(finishCleanup, (error) => { + deps.onCleanupError(error); + finishCleanup(); + }); + }, + (error) => { + phase = 'running'; + windowCreationAbort = new AbortController(); + deps.onPreparationError(error); + focusOrCreateWindow(); + }, + ); }, }; } diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 9d67cf7ac5..b339efe0d9 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -157,6 +157,7 @@ import { startRuntimeHostDesktopManager, type RuntimeHostDesktopManager, } from "./runtime-host-desktop-manager.js"; +import { buildRuntimeHostQuitFailureDialog } from "./runtime-host-quit-copy.js"; import { createRuntimeHostUpgradePrompts } from "./runtime-host-upgrade-dialog.js"; import { registerRuntimeHostMemoryIpc } from "./runtime-host-memory-ipc-main.js"; import { @@ -653,7 +654,14 @@ const updateService = createAppUpdateService({ mainWindowController.send("app:updateStatusChanged", status), prepareInstall: async (input) => { if (!runtimeHostManager) throw new Error("Runtime Host manager is unavailable"); - return runtimeHostManager.prepareForUpdate(input.allowInterruptActiveTasks); + const retirement = await runtimeHostManager.retireOwnedLocalHost( + input.allowInterruptActiveTasks ? "interrupt_active_work" : "refuse_active_work", + ); + if (retirement.kind === "active_tasks") return retirement; + return { + kind: "prepared", + rollback: retirement.kind === "retired" ? retirement.resume : () => {}, + }; }, }); mcpManager.onChange(() => { @@ -1521,11 +1529,18 @@ function emitSessionsChanged( function wireLifecycle(): void { const quitCoordinator = createAppQuitCoordinator({ + prepareToQuit: prepareRuntimeHostDesktopQuit, cleanup: closeRuntimeHostDesktop, focusOrCreateWindow: (signal) => { if (mainWindowController.hasOpenWindows()) mainWindowController.focus(); else return mainWindowController.createWindow(signal); }, + onPreparationError: (error) => { + console.error("[runtime-host] quit retirement failed:", error); + void showRuntimeHostQuitFailure(error).catch((dialogError) => + console.error("[runtime-host] quit failure dialog failed:", dialogError), + ); + }, onCleanupError: (error) => console.error("[runtime-host] shutdown failed:", error), onWindowCreationError: (error) => @@ -1553,6 +1568,20 @@ function wireLifecycle(): void { quitCoordinator.focusOrCreateWindow(); } +async function prepareRuntimeHostDesktopQuit(): Promise { + const retirement = await runtimeHostManager?.retireOwnedLocalHost( + "interrupt_active_work", + ); + if (retirement?.kind === "active_tasks") { + throw new Error("Runtime Host refused authorized quit retirement"); + } +} + +async function showRuntimeHostQuitFailure(error: unknown): Promise { + const locale = await desktopLocale.resolve(); + await dialog.showMessageBox(buildRuntimeHostQuitFailureDialog(error, locale)); +} + async function closeRuntimeHostDesktop(): Promise { clientSettingsWatcher.stop(); updateService.dispose(); diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index 7fd5c647d0..de21a46047 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -46,9 +46,12 @@ import { type DecodedSessionTranscriptPage, type DirectRequestOperationKey, type RuntimeHostConnection, + type RuntimeHostRetirementMode, + type RuntimeHostRetirementPreparation, type RuntimeHostSessionSubscription, RuntimeHostCatalogReadError, RuntimeHostOperationError, + prepareConnectedRuntimeHostRetirement, readRuntimeHostAgentGraphEpochs, readRuntimeHostConnectionCatalog, readRuntimeHostInvocableSkills, @@ -1150,13 +1153,10 @@ export class DesktopRuntimeHostClient { return this.connection.queryHostDiagnostics(2_000); } - prepareHostUpgrade( - allowInterruptActiveTasks: boolean, - ): Promise> { - return this.request("host.upgrade.prepare", { - expectedHostEpoch: this.connection.hostEpoch, - allowInterruptActiveTasks, - }); + prepareHostRetirement( + mode: RuntimeHostRetirementMode, + ): Promise { + return prepareConnectedRuntimeHostRetirement(this.connection, mode); } stopTurn( diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts index 37396022b1..c5f9fb7784 100644 --- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts +++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts @@ -185,6 +185,7 @@ export interface DesktopRuntimeHostCandidate { readonly client: DesktopRuntimeHostClient; readonly closed: Promise; readonly hostLifecycleMode: HostRegistration["lifecycleMode"] | "remote"; + readonly hostPid?: number; stopSession(sessionId: string): Promise; close(): Promise; } @@ -194,6 +195,7 @@ class DesktopRuntimeHostCandidateImpl implements DesktopRuntimeHostCandidate { readonly client: DesktopRuntimeHostClient; readonly closed: Promise; readonly hostLifecycleMode: HostRegistration["lifecycleMode"] | "remote"; + readonly hostPid: number | undefined; readonly #client: DesktopRuntimeHostClient; readonly #observer: RuntimeHostSessionObserver; readonly #ipc: ScopedIpcMain; @@ -219,6 +221,7 @@ class DesktopRuntimeHostCandidateImpl implements DesktopRuntimeHostCandidate { closeSessionObservations: () => Promise; connectionClosed: Promise; hostLifecycleMode: HostRegistration["lifecycleMode"] | "remote"; + hostPid?: number; hasRegisteredCapabilities: () => boolean; stopSession: (sessionId: string) => Promise; }) { @@ -236,6 +239,7 @@ class DesktopRuntimeHostCandidateImpl implements DesktopRuntimeHostCandidate { this.#stopSession = input.stopSession; this.botIncoming = input.botIncoming; this.hostLifecycleMode = input.hostLifecycleMode; + this.hostPid = input.hostPid; this.closed = input.connectionClosed.then(() => this.close()); } @@ -301,6 +305,7 @@ export async function startDesktopRuntimeHostCandidate( observationRegistry, connection.registration.lifecycleMode, "local", + connection.registration.pid, ), }; } catch (error) { @@ -403,6 +408,7 @@ export async function createDesktopRuntimeHostCandidate( observationRegistry: RuntimeHostSessionObservationRegistry | undefined, hostLifecycleMode: HostRegistration["lifecycleMode"] | "remote", targetKind: DesktopRuntimeHostTargetPolicy["kind"], + hostPid?: number, ): Promise { const target: DesktopRuntimeHostTargetPolicy = { kind: targetKind, @@ -724,6 +730,7 @@ export async function createDesktopRuntimeHostCandidate( : Promise.resolve(), connectionClosed: connection.closed, hostLifecycleMode, + ...(hostPid === undefined ? {} : { hostPid }), hasRegisteredCapabilities: () => capabilitiesRegistered, stopSession, }); diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts index eec13f75f0..76c5c82aaa 100644 --- a/apps/desktop/src/main/runtime-host-desktop-manager.ts +++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts @@ -30,6 +30,7 @@ import { type ResolvedRuntimeHostProfile, type RuntimeHostReconnectBackoff, type RuntimeHostReconnectLifecycle, + type RuntimeHostRetirementMode, type RuntimeHostSshInteraction, } from '@maka/runtime-host/client'; import type { HostRegistration } from '@maka/runtime-host/protocol'; @@ -70,9 +71,7 @@ export interface RuntimeHostDesktopManager { signal?: AbortSignal, ): Promise; setDefaultProfile(profileId: string): void; - prepareForUpdate( - allowInterruptActiveTasks: boolean, - ): Promise; + retireOwnedLocalHost(mode: RuntimeHostRetirementMode): Promise; close(): Promise; } @@ -105,9 +104,33 @@ export type RuntimeHostDesktopTargetState = readonly error: Error; }; -export type RuntimeHostUpdatePreparation = +export type DesktopLocalHostRetirement = | { readonly kind: 'active_tasks' } - | { readonly kind: 'prepared'; rollback(): void }; + | { readonly kind: 'not_owned' } + | { readonly kind: 'retired'; resume(): void }; + +interface DesktopLocalHostRetirementTask { + readonly mode: RuntimeHostRetirementMode; + readonly result: Promise; +} + +export interface DesktopLocalHostRetirementFacts { + readonly hostId: string; + readonly hostEpoch: string; + readonly lifecycleMode: 'ephemeral'; + readonly rootPath: string; + readonly pid?: number; +} + +export class DesktopLocalHostRetirementError extends Error { + constructor( + readonly facts: DesktopLocalHostRetirementFacts, + options: ErrorOptions, + ) { + super('Unable to retire the Desktop-owned local Runtime Host', options); + this.name = 'DesktopLocalHostRetirementError'; + } +} export type RuntimeHostRestartDecision = 'restart' | 'wait' | 'cancel'; export type RuntimeHostWaitDecision = 'wait' | 'cancel'; @@ -206,6 +229,8 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { readonly #baseInput: DesktopRuntimeHostCandidateStartInput; readonly #pairingFinalizationShutdown = new AbortController(); #defaultProfileId: string = LOCAL_RUNTIME_HOST_PROFILE.id; + #localHostRetirement: Extract | undefined; + #localHostRetirementTask: DesktopLocalHostRetirementTask | undefined; #closed = false; #closeTask: Promise | undefined; @@ -496,13 +521,43 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { this.onDefaultProfileChanged?.(profileId); } - async prepareForUpdate( - allowInterruptActiveTasks: boolean, - ): Promise { + retireOwnedLocalHost( + mode: RuntimeHostRetirementMode, + ): Promise { + if (this.#localHostRetirement) return Promise.resolve(this.#localHostRetirement); + + const activeTask = this.#localHostRetirementTask; + if (activeTask) { + if ( + activeTask.mode === 'refuse_active_work' && + mode === 'interrupt_active_work' + ) { + return activeTask.result.then((result) => + result.kind === 'active_tasks' + ? this.retireOwnedLocalHost(mode) + : result, + ); + } + return activeTask.result; + } + + const result = this.#retireOwnedLocalHost(mode).finally(() => { + if (this.#localHostRetirementTask?.result === result) { + this.#localHostRetirementTask = undefined; + } + }); + this.#localHostRetirementTask = { mode, result }; + return result; + } + + async #retireOwnedLocalHost( + mode: RuntimeHostRetirementMode, + ): Promise { const lifecycle = this.#requireLifecycle( this.#requireTarget(LOCAL_RUNTIME_HOST_PROFILE.id), ); const quiescence = lifecycle.quiesce(); + let hostPid = quiescence.current.hostPid; let launchBarrierPaused = false; const resume = () => { if (launchBarrierPaused) { @@ -516,29 +571,60 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { quiescence.current.hostLifecycleMode === 'service' || quiescence.current.hostLifecycleMode === 'remote' ) { - return { kind: 'prepared', rollback: resume }; + resume(); + return { kind: 'not_owned' }; } this.#baseInput.candidateLaunchBarrier?.pause(); launchBarrierPaused = this.#baseInput.candidateLaunchBarrier !== undefined; const diagnostics = await quiescence.current.client.queryHostDiagnostics(); + hostPid = diagnostics.pid; // The adopted Host still owns the root here, so every other owned launch // can be settled without allowing it to become a late election winner. await this.#baseInput.candidateLaunchBarrier?.retireExcept(diagnostics.pid); - const result = await quiescence.current.client.prepareHostUpgrade( - allowInterruptActiveTasks, - ); + const result = await quiescence.current.client.prepareHostRetirement(mode); if (result.kind === 'active_tasks') { + if (mode === 'interrupt_active_work') { + throw new Error('Runtime Host refused authorized retirement'); + } resume(); return result; } await this.waitForHostExit(result.pid); - return { kind: 'prepared', rollback: resume }; + return this.#completeLocalHostRetirement(resume); } catch (error) { resume(); - throw error; + throw new DesktopLocalHostRetirementError( + { + hostId: quiescence.current.client.hostId, + hostEpoch: quiescence.current.client.hostEpoch, + lifecycleMode: 'ephemeral', + rootPath: this.#baseInput.rootPath, + ...(hostPid === undefined ? {} : { pid: hostPid }), + }, + { cause: error }, + ); } } + #completeLocalHostRetirement( + resume: () => void, + ): Extract { + let active = true; + const retirement = { + kind: 'retired' as const, + resume: () => { + if (!active) return; + active = false; + if (this.#localHostRetirement !== retirement) return; + this.#localHostRetirement = undefined; + if (this.#closed) return; + resume(); + }, + }; + this.#localHostRetirement = retirement; + return retirement; + } + close(): Promise { this.#closeTask ??= this.#close(); return this.#closeTask; @@ -902,7 +988,7 @@ async function waitForProcessRetirement( async function waitForProcessExit(pid: number): Promise { const deadline = Date.now() + 10_000; while (isProcessAlive(pid)) { - if (Date.now() >= deadline) throw new Error('Runtime Host did not exit before update'); + if (Date.now() >= deadline) throw new Error('Runtime Host did not exit before retirement'); await new Promise((resolve) => setTimeout(resolve, 50)); } } diff --git a/apps/desktop/src/main/runtime-host-quit-copy.ts b/apps/desktop/src/main/runtime-host-quit-copy.ts new file mode 100644 index 0000000000..f9bfff703d --- /dev/null +++ b/apps/desktop/src/main/runtime-host-quit-copy.ts @@ -0,0 +1,75 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { UiLocale } from '@maka/core/ui-locale'; +import type { MessageBoxOptions } from 'electron'; +import { DesktopLocalHostRetirementError } from './runtime-host-desktop-manager.js'; + +export function buildRuntimeHostQuitFailureDialog( + error: unknown, + locale: UiLocale, +): MessageBoxOptions { + const retirement = error instanceof DesktopLocalHostRetirementError ? error : undefined; + 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), 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}`); + return { + type: 'error', + title: copy.title, + message: copy.message, + detail: details.join('\n'), + buttons: [copy.button], + defaultId: 0, + noLink: true, + }; +} + +const COPY = { + en: { + 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.", + cause: 'Cause', + button: 'OK', + }, + zh: { + title: '无法安全退出 Maka', + message: '本地 Runtime Host 未能安全停止,Maka 仍在运行。', + detail: '退出已取消。请重试;如果问题持续存在,请查看诊断信息。', + process: (pid: number) => `Runtime Host 进程 PID:${pid}`, + manual: '如果重试仍然失败,请先确认没有需要保留的执行,再通过操作系统的进程管理工具停止该 PID。', + cause: '原因', + button: '好', + }, +} as const; diff --git a/packages/cli/src/runtime-host-service-manager.ts b/packages/cli/src/runtime-host-service-manager.ts index d6b73d196a..a7b5f7a565 100644 --- a/packages/cli/src/runtime-host-service-manager.ts +++ b/packages/cli/src/runtime-host-service-manager.ts @@ -29,7 +29,10 @@ import { PROJECT_DIRECTORY_ROOT_LABEL_MAX_BYTES, RUNTIME_HOST_PROTOCOL_VERSION, } from '@maka/runtime-host/protocol'; -import { connectExistingRuntimeHost } from '@maka/runtime-host/client'; +import { + connectExistingRuntimeHost, + prepareConnectedRuntimeHostRetirement, +} from '@maka/runtime-host/client'; import { RUNTIME_HOST_SERVICE_LOG_MAX_BYTES } from '@maka/runtime-host/operator'; import { withLegacyFileUpdateLockLease, @@ -1011,10 +1014,10 @@ async function prepareRuntimeHostRetirement( 'The State Root is owned by a different Runtime Host process', ); } - const prepared = await connected.connection.request('host.upgrade.prepare', { - expectedHostEpoch: hostEpoch, - allowInterruptActiveTasks, - }); + const prepared = await prepareConnectedRuntimeHostRetirement( + connected.connection, + allowInterruptActiveTasks ? 'interrupt_active_work' : 'refuse_active_work', + ); if (prepared.kind === 'active_tasks') return prepared; if (prepared.pid !== expectedPid) { throw new RuntimeHostServiceManagerError( diff --git a/packages/runtime-host/src/__tests__/host-retirement.test.ts b/packages/runtime-host/src/__tests__/host-retirement.test.ts new file mode 100644 index 0000000000..890aad0359 --- /dev/null +++ b/packages/runtime-host/src/__tests__/host-retirement.test.ts @@ -0,0 +1,54 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { RuntimeHostConnection } from '../client/connection.js'; +import { prepareConnectedRuntimeHostRetirement } from '../client/host-retirement.js'; + +test('retirement binds both interruption policy choices to the authenticated Host epoch', async () => { + const requests: unknown[] = []; + const connection = { + hostEpoch: 'authenticated-host', + request: async (operation: string, input: unknown) => { + requests.push({ operation, input }); + return { kind: 'prepared', pid: 42 }; + }, + } as unknown as RuntimeHostConnection; + + await prepareConnectedRuntimeHostRetirement(connection, 'refuse_active_work'); + await prepareConnectedRuntimeHostRetirement(connection, 'interrupt_active_work'); + + assert.deepEqual(requests, [ + { + operation: 'host.upgrade.prepare', + input: { + expectedHostEpoch: 'authenticated-host', + allowInterruptActiveTasks: false, + }, + }, + { + operation: 'host.upgrade.prepare', + input: { + expectedHostEpoch: 'authenticated-host', + allowInterruptActiveTasks: true, + }, + }, + ]); +}); diff --git a/packages/runtime-host/src/client/host-retirement.ts b/packages/runtime-host/src/client/host-retirement.ts new file mode 100644 index 0000000000..4ea0bf52b4 --- /dev/null +++ b/packages/runtime-host/src/client/host-retirement.ts @@ -0,0 +1,41 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { OperationOutput } from '../protocol/index.js'; +import type { RuntimeHostConnection } from './connection.js'; + +export type RuntimeHostRetirementMode = 'refuse_active_work' | 'interrupt_active_work'; +export type RuntimeHostRetirementPreparation = OperationOutput<'host.upgrade.prepare'>; + +/** + * Requests retirement of the exact authenticated Host behind `connection`. + * + * `host.upgrade.prepare` is the current wire identifier. Keep that historical + * transport detail here so lifecycle owners can model the operation as + * retirement instead of spreading update-specific authority. + */ +export function prepareConnectedRuntimeHostRetirement( + connection: RuntimeHostConnection, + mode: RuntimeHostRetirementMode, +): Promise { + return connection.request('host.upgrade.prepare', { + expectedHostEpoch: connection.hostEpoch, + allowInterruptActiveTasks: mode === 'interrupt_active_work', + }); +} diff --git a/packages/runtime-host/src/client/index.ts b/packages/runtime-host/src/client/index.ts index 203d051726..168ed8b7a2 100644 --- a/packages/runtime-host/src/client/index.ts +++ b/packages/runtime-host/src/client/index.ts @@ -26,6 +26,11 @@ export { type RuntimeHostConnection, type DirectRequestOperationKey, } from './connection.js'; +export { + prepareConnectedRuntimeHostRetirement, + type RuntimeHostRetirementMode, + type RuntimeHostRetirementPreparation, +} from './host-retirement.js'; export { LOCAL_RUNTIME_HOST_PROFILE, RUNTIME_HOST_ACCESS_CREDENTIAL_MAX_BYTES,