diff --git a/__tests__/liveness-watchdog.test.ts b/__tests__/liveness-watchdog.test.ts index 025b419b..9bc6983e 100644 --- a/__tests__/liveness-watchdog.test.ts +++ b/__tests__/liveness-watchdog.test.ts @@ -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' }, diff --git a/__tests__/startup-handshake.test.ts b/__tests__/startup-handshake.test.ts index 1330c4fa..2c5b9954 100644 --- a/__tests__/startup-handshake.test.ts +++ b/__tests__/startup-handshake.test.ts @@ -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', () => { diff --git a/src/mcp/liveness-watchdog.ts b/src/mcp/liveness-watchdog.ts index 8e253ffa..e0f2e2e2 100644 --- a/src/mcp/liveness-watchdog.ts +++ b/src/mcp/liveness-watchdog.ts @@ -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. */ diff --git a/src/mcp/startup-handshake.ts b/src/mcp/startup-handshake.ts index 16f687f7..0c410dfb 100644 --- a/src/mcp/startup-handshake.ts +++ b/src/mcp/startup-handshake.ts @@ -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); } /**