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.', + ); } }