liveness-watchdog.test.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. import { describe, it, expect, beforeAll } from 'vitest';
  2. import { spawn } from 'child_process';
  3. import * as fs from 'fs';
  4. import * as path from 'path';
  5. import {
  6. parseWatchdogTimeoutMs,
  7. deriveCheckIntervalMs,
  8. installMainThreadWatchdog,
  9. DEFAULT_WATCHDOG_TIMEOUT_MS,
  10. } from '../src/mcp/liveness-watchdog';
  11. describe('config parsing', () => {
  12. it('parseWatchdogTimeoutMs falls back for missing/invalid input', () => {
  13. expect(parseWatchdogTimeoutMs(undefined)).toBe(DEFAULT_WATCHDOG_TIMEOUT_MS);
  14. expect(parseWatchdogTimeoutMs('not-a-number')).toBe(DEFAULT_WATCHDOG_TIMEOUT_MS);
  15. expect(parseWatchdogTimeoutMs('0')).toBe(DEFAULT_WATCHDOG_TIMEOUT_MS);
  16. expect(parseWatchdogTimeoutMs('-5')).toBe(DEFAULT_WATCHDOG_TIMEOUT_MS);
  17. expect(parseWatchdogTimeoutMs('1500')).toBe(1500);
  18. });
  19. it('deriveCheckIntervalMs stays within [50, 2000] and scales with the timeout', () => {
  20. expect(deriveCheckIntervalMs(60_000)).toBe(2000); // clamped high
  21. expect(deriveCheckIntervalMs(500)).toBe(100); // 500/5
  22. expect(deriveCheckIntervalMs(10)).toBe(50); // clamped low
  23. });
  24. });
  25. describe('installMainThreadWatchdog opt-out', () => {
  26. it('returns null (spawns nothing) when CODEGRAPH_NO_WATCHDOG is set', () => {
  27. const prev = process.env.CODEGRAPH_NO_WATCHDOG;
  28. process.env.CODEGRAPH_NO_WATCHDOG = '1';
  29. try {
  30. expect(installMainThreadWatchdog()).toBeNull();
  31. } finally {
  32. if (prev === undefined) delete process.env.CODEGRAPH_NO_WATCHDOG;
  33. else process.env.CODEGRAPH_NO_WATCHDOG = prev;
  34. }
  35. });
  36. });
  37. /**
  38. * End-to-end: spawn a real process, install the real watchdog (which spawns a
  39. * separate watchdog child), and prove it kills a wedged main thread — including
  40. * the case a worker thread could NOT (a non-allocating loop under heap pressure,
  41. * which strands a same-process worker on V8's global safepoint, #850). Drives
  42. * the built module the way mcp-ppid-watchdog.test.ts drives the built CLI.
  43. */
  44. describe('liveness watchdog (spawned, real watchdog process)', () => {
  45. const MODULE = path.resolve(__dirname, '../dist/mcp/liveness-watchdog.js');
  46. beforeAll(() => {
  47. if (!fs.existsSync(MODULE)) {
  48. throw new Error(`Build the project first: ${MODULE} is missing (run npm run build).`);
  49. }
  50. });
  51. function runChild(
  52. env: Record<string, string>,
  53. body: string,
  54. hardTimeoutMs: number,
  55. progressPaths?: string[]
  56. ): Promise<{ code: number | null; signal: NodeJS.Signals | 'TIMEOUT' | null }> {
  57. const src = `
  58. const { installMainThreadWatchdog } = require(${JSON.stringify(MODULE)});
  59. installMainThreadWatchdog(${progressPaths ? JSON.stringify({ progressPaths }) : ''});
  60. ${body}
  61. `;
  62. const child = spawn(process.execPath, ['-e', src], {
  63. env: { ...process.env, ...env },
  64. stdio: ['ignore', 'ignore', 'ignore'],
  65. });
  66. return new Promise((resolve) => {
  67. const timer = setTimeout(() => {
  68. child.kill('SIGKILL');
  69. resolve({ code: null, signal: 'TIMEOUT' });
  70. }, hardTimeoutMs);
  71. child.on('exit', (code, signal) => {
  72. clearTimeout(timer);
  73. resolve({ code, signal });
  74. });
  75. });
  76. }
  77. // Assert the watchdog terminated the process. POSIX surfaces the external
  78. // SIGKILL as signal 'SIGKILL'; Windows has no real signals, so the watchdog's
  79. // `process.kill(pid, 'SIGKILL')` maps to TerminateProcess and an observer sees
  80. // signal=null with a non-zero exit code. Either is a kill; the synthetic
  81. // 'TIMEOUT' (the watchdog never fired) is the failure we're guarding against.
  82. function expectKilled(r: { code: number | null; signal: NodeJS.Signals | 'TIMEOUT' | null }): void {
  83. expect(r.signal === 'SIGKILL' || (r.signal === null && r.code !== 0 && r.code !== null)).toBe(true);
  84. }
  85. it('SIGKILLs a process whose main thread wedges in a sync loop', async () => {
  86. const r = await runChild(
  87. { CODEGRAPH_WATCHDOG_TIMEOUT_MS: '500' },
  88. 'setTimeout(() => { while (true) {} }, 150);',
  89. 8000
  90. );
  91. expectKilled(r);
  92. }, 12000);
  93. it('SIGKILLs a non-allocating wedge under heap pressure (the case worker threads stalled on)', async () => {
  94. const r = await runChild(
  95. { CODEGRAPH_WATCHDOG_TIMEOUT_MS: '500' },
  96. // ~40MB retained so a GC is likely, then a tight NON-allocating loop — the
  97. // exact shape that deadlocks a same-process worker on the global safepoint.
  98. 'const k=[]; for (let i=0;i<40;i++) k.push(Buffer.alloc(1024*1024,i)); global.__k=k; setTimeout(() => { while (true) {} }, 150);',
  99. 8000
  100. );
  101. expectKilled(r);
  102. }, 12000);
  103. it('does NOT kill a healthy process that keeps its event loop turning', async () => {
  104. const { code, signal } = await runChild(
  105. { CODEGRAPH_WATCHDOG_TIMEOUT_MS: '500' },
  106. 'const iv = setInterval(() => {}, 50); setTimeout(() => { clearInterval(iv); process.exit(7); }, 1500);',
  107. 8000
  108. );
  109. expect(signal).toBeNull(); // never signalled
  110. expect(code).toBe(7); // exited on its own terms
  111. }, 12000);
  112. // --- disk-progress deferral (#1231): a blocked event loop is NOT a wedge
  113. // when the watched DB files keep advancing (a slow synchronous SQLite
  114. // statement on degraded storage). ---
  115. /** Grow `file` every 150ms for `forMs`; resolves when done. */
  116. function growFile(file: string, forMs: number): Promise<void> {
  117. return new Promise((resolve) => {
  118. const iv = setInterval(() => { fs.appendFileSync(file, 'x'.repeat(64)); }, 150);
  119. setTimeout(() => { clearInterval(iv); resolve(); }, forMs);
  120. });
  121. }
  122. it('does NOT kill a blocked loop while the watched files advance (slow store, not a wedge)', async () => {
  123. const tmp = path.join(fs.mkdtempSync(path.join(require('os').tmpdir(), 'cg-wd-')), 'db-wal');
  124. fs.writeFileSync(tmp, 'seed');
  125. // Base timeout 500ms; the loop blocks for 2.5s (5 timeouts, under the 10×
  126. // cap) while the test process grows the watched file. Old behavior: killed
  127. // at ~500ms. New: deferred, exits on its own with code 5.
  128. const [r] = await Promise.all([
  129. runChild(
  130. { CODEGRAPH_WATCHDOG_TIMEOUT_MS: '500' },
  131. 'setTimeout(() => { const end = Date.now() + 2500; while (Date.now() < end) {} process.exit(5); }, 200);',
  132. 10_000,
  133. [tmp]
  134. ),
  135. growFile(tmp, 3200),
  136. ]);
  137. expect(r.signal).toBeNull();
  138. expect(r.code).toBe(5);
  139. }, 15000);
  140. it('still kills a blocked loop when the watched files do NOT advance (a true wedge)', async () => {
  141. const tmp = path.join(fs.mkdtempSync(path.join(require('os').tmpdir(), 'cg-wd-')), 'db-wal');
  142. fs.writeFileSync(tmp, 'seed');
  143. // Same blocked loop, nobody grows the file: the base timeout kills it long
  144. // before its own exit(5) at 2.5s.
  145. const r = await runChild(
  146. { CODEGRAPH_WATCHDOG_TIMEOUT_MS: '500' },
  147. 'setTimeout(() => { const end = Date.now() + 2500; while (Date.now() < end) {} process.exit(5); }, 200);',
  148. 10_000,
  149. [tmp]
  150. );
  151. expectKilled(r);
  152. }, 15000);
  153. it('kills at the hard cap even with ongoing file activity (bounded deferral)', async () => {
  154. const tmp = path.join(fs.mkdtempSync(path.join(require('os').tmpdir(), 'cg-wd-')), 'db-wal');
  155. fs.writeFileSync(tmp, 'seed');
  156. // Base timeout 300ms ⇒ cap 3s. The loop blocks for 8s with continuous file
  157. // growth: deferral carries it past 300ms but the cap kills it around ~3s,
  158. // well before its own exit(5).
  159. const [r] = await Promise.all([
  160. runChild(
  161. { CODEGRAPH_WATCHDOG_TIMEOUT_MS: '300' },
  162. 'setTimeout(() => { const end = Date.now() + 8000; while (Date.now() < end) {} process.exit(5); }, 200);',
  163. 15_000,
  164. [tmp]
  165. ),
  166. growFile(tmp, 9000),
  167. ]);
  168. expectKilled(r);
  169. }, 20000);
  170. it('does NOT kill a wedged process when CODEGRAPH_NO_WATCHDOG=1', async () => {
  171. const { code, signal } = await runChild(
  172. { CODEGRAPH_WATCHDOG_TIMEOUT_MS: '500', CODEGRAPH_NO_WATCHDOG: '1' },
  173. 'setTimeout(() => { const end = Date.now() + 1500; while (Date.now() < end) {} process.exit(3); }, 150);',
  174. 8000
  175. );
  176. // It exits with its OWN code 3 — proving nothing killed it. (Checking only
  177. // signal=null is insufficient on Windows, where a kill also reports null.)
  178. expect(signal).toBeNull();
  179. expect(code).toBe(3);
  180. }, 12000);
  181. });