index-orphan-watchdog.test.ts 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120
  1. /**
  2. * `index` / `init` command supervision regression test (#999, secondary issues).
  3. *
  4. * `codegraph index` runs in a child re-exec'd with `--liftoff-only` whose parent
  5. * blocks in `spawnSync` and so cannot forward a signal — when the parent shim is
  6. * killed the indexer used to keep running, orphaned, pinning a CPU core. The
  7. * `#850` liveness watchdog and `#277` ppid watchdog were also wired only into
  8. * `serve`, never `index`/`init`. `installCommandSupervision` (src/bin/
  9. * command-supervision.ts) closes both gaps; this proves the orphan half end to
  10. * end: a process running it self-terminates once its parent dies.
  11. *
  12. * Windows is excluded — `process.kill(pid, 'SIGKILL')` doesn't deliver SIGKILL
  13. * there and the reparenting semantics the ppid watchdog relies on are POSIX-only
  14. * (same exclusion as mcp-ppid-watchdog.test.ts).
  15. */
  16. import { describe, it, expect, afterEach } from 'vitest';
  17. import { spawn, ChildProcessWithoutNullStreams } from 'child_process';
  18. import * as fs from 'fs';
  19. import * as os from 'os';
  20. import * as path from 'path';
  21. const SUPERVISION = path.resolve(__dirname, '../dist/bin/command-supervision.js');
  22. function isAlive(pid: number): boolean {
  23. try { process.kill(pid, 0); return true; } catch { return false; }
  24. }
  25. function waitForExit(pid: number, timeoutMs: number): Promise<boolean> {
  26. return new Promise((resolve) => {
  27. const start = Date.now();
  28. const tick = () => {
  29. if (!isAlive(pid)) return resolve(true);
  30. if (Date.now() - start > timeoutMs) return resolve(false);
  31. setTimeout(tick, 100);
  32. };
  33. tick();
  34. });
  35. }
  36. describe.skipIf(process.platform === 'win32')('index/init orphan supervision (#999)', () => {
  37. let wrapper: ChildProcessWithoutNullStreams | null = null;
  38. let childPid: number | null = null;
  39. afterEach(() => {
  40. if (wrapper && !wrapper.killed) {
  41. try { wrapper.kill('SIGKILL'); } catch { /* already gone */ }
  42. }
  43. if (childPid !== null && isAlive(childPid)) {
  44. try { process.kill(childPid, 'SIGKILL'); } catch { /* already gone */ }
  45. }
  46. wrapper = null;
  47. childPid = null;
  48. });
  49. it("self-terminates when its parent is SIGKILL'd mid-index", async () => {
  50. const stderrLog = path.join(
  51. fs.mkdtempSync(path.join(os.tmpdir(), 'cg-index-orphan-')),
  52. 'child.stderr.log',
  53. );
  54. // The child stands in for a running indexer: it installs the SAME command
  55. // supervision `index`/`init` install, then idles on a ref'd timer so it
  56. // stays alive until the watchdog (not the timer) takes it down.
  57. // CODEGRAPH_NO_WATCHDOG=1 isolates the ppid (orphan) path from the liveness
  58. // child; CODEGRAPH_PPID_POLL_MS=200 keeps it responsive in test.
  59. const childSrc = `
  60. const { installCommandSupervision } = require(${JSON.stringify(SUPERVISION)});
  61. installCommandSupervision('index');
  62. process.stdout.write('UP ' + process.pid + '\\n');
  63. setInterval(() => {}, 60000);
  64. `;
  65. // The wrapper spawns the child detached (so it's reparented to init when the
  66. // wrapper dies, not killed with it), waits for it to report its pid + install
  67. // the watchdog, relays the pid, then idles until SIGKILL'd.
  68. const wrapperSrc = `
  69. const { spawn } = require('child_process');
  70. const fs = require('fs');
  71. const errFd = fs.openSync(${JSON.stringify(stderrLog)}, 'a');
  72. const child = spawn(process.execPath, ['-e', ${JSON.stringify(childSrc)}], {
  73. stdio: ['ignore', 'pipe', errFd],
  74. env: { ...process.env, CODEGRAPH_NO_WATCHDOG: '1', CODEGRAPH_PPID_POLL_MS: '200', CODEGRAPH_WASM_RELAUNCHED: '1' },
  75. detached: true,
  76. });
  77. child.unref();
  78. child.stdout.on('data', (d) => {
  79. const m = /UP (\\d+)/.exec(d.toString());
  80. if (m) process.stdout.write(JSON.stringify({ pid: Number(m[1]) }) + '\\n');
  81. });
  82. setInterval(() => {}, 60000);
  83. `;
  84. wrapper = spawn(process.execPath, ['-e', wrapperSrc], {
  85. stdio: ['pipe', 'pipe', 'inherit'],
  86. }) as ChildProcessWithoutNullStreams;
  87. const { pid } = await new Promise<{ pid: number }>((resolve, reject) => {
  88. let buf = '';
  89. const timer = setTimeout(() => reject(new Error('child did not report its pid in time')), 10000);
  90. wrapper!.stdout.on('data', (chunk: Buffer) => {
  91. buf += chunk.toString('utf8');
  92. const m = buf.match(/\{"pid":(\d+)\}/);
  93. if (m) { clearTimeout(timer); resolve({ pid: parseInt(m[1], 10) }); }
  94. });
  95. wrapper!.on('exit', () => { clearTimeout(timer); reject(new Error('wrapper exited before reporting pid')); });
  96. });
  97. childPid = pid;
  98. expect(isAlive(childPid)).toBe(true);
  99. // SIGKILL the wrapper — no cleanup runs, just like killing the parent shim.
  100. // The child is reparented to init; only its ppid watchdog can take it down.
  101. wrapper.kill('SIGKILL');
  102. const exited = await waitForExit(childPid, 5000);
  103. const stderr = fs.existsSync(stderrLog) ? fs.readFileSync(stderrLog, 'utf-8') : '<none>';
  104. expect(
  105. exited,
  106. `child (pid=${childPid}) did not self-terminate within 5s after parent SIGKILL.\nstderr:\n${stderr}`,
  107. ).toBe(true);
  108. // Confirm it died from the parent-death path, not some other cause.
  109. expect(stderr).toMatch(/Parent process exited.*aborting/);
  110. }, 20000);
  111. });