Files
colbymchenry--codegraph/__tests__/liveness-watchdog.test.ts
T
wehub-resource-sync 5f98960d22
Deploy site to GitHub Pages / build (push) Failing after 1s
Deploy site to GitHub Pages / deploy (push) Has been skipped
chore: import upstream snapshot with attribution
2026-07-13 12:02:56 +08:00

198 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, beforeAll } from 'vitest';
import { spawn } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import {
parseWatchdogTimeoutMs,
deriveCheckIntervalMs,
installMainThreadWatchdog,
DEFAULT_WATCHDOG_TIMEOUT_MS,
} from '../src/mcp/liveness-watchdog';
describe('config parsing', () => {
it('parseWatchdogTimeoutMs falls back for missing/invalid input', () => {
expect(parseWatchdogTimeoutMs(undefined)).toBe(DEFAULT_WATCHDOG_TIMEOUT_MS);
expect(parseWatchdogTimeoutMs('not-a-number')).toBe(DEFAULT_WATCHDOG_TIMEOUT_MS);
expect(parseWatchdogTimeoutMs('0')).toBe(DEFAULT_WATCHDOG_TIMEOUT_MS);
expect(parseWatchdogTimeoutMs('-5')).toBe(DEFAULT_WATCHDOG_TIMEOUT_MS);
expect(parseWatchdogTimeoutMs('1500')).toBe(1500);
});
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
expect(deriveCheckIntervalMs(10)).toBe(50); // clamped low
});
});
describe('installMainThreadWatchdog opt-out', () => {
it('returns null (spawns nothing) when CODEGRAPH_NO_WATCHDOG is set', () => {
const prev = process.env.CODEGRAPH_NO_WATCHDOG;
process.env.CODEGRAPH_NO_WATCHDOG = '1';
try {
expect(installMainThreadWatchdog()).toBeNull();
} finally {
if (prev === undefined) delete process.env.CODEGRAPH_NO_WATCHDOG;
else process.env.CODEGRAPH_NO_WATCHDOG = prev;
}
});
});
/**
* End-to-end: spawn a real process, install the real watchdog (which spawns a
* separate watchdog child), and prove it kills a wedged main thread — including
* the case a worker thread could NOT (a non-allocating loop under heap pressure,
* which strands a same-process worker on V8's global safepoint, #850). Drives
* the built module the way mcp-ppid-watchdog.test.ts drives the built CLI.
*/
describe('liveness watchdog (spawned, real watchdog process)', () => {
const MODULE = path.resolve(__dirname, '../dist/mcp/liveness-watchdog.js');
beforeAll(() => {
if (!fs.existsSync(MODULE)) {
throw new Error(`Build the project first: ${MODULE} is missing (run npm run build).`);
}
});
function runChild(
env: Record<string, string>,
body: string,
hardTimeoutMs: number,
progressPaths?: string[]
): Promise<{ code: number | null; signal: NodeJS.Signals | 'TIMEOUT' | null }> {
const src = `
const { installMainThreadWatchdog } = require(${JSON.stringify(MODULE)});
installMainThreadWatchdog(${progressPaths ? JSON.stringify({ progressPaths }) : ''});
${body}
`;
const child = spawn(process.execPath, ['-e', src], {
env: { ...process.env, ...env },
stdio: ['ignore', 'ignore', 'ignore'],
});
return new Promise((resolve) => {
const timer = setTimeout(() => {
child.kill('SIGKILL');
resolve({ code: null, signal: 'TIMEOUT' });
}, hardTimeoutMs);
child.on('exit', (code, signal) => {
clearTimeout(timer);
resolve({ code, signal });
});
});
}
// Assert the watchdog terminated the process. POSIX surfaces the external
// SIGKILL as signal 'SIGKILL'; Windows has no real signals, so the watchdog's
// `process.kill(pid, 'SIGKILL')` maps to TerminateProcess and an observer sees
// signal=null with a non-zero exit code. Either is a kill; the synthetic
// 'TIMEOUT' (the watchdog never fired) is the failure we're guarding against.
function expectKilled(r: { code: number | null; signal: NodeJS.Signals | 'TIMEOUT' | null }): void {
expect(r.signal === 'SIGKILL' || (r.signal === null && r.code !== 0 && r.code !== null)).toBe(true);
}
it('SIGKILLs a process whose main thread wedges in a sync loop', async () => {
const r = await runChild(
{ CODEGRAPH_WATCHDOG_TIMEOUT_MS: '500' },
'setTimeout(() => { while (true) {} }, 150);',
8000
);
expectKilled(r);
}, 12000);
it('SIGKILLs a non-allocating wedge under heap pressure (the case worker threads stalled on)', async () => {
const r = await runChild(
{ CODEGRAPH_WATCHDOG_TIMEOUT_MS: '500' },
// ~40MB retained so a GC is likely, then a tight NON-allocating loop — the
// exact shape that deadlocks a same-process worker on the global safepoint.
'const k=[]; for (let i=0;i<40;i++) k.push(Buffer.alloc(1024*1024,i)); global.__k=k; setTimeout(() => { while (true) {} }, 150);',
8000
);
expectKilled(r);
}, 12000);
it('does NOT kill a healthy process that keeps its event loop turning', async () => {
const { code, signal } = await runChild(
{ CODEGRAPH_WATCHDOG_TIMEOUT_MS: '500' },
'const iv = setInterval(() => {}, 50); setTimeout(() => { clearInterval(iv); process.exit(7); }, 1500);',
8000
);
expect(signal).toBeNull(); // never signalled
expect(code).toBe(7); // exited on its own terms
}, 12000);
// --- disk-progress deferral (#1231): a blocked event loop is NOT a wedge
// when the watched DB files keep advancing (a slow synchronous SQLite
// statement on degraded storage). ---
/** Grow `file` every 150ms for `forMs`; resolves when done. */
function growFile(file: string, forMs: number): Promise<void> {
return new Promise((resolve) => {
const iv = setInterval(() => { fs.appendFileSync(file, 'x'.repeat(64)); }, 150);
setTimeout(() => { clearInterval(iv); resolve(); }, forMs);
});
}
it('does NOT kill a blocked loop while the watched files advance (slow store, not a wedge)', async () => {
const tmp = path.join(fs.mkdtempSync(path.join(require('os').tmpdir(), 'cg-wd-')), 'db-wal');
fs.writeFileSync(tmp, 'seed');
// Base timeout 500ms; the loop blocks for 2.5s (5 timeouts, under the 10×
// cap) while the test process grows the watched file. Old behavior: killed
// at ~500ms. New: deferred, exits on its own with code 5.
const [r] = await Promise.all([
runChild(
{ CODEGRAPH_WATCHDOG_TIMEOUT_MS: '500' },
'setTimeout(() => { const end = Date.now() + 2500; while (Date.now() < end) {} process.exit(5); }, 200);',
10_000,
[tmp]
),
growFile(tmp, 3200),
]);
expect(r.signal).toBeNull();
expect(r.code).toBe(5);
}, 15000);
it('still kills a blocked loop when the watched files do NOT advance (a true wedge)', async () => {
const tmp = path.join(fs.mkdtempSync(path.join(require('os').tmpdir(), 'cg-wd-')), 'db-wal');
fs.writeFileSync(tmp, 'seed');
// Same blocked loop, nobody grows the file: the base timeout kills it long
// before its own exit(5) at 2.5s.
const r = await runChild(
{ CODEGRAPH_WATCHDOG_TIMEOUT_MS: '500' },
'setTimeout(() => { const end = Date.now() + 2500; while (Date.now() < end) {} process.exit(5); }, 200);',
10_000,
[tmp]
);
expectKilled(r);
}, 15000);
it('kills at the hard cap even with ongoing file activity (bounded deferral)', async () => {
const tmp = path.join(fs.mkdtempSync(path.join(require('os').tmpdir(), 'cg-wd-')), 'db-wal');
fs.writeFileSync(tmp, 'seed');
// Base timeout 300ms ⇒ cap 3s. The loop blocks for 8s with continuous file
// growth: deferral carries it past 300ms but the cap kills it around ~3s,
// well before its own exit(5).
const [r] = await Promise.all([
runChild(
{ CODEGRAPH_WATCHDOG_TIMEOUT_MS: '300' },
'setTimeout(() => { const end = Date.now() + 8000; while (Date.now() < end) {} process.exit(5); }, 200);',
15_000,
[tmp]
),
growFile(tmp, 9000),
]);
expectKilled(r);
}, 20000);
it('does NOT kill a wedged process when CODEGRAPH_NO_WATCHDOG=1', async () => {
const { code, signal } = await runChild(
{ CODEGRAPH_WATCHDOG_TIMEOUT_MS: '500', CODEGRAPH_NO_WATCHDOG: '1' },
'setTimeout(() => { const end = Date.now() + 1500; while (Date.now() < end) {} process.exit(3); }, 150);',
8000
);
// It exits with its OWN code 3 — proving nothing killed it. (Checking only
// signal=null is insufficient on Windows, where a kill also reports null.)
expect(signal).toBeNull();
expect(code).toBe(3);
}, 12000);
});