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..dd4eee9035 100644 --- a/packages/runtime-host/src/__tests__/fixtures/detached-launcher.ts +++ b/packages/runtime-host/src/__tests__/fixtures/detached-launcher.ts @@ -17,24 +17,37 @@ * under the License. */ -import { launchDetachedRuntimeHostCandidate } from '../../client/launcher.js'; +import { + launchDetachedRuntimeHostCandidate, + launchOwnedRuntimeHostCandidate, + 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 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/__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..a8bdd15ce3 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); @@ -101,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); @@ -163,19 +167,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) }),