You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/08/20 13:15:58 UTC
(maka) branch main updated: fix(runtime-host): give owned-candidate connect a CI-safe election window (#3221)
This is an automated email from the ASF dual-hosted git repository.
Astro-Han 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 803edc1ba fix(runtime-host): give owned-candidate connect a CI-safe election window (#3221)
803edc1ba is described below
commit 803edc1baac7e3bc9b7e8dc2f97a511a7f36a071
Author: Victor <79...@users.noreply.github.com>
AuthorDate: Thu Aug 20 21:15:53 2026 +0800
fix(runtime-host): give owned-candidate connect a CI-safe election window (#3221)
* fix(runtime-host): give owned-candidate connect a CI-safe election window
The promptness claim is Host exit after the first connection closes,
not the connect itself. A two-second election misses spawn plus
handshake under a full suite and only reports `failed`. Widen the
window, keep the concrete reason on the assertion, and map a
deadline throw to startup_timeout.
Fixes #3190
* fix(runtime-host): preserve owned election failures
* fix(runtime-host): return missed owned elections without waiting
Election already bounds candidate startup. Awaiting launch.spawned
after a non-connected result hangs connectOwned when the candidate
never reports. Return that result immediately and settle a late
host in the background.
Fixes #3190
* fix(runtime-host): release late owned candidates instead of killing them
A timed-out owned connect no longer settles a late candidate. That
path sent SIGKILL after 1s and could terminate a host another client
had already adopted. Release the process and let idle-grace /
initial-connection policy exit it when unused.
Fixes #3190
* test(runtime-host): assert late owned cleanup releases instead of settling
The adopt regression only checked that the late pid stayed alive. Wrap
the delivered candidate so a settle() cleanup cannot pass that check
and kill the adopted host after the assertion.
Fixes #3190
---
.../src/__tests__/owned-candidate.test.ts | 208 ++++++++++++++++++++-
.../runtime-host/src/client/connect-or-spawn.ts | 30 ++-
2 files changed, 227 insertions(+), 11 deletions(-)
diff --git a/packages/runtime-host/src/__tests__/owned-candidate.test.ts b/packages/runtime-host/src/__tests__/owned-candidate.test.ts
index f9486e1b3..ffad42464 100644
--- a/packages/runtime-host/src/__tests__/owned-candidate.test.ts
+++ b/packages/runtime-host/src/__tests__/owned-candidate.test.ts
@@ -7,9 +7,12 @@ import {
INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
RUNTIME_HOST_PROTOCOL_VERSION,
} from '../protocol/index.js';
-import { connectOwnedRuntimeHostWithDependencies } from '../client/connect-or-spawn.js';
+import {
+ connectOrSpawnRuntimeHostWithDependencies,
+ connectOwnedRuntimeHostWithDependencies,
+} from '../client/connect-or-spawn.js';
import { runHostedExecution } from '../client/hosted-execution.js';
-import { launchOwnedRuntimeHostCandidate } from '../client/launcher.js';
+import { launchOwnedRuntimeHostCandidate, type OwnedCandidateAttempt } from '../client/launcher.js';
test('owned connection keeps a fresh Host alive for its full election window', async () => {
const rootPath = await mkdtemp(join(tmpdir(), 'maka-owned-first-connection-'));
@@ -46,6 +49,173 @@ test('owned connection keeps a fresh Host alive for its full election window', a
}
});
+test('owned connect returns a missed election without waiting for a late candidate', {
+ timeout: 15_000,
+}, async () => {
+ const rootPath = await mkdtemp(join(tmpdir(), 'maka-owned-startup-timeout-'));
+ let launched = 0;
+ let released = 0;
+ let settled = 0;
+ let resolveSpawned!: (host: OwnedCandidateAttempt) => void;
+ let rejectSpawned!: (error: Error) => void;
+ let spawnedSettled = false;
+ const spawned = new Promise<OwnedCandidateAttempt>((resolve, reject) => {
+ resolveSpawned = resolve;
+ rejectSpawned = reject;
+ });
+ void spawned.then(
+ () => {
+ spawnedSettled = true;
+ },
+ () => {
+ spawnedSettled = true;
+ },
+ );
+
+ try {
+ const result = await connectOwnedRuntimeHostWithDependencies(
+ {
+ rootPath,
+ surface: 'run',
+ protocol: {
+ min: RUNTIME_HOST_PROTOCOL_VERSION,
+ max: RUNTIME_HOST_PROTOCOL_VERSION,
+ },
+ compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
+ electionDeadlineMs: 250,
+ },
+ {
+ launchCandidate: () => {
+ launched += 1;
+ return { spawned };
+ },
+ },
+ );
+
+ assert.ok(launched >= 1, 'launchCandidate must run before the election ends');
+ assert.equal(result.kind, 'failed');
+ assert.equal(connectFailure(result), 'failed:startup_timeout');
+ assert.equal(spawnedSettled, false);
+
+ resolveSpawned({
+ pid: 4_242,
+ releaseToEnvironment() {
+ released += 1;
+ },
+ async settle() {
+ settled += 1;
+ return true;
+ },
+ });
+ await spawned;
+ assert.equal(released, 1);
+ assert.equal(settled, 0);
+ } finally {
+ if (!spawnedSettled) rejectSpawned(new Error('abandoned after election'));
+ }
+});
+
+test('a late owned candidate stays alive after another client adopts it', {
+ timeout: 25_000,
+}, async () => {
+ const rootPath = await mkdtemp(join(tmpdir(), 'maka-owned-late-adopt-'));
+ let launchedHost: OwnedCandidateAttempt | undefined;
+ let resolveLate!: (host: OwnedCandidateAttempt) => void;
+ let rejectLate!: (error: Error) => void;
+ let lateDelivered = false;
+ const lateSpawned = new Promise<OwnedCandidateAttempt>((resolve, reject) => {
+ resolveLate = resolve;
+ rejectLate = reject;
+ });
+
+ try {
+ const missed = await connectOwnedRuntimeHostWithDependencies(
+ {
+ rootPath,
+ surface: 'run',
+ protocol: {
+ min: RUNTIME_HOST_PROTOCOL_VERSION,
+ max: RUNTIME_HOST_PROTOCOL_VERSION,
+ },
+ compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
+ electionDeadlineMs: 500,
+ },
+ {
+ launchCandidate(input) {
+ const launch = launchOwnedRuntimeHostCandidate({
+ ...input,
+ initialConnectionTimeoutMs: 10_000,
+ });
+ void launch.spawned.then((host) => {
+ launchedHost = host;
+ });
+ return { spawned: lateSpawned };
+ },
+ },
+ );
+
+ assert.equal(missed.kind, 'failed');
+ assert.equal(connectFailure(missed), 'failed:startup_timeout');
+
+ const host = await waitForDefined(
+ () => launchedHost,
+ 8_000,
+ 'late owned candidate did not spawn',
+ );
+ const adopted = await connectOrSpawnRuntimeHostWithDependencies(
+ {
+ rootPath,
+ surface: 'run',
+ protocol: {
+ min: RUNTIME_HOST_PROTOCOL_VERSION,
+ max: RUNTIME_HOST_PROTOCOL_VERSION,
+ },
+ compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
+ candidateEntrypoint: new URL('../execution-candidate-main.js', import.meta.url),
+ electionDeadlineMs: 8_000,
+ },
+ {
+ random: Math.random,
+ launchCandidate() {
+ throw new Error('adopter must use the late owned candidate');
+ },
+ },
+ );
+
+ try {
+ assert.equal(adopted.kind, 'connected', connectFailure(adopted));
+ if (adopted.kind !== 'connected') return;
+
+ let released = 0;
+ let settled = 0;
+ lateDelivered = true;
+ resolveLate({
+ ...host,
+ releaseToEnvironment() {
+ released += 1;
+ host.releaseToEnvironment();
+ },
+ async settle(timeoutMs) {
+ settled += 1;
+ return host.settle(timeoutMs);
+ },
+ });
+ await lateSpawned;
+ assert.equal(released, 1);
+ assert.equal(settled, 0);
+
+ const diagnostics = await adopted.connection.queryHostDiagnostics();
+ assert.equal(diagnostics.pid, host.pid);
+ assert.doesNotThrow(() => process.kill(host.pid, 0));
+ } finally {
+ if (adopted.kind === 'connected') await adopted.connection.close();
+ }
+ } finally {
+ if (!lateDelivered) rejectLate(new Error('abandoned after election'));
+ if (launchedHost) await launchedHost.settle(5_000);
+ }
+});
+
test('owned Host exits promptly after its first connection closes', async () => {
const rootPath = await mkdtemp(join(tmpdir(), 'maka-owned-first-disconnect-'));
const result = await connectOwnedRuntimeHostWithDependencies(
@@ -56,12 +226,15 @@ test('owned Host exits promptly after its first connection closes', async () =>
max: RUNTIME_HOST_PROTOCOL_VERSION,
},
compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
- electionDeadlineMs: 2_000,
+ // Connection is not the promptness claim. Two seconds is enough on an
+ // idle machine and too little under a full CI suite, where spawn plus
+ // handshake routinely miss the window and surface as `failed`.
+ electionDeadlineMs: 8_000,
},
{ launchCandidate: launchOwnedRuntimeHostCandidate },
);
- assert.equal(result.kind, 'connected');
+ assert.equal(result.kind, 'connected', connectFailure(result));
if (result.kind !== 'connected') return;
await result.connection.close();
assert.equal(await result.host.settle(500), true);
@@ -136,3 +309,30 @@ test('pre-cancelled hosted execution does not start a Runtime Host', async () =>
assert.equal(result.kind, 'indeterminate');
assert.deepEqual(await readdir(rootPath), []);
});
+
+function connectFailure(
+ result:
+ | Awaited<ReturnType<typeof connectOwnedRuntimeHostWithDependencies>>
+ | Awaited<ReturnType<typeof connectOrSpawnRuntimeHostWithDependencies>>,
+): string {
+ if (result.kind === 'connected') return 'connected';
+ if (result.kind === 'failed') return `failed:${result.reason}`;
+ if (result.kind === 'upgrade_required') return 'upgrade_required';
+ return `incompatible:${result.handshake.replacement}`;
+}
+
+async function waitForDefined<T>(
+ read: () => T | undefined,
+ timeoutMs: number,
+ message: string,
+): Promise<T> {
+ const deadline = performance.now() + timeoutMs;
+ for (;;) {
+ const value = read();
+ if (value !== undefined) return value;
+ if (performance.now() >= deadline) throw new Error(message);
+ await new Promise<void>((resolve) => {
+ setTimeout(resolve, 20);
+ });
+ }
+}
diff --git a/packages/runtime-host/src/client/connect-or-spawn.ts b/packages/runtime-host/src/client/connect-or-spawn.ts
index 7fb939ea7..9e7bb3072 100644
--- a/packages/runtime-host/src/client/connect-or-spawn.ts
+++ b/packages/runtime-host/src/client/connect-or-spawn.ts
@@ -129,11 +129,17 @@ export async function connectOwnedRuntimeHostWithDependencies(
random: Math.random,
},
);
- const host = await launch?.spawned;
- if (result.kind !== 'connected' || !host) {
- if (result.kind === 'connected') await result.connection.close();
- await host?.settle(1_000);
- return result.kind === 'connected' ? { kind: 'failed', reason: 'existing_host' } : result;
+ if (result.kind !== 'connected') {
+ releaseOwnedLaunch(launch);
+ return result;
+ }
+ const host = await launch?.spawned.catch(() => undefined);
+ if (!host) {
+ await result.connection.close();
+ return {
+ kind: 'failed',
+ reason: launch ? 'host_unresponsive' : 'existing_host',
+ };
}
const ownedConnection = result.connection;
connection = ownedConnection;
@@ -147,12 +153,22 @@ export async function connectOwnedRuntimeHostWithDependencies(
return { kind: 'connected', connection: ownedConnection, host };
} catch {
await connection?.close().catch(() => undefined);
- const host = await launch?.spawned.catch(() => undefined);
- await host?.settle(1_000);
+ releaseOwnedLaunch(launch);
return { kind: 'failed', reason: 'host_unresponsive' };
}
}
+function releaseOwnedLaunch(
+ launch: ReturnType<typeof launchOwnedRuntimeHostCandidate> | undefined,
+): void {
+ if (!launch) return;
+ void launch.spawned
+ .then((host) => {
+ host.releaseToEnvironment();
+ })
+ .catch(() => undefined);
+}
+
export async function connectOrSpawnRuntimeHostWithDependencies(
input: ConnectOrSpawnRuntimeHostInput,
dependencies: ConnectOrSpawnRuntimeHostDependencies,
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org