Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/src/main/runtime-host-desktop-candidate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -807,6 +807,7 @@ function connectInput(
: { handshakeTimeoutMs: input.handshakeTimeoutMs }),
...(input.signal === undefined ? {} : { signal: input.signal }),
...(input.onExit === undefined ? {} : { onExit: input.onExit }),
closeOnLauncherExit: true,
};
}

Expand Down
25 changes: 17 additions & 8 deletions packages/runtime-host/src/__tests__/fixtures/detached-launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <root> <expected-root-id>');
}
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 });
28 changes: 28 additions & 0 deletions packages/runtime-host/src/__tests__/host-kernel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down
21 changes: 13 additions & 8 deletions packages/runtime-host/src/candidate-launch-owner-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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';
Expand All @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions packages/runtime-host/src/client/connect-or-spawn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
Expand Down
16 changes: 11 additions & 5 deletions packages/runtime-host/src/client/launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<number | 'ignore' | 'pipe' | 'ipc'> =
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) }),
Expand Down