Files
codegraph/__tests__/index-orphan-watchdog.test.ts
T
45d3293c6a fix(resolution): stop "Resolving refs" wedge on theme-vendoring repos; add exclude config + index watchdogs (#999) (#1009)
Three fixes for a repo that commits a large JS/TS theme/SDK (Metronic under
static/, ~1,600 tracked files):

1. A SECOND "Resolving refs" quadratic that #915 didn't cover. #915 capped
   import-name collisions; this caps method-name collisions (init/update/render
   re-declared on every widget), which flow through matchMethodCall Strategy 3
   and findBestMatch instead. New AMBIGUOUS_NAME_CEILING (default 500, env
   CODEGRAPH_AMBIGUOUS_NAME_CEILING): above it the fuzzy strategies decline
   rather than score K candidates — no proximity score can pick the one true
   target among thousands anyway. Resolving drops from O(K^2) to linear in refs
   (e.g. 900-file synthetic: 28.7s -> 3.4s), edge counts unchanged, and the cap
   never fires on normal repos (max real method-collision ~40).

2. A new `exclude` array in codegraph.json keeps git-TRACKED paths out of the
   index, which .gitignore can't do (enumeration is `git ls-files`). Mirrors the
   existing includeIgnored plumbing across the git, sync, and non-git-walk
   paths.

3. `index`/`init` now install the #850 liveness + #277 ppid watchdogs (which
   were serve-only), so a wedged or orphaned indexer self-terminates instead of
   pinning a core. The --liftoff-only relaunch's spawnSync can't forward
   signals, so killing the parent shim used to orphan the worker.

Tests: ubiquitous-name ceiling, exclude (incl. tracked-file exclusion on git +
non-git), orphan self-termination (POSIX), and ppid-parser units. Shared the
ppid parsers out of mcp/index.ts into mcp/ppid-watchdog.ts.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:25:47 -05:00

121 lines
5.2 KiB
TypeScript

/**
* `index` / `init` command supervision regression test (#999, secondary issues).
*
* `codegraph index` runs in a child re-exec'd with `--liftoff-only` whose parent
* blocks in `spawnSync` and so cannot forward a signal — when the parent shim is
* killed the indexer used to keep running, orphaned, pinning a CPU core. The
* `#850` liveness watchdog and `#277` ppid watchdog were also wired only into
* `serve`, never `index`/`init`. `installCommandSupervision` (src/bin/
* command-supervision.ts) closes both gaps; this proves the orphan half end to
* end: a process running it self-terminates once its parent dies.
*
* Windows is excluded — `process.kill(pid, 'SIGKILL')` doesn't deliver SIGKILL
* there and the reparenting semantics the ppid watchdog relies on are POSIX-only
* (same exclusion as mcp-ppid-watchdog.test.ts).
*/
import { describe, it, expect, afterEach } from 'vitest';
import { spawn, ChildProcessWithoutNullStreams } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
const SUPERVISION = path.resolve(__dirname, '../dist/bin/command-supervision.js');
function isAlive(pid: number): boolean {
try { process.kill(pid, 0); return true; } catch { return false; }
}
function waitForExit(pid: number, timeoutMs: number): Promise<boolean> {
return new Promise((resolve) => {
const start = Date.now();
const tick = () => {
if (!isAlive(pid)) return resolve(true);
if (Date.now() - start > timeoutMs) return resolve(false);
setTimeout(tick, 100);
};
tick();
});
}
describe.skipIf(process.platform === 'win32')('index/init orphan supervision (#999)', () => {
let wrapper: ChildProcessWithoutNullStreams | null = null;
let childPid: number | null = null;
afterEach(() => {
if (wrapper && !wrapper.killed) {
try { wrapper.kill('SIGKILL'); } catch { /* already gone */ }
}
if (childPid !== null && isAlive(childPid)) {
try { process.kill(childPid, 'SIGKILL'); } catch { /* already gone */ }
}
wrapper = null;
childPid = null;
});
it("self-terminates when its parent is SIGKILL'd mid-index", async () => {
const stderrLog = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'cg-index-orphan-')),
'child.stderr.log',
);
// The child stands in for a running indexer: it installs the SAME command
// supervision `index`/`init` install, then idles on a ref'd timer so it
// stays alive until the watchdog (not the timer) takes it down.
// CODEGRAPH_NO_WATCHDOG=1 isolates the ppid (orphan) path from the liveness
// child; CODEGRAPH_PPID_POLL_MS=200 keeps it responsive in test.
const childSrc = `
const { installCommandSupervision } = require(${JSON.stringify(SUPERVISION)});
installCommandSupervision('index');
process.stdout.write('UP ' + process.pid + '\\n');
setInterval(() => {}, 60000);
`;
// The wrapper spawns the child detached (so it's reparented to init when the
// wrapper dies, not killed with it), waits for it to report its pid + install
// the watchdog, relays the pid, then idles until SIGKILL'd.
const wrapperSrc = `
const { spawn } = require('child_process');
const fs = require('fs');
const errFd = fs.openSync(${JSON.stringify(stderrLog)}, 'a');
const child = spawn(process.execPath, ['-e', ${JSON.stringify(childSrc)}], {
stdio: ['ignore', 'pipe', errFd],
env: { ...process.env, CODEGRAPH_NO_WATCHDOG: '1', CODEGRAPH_PPID_POLL_MS: '200', CODEGRAPH_WASM_RELAUNCHED: '1' },
detached: true,
});
child.unref();
child.stdout.on('data', (d) => {
const m = /UP (\\d+)/.exec(d.toString());
if (m) process.stdout.write(JSON.stringify({ pid: Number(m[1]) }) + '\\n');
});
setInterval(() => {}, 60000);
`;
wrapper = spawn(process.execPath, ['-e', wrapperSrc], {
stdio: ['pipe', 'pipe', 'inherit'],
}) as ChildProcessWithoutNullStreams;
const { pid } = await new Promise<{ pid: number }>((resolve, reject) => {
let buf = '';
const timer = setTimeout(() => reject(new Error('child did not report its pid in time')), 10000);
wrapper!.stdout.on('data', (chunk: Buffer) => {
buf += chunk.toString('utf8');
const m = buf.match(/\{"pid":(\d+)\}/);
if (m) { clearTimeout(timer); resolve({ pid: parseInt(m[1], 10) }); }
});
wrapper!.on('exit', () => { clearTimeout(timer); reject(new Error('wrapper exited before reporting pid')); });
});
childPid = pid;
expect(isAlive(childPid)).toBe(true);
// SIGKILL the wrapper — no cleanup runs, just like killing the parent shim.
// The child is reparented to init; only its ppid watchdog can take it down.
wrapper.kill('SIGKILL');
const exited = await waitForExit(childPid, 5000);
const stderr = fs.existsSync(stderrLog) ? fs.readFileSync(stderrLog, 'utf-8') : '<none>';
expect(
exited,
`child (pid=${childPid}) did not self-terminate within 5s after parent SIGKILL.\nstderr:\n${stderr}`,
).toBe(true);
// Confirm it died from the parent-death path, not some other cause.
expect(stderr).toMatch(/Parent process exited.*aborting/);
}, 20000);
});