From 44f39f9a33290e91f99b28d7d73c3460c55d22ea Mon Sep 17 00:00:00 2001 From: me2seeks Date: Sat, 22 Aug 2026 18:18:25 +0800 Subject: [PATCH] fix(runtime-host): let operators raise the election deadline and clarify timeout copy (#3474) A workspace whose first post-upgrade start takes longer than the fixed 45s election window fails the client with 'Runtime Host stopped responding during startup', and every candidate spawned along the way keeps starting in the background. Observed on a ~788MB runtime.sqlite: the host emitted runtime_host_ready after ~75s, 30s after the client had already given up. The election deadline was already an interface parameter, but nothing let an operator widen it. MAKA_RUNTIME_HOST_ELECTION_DEADLINE_MS now overrides the default at the single resolution point, so CLI, desktop, and owned launches all honor it. Invalid values fail closed instead of being silently ignored - a typo would otherwise leave the operator believing they widened the window. The two deadline-elapsed reasons also claimed things neither could know ('stopped responding'); both now describe what was observed, name slow first starts on large workspaces as the likely cause, and point at the override. Candidates already self-terminate through their initial connection deadline once startup settles, so no lifecycle change beyond copy is needed there. Fixes #3474 Generated-by: Maka --- .../runtime-host-desktop-manager.test.ts | 4 +- .../__tests__/election-deadline-env.test.ts | 52 +++++++++++++++++++ .../src/__tests__/startup-error.test.ts | 13 ++++- .../src/client/connect-or-spawn.ts | 30 ++++++++++- .../runtime-host/src/client/startup-error.ts | 8 ++- 5 files changed, 101 insertions(+), 6 deletions(-) create mode 100644 packages/runtime-host/src/__tests__/election-deadline-env.test.ts diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts index da9762da31..baf56bc5d4 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts @@ -523,11 +523,11 @@ test('keeps Local explicitly usable without routing default work away from an un }, ); - await assert.rejects(manager.enable(remoteTarget('offline')), /stopped responding/); + await assert.rejects(manager.enable(remoteTarget('offline')), /did not become ready/); manager.setDefaultProfile('offline'); await assert.rejects( manager.handleBotIncomingMessage({ text: 'default' } as BotIncomingMessage), - /stopped responding/, + /did not become ready/, ); assert.equal(local.botMessages, 0); diff --git a/packages/runtime-host/src/__tests__/election-deadline-env.test.ts b/packages/runtime-host/src/__tests__/election-deadline-env.test.ts new file mode 100644 index 0000000000..e650d5b95a --- /dev/null +++ b/packages/runtime-host/src/__tests__/election-deadline-env.test.ts @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + ELECTION_DEADLINE_MS_ENV_VAR, + connectOrSpawnRuntimeHostWithDependencies, + electionDeadlineMsFromEnvironment, +} from '../client/connect-or-spawn.js'; +import { + INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, + RUNTIME_HOST_PROTOCOL_VERSION, +} from '../protocol/index.js'; + +test('treats an unset or blank override as unconfigured', () => { + assert.equal(electionDeadlineMsFromEnvironment(undefined), undefined); + assert.equal(electionDeadlineMsFromEnvironment(''), undefined); + assert.equal(electionDeadlineMsFromEnvironment(' '), undefined); +}); + +test('parses a valid millisecond override', () => { + assert.equal(electionDeadlineMsFromEnvironment('90000'), 90_000); + assert.equal(electionDeadlineMsFromEnvironment(' 5000 '), 5_000); +}); + +test('fails closed on an invalid override instead of silently ignoring it', () => { + for (const invalid of ['abc', '0', '-100', '120001', '1.5']) { + assert.throws(() => electionDeadlineMsFromEnvironment(invalid), RangeError); + } + assert.throws( + () => electionDeadlineMsFromEnvironment('abc'), + new RegExp(`${ELECTION_DEADLINE_MS_ENV_VAR} must be an integer`, 'u'), + ); +}); + +test('an invalid environment override fails the election before touching storage', async () => { + await assert.rejects( + connectOrSpawnRuntimeHostWithDependencies( + { + rootPath: '/nonexistent-maka-3474-root', + protocol: { min: RUNTIME_HOST_PROTOCOL_VERSION, max: RUNTIME_HOST_PROTOCOL_VERSION }, + compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, + candidateEntrypoint: 'candidate-entry.js', + }, + { + launchCandidate: () => ({ spawned: Promise.reject(new Error('must not spawn')) }), + random: Math.random, + env: { [ELECTION_DEADLINE_MS_ENV_VAR]: 'not-a-number' }, + }, + ), + (error: unknown) => + error instanceof RangeError && /MAKA_RUNTIME_HOST_ELECTION_DEADLINE_MS/u.test(error.message), + ); +}); diff --git a/packages/runtime-host/src/__tests__/startup-error.test.ts b/packages/runtime-host/src/__tests__/startup-error.test.ts index 84a7c5571a..ee88bfbb3a 100644 --- a/packages/runtime-host/src/__tests__/startup-error.test.ts +++ b/packages/runtime-host/src/__tests__/startup-error.test.ts @@ -20,7 +20,18 @@ test('presents migration blockers with a permanent previous-release recovery pat test('keeps an unresponsive Host retryable', () => { const error = runtimeHostStartupError('host_unresponsive'); assert.equal(error instanceof RuntimeHostPermanentReconnectError, false); - assert.match(error.message, /stopped responding/u); + assert.match(error.message, /did not become ready before the startup deadline/u); +}); + +test('tells both timeout reasons how to widen the election window', () => { + assert.match( + runtimeHostStartupError('host_unresponsive').message, + /MAKA_RUNTIME_HOST_ELECTION_DEADLINE_MS/u, + ); + assert.match( + runtimeHostStartupError('startup_timeout').message, + /MAKA_RUNTIME_HOST_ELECTION_DEADLINE_MS/u, + ); }); test('keeps internal startup failures retryable', () => { diff --git a/packages/runtime-host/src/client/connect-or-spawn.ts b/packages/runtime-host/src/client/connect-or-spawn.ts index 9e7bb3072a..4009765733 100644 --- a/packages/runtime-host/src/client/connect-or-spawn.ts +++ b/packages/runtime-host/src/client/connect-or-spawn.ts @@ -38,6 +38,7 @@ const DEFAULT_ELECTION_DEADLINE_MS = 45_000; const DEFAULT_BACKOFF_MIN_MS = 20; const DEFAULT_BACKOFF_MAX_MS = 250; const MIN_CANDIDATE_INTERVAL_MS = 250; +export const ELECTION_DEADLINE_MS_ENV_VAR = 'MAKA_RUNTIME_HOST_ELECTION_DEADLINE_MS'; export interface ConnectOrSpawnRuntimeHostInput { rootPath: string; @@ -56,6 +57,8 @@ export interface ConnectOrSpawnRuntimeHostInput { interface ConnectOrSpawnRuntimeHostDependencies { launchCandidate: CandidateLauncher; random(): number; + /** Defaults to `process.env`; injected so tests never mutate the real environment. */ + env?: NodeJS.ProcessEnv; } const defaultDependencies: ConnectOrSpawnRuntimeHostDependencies = { @@ -63,6 +66,26 @@ const defaultDependencies: ConnectOrSpawnRuntimeHostDependencies = { random: Math.random, }; +/** + * Resolves the operator override for the client election deadline. Large + * workspaces can legitimately take longer than the default window on their + * first start after an upgrade, so the deadline must be raisable without a + * code change. Invalid values fail closed: a silently ignored typo would leave + * the operator believing they widened the window when they did not. + */ +export function electionDeadlineMsFromEnvironment( + rawValue: string | undefined, +): number | undefined { + if (rawValue === undefined || rawValue.trim() === '') return undefined; + const parsed = Number(rawValue); + if (!Number.isSafeInteger(parsed) || parsed <= 0 || parsed > 120_000) { + throw new RangeError( + `${ELECTION_DEADLINE_MS_ENV_VAR} must be an integer between 1 and 120000 milliseconds`, + ); + } + return parsed; +} + export type ConnectOrSpawnRuntimeHostResult = | { kind: 'connected'; @@ -173,7 +196,12 @@ export async function connectOrSpawnRuntimeHostWithDependencies( input: ConnectOrSpawnRuntimeHostInput, dependencies: ConnectOrSpawnRuntimeHostDependencies, ): Promise { - const deadlineMs = input.electionDeadlineMs ?? DEFAULT_ELECTION_DEADLINE_MS; + const deadlineMs = + input.electionDeadlineMs ?? + electionDeadlineMsFromEnvironment( + (dependencies.env ?? process.env)[ELECTION_DEADLINE_MS_ENV_VAR], + ) ?? + DEFAULT_ELECTION_DEADLINE_MS; if (!Number.isSafeInteger(deadlineMs) || deadlineMs <= 0 || deadlineMs > 120_000) { throw new RangeError('electionDeadlineMs must be an integer between 1 and 120000'); } diff --git a/packages/runtime-host/src/client/startup-error.ts b/packages/runtime-host/src/client/startup-error.ts index 2d4210afb2..63f9957d09 100644 --- a/packages/runtime-host/src/client/startup-error.ts +++ b/packages/runtime-host/src/client/startup-error.ts @@ -47,8 +47,12 @@ export function runtimeHostStartupError(reason: RuntimeHostStartupFailureReason) 'This workspace belongs to a different Runtime Host composition. Diagnostic code: COMPOSITION_MISMATCH.', ); case 'startup_timeout': - return new Error('Runtime Host did not become ready before the startup deadline'); + return new Error( + 'No Runtime Host became ready before the startup deadline elapsed. Retry; if this workspace needs longer to open (large workspaces can after an upgrade), set MAKA_RUNTIME_HOST_ELECTION_DEADLINE_MS to allow more time.', + ); case 'host_unresponsive': - return new Error('Runtime Host stopped responding during startup'); + return new Error( + 'A Runtime Host was found but did not become ready before the startup deadline elapsed. It may still be opening this workspace (large workspaces can need longer right after an upgrade); retrying once it settles usually succeeds, or set MAKA_RUNTIME_HOST_ELECTION_DEADLINE_MS to allow more time.', + ); } }