mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-04 23:22:40 +08:00
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:
committed by
Colby McHenry
parent
71985f2620
commit
080ceed082
@@ -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' },
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user