From 2f857174f6f298e4797df3da3955bb8417816d02 Mon Sep 17 00:00:00 2001 From: liugddx Date: Fri, 21 Aug 2026 00:17:07 +0800 Subject: [PATCH 1/5] fix(desktop): clear registered Runtime Host residue before update handoff The update drain covered only the tracked connection: installUpdate -> prepareForUpdate -> prepareHostUpgrade drains the host the manager is connected to, but an election winner it never adopted - a late winner, or a survivor from a previous app generation - keeps running from the installation directory the updater is about to replace. Windows has no cross-process SIGTERM, the NSIS upgrade quits when it cannot clear such a process, and the update silently never applies (#3340; run 32382283646 captured the orphan's command line, the untouched files and registration, and the retained pre-upgrade backup). After the tracked drain, read the root-scoped host registration (the authoritative live-host record election winners write) through a boot-injected capability closure; a live registered ephemeral host that is not the drained pid is terminated outright - its image is about to be replaced and its committed state is crash-safe by the platform's recovery evidence - and awaited. A residue that survives termination rejects, surfacing install_failed instead of a doomed installer handoff. Normal-quit host survival (session persistence) is untouched: the sweep runs only on the update path. Unit tests pin the sweep: orphan terminated and awaited, drained-pid and service-mode and dead and unregistered residue left alone, an unkillable residue rejects, an unreadable registration degrades to the installer's own handling. Co-Authored-By: Claude Fable 5 Generated-by: Claude Fable 5 --- .../runtime-host-desktop-manager.test.ts | 128 ++++++++++++++++++ apps/desktop/src/main/runtime-host-boot.ts | 15 +- .../src/main/runtime-host-desktop-manager.ts | 61 +++++++++ packages/runtime-host/src/client/index.ts | 1 + 4 files changed, 204 insertions(+), 1 deletion(-) 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 9516e18abd..e2cda7a71d 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 @@ -5,6 +5,7 @@ import { INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, RUNTIME_HOST_COMPATIBILITY_EPOCH, RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION, + type HostRegistration, } from '@maka/runtime-host/protocol'; import type { DesktopRuntimeHostCandidate, @@ -146,6 +147,133 @@ for (const lifecycleMode of ['service', 'remote'] as const) { }); } +test('terminates a registered host the tracked update drain did not cover', async () => { + const current = candidateHarness({ disconnectOnPrepare: true }); + const events: string[] = []; + const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { + startCandidate: async () => ready(current.candidate), + waitForHostExit: async (pid) => { + events.push(`wait:${pid}`); + }, + readLocalHostRegistration: async () => + ({ pid: 77, lifecycleMode: 'ephemeral' }) as HostRegistration, + isHostAlive: (pid) => pid === 77, + killHost: (pid) => { + events.push(`kill:${pid}`); + }, + }); + + const preparation = await owner.prepareForUpdate(false); + assert.equal(preparation.kind, 'prepared'); + // The tracked host (42) drains through the wire verb first; the registered + // orphan (77) is killed BEFORE being awaited — the await is the + // confirmation, not the mechanism. + assert.deepEqual(events, ['wait:42', 'kill:77', 'wait:77']); + await owner.close(); +}); + +test('leaves the registration alone when it names the drained host', async () => { + const current = candidateHarness({ disconnectOnPrepare: true }); + const killedPids: number[] = []; + const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { + startCandidate: async () => ready(current.candidate), + waitForHostExit: async () => {}, + readLocalHostRegistration: async () => + ({ pid: 42, lifecycleMode: 'ephemeral' }) as HostRegistration, + isHostAlive: () => assert.fail('the drained pid must not be liveness-probed'), + killHost: (pid) => { + killedPids.push(pid); + }, + }); + + assert.equal((await owner.prepareForUpdate(false)).kind, 'prepared'); + assert.deepEqual(killedPids, []); + await owner.close(); +}); + +test('does not terminate service-mode, unlabeled, dead, or unregistered residue', async () => { + // Each case would be killed if its specific guard were removed: the + // service and unlabeled registrations report a LIVE pid, so only the + // explicit-ephemeral polarity protects them; the dead-ephemeral case is + // protected only by the liveness probe; the unregistered case only by the + // registration read. + const cases: { + name: string; + registration: HostRegistration | undefined; + alive: boolean; + }[] = [ + { + name: 'service registration with a live pid', + registration: { pid: 77, lifecycleMode: 'service' } as HostRegistration, + alive: true, + }, + { + // A registration written before lifecycleMode existed could belong to + // a deployment-owned service host; the sweep must stay inert for it. + name: 'unlabeled registration with a live pid', + registration: { pid: 77 } as HostRegistration, + alive: true, + }, + { + name: 'ephemeral registration with a dead pid', + registration: { pid: 77, lifecycleMode: 'ephemeral' } as HostRegistration, + alive: false, + }, + { name: 'no registration', registration: undefined, alive: true }, + ]; + for (const { name, registration, alive } of cases) { + const current = candidateHarness({ disconnectOnPrepare: true }); + const owner = await startRuntimeHostDesktopManager( + {} as DesktopRuntimeHostCandidateStartInput, + { + startCandidate: async () => ready(current.candidate), + waitForHostExit: async () => {}, + readLocalHostRegistration: async () => registration, + isHostAlive: () => alive, + killHost: () => assert.fail(`${name} must not terminate anything`), + }, + ); + assert.equal((await owner.prepareForUpdate(false)).kind, 'prepared', name); + await owner.close(); + } +}); + +test('a residue that survives termination rejects the update preparation', async () => { + const current = candidateHarness({ disconnectOnPrepare: true }); + const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { + startCandidate: async () => ready(current.candidate), + waitForHostExit: async (pid) => { + if (pid === 77) throw new Error('Runtime Host did not exit before update'); + }, + readLocalHostRegistration: async () => + ({ pid: 77, lifecycleMode: 'ephemeral' }) as HostRegistration, + isHostAlive: (pid) => pid === 77, + killHost: () => {}, + }); + + await assert.rejects( + () => owner.prepareForUpdate(false), + /did not exit before update/, + ); + await owner.close(); +}); + +test('an unreadable registration does not fail the update preparation', async () => { + const current = candidateHarness({ disconnectOnPrepare: true }); + const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { + startCandidate: async () => ready(current.candidate), + waitForHostExit: async () => {}, + readLocalHostRegistration: async () => { + throw new Error('registration torn'); + }, + isHostAlive: () => assert.fail('an unreadable registration names no pid to probe'), + killHost: () => assert.fail('an unreadable registration names no pid to terminate'), + }); + + assert.equal((await owner.prepareForUpdate(false)).kind, 'prepared'); + await owner.close(); +}); + test('keeps Local and remote Hosts active and routes work by owning Host', async () => { const local = candidateHarness({ hostId: 'host-a' }); const remote = candidateHarness({ hostId: 'host-b', lifecycleMode: 'remote' }); diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 6171c61c71..c73a459fff 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -20,6 +20,7 @@ import { buildMcpTools } from '@maka/runtime/mcp-tools'; import { LOCAL_RUNTIME_HOST_PROFILE, loadOrCreateRuntimeHostClientInstanceId, + readHostRegistration, } from "@maka/runtime-host/client"; import type { WorkspaceTarget } from "@maka/runtime-host/protocol"; import { McpClientManager } from "@maka/mcp"; @@ -27,7 +28,10 @@ import { createSettingsStore, createMcpConfigStore, } from "@maka/storage"; -import { resolveStorageRoot } from "@maka/storage/root-authority"; +import { + resolveExistingStorageRootControlDirectory, + resolveStorageRoot, +} from "@maka/storage/root-authority"; import { registerAppClientIpc, registerAppIpc } from "./app-ipc-main.js"; import { createAppQuitCoordinator } from "./app-quit-coordinator.js"; import { createAppUpdateService } from "./app-update-service.js"; @@ -578,6 +582,15 @@ runtimeHostManager = await startRuntimeHostDesktopManager( }, { upgradePrompts: createRuntimeHostUpgradePrompts(() => desktopLocale.resolve()), + // Update-drain residue sweep (#3340): the manager needs to read the local + // root's host registration to find an election winner it never adopted, + // and the storage-root capability that authorizes that read lives here. + readLocalHostRegistration: async () => { + const { controlDirectory } = await resolveExistingStorageRootControlDirectory( + startupLocalStorageRoot, + ); + return readHostRegistration(controlDirectory); + }, onTargetStateChanged: (state) => { const hostId = state.readiness === "ready" ? state.candidate.client.hostId diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts index 89e1f93cd9..df81e3fb7d 100644 --- a/apps/desktop/src/main/runtime-host-desktop-manager.ts +++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts @@ -137,6 +137,9 @@ export async function startRuntimeHostDesktopManager( registration: HostRegistration, signal: AbortSignal, ) => Promise; + readLocalHostRegistration?: () => Promise; + isHostAlive?: (pid: number) => boolean; + killHost?: (pid: number) => void; reconnectBackoff?: RuntimeHostReconnectBackoff; onTargetStateChanged?: (state: RuntimeHostDesktopTargetState) => void; onTargetRemoved?: (state: RuntimeHostDesktopTargetState) => void; @@ -151,6 +154,23 @@ export async function startRuntimeHostDesktopManager( options.upgradePrompts, options.waitForHostExit ?? waitForProcessExit, options.waitForHostRetirement ?? waitForProcessRetirement, + // Reading the registration needs the storage-root capability, which the + // boot layer owns; without the wiring the residue sweep is inert and the + // installer's own app-running handling remains the only line of defense. + options.readLocalHostRegistration ?? (async () => undefined), + options.isHostAlive ?? isProcessAlive, + // ESRCH means the residue exited between the liveness probe and the + // kill (an idle ephemeral host self-terminates on its grace timer) — + // a benign race that must not fail the update. EPERM stays fatal: + // an unkillable residue really would defeat the installer. + options.killHost ?? + ((pid) => { + try { + process.kill(pid); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error; + } + }), options.reconnectBackoff, options.onTargetStateChanged, options.onTargetRemoved, @@ -186,6 +206,9 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { registration: HostRegistration, signal: AbortSignal, ) => Promise, + private readonly readLocalHostRegistration: () => Promise, + private readonly isHostAlive: (pid: number) => boolean, + private readonly killHost: (pid: number) => void, private readonly reconnectBackoff: RuntimeHostReconnectBackoff | undefined, private readonly onTargetStateChanged: | ((state: RuntimeHostDesktopTargetState) => void) @@ -412,6 +435,7 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { return result; } await this.waitForHostExit(result.pid); + await this.#drainRegisteredHostResidue(result.pid); return { kind: 'prepared', rollback: quiescence.resume }; } catch (error) { quiescence.resume(); @@ -419,6 +443,43 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { } } + /** + * The tracked connection is not the whole update-drain story. Election + * winners record themselves in the root-scoped registration file, and a + * host this manager never adopted — a late election winner, or a survivor + * from a previous app generation — keeps running from the installation + * directory the updater is about to replace. Windows delivers no + * cross-process SIGTERM, and the NSIS upgrade quits when it cannot clear + * such a process, leaving the update silently unapplied (#3340). + * + * So on the update path, and only here, a live registered ephemeral host + * that is not the one `prepareHostUpgrade` just drained is terminated + * outright: its executable image is about to be replaced, its committed + * state is crash-safe by the platform's recovery evidence, and a failed + * upgrade is strictly worse. A residue that survives termination rejects, + * which surfaces as install_failed instead of a doomed installer handoff. + */ + async #drainRegisteredHostResidue(drainedPid: number): Promise { + let registration: HostRegistration | undefined; + try { + registration = await this.readLocalHostRegistration(); + } catch { + // An unreadable or torn registration cannot name a pid to clear; the + // installer's own app-running handling remains the last line. + return; + } + // Destructive polarity: only an explicitly ephemeral registration is + // swept. lifecycleMode predates none of the current writers, but a + // registration written before the field existed could belong to a + // deployment-owned service host — for those the sweep stays inert and + // the installer's app-running handling remains the line of defense. + if (registration?.lifecycleMode !== 'ephemeral') return; + if (registration.pid === drainedPid) return; + if (!this.isHostAlive(registration.pid)) return; + this.killHost(registration.pid); + await this.waitForHostExit(registration.pid); + } + close(): Promise { this.#closeTask ??= this.#close(); return this.#closeTask; diff --git a/packages/runtime-host/src/client/index.ts b/packages/runtime-host/src/client/index.ts index 41f6c0254b..fe6cbb4c2e 100644 --- a/packages/runtime-host/src/client/index.ts +++ b/packages/runtime-host/src/client/index.ts @@ -95,3 +95,4 @@ export { createOAuthPresentationClientProvider, type OAuthPresentationBackend, } from './oauth-presentation.js'; +export { readHostRegistration } from '../control/registration.js'; From 27adb845a288a080a49a156ad7898ee321991f3b Mon Sep 17 00:00:00 2001 From: liugddx Date: Fri, 21 Aug 2026 00:41:58 +0800 Subject: [PATCH 2/5] ci: retrigger after pre-#3327 CDP smoke flake Run 32392391944 failed at the packaged renderer smoke with the 30-second CDP deadline and no app stderr - the exact flake family #3327 fixes; this branch is based on main, which predates those harness repairs. From 7add6370564e55c9f9b780bc9f446a57edfaf943 Mon Sep 17 00:00:00 2001 From: liugddx Date: Fri, 21 Aug 2026 08:04:01 +0800 Subject: [PATCH 3/5] fix(desktop): prove host identity before terminating registered residue Codex review blocker on #3348: the sweep proved only that the old registration said ephemeral and that some process now occupies its pid; after a host crash leaves a stale registration and Windows reuses the pid, that terminates an unrelated same-user process. The sweep now requires a positive identity proof before the kill, through the mechanism the review pointed at: a boot-injected probe connects through the published control plane only (connectExistingRuntimeHost - no filesystem writes), the existing handshake validates root identity, composition and Host Epoch, and the probe additionally requires the reported epoch to equal the one in the registration the sweep read. A dead endpoint, refused handshake, moved epoch, or probe error all refuse termination - a reused pid cannot answer on the dead host's control plane, so the stale-registration case degrades to the installer's own app-running handling. The default without wiring refuses everything. Regressions: reused-pid stale registration is never killed nor awaited; a probe failure refuses termination without failing the update; the kill path pins verify-then-kill-then-await ordering; the guard-polarity cases inject a proving identity so each stays protected by its own specific guard. Co-Authored-By: Claude Fable 5 Generated-by: Claude Fable 5 --- .../runtime-host-desktop-manager.test.ts | 54 +++++++++++++++++-- apps/desktop/src/main/runtime-host-boot.ts | 30 ++++++++++- .../src/main/runtime-host-desktop-manager.ts | 22 ++++++++ 3 files changed, 102 insertions(+), 4 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 e2cda7a71d..5a7a05c974 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 @@ -157,6 +157,10 @@ test('terminates a registered host the tracked update drain did not cover', asyn }, readLocalHostRegistration: async () => ({ pid: 77, lifecycleMode: 'ephemeral' }) as HostRegistration, + verifyHostIdentity: async (registration) => { + events.push(`verify:${registration.pid}`); + return true; + }, isHostAlive: (pid) => pid === 77, killHost: (pid) => { events.push(`kill:${pid}`); @@ -166,9 +170,49 @@ test('terminates a registered host the tracked update drain did not cover', asyn const preparation = await owner.prepareForUpdate(false); assert.equal(preparation.kind, 'prepared'); // The tracked host (42) drains through the wire verb first; the registered - // orphan (77) is killed BEFORE being awaited — the await is the - // confirmation, not the mechanism. - assert.deepEqual(events, ['wait:42', 'kill:77', 'wait:77']); + // orphan (77) is identity-proved, then killed BEFORE being awaited — the + // await is the confirmation, not the mechanism. + assert.deepEqual(events, ['wait:42', 'verify:77', 'kill:77', 'wait:77']); + await owner.close(); +}); + +test('a stale registration whose pid was reused is never terminated', async () => { + // The Codex-review regression (#3348): a crashed host leaves its + // registration behind, Windows reuses the pid, and the liveness probe + // reports the unrelated process as alive. Identity cannot be proved + // against the dead control plane, so nothing may be killed. + const current = candidateHarness({ disconnectOnPrepare: true }); + const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { + startCandidate: async () => ready(current.candidate), + waitForHostExit: async (pid) => { + assert.notEqual(pid, 77, 'the unproved pid must not be awaited'); + }, + readLocalHostRegistration: async () => + ({ pid: 77, lifecycleMode: 'ephemeral' }) as HostRegistration, + verifyHostIdentity: async () => false, + isHostAlive: (pid) => pid === 77, + killHost: () => assert.fail('an unproved identity must not be terminated'), + }); + + assert.equal((await owner.prepareForUpdate(false)).kind, 'prepared'); + await owner.close(); +}); + +test('an identity probe failure refuses termination rather than failing the update', async () => { + const current = candidateHarness({ disconnectOnPrepare: true }); + const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { + startCandidate: async () => ready(current.candidate), + waitForHostExit: async () => {}, + readLocalHostRegistration: async () => + ({ pid: 77, lifecycleMode: 'ephemeral' }) as HostRegistration, + verifyHostIdentity: async () => { + throw new Error('handshake transport failed'); + }, + isHostAlive: (pid) => pid === 77, + killHost: () => assert.fail('a failed identity probe must not be terminated'), + }); + + assert.equal((await owner.prepareForUpdate(false)).kind, 'prepared'); await owner.close(); }); @@ -229,6 +273,9 @@ test('does not terminate service-mode, unlabeled, dead, or unregistered residue' startCandidate: async () => ready(current.candidate), waitForHostExit: async () => {}, readLocalHostRegistration: async () => registration, + // Identity would prove: each case must be protected by its own + // specific guard, not by the identity refusal. + verifyHostIdentity: async () => true, isHostAlive: () => alive, killHost: () => assert.fail(`${name} must not terminate anything`), }, @@ -247,6 +294,7 @@ test('a residue that survives termination rejects the update preparation', async }, readLocalHostRegistration: async () => ({ pid: 77, lifecycleMode: 'ephemeral' }) as HostRegistration, + verifyHostIdentity: async () => true, isHostAlive: (pid) => pid === 77, killHost: () => {}, }); diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index c73a459fff..fc0f641510 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -18,11 +18,16 @@ import { } from '@maka/runtime/scheduled-task-tools'; import { buildMcpTools } from '@maka/runtime/mcp-tools'; import { + connectExistingRuntimeHost, LOCAL_RUNTIME_HOST_PROFILE, loadOrCreateRuntimeHostClientInstanceId, readHostRegistration, } from "@maka/runtime-host/client"; -import type { WorkspaceTarget } from "@maka/runtime-host/protocol"; +import { + INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, + RUNTIME_HOST_PROTOCOL_VERSION, + type WorkspaceTarget, +} from "@maka/runtime-host/protocol"; import { McpClientManager } from "@maka/mcp"; import { createSettingsStore, @@ -591,6 +596,29 @@ runtimeHostManager = await startRuntimeHostDesktopManager( ); return readHostRegistration(controlDirectory); }, + // Identity proof for the residue sweep: connect through the published + // control plane only (no filesystem writes) - the handshake validates + // root identity, composition and Host Epoch - and require the epoch to + // match the registration the sweep read. A dead endpoint, a refused + // handshake, or a moved epoch all report false, and the sweep then + // leaves the process alone. + verifyHostIdentity: async (registration) => { + const result = await connectExistingRuntimeHost({ + rootPath: workspaceRoot, + protocol: { + min: RUNTIME_HOST_PROTOCOL_VERSION, + max: RUNTIME_HOST_PROTOCOL_VERSION, + }, + compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, + clientInstanceId: runtimeHostClientInstanceId, + connectTimeoutMs: 5_000, + handshakeTimeoutMs: 5_000, + }); + if (result.kind !== "connected") return false; + const proved = result.connection.hostEpoch === registration.hostEpoch; + await result.connection.close().catch(() => undefined); + return proved; + }, onTargetStateChanged: (state) => { const hostId = state.readiness === "ready" ? state.candidate.client.hostId diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts index df81e3fb7d..beaff8217d 100644 --- a/apps/desktop/src/main/runtime-host-desktop-manager.ts +++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts @@ -138,6 +138,7 @@ export async function startRuntimeHostDesktopManager( signal: AbortSignal, ) => Promise; readLocalHostRegistration?: () => Promise; + verifyHostIdentity?: (registration: HostRegistration) => Promise; isHostAlive?: (pid: number) => boolean; killHost?: (pid: number) => void; reconnectBackoff?: RuntimeHostReconnectBackoff; @@ -158,6 +159,10 @@ export async function startRuntimeHostDesktopManager( // boot layer owns; without the wiring the residue sweep is inert and the // installer's own app-running handling remains the only line of defense. options.readLocalHostRegistration ?? (async () => undefined), + // Identity is proved by the boot-injected handshake probe; without the + // wiring nothing can be proved, so the default refuses and the sweep + // never terminates anything. + options.verifyHostIdentity ?? (async () => false), options.isHostAlive ?? isProcessAlive, // ESRCH means the residue exited between the liveness probe and the // kill (an idle ephemeral host self-terminates on its grace timer) — @@ -207,6 +212,7 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { signal: AbortSignal, ) => Promise, private readonly readLocalHostRegistration: () => Promise, + private readonly verifyHostIdentity: (registration: HostRegistration) => Promise, private readonly isHostAlive: (pid: number) => boolean, private readonly killHost: (pid: number) => void, private readonly reconnectBackoff: RuntimeHostReconnectBackoff | undefined, @@ -476,6 +482,22 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { if (registration?.lifecycleMode !== 'ephemeral') return; if (registration.pid === drainedPid) return; if (!this.isHostAlive(registration.pid)) return; + // Identity before destruction: a crashed host leaves its registration + // behind, and Windows reuses pids, so a liveness probe alone cannot + // prove the pid still belongs to the registered host. The probe + // authenticates the registered control plane through the existing + // connection handshake (root identity, composition and Host Epoch) and + // must report the same Host Epoch this sweep read; anything short of a + // full match - endpoint dead, handshake refused, epoch moved, probe + // threw - leaves the process alone, with the installer's own + // app-running handling as the remaining line. + let identityProved = false; + try { + identityProved = await this.verifyHostIdentity(registration); + } catch { + identityProved = false; + } + if (!identityProved) return; this.killHost(registration.pid); await this.waitForHostExit(registration.pid); } From 3bae05d78192d157965be70edb5acbf98e2c2524 Mon Sep 17 00:00:00 2001 From: liugddx Date: Fri, 21 Aug 2026 10:34:42 +0800 Subject: [PATCH 4/5] fix(desktop): fail closed on untracked update residue --- .../runtime-host-desktop-manager.test.ts | 176 ------------------ apps/desktop/src/main/runtime-host-boot.ts | 41 +--- .../src/main/runtime-host-desktop-manager.ts | 85 +-------- 3 files changed, 3 insertions(+), 299 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 5a7a05c974..9516e18abd 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 @@ -5,7 +5,6 @@ import { INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, RUNTIME_HOST_COMPATIBILITY_EPOCH, RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION, - type HostRegistration, } from '@maka/runtime-host/protocol'; import type { DesktopRuntimeHostCandidate, @@ -147,181 +146,6 @@ for (const lifecycleMode of ['service', 'remote'] as const) { }); } -test('terminates a registered host the tracked update drain did not cover', async () => { - const current = candidateHarness({ disconnectOnPrepare: true }); - const events: string[] = []; - const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { - startCandidate: async () => ready(current.candidate), - waitForHostExit: async (pid) => { - events.push(`wait:${pid}`); - }, - readLocalHostRegistration: async () => - ({ pid: 77, lifecycleMode: 'ephemeral' }) as HostRegistration, - verifyHostIdentity: async (registration) => { - events.push(`verify:${registration.pid}`); - return true; - }, - isHostAlive: (pid) => pid === 77, - killHost: (pid) => { - events.push(`kill:${pid}`); - }, - }); - - const preparation = await owner.prepareForUpdate(false); - assert.equal(preparation.kind, 'prepared'); - // The tracked host (42) drains through the wire verb first; the registered - // orphan (77) is identity-proved, then killed BEFORE being awaited — the - // await is the confirmation, not the mechanism. - assert.deepEqual(events, ['wait:42', 'verify:77', 'kill:77', 'wait:77']); - await owner.close(); -}); - -test('a stale registration whose pid was reused is never terminated', async () => { - // The Codex-review regression (#3348): a crashed host leaves its - // registration behind, Windows reuses the pid, and the liveness probe - // reports the unrelated process as alive. Identity cannot be proved - // against the dead control plane, so nothing may be killed. - const current = candidateHarness({ disconnectOnPrepare: true }); - const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { - startCandidate: async () => ready(current.candidate), - waitForHostExit: async (pid) => { - assert.notEqual(pid, 77, 'the unproved pid must not be awaited'); - }, - readLocalHostRegistration: async () => - ({ pid: 77, lifecycleMode: 'ephemeral' }) as HostRegistration, - verifyHostIdentity: async () => false, - isHostAlive: (pid) => pid === 77, - killHost: () => assert.fail('an unproved identity must not be terminated'), - }); - - assert.equal((await owner.prepareForUpdate(false)).kind, 'prepared'); - await owner.close(); -}); - -test('an identity probe failure refuses termination rather than failing the update', async () => { - const current = candidateHarness({ disconnectOnPrepare: true }); - const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { - startCandidate: async () => ready(current.candidate), - waitForHostExit: async () => {}, - readLocalHostRegistration: async () => - ({ pid: 77, lifecycleMode: 'ephemeral' }) as HostRegistration, - verifyHostIdentity: async () => { - throw new Error('handshake transport failed'); - }, - isHostAlive: (pid) => pid === 77, - killHost: () => assert.fail('a failed identity probe must not be terminated'), - }); - - assert.equal((await owner.prepareForUpdate(false)).kind, 'prepared'); - await owner.close(); -}); - -test('leaves the registration alone when it names the drained host', async () => { - const current = candidateHarness({ disconnectOnPrepare: true }); - const killedPids: number[] = []; - const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { - startCandidate: async () => ready(current.candidate), - waitForHostExit: async () => {}, - readLocalHostRegistration: async () => - ({ pid: 42, lifecycleMode: 'ephemeral' }) as HostRegistration, - isHostAlive: () => assert.fail('the drained pid must not be liveness-probed'), - killHost: (pid) => { - killedPids.push(pid); - }, - }); - - assert.equal((await owner.prepareForUpdate(false)).kind, 'prepared'); - assert.deepEqual(killedPids, []); - await owner.close(); -}); - -test('does not terminate service-mode, unlabeled, dead, or unregistered residue', async () => { - // Each case would be killed if its specific guard were removed: the - // service and unlabeled registrations report a LIVE pid, so only the - // explicit-ephemeral polarity protects them; the dead-ephemeral case is - // protected only by the liveness probe; the unregistered case only by the - // registration read. - const cases: { - name: string; - registration: HostRegistration | undefined; - alive: boolean; - }[] = [ - { - name: 'service registration with a live pid', - registration: { pid: 77, lifecycleMode: 'service' } as HostRegistration, - alive: true, - }, - { - // A registration written before lifecycleMode existed could belong to - // a deployment-owned service host; the sweep must stay inert for it. - name: 'unlabeled registration with a live pid', - registration: { pid: 77 } as HostRegistration, - alive: true, - }, - { - name: 'ephemeral registration with a dead pid', - registration: { pid: 77, lifecycleMode: 'ephemeral' } as HostRegistration, - alive: false, - }, - { name: 'no registration', registration: undefined, alive: true }, - ]; - for (const { name, registration, alive } of cases) { - const current = candidateHarness({ disconnectOnPrepare: true }); - const owner = await startRuntimeHostDesktopManager( - {} as DesktopRuntimeHostCandidateStartInput, - { - startCandidate: async () => ready(current.candidate), - waitForHostExit: async () => {}, - readLocalHostRegistration: async () => registration, - // Identity would prove: each case must be protected by its own - // specific guard, not by the identity refusal. - verifyHostIdentity: async () => true, - isHostAlive: () => alive, - killHost: () => assert.fail(`${name} must not terminate anything`), - }, - ); - assert.equal((await owner.prepareForUpdate(false)).kind, 'prepared', name); - await owner.close(); - } -}); - -test('a residue that survives termination rejects the update preparation', async () => { - const current = candidateHarness({ disconnectOnPrepare: true }); - const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { - startCandidate: async () => ready(current.candidate), - waitForHostExit: async (pid) => { - if (pid === 77) throw new Error('Runtime Host did not exit before update'); - }, - readLocalHostRegistration: async () => - ({ pid: 77, lifecycleMode: 'ephemeral' }) as HostRegistration, - verifyHostIdentity: async () => true, - isHostAlive: (pid) => pid === 77, - killHost: () => {}, - }); - - await assert.rejects( - () => owner.prepareForUpdate(false), - /did not exit before update/, - ); - await owner.close(); -}); - -test('an unreadable registration does not fail the update preparation', async () => { - const current = candidateHarness({ disconnectOnPrepare: true }); - const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { - startCandidate: async () => ready(current.candidate), - waitForHostExit: async () => {}, - readLocalHostRegistration: async () => { - throw new Error('registration torn'); - }, - isHostAlive: () => assert.fail('an unreadable registration names no pid to probe'), - killHost: () => assert.fail('an unreadable registration names no pid to terminate'), - }); - - assert.equal((await owner.prepareForUpdate(false)).kind, 'prepared'); - await owner.close(); -}); - test('keeps Local and remote Hosts active and routes work by owning Host', async () => { const local = candidateHarness({ hostId: 'host-a' }); const remote = candidateHarness({ hostId: 'host-b', lifecycleMode: 'remote' }); diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index fc0f641510..40f0e9745b 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -18,23 +18,16 @@ import { } from '@maka/runtime/scheduled-task-tools'; import { buildMcpTools } from '@maka/runtime/mcp-tools'; import { - connectExistingRuntimeHost, LOCAL_RUNTIME_HOST_PROFILE, loadOrCreateRuntimeHostClientInstanceId, - readHostRegistration, } from "@maka/runtime-host/client"; -import { - INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, - RUNTIME_HOST_PROTOCOL_VERSION, - type WorkspaceTarget, -} from "@maka/runtime-host/protocol"; +import type { WorkspaceTarget } from "@maka/runtime-host/protocol"; import { McpClientManager } from "@maka/mcp"; import { createSettingsStore, createMcpConfigStore, } from "@maka/storage"; import { - resolveExistingStorageRootControlDirectory, resolveStorageRoot, } from "@maka/storage/root-authority"; import { registerAppClientIpc, registerAppIpc } from "./app-ipc-main.js"; @@ -587,38 +580,6 @@ runtimeHostManager = await startRuntimeHostDesktopManager( }, { upgradePrompts: createRuntimeHostUpgradePrompts(() => desktopLocale.resolve()), - // Update-drain residue sweep (#3340): the manager needs to read the local - // root's host registration to find an election winner it never adopted, - // and the storage-root capability that authorizes that read lives here. - readLocalHostRegistration: async () => { - const { controlDirectory } = await resolveExistingStorageRootControlDirectory( - startupLocalStorageRoot, - ); - return readHostRegistration(controlDirectory); - }, - // Identity proof for the residue sweep: connect through the published - // control plane only (no filesystem writes) - the handshake validates - // root identity, composition and Host Epoch - and require the epoch to - // match the registration the sweep read. A dead endpoint, a refused - // handshake, or a moved epoch all report false, and the sweep then - // leaves the process alone. - verifyHostIdentity: async (registration) => { - const result = await connectExistingRuntimeHost({ - rootPath: workspaceRoot, - protocol: { - min: RUNTIME_HOST_PROTOCOL_VERSION, - max: RUNTIME_HOST_PROTOCOL_VERSION, - }, - compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, - clientInstanceId: runtimeHostClientInstanceId, - connectTimeoutMs: 5_000, - handshakeTimeoutMs: 5_000, - }); - if (result.kind !== "connected") return false; - const proved = result.connection.hostEpoch === registration.hostEpoch; - await result.connection.close().catch(() => undefined); - return proved; - }, onTargetStateChanged: (state) => { const hostId = state.readiness === "ready" ? state.candidate.client.hostId diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts index beaff8217d..4561de9a62 100644 --- a/apps/desktop/src/main/runtime-host-desktop-manager.ts +++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts @@ -137,10 +137,6 @@ export async function startRuntimeHostDesktopManager( registration: HostRegistration, signal: AbortSignal, ) => Promise; - readLocalHostRegistration?: () => Promise; - verifyHostIdentity?: (registration: HostRegistration) => Promise; - isHostAlive?: (pid: number) => boolean; - killHost?: (pid: number) => void; reconnectBackoff?: RuntimeHostReconnectBackoff; onTargetStateChanged?: (state: RuntimeHostDesktopTargetState) => void; onTargetRemoved?: (state: RuntimeHostDesktopTargetState) => void; @@ -155,27 +151,6 @@ export async function startRuntimeHostDesktopManager( options.upgradePrompts, options.waitForHostExit ?? waitForProcessExit, options.waitForHostRetirement ?? waitForProcessRetirement, - // Reading the registration needs the storage-root capability, which the - // boot layer owns; without the wiring the residue sweep is inert and the - // installer's own app-running handling remains the only line of defense. - options.readLocalHostRegistration ?? (async () => undefined), - // Identity is proved by the boot-injected handshake probe; without the - // wiring nothing can be proved, so the default refuses and the sweep - // never terminates anything. - options.verifyHostIdentity ?? (async () => false), - options.isHostAlive ?? isProcessAlive, - // ESRCH means the residue exited between the liveness probe and the - // kill (an idle ephemeral host self-terminates on its grace timer) — - // a benign race that must not fail the update. EPERM stays fatal: - // an unkillable residue really would defeat the installer. - options.killHost ?? - ((pid) => { - try { - process.kill(pid); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error; - } - }), options.reconnectBackoff, options.onTargetStateChanged, options.onTargetRemoved, @@ -211,10 +186,6 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { registration: HostRegistration, signal: AbortSignal, ) => Promise, - private readonly readLocalHostRegistration: () => Promise, - private readonly verifyHostIdentity: (registration: HostRegistration) => Promise, - private readonly isHostAlive: (pid: number) => boolean, - private readonly killHost: (pid: number) => void, private readonly reconnectBackoff: RuntimeHostReconnectBackoff | undefined, private readonly onTargetStateChanged: | ((state: RuntimeHostDesktopTargetState) => void) @@ -441,7 +412,8 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { return result; } await this.waitForHostExit(result.pid); - await this.#drainRegisteredHostResidue(result.pid); + // Only the Host authenticated by this connection is safe to drain. + // The installer remains responsible for any untracked process residue. return { kind: 'prepared', rollback: quiescence.resume }; } catch (error) { quiescence.resume(); @@ -449,59 +421,6 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { } } - /** - * The tracked connection is not the whole update-drain story. Election - * winners record themselves in the root-scoped registration file, and a - * host this manager never adopted — a late election winner, or a survivor - * from a previous app generation — keeps running from the installation - * directory the updater is about to replace. Windows delivers no - * cross-process SIGTERM, and the NSIS upgrade quits when it cannot clear - * such a process, leaving the update silently unapplied (#3340). - * - * So on the update path, and only here, a live registered ephemeral host - * that is not the one `prepareHostUpgrade` just drained is terminated - * outright: its executable image is about to be replaced, its committed - * state is crash-safe by the platform's recovery evidence, and a failed - * upgrade is strictly worse. A residue that survives termination rejects, - * which surfaces as install_failed instead of a doomed installer handoff. - */ - async #drainRegisteredHostResidue(drainedPid: number): Promise { - let registration: HostRegistration | undefined; - try { - registration = await this.readLocalHostRegistration(); - } catch { - // An unreadable or torn registration cannot name a pid to clear; the - // installer's own app-running handling remains the last line. - return; - } - // Destructive polarity: only an explicitly ephemeral registration is - // swept. lifecycleMode predates none of the current writers, but a - // registration written before the field existed could belong to a - // deployment-owned service host — for those the sweep stays inert and - // the installer's app-running handling remains the line of defense. - if (registration?.lifecycleMode !== 'ephemeral') return; - if (registration.pid === drainedPid) return; - if (!this.isHostAlive(registration.pid)) return; - // Identity before destruction: a crashed host leaves its registration - // behind, and Windows reuses pids, so a liveness probe alone cannot - // prove the pid still belongs to the registered host. The probe - // authenticates the registered control plane through the existing - // connection handshake (root identity, composition and Host Epoch) and - // must report the same Host Epoch this sweep read; anything short of a - // full match - endpoint dead, handshake refused, epoch moved, probe - // threw - leaves the process alone, with the installer's own - // app-running handling as the remaining line. - let identityProved = false; - try { - identityProved = await this.verifyHostIdentity(registration); - } catch { - identityProved = false; - } - if (!identityProved) return; - this.killHost(registration.pid); - await this.waitForHostExit(registration.pid); - } - close(): Promise { this.#closeTask ??= this.#close(); return this.#closeTask; From 86c00333159f9bb7d2fba9c809a31fee967865e5 Mon Sep 17 00:00:00 2001 From: liugddx Date: Fri, 21 Aug 2026 14:21:28 +0800 Subject: [PATCH 5/5] refactor(desktop): remove retired residue sweep exports --- apps/desktop/src/main/runtime-host-boot.ts | 4 +--- packages/runtime-host/src/client/index.ts | 1 - 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index bc0dc45e86..5883af3d96 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -30,9 +30,7 @@ import { createSettingsStore, createMcpConfigStore, } from "@maka/storage"; -import { - resolveStorageRoot, -} from "@maka/storage/root-authority"; +import { resolveStorageRoot } from "@maka/storage/root-authority"; import { registerAppClientIpc, registerAppIpc } from "./app-ipc-main.js"; import { createAppQuitCoordinator } from "./app-quit-coordinator.js"; import { createAppUpdateService } from "./app-update-service.js"; diff --git a/packages/runtime-host/src/client/index.ts b/packages/runtime-host/src/client/index.ts index 3f596665fa..bc19e6c9b5 100644 --- a/packages/runtime-host/src/client/index.ts +++ b/packages/runtime-host/src/client/index.ts @@ -101,4 +101,3 @@ export { createOAuthPresentationClientProvider, type OAuthPresentationBackend, } from './oauth-presentation.js'; -export { readHostRegistration } from '../control/registration.js';