fix(mcp): cap watchdog and startup-handshake timeouts at the largest timer delay (#1966)

Node runs a setTimeout delay above 2^31-1 ms after 1 ms instead, so a huge
CODEGRAPH_WATCHDOG_TIMEOUT_MS SIGKILLed a healthy server at once and a huge
CODEGRAPH_STARTUP_HANDSHAKE_TIMEOUT_MS abandoned the launch within
milliseconds. Both parsers now cap at 2147483647.

(cherry picked from commit a242f579ce)
This commit is contained in:
Eric Minish
2026-09-27 08:57:42 -05:00
committed by Colby McHenry
parent 71985f2620
commit 080ceed082
4 changed files with 57 additions and 4 deletions
+16
View File
@@ -18,6 +18,13 @@ describe('config parsing', () => {
expect(parseWatchdogTimeoutMs('1500')).toBe(1500);
});
it('parseWatchdogTimeoutMs caps at the largest delay a timer can hold (#1966)', () => {
// Node runs a setTimeout delay above 2^31-1 ms after 1 ms instead.
expect(parseWatchdogTimeoutMs('2147483647')).toBe(2147483647);
expect(parseWatchdogTimeoutMs('2147483648')).toBe(2147483647);
expect(parseWatchdogTimeoutMs('3000000000')).toBe(2147483647);
});
it('deriveCheckIntervalMs stays within [50, 2000] and scales with the timeout', () => {
expect(deriveCheckIntervalMs(60_000)).toBe(2000); // clamped high
expect(deriveCheckIntervalMs(500)).toBe(100); // 500/5
@@ -90,6 +97,15 @@ describe('liveness watchdog (spawned, real watchdog process)', () => {
expect(r.signal === 'SIGKILL' || (r.signal === null && r.code !== 0 && r.code !== null)).toBe(true);
}
it('leaves a healthy process alone when the timeout is set past what a timer can hold (#1966)', async () => {
const r = await runChild(
{ CODEGRAPH_WATCHDOG_TIMEOUT_MS: '3000000000' },
'setTimeout(() => process.exit(0), 1000);',
8000
);
expect(r).toEqual({ code: 0, signal: null });
}, 12000);
it('SIGKILLs a process whose main thread wedges in a sync loop', async () => {
const r = await runChild(
{ CODEGRAPH_WATCHDOG_TIMEOUT_MS: '500' },
+18
View File
@@ -38,6 +38,24 @@ describe('parseStartupHandshakeTimeoutMs', () => {
it('floors fractional values', () => {
expect(parseStartupHandshakeTimeoutMs('2500.7')).toBe(2500);
});
it('caps at the largest delay a timer can hold (#1966)', () => {
expect(parseStartupHandshakeTimeoutMs('2147483647')).toBe(2147483647);
expect(parseStartupHandshakeTimeoutMs('2147483648')).toBe(2147483647);
expect(parseStartupHandshakeTimeoutMs('3000000000')).toBe(2147483647);
});
it('does not abandon a launch at once when the timeout is set past what a timer can hold (#1966)', async () => {
let abandoned = false;
const disarm = armStartupHandshakeTimeout(
() => { abandoned = true; },
new PassThrough(),
parseStartupHandshakeTimeoutMs('3000000000'),
);
await sleep(100);
disarm();
expect(abandoned).toBe(false);
});
});
describe('armStartupHandshakeTimeout', () => {
+12 -2
View File
@@ -71,14 +71,24 @@ function isEnvTruthy(raw: string | undefined): boolean {
return ['1', 'true', 'yes', 'on'].includes(raw.trim().toLowerCase());
}
/** Parse the timeout env, falling back to the default for missing/invalid values. */
/**
* The longest delay a Node timer holds: anything above 2^31-1 ms is run after
* 1 ms instead (with a TimeoutOverflowWarning), so a huge value meant as
* "effectively never" would fire at once (#1966).
*/
const MAX_TIMER_DELAY_MS = 2_147_483_647;
/**
* Parse the timeout env, falling back to the default for missing/invalid values
* and capping at {@link MAX_TIMER_DELAY_MS}.
*/
export function parseWatchdogTimeoutMs(
raw: string | undefined,
fallback: number = DEFAULT_WATCHDOG_TIMEOUT_MS
): number {
if (raw === undefined) return fallback;
const n = Number(raw);
return Number.isFinite(n) && n > 0 ? n : fallback;
return Number.isFinite(n) && n > 0 ? Math.min(n, MAX_TIMER_DELAY_MS) : fallback;
}
/** Derive a heartbeat cadence that emits several beats inside the timeout window. */
+11 -2
View File
@@ -28,6 +28,13 @@
* Tune with `CODEGRAPH_STARTUP_HANDSHAKE_TIMEOUT_MS`; `0` disables.
*/
/**
* The longest delay a Node timer holds (2^31-1 ms). Kept here rather than
* imported from liveness-watchdog.ts because early-ppid.ts loads this module
* at startup and must stay free of that module's imports.
*/
const MAX_TIMER_DELAY_MS = 2_147_483_647;
/** Default wait for the first byte of MCP traffic before assuming orphaned. */
export const DEFAULT_STARTUP_HANDSHAKE_TIMEOUT_MS = 900_000; // 15 min
@@ -35,14 +42,16 @@ export const STARTUP_HANDSHAKE_TIMEOUT_ENV = 'CODEGRAPH_STARTUP_HANDSHAKE_TIMEOU
/**
* Parse the timeout env override. Missing/invalid → default; `<= 0` → `0`
* (disabled), the same disable convention as `CODEGRAPH_PPID_POLL_MS`.
* (disabled), the same disable convention as `CODEGRAPH_PPID_POLL_MS`. Capped
* at {@link MAX_TIMER_DELAY_MS}: a larger delay makes Node fire the timer after
* 1 ms, abandoning a healthy launch at once (#1966).
*/
export function parseStartupHandshakeTimeoutMs(raw: string | undefined): number {
if (raw === undefined || raw === '') return DEFAULT_STARTUP_HANDSHAKE_TIMEOUT_MS;
const parsed = Number(raw);
if (!Number.isFinite(parsed)) return DEFAULT_STARTUP_HANDSHAKE_TIMEOUT_MS;
if (parsed <= 0) return 0;
return Math.floor(parsed);
return Math.min(Math.floor(parsed), MAX_TIMER_DELAY_MS);
}
/**