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/08/29 11:38:20 UTC
(maka) branch main updated: fix(desktop): stabilize local peer recovery (#4146)
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 8c1586193 fix(desktop): stabilize local peer recovery (#4146)
8c1586193 is described below
commit 8c158619394a2d9e8ed585c5d83e793bc6ad4220
Author: Wang <m4...@gmail.com>
AuthorDate: Sat Aug 29 19:38:15 2026 +0800
fix(desktop): stabilize local peer recovery (#4146)
---
.../__tests__/runtime-host-desktop-manager.test.ts | 135 +++++++++++-
.../runtime-host-local-remote-access.test.ts | 181 +++++++++++++++-
apps/desktop/src/main/runtime-host-boot.ts | 1 +
.../src/main/runtime-host-desktop-candidate.ts | 7 +
.../src/main/runtime-host-desktop-manager.ts | 148 ++++++++++---
.../src/main/runtime-host-local-remote-access.ts | 239 ++++++++++++++++++---
native/runtime-host-peer/Cargo.toml | 4 +
.../cli/src/__tests__/runtime-host-setup.test.ts | 27 ++-
packages/cli/src/runtime-host-access-command.ts | 21 +-
packages/cli/src/runtime-host-setup-command.ts | 27 ++-
.../runtime-host/src/__tests__/peer-mesh.test.ts | 2 +-
packages/runtime-host/src/peer-mesh/node.ts | 5 +-
12 files changed, 712 insertions(+), 85 deletions(-)
diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts
index d2b9accf0..cbc687b66 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
@@ -23,6 +23,7 @@ import type { BotIncomingMessage } from '@maka/runtime/bots';
import {
RuntimeHostOperationError,
RuntimeHostRequestInterruptedError,
+ type RuntimeHostSpawnedProcess,
} from '@maka/runtime-host/client';
import {
INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
@@ -270,6 +271,33 @@ test('does not retire the local Host twice when an update handoff triggers quit'
await owner.close();
});
+test('does not block quit after a retired Local Host hands off to an unavailable supervisor', async () => {
+ const current = candidateHarness({ disconnectOnPrepare: true });
+ let starts = 0;
+ let reportFatal!: (error: Error) => void;
+ const fatalReported = new Promise<Error>((resolve) => {
+ reportFatal = resolve;
+ });
+ const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, {
+ startCandidate: async () => {
+ starts += 1;
+ return starts === 1 ? ready(current.candidate) : incompatibleHost('wait_for_idle_exit');
+ },
+ waitForHostExit: async () => undefined,
+ onFatalError: reportFatal,
+ });
+
+ const handoff = await owner.retireOwnedLocalHost('interrupt_active_work');
+ assert.equal(handoff.kind, 'retired');
+ if (handoff.kind === 'retired') handoff.resume();
+ await fatalReported;
+
+ assert.deepEqual(await owner.retireOwnedLocalHost('interrupt_active_work'), {
+ kind: 'not_owned',
+ });
+ await owner.close();
+});
+
test('coalesces concurrent retirement intents onto one exact Host request', async () => {
const current = candidateHarness({ disconnectOnPrepare: true });
let releaseExitWait!: () => void;
@@ -894,8 +922,55 @@ test('keeps reconnecting through transient startup failures until the Desktop ad
await owner.close();
});
+test('reconciles interrupted managed setup after a Local discovery result', async () => {
+ const managed = candidateHarness({ ownership: 'supervised' });
+ const events: string[] = [];
+ let starts = 0;
+ const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, {
+ startCandidate: async () => {
+ starts += 1;
+ events.push(`discover:${starts}`);
+ return starts === 1
+ ? { kind: 'failed', reason: 'managed_root_requires_operator' }
+ : ready(managed.candidate);
+ },
+ recoverLocalHost: async () => {
+ events.push('reconcile');
+ return true;
+ },
+ });
+
+ assert.deepEqual(events, ['discover:1', 'reconcile', 'discover:2']);
+ assert.equal(owner.current('local')?.candidate?.hostOwnership, 'supervised');
+ await owner.close();
+});
+
+test('reconciles interrupted managed setup after Local discovery throws', async () => {
+ const managed = candidateHarness({ ownership: 'supervised' });
+ const events: string[] = [];
+ let starts = 0;
+ const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, {
+ startCandidate: async () => {
+ starts += 1;
+ events.push(`discover:${starts}`);
+ if (starts === 1) throw new Error('managed deployment transition is in progress');
+ return ready(managed.candidate);
+ },
+ recoverLocalHost: async () => {
+ events.push('reconcile');
+ return true;
+ },
+ });
+
+ assert.deepEqual(events, ['discover:1', 'reconcile', 'discover:2']);
+ assert.equal(owner.current('local')?.candidate?.hostOwnership, 'supervised');
+ await owner.close();
+});
+
test('stops reconnecting when the replacement Host is incompatible', async () => {
- const first = candidateHarness();
+ const first = candidateHarness({
+ ownedProcess: { pid: 42, exited: new Promise(() => undefined) },
+ });
let reportFatal!: (error: Error) => void;
const fatalReported = new Promise<Error>((resolve) => {
reportFatal = resolve;
@@ -911,6 +986,62 @@ test('stops reconnecting when the replacement Host is incompatible', async () =>
await first.candidate.close();
const fatal = await fatalReported;
assert.match(fatal.message, /older Runtime Host/);
+ await assert.rejects(
+ owner.retireOwnedLocalHost('interrupt_active_work'),
+ (error: unknown) =>
+ error instanceof DesktopLocalHostRetirementError &&
+ error.facts.pid === 42 &&
+ error.cause === fatal,
+ );
+ await owner.close();
+});
+
+test('does not retain manual-stop authority after the owned Host process exits', async () => {
+ const first = candidateHarness({
+ ownedProcess: {
+ pid: 42,
+ exited: Promise.resolve({ code: 0, signal: null, stderr: '', stderrTruncated: false }),
+ },
+ });
+ let reportFatal!: (error: Error) => void;
+ const fatalReported = new Promise<Error>((resolve) => {
+ reportFatal = resolve;
+ });
+ const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, {
+ startCandidate: async () =>
+ first.closeCalls === 0
+ ? ready(first.candidate)
+ : incompatibleHost('wait_for_idle_exit'),
+ onFatalError: reportFatal,
+ });
+
+ await first.candidate.close();
+ await fatalReported;
+ assert.deepEqual(await owner.retireOwnedLocalHost('interrupt_active_work'), {
+ kind: 'not_owned',
+ });
+ await owner.close();
+});
+
+test('does not block quit after a supervised Local Host becomes permanently unavailable', async () => {
+ const first = candidateHarness({ ownership: 'supervised' });
+ let reportFatal!: (error: Error) => void;
+ const fatalReported = new Promise<Error>((resolve) => {
+ reportFatal = resolve;
+ });
+ const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, {
+ startCandidate: async () =>
+ first.closeCalls === 0
+ ? ready(first.candidate)
+ : incompatibleHost('wait_for_idle_exit'),
+ onFatalError: reportFatal,
+ });
+
+ await first.candidate.close();
+ await fatalReported;
+ assert.deepEqual(await owner.retireOwnedLocalHost('interrupt_active_work'), {
+ kind: 'not_owned',
+ });
await owner.close();
});
@@ -1160,6 +1291,7 @@ function candidateHarness(
disconnectOnPrepare?: boolean;
activeTasks?: boolean | 'always';
ownership?: 'owned_ephemeral' | 'supervised' | 'external';
+ ownedProcess?: RuntimeHostSpawnedProcess;
hostId?: string;
hostEpoch?: string;
finalizeFailures?: Error[];
@@ -1184,6 +1316,7 @@ function candidateHarness(
closed,
hostOwnership: options.ownership ?? 'owned_ephemeral',
hostPid: 42,
+ ...(options.ownedProcess ? { ownedProcess: options.ownedProcess } : {}),
client: {
hostId: options.hostId ?? 'test-host',
hostEpoch: options.hostEpoch ?? 'test-host-epoch',
diff --git a/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts b/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts
index 1fbc1844b..1158c7c45 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts
@@ -198,7 +198,175 @@ test('revokes the one Local sharing authority without changing peer connectivity
]);
});
-test('an interrupted Local Host handoff converges to its exact managed service', async (t) => {
+test('does not persist recoverable setup authority before Desktop ownership commits', async (t) => {
+ const base = await mkdtemp(join(tmpdir(), 'maka-local-remote-access-ownership-'));
+ t.after(() => rm(base, { recursive: true, force: true }));
+ const clientDataRoot = join(base, 'client');
+ const rootPath = join(clientDataRoot, 'workspaces', 'default');
+ const rootId = 'a'.repeat(64);
+ const lifecyclePath = join(clientDataRoot, 'runtime-host-local-service.json');
+ await mkdir(rootPath, { recursive: true });
+ const handlers = new Map<string, Parameters<Electron.IpcMain['handle']>[1]>();
+ let ownershipChecked = false;
+ let setupCalls = 0;
+ const service = createDesktopLocalRuntimeHostRemoteAccess({
+ ipcMain: {
+ handle: (channel, handler) => { handlers.set(channel, handler); },
+ removeHandler: (channel) => { handlers.delete(channel); },
+ },
+ clientDataRoot,
+ rootPath,
+ rootId,
+ directPeerAvailable: true,
+ manager: () =>
+ ({
+ async retireOwnedLocalHost() {
+ ownershipChecked = true;
+ await assert.rejects(readFile(lifecyclePath, 'utf8'), { code: 'ENOENT' });
+ return { kind: 'not_owned' as const };
+ },
+ }) as unknown as RuntimeHostDesktopManager,
+ resolveSetupPackage: async () => ({ kind: 'npm', specifier: 'maka-agent@0.2.0' }),
+ operator: {
+ async runSetup() {
+ setupCalls += 1;
+ throw new Error('setup must not run for an externally managed Host');
+ },
+ async close() {},
+ } as unknown as ReturnType<typeof createDesktopRuntimeHostLocalOperator>,
+ });
+ t.after(() => service.close());
+
+ const enable = handlers.get('local-runtime-host-remote-access:enable');
+ assert.ok(enable);
+ const enabling = enable({} as Electron.IpcMainInvokeEvent, {
+ allowInterruptActiveTasks: false,
+ coordinationRelays: [],
+ });
+ await assert.rejects(enabling, /already managed outside this Desktop/u);
+ assert.equal(ownershipChecked, true);
+ await assert.rejects(readFile(lifecyclePath, 'utf8'), { code: 'ENOENT' });
+ assert.equal(setupCalls, 0);
+});
+
+test('adopts committed managed authority for every pending receipt without replaying setup', async (t) => {
+ const base = await mkdtemp(join(tmpdir(), 'maka-local-remote-access-prestart-'));
+ t.after(() => rm(base, { recursive: true, force: true }));
+ for (const state of ['handoff', 'setupPending'] as const) {
+ const clientDataRoot = join(base, state);
+ const rootPath = join(clientDataRoot, 'workspaces', 'default');
+ const rootId = 'a'.repeat(64);
+ const deploymentId = '22222222-2222-4222-8222-222222222222';
+ const operatorPath = join(base, 'installed', 'operator');
+ await mkdir(rootPath, { recursive: true });
+ await writeFile(
+ join(clientDataRoot, 'runtime-host-local-service.json'),
+ `${JSON.stringify({
+ schemaVersion: 1,
+ state,
+ rootPath,
+ rootId,
+ coordinationRelays: [],
+ allowInterruptActiveTasks: true,
+ })}\n`,
+ );
+ const service = createDesktopLocalRuntimeHostRemoteAccess({
+ ipcMain: { handle() {}, removeHandler() {} },
+ clientDataRoot,
+ rootPath,
+ rootId,
+ directPeerAvailable: true,
+ manager: () => assert.fail('pre-start reconciliation must not require the Local manager'),
+ resolveManagedDeploymentAuthority: async () => ({
+ kind: 'active',
+ target: {
+ schemaVersion: 1,
+ serviceId: rootId,
+ operatorPath,
+ rootPath,
+ rootId,
+ deploymentId,
+ },
+ }),
+ resolveSetupPackage: async () =>
+ assert.fail('committed authority must not resolve a package'),
+ operator: {
+ async runSetup() {
+ assert.fail('committed authority must not replay setup');
+ },
+ async close() {},
+ } as unknown as ReturnType<typeof createDesktopRuntimeHostLocalOperator>,
+ });
+ t.after(() => service.close());
+
+ assert.equal(await service.recoverManagedSetup(), true);
+ assert.deepEqual(
+ JSON.parse(await readFile(join(clientDataRoot, 'runtime-host-local-service.json'), 'utf8')),
+ {
+ schemaVersion: 1,
+ state: 'managed',
+ serviceId: rootId,
+ operatorPath,
+ rootPath,
+ rootId,
+ deploymentId,
+ },
+ );
+ }
+});
+
+test('discards a legacy handoff that belongs to an externally managed Host', async (t) => {
+ const base = await mkdtemp(join(tmpdir(), 'maka-local-remote-access-legacy-external-'));
+ t.after(() => rm(base, { recursive: true, force: true }));
+ const clientDataRoot = join(base, 'client');
+ const rootPath = join(clientDataRoot, 'workspaces', 'default');
+ const rootId = 'a'.repeat(64);
+ const lifecyclePath = join(clientDataRoot, 'runtime-host-local-service.json');
+ await mkdir(rootPath, { recursive: true });
+ await writeFile(
+ lifecyclePath,
+ `${JSON.stringify({
+ schemaVersion: 1,
+ state: 'handoff',
+ rootPath,
+ rootId,
+ coordinationRelays: [],
+ allowInterruptActiveTasks: false,
+ })}\n`,
+ );
+ let setupCalls = 0;
+ const service = createDesktopLocalRuntimeHostRemoteAccess({
+ ipcMain: { handle() {}, removeHandler() {} },
+ clientDataRoot,
+ rootPath,
+ rootId,
+ directPeerAvailable: true,
+ manager: () =>
+ ({
+ async retireOwnedLocalHost() {
+ return { kind: 'not_owned' as const };
+ },
+ }) as unknown as RuntimeHostDesktopManager,
+ resolveManagedDeploymentAuthority: async () => undefined,
+ resolveSetupPackage: async () => ({ kind: 'npm', specifier: 'maka-agent@0.2.0' }),
+ operator: {
+ async runSetup() {
+ setupCalls += 1;
+ throw new Error('setup must not replace an externally managed Host');
+ },
+ async close() {},
+ } as unknown as ReturnType<typeof createDesktopRuntimeHostLocalOperator>,
+ });
+ t.after(() => service.close());
+
+ assert.equal(await service.recoverManagedSetup(), false);
+ await service.recover();
+
+ assert.equal(setupCalls, 0);
+ await assert.rejects(readFile(lifecyclePath, 'utf8'), { code: 'ENOENT' });
+});
+
+test('interrupted Local Host setup converges to its exact managed service', async (t) => {
const base = await mkdtemp(join(tmpdir(), 'maka-local-remote-access-recovery-'));
t.after(() => rm(base, { recursive: true, force: true }));
const clientDataRoot = join(base, 'client');
@@ -209,7 +377,7 @@ test('an interrupted Local Host handoff converges to its exact managed service',
join(clientDataRoot, 'runtime-host-local-service.json'),
`${JSON.stringify({
schemaVersion: 1,
- state: 'handoff',
+ state: 'setupPending',
rootPath,
rootId,
coordinationRelays: [],
@@ -217,6 +385,7 @@ test('an interrupted Local Host handoff converges to its exact managed service',
})}\n`,
);
let setupCalls = 0;
+ let setupQuiesced = false;
const service = createDesktopLocalRuntimeHostRemoteAccess({
ipcMain: { handle() {}, removeHandler() {} },
clientDataRoot,
@@ -229,7 +398,12 @@ test('an interrupted Local Host handoff converges to its exact managed service',
assert.equal(mode, 'interrupt_active_work');
return { kind: 'not_owned' as const };
},
+ async runManagedLocalHostChange(change: () => Promise<unknown>) {
+ setupQuiesced = true;
+ return change();
+ },
}) as unknown as RuntimeHostDesktopManager,
+ resolveManagedDeploymentAuthority: async () => undefined,
resolveSetupPackage: async () => ({ kind: 'npm', specifier: 'maka-agent@0.2.0' }),
operator: {
async runSetup() {
@@ -253,9 +427,12 @@ test('an interrupted Local Host handoff converges to its exact managed service',
});
t.after(() => service.close());
+ assert.equal(await service.recoverManagedSetup(), false);
+ assert.equal(setupCalls, 0);
await service.recover();
assert.equal(setupCalls, 1);
+ assert.equal(setupQuiesced, true);
assert.equal(
JSON.parse(await readFile(join(clientDataRoot, 'runtime-host-local-service.json'), 'utf8'))
.state,
diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts
index 0913bbee6..8a73c0be6 100644
--- a/apps/desktop/src/main/runtime-host-boot.ts
+++ b/apps/desktop/src/main/runtime-host-boot.ts
@@ -991,6 +991,7 @@ runtimeHostManager = await startRuntimeHostDesktopManager(
isDefault: true,
});
},
+ recoverLocalHost: (signal) => localRuntimeHostRemoteAccess.recoverManagedSetup(signal),
onFatalError: (error, target) => {
if (error instanceof RuntimeHostUpgradeCancelledError) {
if (target.profile.kind === "local") app.quit();
diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts
index 3ecce8214..03ce20e79 100644
--- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts
+++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts
@@ -201,6 +201,7 @@ export interface DesktopRuntimeHostCandidate {
readonly closed: Promise<void>;
readonly hostOwnership: DesktopRuntimeHostOwnership;
readonly hostPid?: number;
+ readonly ownedProcess?: RuntimeHostSpawnedProcess;
stopSession(sessionId: string): Promise<void>;
close(): Promise<void>;
}
@@ -211,6 +212,7 @@ class DesktopRuntimeHostCandidateImpl implements DesktopRuntimeHostCandidate {
readonly closed: Promise<void>;
readonly hostOwnership: DesktopRuntimeHostOwnership;
readonly hostPid: number | undefined;
+ readonly ownedProcess: RuntimeHostSpawnedProcess | undefined;
readonly #client: DesktopRuntimeHostClient;
readonly #observer: RuntimeHostSessionObserver;
readonly #ipc: ScopedIpcMain;
@@ -237,6 +239,7 @@ class DesktopRuntimeHostCandidateImpl implements DesktopRuntimeHostCandidate {
connectionClosed: Promise<void>;
hostOwnership: DesktopRuntimeHostOwnership;
hostPid?: number;
+ ownedProcess?: RuntimeHostSpawnedProcess;
hasRegisteredCapabilities: () => boolean;
stopSession: (sessionId: string) => Promise<void>;
}) {
@@ -255,6 +258,7 @@ class DesktopRuntimeHostCandidateImpl implements DesktopRuntimeHostCandidate {
this.botIncoming = input.botIncoming;
this.hostOwnership = input.hostOwnership;
this.hostPid = input.hostPid;
+ this.ownedProcess = input.ownedProcess;
this.closed = input.connectionClosed.then(() => this.close());
}
@@ -323,6 +327,7 @@ export async function startDesktopRuntimeHostCandidate(
: 'supervised',
"local",
connection.registration.pid,
+ connection.spawnedProcess,
),
};
} catch (error) {
@@ -432,6 +437,7 @@ export async function createDesktopRuntimeHostCandidate(
hostOwnership: DesktopRuntimeHostOwnership,
targetKind: DesktopRuntimeHostTargetPolicy["kind"],
hostPid?: number,
+ ownedProcess?: RuntimeHostSpawnedProcess,
): Promise<DesktopRuntimeHostCandidate> {
const target: DesktopRuntimeHostTargetPolicy = {
kind: targetKind,
@@ -760,6 +766,7 @@ export async function createDesktopRuntimeHostCandidate(
connectionClosed: connection.closed,
hostOwnership,
...(hostPid === undefined ? {} : { hostPid }),
+ ...(ownedProcess === undefined ? {} : { ownedProcess }),
hasRegisteredCapabilities: () => capabilitiesRegistered,
stopSession,
});
diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts
index c03c6f6af..e50c03e4d 100644
--- a/apps/desktop/src/main/runtime-host-desktop-manager.ts
+++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts
@@ -41,6 +41,7 @@ import {
type DesktopRuntimeHostCandidate,
type DesktopRuntimeHostCandidateStartInput,
type DesktopRuntimeHostCandidateStartResult,
+ type DesktopRuntimeHostOwnership,
} from './runtime-host-desktop-candidate.js';
import { RuntimeHostReconnectingIpcMain } from './runtime-host-reconnecting-ipc-main.js';
import { RuntimeHostSessionObservationRegistry } from './runtime-host-session-observation-registry.js';
@@ -181,9 +182,20 @@ interface DesktopRuntimeHostTargetGeneration {
hostId?: string;
lifecycle?: RuntimeHostReconnectLifecycle<DesktopRuntimeHostCandidate>;
unsubscribeLifecycle?: () => void;
+ lastCandidate?: {
+ readonly hostId: string;
+ readonly hostEpoch: string;
+ readonly ownership: DesktopRuntimeHostOwnership;
+ readonly ownedProcess?: DesktopOwnedProcessEvidence;
+ };
valid: boolean;
}
+interface DesktopOwnedProcessEvidence {
+ readonly pid: number;
+ state: 'running' | 'exited' | 'unknown';
+}
+
export async function startRuntimeHostDesktopManager(
input: DesktopRuntimeHostCandidateStartInput,
options: {
@@ -198,6 +210,7 @@ export async function startRuntimeHostDesktopManager(
registration: HostRegistration,
signal: AbortSignal,
) => Promise<void>;
+ recoverLocalHost?: (signal: AbortSignal) => Promise<boolean>;
reconnectBackoff?: RuntimeHostReconnectBackoff;
pairingFinalizationTimeoutMs?: number;
onTargetStateChanged?: (state: RuntimeHostDesktopTargetState) => void;
@@ -213,6 +226,7 @@ export async function startRuntimeHostDesktopManager(
options.upgradePrompts,
options.waitForHostExit ?? waitForProcessExit,
options.waitForHostRetirement ?? waitForProcessRetirement,
+ options.recoverLocalHost,
options.reconnectBackoff,
options.pairingFinalizationTimeoutMs ?? DEFAULT_PAIRING_FINALIZATION_TIMEOUT_MS,
options.onTargetStateChanged,
@@ -252,6 +266,9 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager {
registration: HostRegistration,
signal: AbortSignal,
) => Promise<void>,
+ private readonly recoverLocalHost:
+ | ((signal: AbortSignal) => Promise<boolean>)
+ | undefined,
private readonly reconnectBackoff: RuntimeHostReconnectBackoff | undefined,
private readonly pairingFinalizationTimeoutMs: number,
private readonly onTargetStateChanged:
@@ -576,10 +593,20 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager {
async #retireOwnedLocalHost(
mode: RuntimeHostRetirementMode,
): Promise<DesktopLocalHostRetirement> {
- const lifecycle = this.#requireLifecycle(
- this.#requireTarget(LOCAL_RUNTIME_HOST_PROFILE.id),
- );
- const quiescence = await lifecycle.quiesce();
+ const target = this.#requireTarget(LOCAL_RUNTIME_HOST_PROFILE.id);
+ const lifecycle = this.#requireLifecycle(target);
+ const unavailable = this.#unavailableLocalHostRetirement(target);
+ if (unavailable) return unavailable;
+ let quiescence: Awaited<
+ ReturnType<RuntimeHostReconnectLifecycle<DesktopRuntimeHostCandidate>['quiesce']>
+ >;
+ try {
+ quiescence = await lifecycle.quiesce();
+ } catch (error) {
+ const terminal = this.#unavailableLocalHostRetirement(target, error);
+ if (terminal) return terminal;
+ throw error;
+ }
let hostPid = quiescence.current.hostPid;
let launchBarrierPaused = false;
const resume = () => {
@@ -610,6 +637,7 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager {
return result;
}
await this.waitForHostExit(result.pid);
+ target.lastCandidate = undefined;
return this.#completeLocalHostRetirement(resume);
} catch (error) {
resume();
@@ -626,6 +654,28 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager {
}
}
+ #unavailableLocalHostRetirement(
+ target: DesktopRuntimeHostTargetGeneration,
+ cause: unknown = target.state.readiness === 'unavailable' ? target.state.error : undefined,
+ ): DesktopLocalHostRetirement | undefined {
+ if (target.state.readiness !== 'unavailable') return 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(
+ {
+ hostId: last.hostId,
+ hostEpoch: last.hostEpoch,
+ lifecycleMode: 'ephemeral',
+ rootPath: this.#baseInput.rootPath,
+ ...(last.ownedProcess?.state === 'running'
+ ? { pid: last.ownedProcess.pid }
+ : {}),
+ },
+ { cause: cause instanceof Error ? cause : new Error(String(cause)) },
+ );
+ }
+
#completeLocalHostRetirement(
resume: () => void,
): Extract<DesktopLocalHostRetirement, { kind: 'retired' }> {
@@ -719,30 +769,64 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager {
sshInteraction: RuntimeHostSshInteraction | undefined,
): Promise<DesktopRuntimeHostCandidate> {
let takeoverHostEpoch: string | undefined;
+ let localRecoveryAttempted = false;
const inheritedExit = target.input.onExit;
+ const tryRecoverLocalHost = async (): Promise<boolean> => {
+ if (target.input.profileTarget || localRecoveryAttempted || !this.recoverLocalHost) {
+ return false;
+ }
+ localRecoveryAttempted = true;
+ return this.recoverLocalHost(signal);
+ };
while (true) {
- const result = await this.startCandidate(
- {
- ...target.input,
- onExit: (details) => this.#reportCandidateExit(inheritedExit, details),
- ...(target.input.profileTarget
- ? {
- profileTarget: {
- ...target.input.profileTarget,
- ...(sshInteraction === undefined ? {} : { sshInteraction }),
- },
- }
- : {}),
- ipcMain: this.#ipcMain.createTarget(target.epoch),
- isTargetActive: () => this.#ipcMain.isActive(target.epoch),
- isTargetValid: () => target.valid,
- signal,
- ...(takeoverHostEpoch === undefined ? {} : { takeoverHostEpoch }),
- },
- target.observations,
- );
+ let result: DesktopRuntimeHostCandidateStartResult;
+ try {
+ result = await this.startCandidate(
+ {
+ ...target.input,
+ onExit: (details) => this.#reportCandidateExit(inheritedExit, details),
+ ...(target.input.profileTarget
+ ? {
+ profileTarget: {
+ ...target.input.profileTarget,
+ ...(sshInteraction === undefined ? {} : { sshInteraction }),
+ },
+ }
+ : {}),
+ ipcMain: this.#ipcMain.createTarget(target.epoch),
+ isTargetActive: () => this.#ipcMain.isActive(target.epoch),
+ isTargetValid: () => target.valid,
+ signal,
+ ...(takeoverHostEpoch === undefined ? {} : { takeoverHostEpoch }),
+ },
+ target.observations,
+ );
+ } catch (error) {
+ signal.throwIfAborted();
+ if (await tryRecoverLocalHost()) continue;
+ throw error;
+ }
if (result.kind === 'ready') {
target.hostId = result.candidate.client.hostId;
+ const previous = target.lastCandidate;
+ const retainedOwnedProcess =
+ previous?.hostId === result.candidate.client.hostId &&
+ previous.hostEpoch === result.candidate.client.hostEpoch &&
+ previous.ownership === 'owned_ephemeral' &&
+ result.candidate.hostOwnership === 'owned_ephemeral' &&
+ previous.ownedProcess?.pid === result.candidate.hostPid
+ ? previous.ownedProcess
+ : undefined;
+ target.lastCandidate = {
+ hostId: result.candidate.client.hostId,
+ hostEpoch: result.candidate.client.hostEpoch,
+ ownership: result.candidate.hostOwnership,
+ ...(result.candidate.ownedProcess
+ ? { ownedProcess: trackOwnedProcess(result.candidate.ownedProcess) }
+ : retainedOwnedProcess
+ ? { ownedProcess: retainedOwnedProcess }
+ : {}),
+ };
return result.candidate;
}
if (result.kind === 'upgrade_required' && result.restartable) {
@@ -775,6 +859,7 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager {
await this.waitForHostRetirement(result.registration, signal);
continue;
}
+ if (await tryRecoverLocalHost()) continue;
throw runtimeHostStartupError(result.reason, result.diagnostic);
}
}
@@ -975,6 +1060,21 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager {
}
}
+function trackOwnedProcess(
+ process: NonNullable<DesktopRuntimeHostCandidate['ownedProcess']>,
+): DesktopOwnedProcessEvidence {
+ const evidence: DesktopOwnedProcessEvidence = { pid: process.pid, state: 'running' };
+ void process.exited.then(
+ () => {
+ evidence.state = 'exited';
+ },
+ () => {
+ evidence.state = 'unknown';
+ },
+ );
+ return evidence;
+}
+
function pairingFinalizeRetry(error: unknown): boolean {
// Finalization is idempotent for the current credential, so both a known
// non-dispatch and an unknown outcome converge on the replacement connection.
diff --git a/apps/desktop/src/main/runtime-host-local-remote-access.ts b/apps/desktop/src/main/runtime-host-local-remote-access.ts
index d19b3803b..ab6659d08 100644
--- a/apps/desktop/src/main/runtime-host-local-remote-access.ts
+++ b/apps/desktop/src/main/runtime-host-local-remote-access.ts
@@ -26,6 +26,7 @@ import {
consumeAccessCredentialDelivery,
encodeRuntimeHostOwnerConnectionCode,
} from '@maka/runtime-host/client';
+import { resolveRuntimeHostManagedDeploymentAuthority } from '@maka/runtime-host/operator';
import { REMOTE_OWNER_OPERATION_GRANTS } from '@maka/runtime-host/protocol';
import type {
DesktopLocalRuntimeHostRemoteAccessEnableResult,
@@ -54,7 +55,18 @@ interface LocalServiceTarget extends DesktopRuntimeHostLocalServiceTarget {
readonly deploymentId: string;
}
-interface LocalServiceHandoff {
+interface LocalServiceSetupPending {
+ readonly schemaVersion: 1;
+ /** Persisted only after the Desktop-owned Host has retired. */
+ readonly state: 'setupPending';
+ readonly rootPath: string;
+ readonly rootId: string;
+ readonly coordinationRelays: readonly string[];
+ readonly allowInterruptActiveTasks: boolean;
+}
+
+/** Schema-v1 setup intent written by Desktop releases before ownership was established. */
+interface LocalServiceLegacyHandoff {
readonly schemaVersion: 1;
readonly state: 'handoff';
readonly rootPath: string;
@@ -67,6 +79,10 @@ interface LocalServiceManaged extends LocalServiceTarget {
readonly state: 'managed';
}
+type LocalManagedDeploymentAuthority =
+ | { readonly kind: 'active'; readonly target: LocalServiceTarget }
+ | { readonly kind: 'transition' };
+
interface LocalServicePeerChanging extends LocalServiceTarget {
readonly state: 'peerChanging';
readonly peerEnabled: boolean;
@@ -80,7 +96,8 @@ interface LocalServiceUninstalling extends LocalServiceTarget {
}
type LocalServiceLifecycle =
- | LocalServiceHandoff
+ | LocalServiceLegacyHandoff
+ | LocalServiceSetupPending
| LocalServiceManaged
| LocalServicePeerChanging
| LocalServiceUninstalling;
@@ -110,7 +127,14 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: {
signal?: AbortSignal,
) => DesktopRuntimeHostSetupPackage | Promise<DesktopRuntimeHostSetupPackage>;
readonly operator: DesktopRuntimeHostLocalOperator;
-}): { recover(): Promise<void>; close(): Promise<void> } {
+ readonly resolveManagedDeploymentAuthority?: (
+ rootId: string,
+ ) => Promise<LocalManagedDeploymentAuthority | undefined>;
+}): {
+ recoverManagedSetup(signal?: AbortSignal): Promise<boolean>;
+ recover(): Promise<void>;
+ close(): Promise<void>;
+} {
const lifecyclePath = join(input.clientDataRoot, LIFECYCLE_FILE);
const closing = new AbortController();
let mutation = Promise.resolve();
@@ -122,6 +146,41 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: {
);
return result;
};
+ const resolveManagedDeploymentAuthority =
+ input.resolveManagedDeploymentAuthority ??
+ (async (rootId: string): Promise<LocalManagedDeploymentAuthority | undefined> => {
+ const authority = await resolveRuntimeHostManagedDeploymentAuthority(rootId);
+ if (!authority) return undefined;
+ if (authority.record.state !== 'active') return { kind: 'transition' };
+ return {
+ kind: 'active',
+ target: requireServiceTarget(
+ {
+ schemaVersion: 1,
+ serviceId: authority.record.root.id,
+ operatorPath: join(authority.record.deploymentRoot, 'operator'),
+ rootPath: authority.record.root.path,
+ rootId: authority.record.root.id,
+ deploymentId: authority.record.deploymentId,
+ },
+ input.rootPath,
+ ),
+ };
+ });
+
+ const adoptCommittedSetup = async (
+ setup: LocalServiceSetupPending,
+ ): Promise<
+ | { readonly kind: 'absent' | 'transition' }
+ | { readonly kind: 'managed'; readonly managed: LocalServiceManaged }
+ > => {
+ const authority = await resolveManagedDeploymentAuthority(setup.rootId);
+ if (!authority) return { kind: 'absent' };
+ if (authority.kind === 'transition') return authority;
+ const managed = managedLifecycle(authority.target);
+ await writeDocument(lifecyclePath, managed);
+ return { kind: 'managed', managed };
+ };
const getSnapshot = (): Promise<DesktopLocalRuntimeHostRemoteAccessSnapshot> =>
serialize(async () => {
@@ -133,7 +192,7 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: {
return {
state: 'unavailable',
message:
- lifecycle.state === 'handoff'
+ lifecycle.state === 'setupPending' || lifecycle.state === 'handoff'
? 'Local Runtime Host setup is being recovered'
: lifecycle.state === 'peerChanging'
? 'Local Runtime Host remote access is being recovered'
@@ -170,10 +229,23 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: {
lifecycle = managedLifecycle(lifecycle);
}
if (lifecycle?.state === 'handoff') {
- const recovered = await finishHandoff(lifecycle, true);
+ const recovered = await recoverLegacyHandoff(lifecycle);
if (recovered.kind === 'active_tasks') return recovered;
+ if (recovered.kind === 'external') {
+ throw new Error('The Local Runtime Host is already managed outside this Desktop');
+ }
lifecycle = recovered.managed;
}
+ if (lifecycle?.state === 'setupPending') {
+ const committed = await adoptCommittedSetup(lifecycle);
+ if (committed.kind === 'managed') {
+ lifecycle = committed.managed;
+ } else {
+ const recovered = await finishSetup(lifecycle, 'recovery');
+ if (recovered.kind === 'active_tasks') return recovered;
+ lifecycle = recovered.managed;
+ }
+ }
if (lifecycle?.state === 'managed') {
const manager = requireManager(input.manager);
const previousHostEpoch = manager.current('local')?.candidate?.client.hostEpoch;
@@ -199,16 +271,15 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: {
);
}
- const handoff: LocalServiceHandoff = {
+ const setup: LocalServiceSetupPending = {
schemaVersion: 1,
- state: 'handoff',
+ state: 'setupPending',
rootPath: input.rootPath,
rootId: input.rootId,
coordinationRelays: request.coordinationRelays,
allowInterruptActiveTasks: request.allowInterruptActiveTasks,
};
- await writeDocument(lifecyclePath, handoff);
- const completed = await finishHandoff(handoff, false);
+ const completed = await finishSetup(setup, 'request');
if (completed.kind === 'active_tasks') return completed;
return enabledResult(
encodeRuntimeHostOwnerConnectionCode({
@@ -220,9 +291,9 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: {
);
});
- const finishHandoff = async (
- handoff: LocalServiceHandoff,
- allowAlreadyManaged: boolean,
+ const finishSetup = async (
+ setup: LocalServiceSetupPending,
+ origin: 'request' | 'recovery',
): Promise<
| { readonly kind: 'active_tasks' }
| {
@@ -235,38 +306,60 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: {
const setupPackage = await input.resolveSetupPackage(closing.signal);
const manager = requireManager(input.manager);
const retirement = await manager.retireOwnedLocalHost(
- handoff.allowInterruptActiveTasks ? 'interrupt_active_work' : 'refuse_active_work',
+ setup.allowInterruptActiveTasks ? 'interrupt_active_work' : 'refuse_active_work',
);
if (retirement.kind === 'active_tasks') {
- if (!allowAlreadyManaged) await removeDocument(lifecyclePath);
return { kind: 'active_tasks' };
}
- if (retirement.kind === 'not_owned' && !allowAlreadyManaged) {
- await removeDocument(lifecyclePath);
+ if (retirement.kind === 'not_owned' && origin === 'request') {
throw new Error('The Local Runtime Host is already managed outside this Desktop');
}
+ try {
+ if (origin === 'request') await writeDocument(lifecyclePath, setup);
+ const reconcile = () => reconcileSetup(setup, setupPackage);
+ // Recovery may find that the operator already owns the root. Its setup
+ // can still restart that Host, so keep Desktop reconnect quiesced across
+ // the entire reconciliation just like every other managed-service change.
+ return retirement.kind === 'not_owned'
+ ? await manager.runManagedLocalHostChange(reconcile)
+ : await reconcile();
+ } finally {
+ if (retirement.kind === 'retired') retirement.resume();
+ }
+ };
+
+ const reconcileSetup = async (
+ setup: LocalServiceSetupPending,
+ setupPackage: DesktopRuntimeHostSetupPackage,
+ signal: AbortSignal = closing.signal,
+ ): Promise<{
+ readonly kind: 'complete';
+ readonly managed: LocalServiceManaged;
+ readonly peer: LocalPeerDescriptor;
+ readonly credential: string;
+ }> => {
let target: LocalServiceTarget | undefined;
try {
const complete = await input.operator.runSetup(
{
setupPackage,
clientDataRoot: input.clientDataRoot,
- rootPath: handoff.rootPath,
+ rootPath: setup.rootPath,
principalId: LOCAL_REMOTE_ACCESS_PRINCIPAL_ID,
- coordinationRelays: handoff.coordinationRelays,
+ coordinationRelays: setup.coordinationRelays,
expectedTarget: {
- serviceId: handoff.rootId,
- rootPath: handoff.rootPath,
- rootId: handoff.rootId,
+ serviceId: setup.rootId,
+ rootPath: setup.rootPath,
+ rootId: setup.rootId,
},
- signal: closing.signal,
+ signal,
},
() => undefined,
);
if (
- complete.serviceId !== handoff.rootId ||
- complete.rootPath !== handoff.rootPath ||
- complete.rootId !== handoff.rootId ||
+ complete.serviceId !== setup.rootId ||
+ complete.rootPath !== setup.rootPath ||
+ complete.rootId !== setup.rootId ||
!DEPLOYMENT_ID_PATTERN.test(complete.deploymentId) ||
!complete.directPeer
) {
@@ -281,13 +374,10 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: {
rootId: complete.rootId,
deploymentId: complete.deploymentId,
},
- handoff.rootPath,
+ setup.rootPath,
);
const peer = requireEnabledPeer({ state: 'enabled', ...complete.directPeer });
- const managed: LocalServiceManaged = {
- ...target,
- state: 'managed',
- };
+ const managed: LocalServiceManaged = { ...target, state: 'managed' };
await writeDocument(lifecyclePath, managed);
return { kind: 'complete', managed, peer, credential: complete.credential };
} catch (error) {
@@ -302,6 +392,49 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: {
);
}
throw error;
+ }
+ };
+
+ const recoverLegacyHandoff = async (
+ legacy: LocalServiceLegacyHandoff,
+ ): Promise<
+ | { readonly kind: 'active_tasks' }
+ | { readonly kind: 'external' }
+ | { readonly kind: 'complete'; readonly managed: LocalServiceManaged }
+ > => {
+ const pending = pendingSetup(legacy);
+ const authority = await adoptCommittedSetup(pending);
+ if (authority.kind === 'managed') {
+ return { kind: 'complete', managed: authority.managed };
+ }
+ if (authority.kind === 'transition') {
+ await writeDocument(lifecyclePath, pending);
+ return finishSetup(pending, 'recovery');
+ }
+
+ const manager = requireManager(input.manager);
+ const retirement = await manager.retireOwnedLocalHost(
+ legacy.allowInterruptActiveTasks ? 'interrupt_active_work' : 'refuse_active_work',
+ );
+ if (retirement.kind === 'active_tasks') return { kind: 'active_tasks' };
+ if (retirement.kind === 'not_owned') {
+ const raced = await adoptCommittedSetup(pending);
+ if (raced.kind === 'managed') {
+ return { kind: 'complete', managed: raced.managed };
+ }
+ if (raced.kind === 'absent') {
+ await removeDocument(lifecyclePath);
+ return { kind: 'external' };
+ }
+ }
+
+ try {
+ await writeDocument(lifecyclePath, pending);
+ const setupPackage = await input.resolveSetupPackage(closing.signal);
+ const reconcile = () => reconcileSetup(pending, setupPackage);
+ return retirement.kind === 'not_owned'
+ ? await manager.runManagedLocalHostChange(reconcile)
+ : await reconcile();
} finally {
if (retirement.kind === 'retired') retirement.resume();
}
@@ -486,13 +619,38 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: {
input.ipcMain.handle(channels[5], (_event, value: unknown) => uninstall(value));
return {
+ recoverManagedSetup: async (signal) => {
+ if (!supported(input.directPeerAvailable)) return false;
+ const operationSignal = signal ? AbortSignal.any([signal, closing.signal]) : closing.signal;
+ operationSignal.throwIfAborted();
+ const observed = await readLifecycle(lifecyclePath, input.rootPath, input.rootId);
+ if (observed?.state !== 'setupPending' && observed?.state !== 'handoff') return false;
+ return serialize(async () => {
+ operationSignal.throwIfAborted();
+ const pending = await readLifecycle(lifecyclePath, input.rootPath, input.rootId);
+ if (pending?.state !== 'setupPending' && pending?.state !== 'handoff') return false;
+ const committed = pendingSetup(pending);
+ const authority = await adoptCommittedSetup(committed);
+ if (authority.kind === 'absent') return false;
+ if (authority.kind === 'managed') return true;
+ if (pending.state === 'handoff') await writeDocument(lifecyclePath, committed);
+ const setupPackage = await input.resolveSetupPackage(operationSignal);
+ await reconcileSetup(committed, setupPackage, operationSignal);
+ return true;
+ });
+ },
recover: () =>
serialize(async () => {
if (!supported(input.directPeerAvailable)) return;
const lifecycle = await readLifecycle(lifecyclePath, input.rootPath, input.rootId);
if (!lifecycle) return;
if (lifecycle.state === 'handoff') {
- await finishHandoff(lifecycle, true);
+ await recoverLegacyHandoff(lifecycle);
+ return;
+ }
+ if (lifecycle.state === 'setupPending') {
+ const committed = await adoptCommittedSetup(lifecycle);
+ if (committed.kind !== 'managed') await finishSetup(lifecycle, 'recovery');
return;
}
if (lifecycle.state === 'uninstalling') {
@@ -694,6 +852,19 @@ function requireManaged(lifecycle: LocalServiceLifecycle | undefined): LocalServ
return lifecycle;
}
+function pendingSetup(
+ intent: LocalServiceSetupPending | LocalServiceLegacyHandoff,
+): LocalServiceSetupPending {
+ return {
+ schemaVersion: 1,
+ state: 'setupPending',
+ rootPath: intent.rootPath,
+ rootId: intent.rootId,
+ coordinationRelays: intent.coordinationRelays,
+ allowInterruptActiveTasks: intent.allowInterruptActiveTasks,
+ };
+}
+
function managedLifecycle(intent: LocalServiceTarget): LocalServiceManaged {
return {
schemaVersion: 1,
@@ -726,7 +897,7 @@ async function readLifecycle(
) {
throw new Error('Local Runtime Host service lifecycle is invalid');
}
- if (value.state === 'handoff') {
+ if (value.state === 'setupPending' || value.state === 'handoff') {
assertExactKeys(value, [
'schemaVersion',
'state',
@@ -738,11 +909,11 @@ async function readLifecycle(
if (
typeof value.allowInterruptActiveTasks !== 'boolean'
) {
- throw new Error('Local Runtime Host handoff intent is invalid');
+ throw new Error('Local Runtime Host setup intent is invalid');
}
return {
schemaVersion: 1,
- state: 'handoff',
+ state: value.state,
rootPath,
rootId,
coordinationRelays: requireAddresses(value.coordinationRelays),
diff --git a/native/runtime-host-peer/Cargo.toml b/native/runtime-host-peer/Cargo.toml
index 01e367934..35c4f35a3 100644
--- a/native/runtime-host-peer/Cargo.toml
+++ b/native/runtime-host-peer/Cargo.toml
@@ -49,3 +49,7 @@ tokio = { version = "1.53", features = ["fs", "io-util", "rt-multi-thread", "syn
[build-dependencies]
napi-build = "2.4"
+
+[profile.release]
+# build.mjs strips the copied addon once; Cargo stripping every intermediate artifact is redundant.
+strip = "none"
diff --git a/packages/cli/src/__tests__/runtime-host-setup.test.ts b/packages/cli/src/__tests__/runtime-host-setup.test.ts
index 6c6f1f4e8..0854089df 100644
--- a/packages/cli/src/__tests__/runtime-host-setup.test.ts
+++ b/packages/cli/src/__tests__/runtime-host-setup.test.ts
@@ -59,6 +59,7 @@ import {
resolveRuntimeHostManagedDeploymentRoot,
} from '../runtime-host-managed-deployment.js';
import { runRuntimeHostSetupCli } from '../runtime-host-setup-command.js';
+import { RuntimeHostAccessUnavailableError } from '../runtime-host-access-command.js';
import { manageRuntimeHostManagedLifecycle } from '../runtime-host-managed-lifecycle-manager.js';
import {
resolveRuntimeHostLifecycleProvider,
@@ -86,6 +87,7 @@ test('on-demand setup installs one exact deployment without a service backend',
const outputs: string[] = [];
let rootId = '';
let projectedOperatorDeploymentRoot = '';
+ let pairingAttempts = 0;
t.after(async () => {
if (previousDataHome === undefined) delete process.env.XDG_DATA_HOME;
else process.env.XDG_DATA_HOME = previousDataHome;
@@ -166,16 +168,20 @@ test('on-demand setup installs one exact deployment without a service backend',
projectedOperatorDeploymentRoot = desired?.deploymentRoot ?? '';
},
verifyOperator: async () => undefined,
- replaceCredential: async () => ({
- rootId,
- credential: 'secret-token',
- credentialId: 'credential-1',
- principalKind: 'remote_owner' as const,
- principalId: 'desktop:client-1',
- operationGrants: [] as const,
- canPublishClientCapabilities: false,
- canUseHostPaths: false,
- }),
+ replaceCredential: async () => {
+ pairingAttempts += 1;
+ if (pairingAttempts === 1) throw new RuntimeHostAccessUnavailableError('unavailable');
+ return {
+ rootId,
+ credential: 'secret-token',
+ credentialId: 'credential-1',
+ principalKind: 'remote_owner' as const,
+ principalId: 'desktop:client-1',
+ operationGrants: [] as const,
+ canPublishClientCapabilities: false,
+ canUseHostPaths: false,
+ };
+ },
verifyCredential: async ({ endpoint, rootId: expectedRootId }) => {
assert.equal(endpoint, 'ws://127.0.0.1:43210/runtime-host');
assert.equal(expectedRootId, rootId);
@@ -183,6 +189,7 @@ test('on-demand setup installs one exact deployment without a service backend',
writeOutput: (value) => outputs.push(value),
} satisfies NonNullable<Parameters<typeof runRuntimeHostSetupCli>[1]>;
assert.equal(await runRuntimeHostSetupCli(options, overrides), 0);
+ assert.equal(pairingAttempts, 2);
const complete = outputs
.map(decodeRuntimeHostSetupFrame)
.find((frame) => frame?.kind === 'complete');
diff --git a/packages/cli/src/runtime-host-access-command.ts b/packages/cli/src/runtime-host-access-command.ts
index 40f4bd207..47311f16a 100644
--- a/packages/cli/src/runtime-host-access-command.ts
+++ b/packages/cli/src/runtime-host-access-command.ts
@@ -45,6 +45,16 @@ const PROTOCOL = {
max: RUNTIME_HOST_PROTOCOL_VERSION,
} as const;
+export class RuntimeHostAccessUnavailableError extends Error {
+ constructor(
+ readonly reason: string,
+ options?: ErrorOptions,
+ ) {
+ super(`Runtime Host service is not available (${reason})`, options);
+ this.name = 'RuntimeHostAccessUnavailableError';
+ }
+}
+
export interface RuntimeHostAccessIssueOptions {
readonly rootPath: string;
readonly expectedRootId?: string;
@@ -350,12 +360,13 @@ export async function revokeRuntimeHostAccessCredential(
}
async function connectLocalOwner(rootPath: string, expectedRootId?: string) {
- const result = await connectExistingRuntimeHost({
- rootPath,
- protocol: PROTOCOL,
- });
+ const result = await connectExistingRuntimeHost({ rootPath, protocol: PROTOCOL }).catch(
+ (error: unknown) => {
+ throw new RuntimeHostAccessUnavailableError('connection_failed', { cause: error });
+ },
+ );
if (result.kind !== 'connected') {
- throw new Error(`Runtime Host service is not available (${result.kind})`);
+ throw new RuntimeHostAccessUnavailableError(result.kind);
}
if (expectedRootId && result.connection.rootId !== expectedRootId) {
await result.connection.close();
diff --git a/packages/cli/src/runtime-host-setup-command.ts b/packages/cli/src/runtime-host-setup-command.ts
index 275213356..5a933d938 100644
--- a/packages/cli/src/runtime-host-setup-command.ts
+++ b/packages/cli/src/runtime-host-setup-command.ts
@@ -22,6 +22,7 @@ import { homedir } from 'node:os';
import { join, resolve } from 'node:path';
import { isDeepStrictEqual } from 'node:util';
import { truncateUtf8 } from '@maka/core/diagnostic-log';
+import { generalizedErrorMessage } from '@maka/core/redaction';
import {
activateRuntimeHostManagedDeployment,
connectRemoteRuntimeHost,
@@ -47,6 +48,7 @@ import {
prepareRuntimeHostAccessCredential,
replaceRuntimeHostAccessCredential,
revokeRuntimeHostAccessCredential,
+ RuntimeHostAccessUnavailableError,
type RuntimeHostAccessPreset,
} from './runtime-host-access-command.js';
import {
@@ -118,6 +120,8 @@ import {
import { activateRuntimeHostManagedDeploymentWithReconciliation } from './runtime-host-activation-command.js';
const SETUP_LOCK_TIMEOUT_MS = 5 * 60_000;
+const PAIRING_AVAILABILITY_TIMEOUT_MS = 10_000;
+const PAIRING_AVAILABILITY_POLL_MS = 100;
export interface RuntimeHostSetupCliOptions {
readonly json: boolean;
@@ -1075,20 +1079,35 @@ async function pairAndVerifyRuntimeHostSetup(
const pairCredential = options.deferPairingCommit
? deps.prepareCredential
: deps.replaceCredential;
- paired = await pairCredential({
+ const credentialInput = {
rootPath: target.rootPath,
- principalKind: 'remote_owner',
+ principalKind: 'remote_owner' as const,
principalId: options.principalId,
operationGrants: [],
canPublishClientCapabilities: false,
canUseHostPaths: false,
preset: options.preset,
...(options.bindPairingToClient ? { bindClientInstance: true } : {}),
- });
+ };
+ const deadline = Date.now() + PAIRING_AVAILABILITY_TIMEOUT_MS;
+ while (true) {
+ try {
+ paired = await pairCredential(credentialInput);
+ break;
+ } catch (error) {
+ if (!(error instanceof RuntimeHostAccessUnavailableError) || Date.now() >= deadline) {
+ throw error;
+ }
+ await new Promise<void>((resolveWait) =>
+ setTimeout(resolveWait, Math.min(PAIRING_AVAILABILITY_POLL_MS, deadline - Date.now())),
+ );
+ }
+ }
} catch (error) {
+ const reason = generalizedErrorMessage(error, 'Runtime Host access service is unavailable');
throw new RuntimeHostSetupError(
'pairing_failed',
- 'Runtime Host could not pair the requested Client identity',
+ `Runtime Host could not pair the requested Client identity: ${reason}`,
{ cause: error },
);
}
diff --git a/packages/runtime-host/src/__tests__/peer-mesh.test.ts b/packages/runtime-host/src/__tests__/peer-mesh.test.ts
index 78c598fe6..8ee98365f 100644
--- a/packages/runtime-host/src/__tests__/peer-mesh.test.ts
+++ b/packages/runtime-host/src/__tests__/peer-mesh.test.ts
@@ -233,7 +233,7 @@ test('closed Mesh records do not permanently consume membership capacity', async
if (index === 0) assert.equal(await hasActivePeerMeshMembership(root, 'peer-a'), false);
}
assert.equal((await node.create()).roster.roster.closed, false);
- assert.equal(node.status().length, 16);
+ assert.equal(node.status().length, 1);
} finally {
await node.close();
await peer.close();
diff --git a/packages/runtime-host/src/peer-mesh/node.ts b/packages/runtime-host/src/peer-mesh/node.ts
index edbbdcbe5..4aa43b614 100644
--- a/packages/runtime-host/src/peer-mesh/node.ts
+++ b/packages/runtime-host/src/peer-mesh/node.ts
@@ -233,10 +233,7 @@ class PeerMeshNodeImpl implements PeerMeshNode {
return Object.freeze(
this.#store
.read()
- .meshes.filter(
- (state) =>
- state.role === 'authority' || state.roster.roster.members.includes(identity.peerId),
- )
+ .meshes.filter((state) => isActiveMembership(state, identity.peerId))
.map((state) => peerMeshStatus(state, identity, this.#store.read().routes, this.#now())),
);
}
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org