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