You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/09/04 04:38:23 UTC
(maka) branch main updated: fix(desktop): make Runtime Host quit intentional (#4709)
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git
The following commit(s) were added to refs/heads/main by this push:
new d422246e12 fix(desktop): make Runtime Host quit intentional (#4709)
d422246e12 is described below
commit d422246e124723f9e12c1eafe81bf8ccb777fec1
Author: Wang <m4...@gmail.com>
AuthorDate: Fri Sep 4 12:38:17 2026 +0800
fix(desktop): make Runtime Host quit intentional (#4709)
* fix(desktop): make Runtime Host quit intentional
Prompt before interrupting active local Host work, avoid the shutdown deadline race, and offer identity-bound forced recovery when graceful retirement fails.
Generated-by: Codex
* fix(desktop): fence forced Host retirement
Keep quit recovery inside the coordinator preparation and allow forced termination only while the exact Desktop-owned process remains live.\n\nGenerated-by: Codex
* fix(desktop): bound Host retirement recovery
Fail closed when quit cannot reacquire a reconnecting Host, bound the retirement request, and prevent candidate settlement from waiting forever after forced termination.\n\nGenerated-by: Codex
---
.../main/__tests__/app-quit-coordinator.test.ts | 39 +++++-
.../__tests__/runtime-host-desktop-manager.test.ts | 110 ++++++++++++++++
.../main/__tests__/runtime-host-quit-copy.test.ts | 33 +++--
.../src/main/__tests__/runtime-host-quit.test.ts | 104 +++++++++++++++
.../__tests__/runtime-host-ssh-terminal.test.ts | 2 +-
apps/desktop/src/main/app-quit-coordinator.ts | 10 +-
apps/desktop/src/main/runtime-host-boot.ts | 39 +++---
apps/desktop/src/main/runtime-host-client.ts | 7 +-
.../src/main/runtime-host-desktop-manager.ts | 111 +++++++++++++++-
apps/desktop/src/main/runtime-host-quit-copy.ts | 75 +++++++++--
apps/desktop/src/main/runtime-host-quit.ts | 101 +++++++++++++++
.../__tests__/registered-host-termination.test.ts | 121 ++++++++++++++++++
.../runtime-host/src/client/host-retirement.ts | 13 +-
packages/runtime-host/src/client/index.ts | 4 +
packages/runtime-host/src/client/launcher.ts | 2 +-
.../runtime-host/src/client/reconnect-lifecycle.ts | 8 +-
.../src/client/registered-host-termination.ts | 141 +++++++++++++++++++++
packages/runtime/src/process-tree-terminator.ts | 6 +-
18 files changed, 865 insertions(+), 61 deletions(-)
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<void>(() => {}),
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 d4f432b498..ad5d984426 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<void>((resolve) => {
+ reportReconnectStart = resolve;
+ });
+ const reconnectReleased = new Promise<void>((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<never>((_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<void>;
+ prepareToQuit(): Promise<'ready' | 'cancelled'>;
cleanup(): Promise<void>;
focusOrCreateWindow(signal: AbortSignal): void | Promise<void>;
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 211d8981a3..608ebfd96c 100644
--- a/apps/desktop/src/main/runtime-host-boot.ts
+++ b/apps/desktop/src/main/runtime-host-boot.ts
@@ -193,7 +193,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 {
@@ -1939,9 +1943,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),
@@ -1971,19 +1972,23 @@ function wireLifecycle(): void {
quitCoordinator.focusOrCreateWindow();
}
-async function prepareRuntimeHostDesktopQuit(): Promise<void> {
- 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<void> {
- 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<void> {
diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts
index 61c6990f11..f548396cb5 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;
@@ -1322,7 +1323,11 @@ export class DesktopRuntimeHostClient {
prepareHostRetirement(
mode: RuntimeHostRetirementMode,
): Promise<RuntimeHostRetirementPreparation> {
- 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 b514afaec1..6731a4aae2 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,
@@ -96,6 +97,7 @@ export interface RuntimeHostDesktopManager {
runManagedLocalHostChange<T>(change: () => Promise<T>): Promise<T>;
setDefaultProfile(profileId: string): void;
retireOwnedLocalHost(mode: RuntimeHostRetirementMode): Promise<DesktopLocalHostRetirement>;
+ forceTerminateOwnedLocalHost(facts: DesktopLocalHostRetirementFacts): Promise<boolean>;
close(): Promise<void>;
}
@@ -144,6 +146,7 @@ export interface DesktopLocalHostRetirementFacts {
readonly lifecycleMode: 'ephemeral';
readonly rootPath: string;
readonly pid?: number;
+ readonly forceTerminationAvailable: boolean;
}
export class DesktopLocalHostRetirementError extends Error {
@@ -184,6 +187,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,
@@ -242,6 +246,7 @@ export async function startRuntimeHostDesktopManager(
onFatalError?: (error: Error, target: ResolvedRuntimeHostProfile) => void;
upgradePrompts?: RuntimeHostUpgradePrompts;
waitForHostExit?: (pid: number) => Promise<void>;
+ forceTerminateHost?: typeof forceTerminateRegisteredRuntimeHost;
waitForHostRetirement?: (
registration: HostRegistration,
signal: AbortSignal,
@@ -265,6 +270,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,
@@ -303,6 +309,7 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager {
) => void,
private readonly upgradePrompts: RuntimeHostUpgradePrompts | undefined,
private readonly waitForHostExit: (pid: number) => Promise<void>,
+ private readonly forceTerminateHost: typeof forceTerminateRegisteredRuntimeHost,
private readonly waitForHostRetirement: (
registration: HostRegistration,
signal: AbortSignal,
@@ -751,6 +758,61 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager {
return result;
}
+ async forceTerminateOwnedLocalHost(
+ facts: DesktopLocalHostRetirementFacts,
+ ): Promise<boolean> {
+ 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<DesktopLocalHostRetirement> {
@@ -761,12 +823,22 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager {
let quiescence: Awaited<
ReturnType<RuntimeHostReconnectLifecycle<DesktopRuntimeHostCandidate>['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;
@@ -801,6 +873,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(
{
@@ -809,6 +889,7 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager {
lifecycleMode: 'ephemeral',
rootPath: this.#baseInput.rootPath,
...(hostPid === undefined ? {} : { pid: hostPid }),
+ forceTerminationAvailable,
},
{ cause: error },
);
@@ -820,10 +901,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,
@@ -832,6 +930,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)) },
);
@@ -1379,7 +1478,9 @@ async function waitForProcessRetirement(
}
async function waitForProcessExit(pid: number): Promise<void> {
- 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<void>((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<Decision extends string> {
+ readonly options: MessageBoxOptions;
+ readonly decisions: readonly Decision[];
+}
+
+export type RuntimeHostActiveQuitDecision = 'quit' | 'cancel';
+
+export function buildRuntimeHostActiveQuitDialog(
+ locale: UiLocale,
+): RuntimeHostQuitDialog<RuntimeHostActiveQuitDecision> {
+ 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<RuntimeHostQuitFailureDecision> {
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<boolean>;
+ recoverFailure(error: unknown): Promise<RuntimeHostQuitFailureDecision>;
+}
+
+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<boolean> }) => {
+ 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<RuntimeHostRetirementPreparation> {
- 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<T extends RuntimeHostReconnectRes
subscribe(listener: (current: T | undefined) => void): () => void;
wake(): void;
suspend(): Promise<RuntimeHostReconnectSuspension<T>>;
- quiesce(): Promise<RuntimeHostReconnectQuiescence<T>>;
+ quiesce(signal?: AbortSignal): Promise<RuntimeHostReconnectQuiescence<T>>;
close(): Promise<void>;
}
@@ -251,7 +251,8 @@ class RuntimeHostReconnectLifecycleImpl<T extends RuntimeHostReconnectResource>
return this.#suspension(this.#current);
}
- async quiesce(): Promise<RuntimeHostReconnectQuiescence<T>> {
+ async quiesce(signal?: AbortSignal): Promise<RuntimeHostReconnectQuiescence<T>> {
+ 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<T extends RuntimeHostReconnectResource>
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<boolean> {
+ return forceTerminateRegisteredRuntimeHostWithDependencies(
+ identity,
+ stillOwnsProcess,
+ defaultDependencies,
+ );
+}
+
+export async function forceTerminateRegisteredRuntimeHostWithDependencies(
+ identity: RegisteredRuntimeHostIdentity,
+ stillOwnsProcess: () => boolean,
+ dependencies: RegisteredRuntimeHostTerminationDependencies,
+): Promise<boolean> {
+ 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<boolean> {
+ const deadline = Date.now() + dependencies.settleMs;
+ while (dependencies.isProcessAlive(pid)) {
+ if (Date.now() >= deadline) return false;
+ await new Promise<void>((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<boolean>;
}
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;
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org