From 07018fd3336200f71f97a50410ceb723c6341655 Mon Sep 17 00:00:00 2001 From: Bryan Nathan Date: Sat, 29 Aug 2026 07:06:44 +0800 Subject: [PATCH 1/2] fix(desktop): stop Runtime Host after launcher loss Bind Desktop-spawned ephemeral Runtime Hosts to the launcher's IPC lifecycle so an abrupt Electron exit cannot leave an orphan blocking the next launch. Preserve the reusable lifetime of generic detached launches. Generated-by: OpenAI Codex --- .../main/runtime-host-desktop-candidate.ts | 1 + .../__tests__/fixtures/detached-launcher.ts | 25 +++++++++++------ .../src/__tests__/host-kernel.test.ts | 28 +++++++++++++++++++ .../src/candidate-launch-owner-guard.ts | 21 ++++++++------ .../src/client/connect-or-spawn.ts | 5 ++++ packages/runtime-host/src/client/launcher.ts | 16 +++++++---- 6 files changed, 75 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts index 3ecce8214c..ba2d0dc7ff 100644 --- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts +++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts @@ -807,6 +807,7 @@ function connectInput( : { handshakeTimeoutMs: input.handshakeTimeoutMs }), ...(input.signal === undefined ? {} : { signal: input.signal }), ...(input.onExit === undefined ? {} : { onExit: input.onExit }), + closeOnLauncherExit: true, }; } diff --git a/packages/runtime-host/src/__tests__/fixtures/detached-launcher.ts b/packages/runtime-host/src/__tests__/fixtures/detached-launcher.ts index d22842ad00..cbd7d493ed 100644 --- a/packages/runtime-host/src/__tests__/fixtures/detached-launcher.ts +++ b/packages/runtime-host/src/__tests__/fixtures/detached-launcher.ts @@ -17,24 +17,33 @@ * under the License. */ -import { launchDetachedRuntimeHostCandidate } from '../../client/launcher.js'; +import { + launchDetachedRuntimeHostCandidate, + type DetachedCandidateInput, +} from '../../client/launcher.js'; -const [rootPath, expectedRootId, stderrMarkerPath] = process.argv.slice(2); +const [rootPath, expectedRootId, mode] = process.argv.slice(2); if (!rootPath || !expectedRootId) { throw new Error('usage: detached-launcher '); } -const candidateEntrypoint = new URL( - stderrMarkerPath ? './stderr-after-launcher-exit.js' : './kernel-candidate.js', - import.meta.url, -); +const closeOnLauncherExit = mode === 'close-on-launcher-exit'; +const stderrMarkerPath = closeOnLauncherExit ? undefined : mode; +const candidateEntrypoint = closeOnLauncherExit + ? new URL('../../execution-candidate-main.js', import.meta.url) + : new URL( + stderrMarkerPath ? './stderr-after-launcher-exit.js' : './kernel-candidate.js', + import.meta.url, + ); -const attempt = await launchDetachedRuntimeHostCandidate({ +const launchInput = { rootPath, expectedRootId, entrypoint: candidateEntrypoint, idleGraceMs: 10_000, + ...(closeOnLauncherExit ? { closeOnLauncherExit: true } : {}), ...(stderrMarkerPath ? { env: { MAKA_TEST_STDERR_AFTER_PARENT_EXIT_MARKER: stderrMarkerPath } } : {}), -}).spawned; +} satisfies DetachedCandidateInput; +const attempt = await launchDetachedRuntimeHostCandidate(launchInput).spawned; process.send?.({ type: 'launched', pid: attempt.pid }); diff --git a/packages/runtime-host/src/__tests__/host-kernel.test.ts b/packages/runtime-host/src/__tests__/host-kernel.test.ts index cfd6ecd13e..3cb9781e0a 100644 --- a/packages/runtime-host/src/__tests__/host-kernel.test.ts +++ b/packages/runtime-host/src/__tests__/host-kernel.test.ts @@ -1814,6 +1814,34 @@ describe('non-serving Runtime Host kernel', () => { }); }); + test('a launcher-owned detached Host exits when its launcher is killed', async () => { + await withHostPaths(async (paths) => { + const capability = await resolveStorageRoot({ path: paths.root, kind: 'interactive' }); + const launcher = paths.resources.trackChild( + fork( + new URL('./fixtures/detached-launcher.js', import.meta.url), + [paths.root, capability.rootId, 'close-on-launcher-exit'], + { stdio: ['ignore', 'ignore', 'inherit', 'ipc'] }, + ), + ); + const launchedPid = paths.resources.trackPid(await waitForLaunch(launcher)); + const connected = await retryConnect(paths, CURRENT_PROTOCOL); + assert.equal(connected.kind, 'connected'); + if (connected.kind !== 'connected') return; + assert.equal(connected.registration.pid, launchedPid); + + launcher.kill('SIGKILL'); + await waitForExit(launcher); + await withTimeout( + connected.connection.closed, + 5_000, + 'launcher-owned detached Host survived its launcher', + ); + await waitForProcessExit(launchedPid); + paths.resources.forgetPid(launchedPid); + }); + }); + test('an authority-supervised Candidate exits if its launch owner is killed', async () => { await withHostPaths(async (paths) => { const capability = await resolveStorageRoot({ path: paths.root, kind: 'interactive' }); diff --git a/packages/runtime-host/src/candidate-launch-owner-guard.ts b/packages/runtime-host/src/candidate-launch-owner-guard.ts index 839f78e166..45dbf4f3fd 100644 --- a/packages/runtime-host/src/candidate-launch-owner-guard.ts +++ b/packages/runtime-host/src/candidate-launch-owner-guard.ts @@ -20,6 +20,7 @@ import { closeSync } from 'node:fs'; export const RUNTIME_HOST_LAUNCH_OWNER_LEASE_FD_ENV = 'MAKA_RUNTIME_HOST_LAUNCH_OWNER_LEASE_FD'; +export const RUNTIME_HOST_LAUNCH_OWNER_GUARD_ENV = 'MAKA_RUNTIME_HOST_LAUNCH_OWNER_GUARD'; export const RUNTIME_HOST_LAUNCH_OWNER_RELEASE_KIND = 'runtime-host-launch-owner-release'; export interface RuntimeHostLaunchOwnerGuard { @@ -28,18 +29,22 @@ export interface RuntimeHostLaunchOwnerGuard { } /** - * Keeps the updater's authority lease inside a Candidate until its launcher - * explicitly releases it. Launcher loss closes the Host before the lease, so - * no second owner can enter while the uncommitted target remains a writer. + * Closes a launcher-owned Host if its launcher disappears. An optional updater + * authority lease stays inside the Candidate until the launcher explicitly + * releases it, so launcher loss closes the Host before releasing that lease. */ export function createRuntimeHostLaunchOwnerGuard( env: NodeJS.ProcessEnv = process.env, ): RuntimeHostLaunchOwnerGuard | undefined { const rawFd = env[RUNTIME_HOST_LAUNCH_OWNER_LEASE_FD_ENV]; - if (rawFd === undefined) return undefined; - const leaseFd = Number(rawFd); - if (!Number.isSafeInteger(leaseFd) || leaseFd < 3) { - throw new Error('Runtime Host launch-owner authority descriptor is invalid'); + const guardRequested = env[RUNTIME_HOST_LAUNCH_OWNER_GUARD_ENV] === '1'; + if (rawFd === undefined && !guardRequested) return undefined; + let leaseFd: number | undefined; + if (rawFd !== undefined) { + leaseFd = Number(rawFd); + if (!Number.isSafeInteger(leaseFd) || leaseFd < 3) { + throw new Error('Runtime Host launch-owner authority descriptor is invalid'); + } } let state: 'owned' | 'released' | 'lost' = process.connected ? 'owned' : 'lost'; @@ -50,7 +55,7 @@ export function createRuntimeHostLaunchOwnerGuard( const closeLease = () => { if (leaseClosed) return; leaseClosed = true; - closeSync(leaseFd); + if (leaseFd !== undefined) closeSync(leaseFd); }; const settleLoss = () => { if (state !== 'lost' || !closeHost || lossSettlement) return; diff --git a/packages/runtime-host/src/client/connect-or-spawn.ts b/packages/runtime-host/src/client/connect-or-spawn.ts index 33c6a76f05..7a992708d2 100644 --- a/packages/runtime-host/src/client/connect-or-spawn.ts +++ b/packages/runtime-host/src/client/connect-or-spawn.ts @@ -89,6 +89,8 @@ export interface ConnectOrSpawnRuntimeHostInput { signal?: AbortSignal; /** Existing authority lease inherited by a launch-owner-supervised Candidate. */ inheritableAuthorityLeaseFd?: number; + /** Close a newly spawned ephemeral Candidate if this launcher exits. */ + closeOnLauncherExit?: boolean; /** Candidate-exit sink forwarded to the launcher; the embedder owns the sink. */ onExit?: (details: CandidateExitDetails) => void; } @@ -466,6 +468,9 @@ export async function connectOrSpawnRuntimeHostWithDependencies( ...(input.inheritableAuthorityLeaseFd === undefined ? {} : { inheritableAuthorityLeaseFd: input.inheritableAuthorityLeaseFd }), + ...(input.closeOnLauncherExit === undefined + ? {} + : { closeOnLauncherExit: input.closeOnLauncherExit }), }); candidateLaunches.add(launch); const attempt = await settleBeforeDeadline(launch.spawned, deadline, input.signal); diff --git a/packages/runtime-host/src/client/launcher.ts b/packages/runtime-host/src/client/launcher.ts index a957ac69e0..e654c395f0 100644 --- a/packages/runtime-host/src/client/launcher.ts +++ b/packages/runtime-host/src/client/launcher.ts @@ -26,6 +26,7 @@ import { type CandidateStartupFailureReport, } from '../candidate-startup-failure.js'; import { + RUNTIME_HOST_LAUNCH_OWNER_GUARD_ENV, RUNTIME_HOST_LAUNCH_OWNER_LEASE_FD_ENV, runtimeHostLaunchOwnerReleaseMessage, } from '../candidate-launch-owner-guard.js'; @@ -53,6 +54,8 @@ export interface DetachedCandidateInput { env?: NodeJS.ProcessEnv; /** Existing authority lease inherited only by a launch-owner-supervised Candidate. */ inheritableAuthorityLeaseFd?: number; + /** Keep this Candidate bound to the launcher process for its whole lifetime. */ + closeOnLauncherExit?: boolean; /** Called with the candidate's exit details; the embedder owns the sink. */ readonly onExit?: (details: CandidateExitDetails) => void; } @@ -86,7 +89,7 @@ export function launchDetachedRuntimeHostCandidate( input: DetachedCandidateInput, ): DetachedCandidateLaunch { const startupAttemptId = randomUUID(); - const child = spawnCandidate(input, true, startupAttemptId, false); + const child = spawnCandidate(input, true, startupAttemptId, input.closeOnLauncherExit === true); const exited = observeCandidateExit(child); notifyCandidateExit(child, exited, input.onExit); const startupFailure = readStartupFailure(exited, startupAttemptId); @@ -163,19 +166,22 @@ function spawnCandidate( // spawn() commits the side effect synchronously; spawned only reports that commit's outcome. const inheritedLeaseFd = input.inheritableAuthorityLeaseFd; - const childLeaseFd = guarded ? 4 : undefined; + const childLeaseFd = inheritedLeaseFd === undefined ? undefined : 4; + const guardedStdio: Array = + childLeaseFd === undefined + ? ['ignore', 'ignore', 'pipe', 'ipc'] + : ['ignore', 'ignore', 'pipe', 'ipc', inheritedLeaseFd!]; const child = spawn(executable, args, { cwd: dirname(isAbsolute(executable) ? executable : process.execPath), detached, - stdio: guarded - ? ['ignore', 'ignore', 'pipe', 'ipc', inheritedLeaseFd!] - : ['ignore', 'ignore', 'pipe'], + stdio: guarded ? guardedStdio : ['ignore', 'ignore', 'pipe'], windowsHide: true, env: { ...process.env, ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: '1' } : {}), ...input.env, [RUNTIME_HOST_STDERR_PIPE_ENV]: '1', + ...(guarded ? { [RUNTIME_HOST_LAUNCH_OWNER_GUARD_ENV]: '1' } : {}), ...(childLeaseFd === undefined ? {} : { [RUNTIME_HOST_LAUNCH_OWNER_LEASE_FD_ENV]: String(childLeaseFd) }), From c945d9929eda9548484217f3f5d06ffac830054f Mon Sep 17 00:00:00 2001 From: Bryan Nathan Date: Sun, 30 Aug 2026 06:52:05 +0800 Subject: [PATCH 2/2] fix(runtime-host): guard owned desktop candidates Generated-by: GitHub Copilot --- .../src/__tests__/fixtures/detached-launcher.ts | 6 +++++- packages/runtime-host/src/client/launcher.ts | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/runtime-host/src/__tests__/fixtures/detached-launcher.ts b/packages/runtime-host/src/__tests__/fixtures/detached-launcher.ts index cbd7d493ed..dd4eee9035 100644 --- a/packages/runtime-host/src/__tests__/fixtures/detached-launcher.ts +++ b/packages/runtime-host/src/__tests__/fixtures/detached-launcher.ts @@ -19,6 +19,7 @@ import { launchDetachedRuntimeHostCandidate, + launchOwnedRuntimeHostCandidate, type DetachedCandidateInput, } from '../../client/launcher.js'; @@ -45,5 +46,8 @@ const launchInput = { ? { env: { MAKA_TEST_STDERR_AFTER_PARENT_EXIT_MARKER: stderrMarkerPath } } : {}), } satisfies DetachedCandidateInput; -const attempt = await launchDetachedRuntimeHostCandidate(launchInput).spawned; +const launch = closeOnLauncherExit + ? launchOwnedRuntimeHostCandidate(launchInput) + : launchDetachedRuntimeHostCandidate(launchInput); +const attempt = await launch.spawned; process.send?.({ type: 'launched', pid: attempt.pid }); diff --git a/packages/runtime-host/src/client/launcher.ts b/packages/runtime-host/src/client/launcher.ts index e654c395f0..a8bdd15ce3 100644 --- a/packages/runtime-host/src/client/launcher.ts +++ b/packages/runtime-host/src/client/launcher.ts @@ -104,7 +104,8 @@ export function launchOwnedRuntimeHostCandidate(input: DetachedCandidateInput): readonly spawned: Promise; } { const startupAttemptId = randomUUID(); - const guarded = input.inheritableAuthorityLeaseFd !== undefined; + const guarded = + input.inheritableAuthorityLeaseFd !== undefined || input.closeOnLauncherExit === true; const child = spawnCandidate(input, false, startupAttemptId, guarded); const exited = observeCandidateExit(child); notifyCandidateExit(child, exited, input.onExit);