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 e3bab13b5e..190516dd4e 100644 --- a/apps/desktop/src/main/__tests__/app-quit-coordinator.test.ts +++ b/apps/desktop/src/main/__tests__/app-quit-coordinator.test.ts @@ -26,7 +26,7 @@ describe('app quit coordinator', () => { let resumeQuitCount = 0; let preventedCount = 0; const coordinator = createAppQuitCoordinator({ - prepareToQuit: async () => {}, + prepareToQuit: async () => 'ready' as const, cleanup: async () => {}, focusOrCreateWindow: () => {}, onPreparationError: () => {}, @@ -64,7 +64,7 @@ describe('app quit coordinator', () => { releaseCleanup = resolve; }); const coordinator = createAppQuitCoordinator({ - prepareToQuit: async () => {}, + prepareToQuit: async () => 'ready', cleanup: async () => { cleanupCount += 1; await cleanupPending; @@ -113,7 +113,7 @@ describe('app quit coordinator', () => { let focusOrCreateCount = 0; let windowCreationSignal: AbortSignal | undefined; const coordinator = createAppQuitCoordinator({ - prepareToQuit: async () => {}, + prepareToQuit: async () => 'ready', cleanup: () => new Promise(() => {}), focusOrCreateWindow: (signal) => { focusOrCreateCount += 1; @@ -133,11 +133,39 @@ describe('app quit coordinator', () => { assert.equal(windowCreationSignal?.aborted, true); }); + it('restores the running app when quit preparation is cancelled', async () => { + let cleanupCount = 0; + let focusOrCreateCount = 0; + let resumeQuitCount = 0; + const coordinator = createAppQuitCoordinator({ + prepareToQuit: async () => 'cancelled', + cleanup: async () => { + cleanupCount += 1; + }, + focusOrCreateWindow: () => { + focusOrCreateCount += 1; + }, + onPreparationError: () => {}, + onCleanupError: () => {}, + onWindowCreationError: () => {}, + resumeQuit: () => { + resumeQuitCount += 1; + }, + }); + + coordinator.handleBeforeQuit({ preventDefault: () => {} }); + await flushQuitCoordinator(); + + assert.equal(cleanupCount, 0); + assert.equal(resumeQuitCount, 0); + assert.equal(focusOrCreateCount, 1); + }); + it('reports window creation failure without leaking an unhandled rejection', async () => { const failure = new Error('window load failed'); const reportedErrors: unknown[] = []; const coordinator = createAppQuitCoordinator({ - prepareToQuit: async () => {}, + prepareToQuit: async () => 'ready', cleanup: async () => {}, focusOrCreateWindow: async () => { throw failure; @@ -166,6 +194,7 @@ describe('app quit coordinator', () => { prepareToQuit: async () => { preparationCount += 1; if (preparationCount === 1) throw preparationError; + return 'ready'; }, cleanup: async () => { cleanupCount += 1; @@ -203,7 +232,7 @@ describe('app quit coordinator', () => { let focusOrCreateCount = 0; let resumeQuitCount = 0; const deps = { - prepareToQuit: async () => {}, + prepareToQuit: async () => 'ready' as const, cleanup: async () => { throw cleanupError; }, 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 7320f3e868..c4cdffd265 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 @@ -215,6 +215,65 @@ test('waits through a reconnect gap before quiescing Host retirement', async () await owner.close(); }); +test('does not treat an in-flight replacement as retired after admission times out', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const first = candidateHarness({ + ownedProcess: { + pid: 42, + exited: Promise.resolve({ code: 1, signal: null, stderr: '', stderrTruncated: false }), + }, + }); + const replacement = candidateHarness(); + let starts = 0; + let reportReconnectStart!: () => void; + let releaseReconnect!: () => void; + const reconnectStarted = new Promise((resolve) => { + reportReconnectStart = resolve; + }); + const reconnectReleased = new Promise((resolve) => { + releaseReconnect = resolve; + }); + const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { + startCandidate: async (input) => { + starts += 1; + if (starts === 1) return ready(first.candidate); + reportReconnectStart(); + const signal = input.signal; + assert.ok(signal); + await Promise.race([ + reconnectReleased, + new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }), + ]); + return ready(replacement.candidate); + }, + reconnectBackoff: { minMs: 0, maxMs: 0 }, + waitForHostExit: async () => {}, + }); + + first.disconnect(); + await reconnectStarted; + const retirement = owner.retireOwnedLocalHost('interrupt_active_work'); + t.mock.timers.tick(5_000); + await assert.rejects( + retirement, + (error: unknown) => + error instanceof DesktopLocalHostRetirementError && + error.facts.pid === undefined && + !error.facts.forceTerminationAvailable, + ); + + releaseReconnect(); + await owner.waitUntilReady('local'); + assert.equal( + (await owner.retireOwnedLocalHost('interrupt_active_work')).kind, + 'retired', + ); + assert.equal(replacement.prepareRetirementCalls, 1); + await owner.close(); +}); + test('retires the owned ephemeral Host before Desktop quit', async () => { const events: string[] = []; const current = candidateHarness({ @@ -452,6 +511,57 @@ 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(); 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 bf1aab2f04..0b03b5a540 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 @@ -20,7 +20,10 @@ 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'; +import { + buildRuntimeHostActiveQuitDialog, + buildRuntimeHostQuitFailureDialog, +} from '../runtime-host-quit-copy.js'; const failure = new DesktopLocalHostRetirementError( { @@ -29,26 +32,40 @@ const failure = new DesktopLocalHostRetirementError( 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'] as const) { test(`quit failure copy exposes actionable Host facts in ${locale}`, () => { - const dialog = buildRuntimeHostQuitFailureDialog(failure, locale); + const dialog = buildRuntimeHostQuitFailureDialog(manualFailure, 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/); + 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(failure, 'en').detail ?? ''; - const chinese = buildRuntimeHostQuitFailureDialog(failure, 'zh').detail ?? ''; + const english = buildRuntimeHostQuitFailureDialog(manualFailure, 'en').options.detail ?? ''; + const chinese = buildRuntimeHostQuitFailureDialog(manualFailure, 'zh').options.detail ?? ''; assert.match(english, /operating system's process-management tool/); assert.match(chinese, /操作系统的进程管理工具/); assert.doesNotMatch(`${english}\n${chinese}`, /Activity Monitor|Task Manager|活动监视器|任务管理器/); }); + +test('quit dialogs default to preserving background work', () => { + const active = buildRuntimeHostActiveQuitDialog('en'); + const recovery = buildRuntimeHostQuitFailureDialog(failure, 'en'); + + 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']); +}); diff --git a/apps/desktop/src/main/__tests__/runtime-host-quit.test.ts b/apps/desktop/src/main/__tests__/runtime-host-quit.test.ts new file mode 100644 index 0000000000..61f1814bd1 --- /dev/null +++ b/apps/desktop/src/main/__tests__/runtime-host-quit.test.ts @@ -0,0 +1,104 @@ +/* + * 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 { 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[] = []; + const owner = { + retireOwnedLocalHost: async (mode: RuntimeHostRetirementMode) => { + modes.push(mode); + return mode === 'refuse_active_work' + ? ({ kind: 'active_tasks' } as const) + : ({ kind: 'retired', resume: () => {} } 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, + }), + 'cancelled', + ); + assert.deepEqual(modes, ['refuse_active_work']); + + assert.equal( + await prepareRuntimeHostQuit(owner, { + confirmInterrupt: async () => true, + recoverFailure, + }), + '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 }> = []; + const owner = { + retireOwnedLocalHost: async () => Promise.reject(retirement), + forceTerminateOwnedLocalHost: async () => { + throw new Error('process access denied'); + }, + }; + + 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'; + }, + }), + 'cancelled', + ); + assert.deepEqual(recovery, [ + { canForceTerminate: true, cause: 'graceful retirement timed out' }, + { canForceTerminate: false, cause: 'process access denied' }, + ]); +}); diff --git a/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts b/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts index 107f3ebb87..aa1a9a6d97 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts @@ -1093,7 +1093,7 @@ function createHarness( terminateProcessTree: async ({ pid, signal, fallback, hasExited, beforeSignal }) => { terminatedProcesses.push({ pid, signal }); await Promise.resolve(); - if (hasExited?.() || beforeSignal?.() === false) return false; + if (hasExited?.() || (beforeSignal && !(await beforeSignal()))) return false; fallback?.(); return true; }, diff --git a/apps/desktop/src/main/app-quit-coordinator.ts b/apps/desktop/src/main/app-quit-coordinator.ts index e6c997da77..5ff773284f 100644 --- a/apps/desktop/src/main/app-quit-coordinator.ts +++ b/apps/desktop/src/main/app-quit-coordinator.ts @@ -27,7 +27,7 @@ export interface AppQuitCoordinator { } export interface AppQuitCoordinatorDeps { - prepareToQuit(): Promise; + prepareToQuit(): Promise<'ready' | 'cancelled'>; cleanup(): Promise; focusOrCreateWindow(signal: AbortSignal): void | Promise; onPreparationError(error: unknown): void; @@ -75,7 +75,13 @@ export function createAppQuitCoordinator(deps: AppQuitCoordinatorDeps): AppQuitC void Promise.resolve() .then(() => deps.prepareToQuit()) .then( - () => { + (preparation) => { + if (preparation === 'cancelled') { + phase = 'running'; + windowCreationAbort = new AbortController(); + focusOrCreateWindow(); + return; + } phase = 'cleaning'; return Promise.resolve() .then(() => deps.cleanup()) diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 69c98b496c..f9b9ad9468 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -192,7 +192,11 @@ import { DesktopRuntimeHostStartupRecoveryCancelledError, startDesktopRuntimeHostWithRecovery, } from "./runtime-host-startup-recovery.js"; -import { buildRuntimeHostQuitFailureDialog } from "./runtime-host-quit-copy.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"; import { registerRuntimeHostMemoryIpc } from "./runtime-host-memory-ipc-main.js"; import { @@ -1897,9 +1901,6 @@ function wireLifecycle(): void { }, 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), @@ -1929,19 +1930,23 @@ function wireLifecycle(): void { quitCoordinator.focusOrCreateWindow(); } -async function prepareRuntimeHostDesktopQuit(): Promise { - mainWindowController.browserWindow()?.destroy(); - 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 showDesktopMessageBox(buildRuntimeHostQuitFailureDialog(error, locale), { locale }); +async function prepareRuntimeHostDesktopQuit(): Promise<'ready' | 'cancelled'> { + const preparation = await prepareRuntimeHostQuit(runtimeHostManager, { + confirmInterrupt: async () => { + const locale = await desktopLocale.resolve(); + const dialog = buildRuntimeHostActiveQuitDialog(locale); + 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; } async function closeRuntimeHostDesktop(): Promise { diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index 5a39dba44b..ea4db114b0 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -159,6 +159,7 @@ const decodeStoredMessage = (value: unknown): StoredMessage => const MAX_OPTIMISTIC_ATTEMPTS = 3; const MAX_SESSION_REVISION_ATTEMPTS = 8; const MAX_PRICING_SNAPSHOT_ATTEMPTS = 3; +const RUNTIME_HOST_RETIREMENT_TIMEOUT_MS = 5_000; export type DesktopSessionConfigurationPatch = SessionConfigurationPatch; @@ -1326,7 +1327,11 @@ export class DesktopRuntimeHostClient { prepareHostRetirement( mode: RuntimeHostRetirementMode, ): Promise { - return prepareConnectedRuntimeHostRetirement(this.connection, mode); + return prepareConnectedRuntimeHostRetirement( + this.connection, + mode, + RUNTIME_HOST_RETIREMENT_TIMEOUT_MS, + ); } stopTurn( diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts index 150e760454..16ba61b5a7 100644 --- a/apps/desktop/src/main/runtime-host-desktop-manager.ts +++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts @@ -21,6 +21,7 @@ import { randomUUID } from 'node:crypto'; import type { BotIncomingMessage } from '@maka/runtime/bots'; import { abortable, + forceTerminateRegisteredRuntimeHost, RuntimeHostOperationError, RuntimeHostPermanentReconnectError, RuntimeHostRequestInterruptedError, @@ -95,6 +96,7 @@ export interface RuntimeHostDesktopManager { runManagedLocalHostChange(change: () => Promise): Promise; setDefaultProfile(profileId: string): void; retireOwnedLocalHost(mode: RuntimeHostRetirementMode): Promise; + forceTerminateOwnedLocalHost(facts: DesktopLocalHostRetirementFacts): Promise; close(): Promise; } @@ -143,6 +145,7 @@ export interface DesktopLocalHostRetirementFacts { readonly lifecycleMode: 'ephemeral'; readonly rootPath: string; readonly pid?: number; + readonly forceTerminationAvailable: boolean; } export class DesktopLocalHostRetirementError extends Error { @@ -183,6 +186,7 @@ export class RuntimeHostPairingFinalizationInterruptedError extends Error { export type RuntimeHostGuestAccessFinalization = 'ready' | 'reconnecting'; const DEFAULT_PAIRING_FINALIZATION_TIMEOUT_MS = 30_000; +const LOCAL_HOST_RETIREMENT_ADMISSION_TIMEOUT_MS = 5_000; export type RuntimeHostRestartableConflict = Extract< DesktopRuntimeHostCandidateStartResult, @@ -241,6 +245,7 @@ export async function startRuntimeHostDesktopManager( onFatalError?: (error: Error, target: ResolvedRuntimeHostProfile) => void; upgradePrompts?: RuntimeHostUpgradePrompts; waitForHostExit?: (pid: number) => Promise; + forceTerminateHost?: typeof forceTerminateRegisteredRuntimeHost; waitForHostRetirement?: ( registration: HostRegistration, signal: AbortSignal, @@ -264,6 +269,7 @@ export async function startRuntimeHostDesktopManager( options.onFatalError ?? ((error) => console.error('[runtime-host] reconnect failed:', error)), options.upgradePrompts, options.waitForHostExit ?? waitForProcessExit, + options.forceTerminateHost ?? forceTerminateRegisteredRuntimeHost, options.waitForHostRetirement ?? waitForProcessRetirement, options.resolveLocalHostReplacement, options.recoverLocalHost, @@ -302,6 +308,7 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { ) => void, private readonly upgradePrompts: RuntimeHostUpgradePrompts | undefined, private readonly waitForHostExit: (pid: number) => Promise, + private readonly forceTerminateHost: typeof forceTerminateRegisteredRuntimeHost, private readonly waitForHostRetirement: ( registration: HostRegistration, signal: AbortSignal, @@ -740,6 +747,61 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { return result; } + async forceTerminateOwnedLocalHost( + facts: DesktopLocalHostRetirementFacts, + ): Promise { + if (this.#localHostRetirement) return true; + 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 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(); + } + } + async #retireOwnedLocalHost( mode: RuntimeHostRetirementMode, ): Promise { @@ -750,12 +812,22 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { let quiescence: Awaited< ReturnType['quiesce']> >; + const admissionAbort = new AbortController(); + const admissionTimeout = setTimeout( + () => admissionAbort.abort(new Error('Runtime Host did not reconnect before retirement')), + LOCAL_HOST_RETIREMENT_ADMISSION_TIMEOUT_MS, + ); try { - quiescence = await lifecycle.quiesce(); + quiescence = await lifecycle.quiesce(admissionAbort.signal); } catch (error) { const terminal = this.#unavailableLocalHostRetirement(target, error); if (terminal) return terminal; + if (admissionAbort.signal.aborted) { + throw this.#localHostRetirementError(target, error) ?? error; + } throw error; + } finally { + clearTimeout(admissionTimeout); } let hostPid = quiescence.current.hostPid; let launchBarrierPaused = false; @@ -790,6 +862,14 @@ 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( { @@ -798,6 +878,7 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { lifecycleMode: 'ephemeral', rootPath: this.#baseInput.rootPath, ...(hostPid === undefined ? {} : { pid: hostPid }), + forceTerminationAvailable, }, { cause: error }, ); @@ -809,10 +890,27 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { cause: unknown = target.state.readiness === 'unavailable' ? target.state.error : undefined, ): DesktopLocalHostRetirement | undefined { if (target.state.readiness !== 'unavailable') return undefined; + return this.#retirementWithoutCurrentHost(target, cause); + } + + #retirementWithoutCurrentHost( + target: DesktopRuntimeHostTargetGeneration, + cause: unknown, + ): DesktopLocalHostRetirement { + const failure = this.#localHostRetirementError(target, cause); + if (!failure || target.lastCandidate?.ownedProcess?.state === 'exited') { + return { kind: 'not_owned' }; + } + throw failure; + } + + #localHostRetirementError( + target: DesktopRuntimeHostTargetGeneration, + cause: unknown, + ): DesktopLocalHostRetirementError | undefined { const last = target.lastCandidate; - if (!last || last.ownership !== 'owned_ephemeral') return { kind: 'not_owned' }; - if (last.ownedProcess?.state === 'exited') return { kind: 'not_owned' }; - throw new DesktopLocalHostRetirementError( + if (!last || last.ownership !== 'owned_ephemeral') return undefined; + return new DesktopLocalHostRetirementError( { hostId: last.hostId, hostEpoch: last.hostEpoch, @@ -821,6 +919,7 @@ 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)) }, ); @@ -1368,7 +1467,9 @@ async function waitForProcessRetirement( } async function waitForProcessExit(pid: number): Promise { - const deadline = Date.now() + 10_000; + // The Host owns a 10-second graceful-shutdown deadline. Keep a separate + // observation margin so Desktop cannot race the Host's final process.exit. + const deadline = Date.now() + 12_000; while (isProcessAlive(pid)) { 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 index f9bfff703d..26db6a37e4 100644 --- a/apps/desktop/src/main/runtime-host-quit-copy.ts +++ b/apps/desktop/src/main/runtime-host-quit-copy.ts @@ -20,19 +20,48 @@ 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; + readonly decisions: readonly Decision[]; +} + +export type RuntimeHostActiveQuitDecision = 'quit' | 'cancel'; + +export function buildRuntimeHostActiveQuitDialog( + locale: UiLocale, +): RuntimeHostQuitDialog { + const copy = COPY[locale]; + return { + options: { + type: 'warning', + title: copy.activeTitle, + message: copy.activeMessage, + detail: copy.activeDetail, + buttons: [copy.stopAndQuit, copy.keepRunning], + defaultId: 1, + cancelId: 1, + noLink: true, + }, + decisions: ['quit', 'cancel'], + }; +} export function buildRuntimeHostQuitFailureDialog( error: unknown, locale: UiLocale, -): MessageBoxOptions { +): 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), copy.manual); + details.push(copy.process(retirement.facts.pid)); + details.push(canForceTerminate ? copy.forceWarning : copy.manual); } } const cause = error instanceof Error && error.cause instanceof Error @@ -41,35 +70,59 @@ export function buildRuntimeHostQuitFailureDialog( ? error.message : String(error); details.push(`${copy.cause}: ${cause}`); + const decisions: RuntimeHostQuitFailureDecision[] = canForceTerminate + ? ['retry', 'force', 'cancel'] + : ['retry', 'cancel']; return { - type: 'error', - title: copy.title, - message: copy.message, - detail: details.join('\n'), - buttons: [copy.button], - defaultId: 0, - noLink: true, + 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.', + 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', - button: 'OK', + retry: 'Retry Quit', + forceQuit: 'Force Quit Maka', }, zh: { + activeTitle: 'Maka 正在后台工作', + activeMessage: '仍有后台工作正在运行。', + activeDetail: '现在退出会停止 Runtime Host,并可能中断正在执行或等待运行的后台任务。', + stopAndQuit: '停止任务并退出', + keepRunning: '继续运行 Maka', title: '无法安全退出 Maka', message: '本地 Runtime Host 未能安全停止,Maka 仍在运行。', detail: '退出已取消。请重试;如果问题持续存在,请查看诊断信息。', process: (pid: number) => `Runtime Host 进程 PID:${pid}`, manual: '如果重试仍然失败,请先确认没有需要保留的执行,再通过操作系统的进程管理工具停止该 PID。', + forceWarning: '强制退出可能丢弃尚未完成的外部工作。', cause: '原因', - button: '好', + 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 new file mode 100644 index 0000000000..b45529e1a7 --- /dev/null +++ b/apps/desktop/src/main/runtime-host-quit.ts @@ -0,0 +1,101 @@ +/* + * 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 { + DesktopLocalHostRetirementError, + type RuntimeHostDesktopManager, +} from './runtime-host-desktop-manager.js'; + +type RetirementOwner = Pick< + RuntimeHostDesktopManager, + 'retireOwnedLocalHost' | 'forceTerminateOwnedLocalHost' +>; + +export type RuntimeHostQuitFailureDecision = 'retry' | 'force' | 'cancel'; + +export interface RuntimeHostQuitPrompts { + confirmInterrupt(): Promise; + recoverFailure(error: unknown): Promise; +} + +export async function prepareRuntimeHostQuit( + owner: RetirementOwner | 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)) }, + ); +} diff --git a/packages/runtime-host/src/__tests__/registered-host-termination.test.ts b/packages/runtime-host/src/__tests__/registered-host-termination.test.ts new file mode 100644 index 0000000000..98f073231b --- /dev/null +++ b/packages/runtime-host/src/__tests__/registered-host-termination.test.ts @@ -0,0 +1,121 @@ +/* + * 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 { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { + prepareStorageRootControlDirectory, + resolveStorageRoot, +} from '@maka/storage/root-authority'; +import { forceTerminateRegisteredRuntimeHostWithDependencies } from '../client/registered-host-termination.js'; +import { writeHostRegistration } from '../control/registration.js'; +import { + RUNTIME_HOST_COMPATIBILITY_EPOCH, + RUNTIME_HOST_PROTOCOL_VERSION, + RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION, + type HostRegistration, +} from '../protocol/index.js'; + +test('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); +}); diff --git a/packages/runtime-host/src/client/host-retirement.ts b/packages/runtime-host/src/client/host-retirement.ts index 4ea0bf52b4..5696f529fa 100644 --- a/packages/runtime-host/src/client/host-retirement.ts +++ b/packages/runtime-host/src/client/host-retirement.ts @@ -33,9 +33,14 @@ export type RuntimeHostRetirementPreparation = OperationOutput<'host.upgrade.pre export function prepareConnectedRuntimeHostRetirement( connection: RuntimeHostConnection, mode: RuntimeHostRetirementMode, + timeoutMs?: number, ): Promise { - return connection.request('host.upgrade.prepare', { - expectedHostEpoch: connection.hostEpoch, - allowInterruptActiveTasks: mode === 'interrupt_active_work', - }); + return connection.request( + 'host.upgrade.prepare', + { + expectedHostEpoch: connection.hostEpoch, + allowInterruptActiveTasks: mode === 'interrupt_active_work', + }, + timeoutMs, + ); } diff --git a/packages/runtime-host/src/client/index.ts b/packages/runtime-host/src/client/index.ts index 13bac9ed7c..134aede060 100644 --- a/packages/runtime-host/src/client/index.ts +++ b/packages/runtime-host/src/client/index.ts @@ -44,6 +44,10 @@ export { type RuntimeHostRetirementMode, type RuntimeHostRetirementPreparation, } from './host-retirement.js'; +export { + forceTerminateRegisteredRuntimeHost, + type RegisteredRuntimeHostIdentity, +} from './registered-host-termination.js'; export { LOCAL_RUNTIME_HOST_PROFILE, RUNTIME_HOST_ACCESS_CREDENTIAL_MAX_BYTES, diff --git a/packages/runtime-host/src/client/launcher.ts b/packages/runtime-host/src/client/launcher.ts index 2b80ee1266..821a402d01 100644 --- a/packages/runtime-host/src/client/launcher.ts +++ b/packages/runtime-host/src/client/launcher.ts @@ -139,7 +139,7 @@ export function launchOwnedRuntimeHostCandidate(input: DetachedCandidateInput): const result = await within(exited, timeoutMs); if (result) return result.code === 0 && result.signal === null; child.kill('SIGKILL'); - await exited; + await within(exited, timeoutMs); return false; }, })), diff --git a/packages/runtime-host/src/client/reconnect-lifecycle.ts b/packages/runtime-host/src/client/reconnect-lifecycle.ts index eedbf6f6c7..c6cab25a49 100644 --- a/packages/runtime-host/src/client/reconnect-lifecycle.ts +++ b/packages/runtime-host/src/client/reconnect-lifecycle.ts @@ -51,7 +51,7 @@ export interface RuntimeHostReconnectLifecycle void): () => void; wake(): void; suspend(): Promise>; - quiesce(): Promise>; + quiesce(signal?: AbortSignal): Promise>; close(): Promise; } @@ -251,7 +251,8 @@ class RuntimeHostReconnectLifecycleImpl return this.#suspension(this.#current); } - async quiesce(): Promise> { + async quiesce(signal?: AbortSignal): Promise> { + signal?.throwIfAborted(); while (!this.#current) { if (this.#closed || this.#terminalError) { throw new Error('Runtime Host reconnect lifecycle is closed'); @@ -259,8 +260,9 @@ class RuntimeHostReconnectLifecycleImpl if (this.#quiesced) { throw new Error('Runtime Host reconnect lifecycle is already quiesced'); } - await this.waitForCurrent(); + await this.waitForCurrent(undefined, signal); } + signal?.throwIfAborted(); if (this.#closed || this.#terminalError) { throw new Error('Runtime Host reconnect lifecycle is closed'); } diff --git a/packages/runtime-host/src/client/registered-host-termination.ts b/packages/runtime-host/src/client/registered-host-termination.ts new file mode 100644 index 0000000000..4832b08c99 --- /dev/null +++ b/packages/runtime-host/src/client/registered-host-termination.ts @@ -0,0 +1,141 @@ +/* + * 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 { terminateProcessTree } from '@maka/runtime/process-tree-terminator'; +import { + prepareStorageRootControlDirectory, + resolveStorageRoot, +} from '@maka/storage/root-authority'; +import { readHostRegistration } from '../control/registration.js'; +import type { HostRegistration } from '../protocol/index.js'; + +const TERMINATION_SETTLE_MS = 2_000; + +export interface RegisteredRuntimeHostIdentity { + readonly rootPath: string; + readonly rootId: string; + readonly hostEpoch: string; + readonly pid: number; +} + +interface RegisteredRuntimeHostTerminationDependencies { + readonly terminateProcess: typeof terminateProcessTree; + readonly isProcessAlive: (pid: number) => boolean; + readonly settleMs: number; +} + +const defaultDependencies: RegisteredRuntimeHostTerminationDependencies = { + terminateProcess: terminateProcessTree, + isProcessAlive, + settleMs: TERMINATION_SETTLE_MS, +}; + +/** + * Force-terminates only the exact local ephemeral Host still registered for + * the expected State Root. Callers must reserve this for explicit recovery + * after graceful retirement fails. + */ +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); +} + +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: RegisteredRuntimeHostTerminationDependencies, +): Promise { + const deadline = Date.now() + dependencies.settleMs; + while (dependencies.isProcessAlive(pid)) { + if (Date.now() >= deadline) return false; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + return true; +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return !( + error instanceof Error && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ESRCH' + ); + } +} diff --git a/packages/runtime/src/process-tree-terminator.ts b/packages/runtime/src/process-tree-terminator.ts index 95284230fd..99ff479497 100644 --- a/packages/runtime/src/process-tree-terminator.ts +++ b/packages/runtime/src/process-tree-terminator.ts @@ -32,7 +32,7 @@ interface ProcessTreeTerminationOptions { fallback?: () => boolean | void; hasExited?: () => boolean; /** Runs after asynchronous topology discovery and before the first OS action. */ - beforeSignal?: () => boolean; + beforeSignal?: () => boolean | Promise; } interface PosixProcess { @@ -69,7 +69,7 @@ export async function terminateProcessTree( const { pid, signal, fallback, hasExited, beforeSignal } = options; if (hasExited?.()) return false; if (process.platform === 'win32') { - if (beforeSignal && !beforeSignal()) return false; + if (beforeSignal && !(await beforeSignal())) return false; if (await killWindowsTree(pid)) return true; if (hasExited?.()) return false; return invokeFallback(fallback); @@ -77,7 +77,7 @@ export async function terminateProcessTree( const processes = await readPosixProcesses(); if (hasExited?.()) return false; - if (beforeSignal && !beforeSignal()) return false; + if (beforeSignal && !(await beforeSignal())) return false; const escapedDescendantSignaled = forceKillEscapedDescendants(pid, processes); if (hasExited?.()) return escapedDescendantSignaled;