mcp-ppid-watchdog.test.ts 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  1. /**
  2. * PPID watchdog regression test (#277).
  3. *
  4. * On Linux, when an MCP host (Claude Code, opencode, …) is SIGKILL'd by the
  5. * OOM killer / a force-quit / a container teardown, the kernel does NOT
  6. * propagate the death to its `codegraph serve --mcp` child. The child gets
  7. * reparented to init/systemd, its stdin stays half-open in some
  8. * configurations, and the existing `stdin.on('end' | 'close')` handlers
  9. * never fire — the server lingers indefinitely, holding inotify watches,
  10. * file descriptors, and the SQLite WAL.
  11. *
  12. * `src/mcp/index.ts` polls `process.ppid` and shuts down the moment it
  13. * diverges from the value observed at startup. This test stands up a
  14. * four-tier process tree (vitest → wrapper → {stdin-holder, codegraph}) and
  15. * SIGKILL's the wrapper. The stdin-holder is a long-lived sibling whose
  16. * `stdout` pipe is dup'd into codegraph's `stdin`. After the wrapper dies
  17. * the pipe stays open (stdin-holder still owns the write-end), so the
  18. * existing stdin close handlers do **not** fire — the only thing that can
  19. * terminate codegraph then is the PPID watchdog.
  20. *
  21. * Windows is excluded — `process.kill(pid, 'SIGKILL')` does not actually
  22. * deliver SIGKILL there, and the per-OS reparenting semantics the watchdog
  23. * relies on are POSIX-specific.
  24. */
  25. import { describe, it, expect, afterEach } from 'vitest';
  26. import { spawn, ChildProcessWithoutNullStreams } from 'child_process';
  27. import * as fs from 'fs';
  28. import * as os from 'os';
  29. import * as path from 'path';
  30. const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
  31. function isAlive(pid: number): boolean {
  32. try {
  33. process.kill(pid, 0);
  34. return true;
  35. } catch {
  36. return false;
  37. }
  38. }
  39. function waitForExit(pid: number, timeoutMs: number): Promise<boolean> {
  40. return new Promise((resolve) => {
  41. const start = Date.now();
  42. const tick = () => {
  43. if (!isAlive(pid)) return resolve(true);
  44. if (Date.now() - start > timeoutMs) return resolve(false);
  45. setTimeout(tick, 100);
  46. };
  47. tick();
  48. });
  49. }
  50. describe.skipIf(process.platform === 'win32')('MCP PPID watchdog (#277)', () => {
  51. let wrapper: ChildProcessWithoutNullStreams | null = null;
  52. let childPid: number | null = null;
  53. let stdinHolderPid: number | null = null;
  54. let tmpDir: string | null = null;
  55. afterEach(() => {
  56. if (wrapper && !wrapper.killed) {
  57. try { wrapper.kill('SIGKILL'); } catch { /* already gone */ }
  58. }
  59. // Belt and suspenders — don't leak processes if an assertion failed.
  60. for (const pid of [childPid, stdinHolderPid]) {
  61. if (pid !== null && isAlive(pid)) {
  62. try { process.kill(pid, 'SIGKILL'); } catch { /* already gone */ }
  63. }
  64. }
  65. wrapper = null;
  66. childPid = null;
  67. stdinHolderPid = null;
  68. if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
  69. tmpDir = null;
  70. });
  71. it("shuts down when its parent is SIGKILL'd and stdin stays open", async () => {
  72. // The wrapper:
  73. // 1. Spawns a "stdin-holder" — a tiny long-lived node process whose
  74. // `stdout` pipe is dup'd into codegraph's `stdin`. As long as the
  75. // stdin-holder is alive (it is — it's an orphan after the wrapper
  76. // dies), codegraph's stdin never sees EOF.
  77. // 2. Spawns codegraph with that pipe as fd 0 and its stderr redirected
  78. // to a tmp file that survives the wrapper, then reports both PIDs.
  79. // 3. Idles until SIGKILL'd from the test.
  80. //
  81. // CODEGRAPH_PPID_POLL_MS=200 keeps the watchdog responsive in test; the
  82. // production default is 5000ms.
  83. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ppid-watchdog-'));
  84. const stderrLog = path.join(tmpDir, 'codegraph.stderr.log');
  85. // The wrapper waits 800ms before reporting the PIDs so the codegraph
  86. // child has time to finish its async start() (dynamic import + transport
  87. // setup + watchdog registration). Otherwise the test races: it
  88. // SIGKILL's the wrapper before the watchdog interval is installed, and
  89. // nothing terminates codegraph.
  90. const wrapperSrc = `
  91. const { spawn } = require('child_process');
  92. const fs = require('fs');
  93. const stderrFd = fs.openSync(${JSON.stringify(stderrLog)}, 'a');
  94. const stdinHolder = spawn(process.execPath, ['-e', 'setInterval(() => {}, 60000)'], {
  95. stdio: ['ignore', 'pipe', 'ignore'],
  96. detached: true,
  97. });
  98. stdinHolder.unref();
  99. const child = spawn(process.execPath, [${JSON.stringify(BIN)}, 'serve', '--mcp'], {
  100. stdio: [stdinHolder.stdout, 'ignore', stderrFd],
  101. // Pin to direct (in-process) mode: this test targets the in-process
  102. // server's PPID watchdog (#277). The detached-daemon/proxy watchdog is
  103. // covered separately in mcp-daemon.test.ts ("daemon survives the first
  104. // client dying"). Without this the spawned process becomes a proxy and
  105. // also spawns a detached daemon that would outlive the test.
  106. env: { ...process.env, CODEGRAPH_PPID_POLL_MS: '200', CODEGRAPH_NO_DAEMON: '1' },
  107. detached: true,
  108. });
  109. child.unref();
  110. setTimeout(() => {
  111. process.stdout.write(JSON.stringify({ pid: child.pid, stdinHolderPid: stdinHolder.pid }) + '\\n');
  112. }, 800);
  113. setInterval(() => {}, 60000);
  114. `;
  115. wrapper = spawn(process.execPath, ['-e', wrapperSrc], {
  116. stdio: ['pipe', 'pipe', 'pipe'],
  117. // All descendants inherit an isolated project. An editor's live writer
  118. // lock in the repository must not terminate the child before the watchdog.
  119. cwd: tmpDir,
  120. }) as ChildProcessWithoutNullStreams;
  121. const pids = await new Promise<{ pid: number; stdinHolderPid: number }>((resolve, reject) => {
  122. let buf = '';
  123. const timer = setTimeout(
  124. () => reject(new Error('wrapper did not report PIDs in time')),
  125. 10000,
  126. );
  127. wrapper!.stdout.on('data', (chunk: Buffer) => {
  128. buf += chunk.toString('utf8');
  129. const m = buf.match(/\{"pid":(\d+),"stdinHolderPid":(\d+)\}/);
  130. if (m) {
  131. clearTimeout(timer);
  132. resolve({ pid: parseInt(m[1], 10), stdinHolderPid: parseInt(m[2], 10) });
  133. }
  134. });
  135. wrapper!.on('exit', () => {
  136. clearTimeout(timer);
  137. reject(new Error('wrapper exited before reporting PIDs'));
  138. });
  139. });
  140. childPid = pids.pid;
  141. stdinHolderPid = pids.stdinHolderPid;
  142. expect(isAlive(childPid)).toBe(true);
  143. expect(isAlive(stdinHolderPid)).toBe(true);
  144. // SIGKILL the wrapper — no cleanup runs, just like a real OOM kill.
  145. // codegraph and the stdin-holder both get reparented to init/systemd.
  146. // Crucially, the pipe between them stays open, so codegraph's stdin
  147. // doesn't close: only the watchdog can take it down.
  148. wrapper.kill('SIGKILL');
  149. // Watchdog runs every 200ms in this test → 5s gives ~25 polls of headroom.
  150. const exited = await waitForExit(childPid, 5000);
  151. const stderrContent = fs.existsSync(stderrLog) ? fs.readFileSync(stderrLog, 'utf-8') : '<no stderr captured>';
  152. expect(
  153. exited,
  154. `codegraph child (pid=${childPid}) did not exit within 5s after wrapper was SIGKILL'd.\nstderr:\n${stderrContent}`,
  155. ).toBe(true);
  156. // The watchdog announces itself before tearing down — assert that the
  157. // shutdown came from the parent-death path, not from any other signal.
  158. expect(stderrContent).toMatch(/Parent process exited.*shutting down/);
  159. // The stdin-holder is now an orphan — kill it explicitly so it doesn't
  160. // outlive the test. It's still tracked in `stdinHolderPid` for the
  161. // afterEach safety net, but we tidy up proactively here too.
  162. if (isAlive(stdinHolderPid)) {
  163. try { process.kill(stdinHolderPid, 'SIGKILL'); } catch { /* race */ }
  164. }
  165. }, 20000);
  166. });