liveness-watchdog.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  1. /**
  2. * Main-thread liveness watchdog — belt-and-suspenders for #850.
  3. *
  4. * The #850 fix removes the one *known* trigger (the uncaught-exception handler
  5. * no longer formats a raw Error's `.stack`). But ANY synchronous, non-yielding
  6. * loop on the main thread — a future V8 stack-format pathology, a runaway
  7. * regex, an accidental `while (true)` — wedges the event loop, and from JS you
  8. * cannot interrupt it: timers, signal handlers, and the PPID watchdog all run
  9. * *on* that blocked loop, so the process pins a core forever with no
  10. * self-recovery (the exact unrecoverable state #850 reported).
  11. *
  12. * **Why a separate PROCESS, not a worker thread.** A worker thread was the
  13. * obvious first choice and it works in a toy process — but it was validated to
  14. * FAIL in the real daemon (#850 live test). V8 isolates in one process
  15. * coordinate on global safepoints, so when one thread requests a GC every other
  16. * thread must reach a safepoint before it can proceed. A main thread wedged in
  17. * a tight, non-allocating loop never reaches one, which strands the watchdog
  18. * worker on its very next allocation/safepoint check — and the #850 hot loop
  19. * (`SourcePositionTableIterator::Advance`, a non-allocating C++ table walk) is
  20. * exactly that shape. A child process shares no isolate and no heap with the
  21. * parent, so the wedge cannot touch it; it kills via the kernel, which honours
  22. * SIGKILL regardless of what the parent's threads are doing.
  23. *
  24. * **How.** The parent writes a heartbeat byte to the child's stdin every
  25. * `checkMs` from a timer — firing at all means the event loop is turning. The
  26. * child resets a kill-timer on each byte; if none arrives for `timeoutMs` it
  27. * `SIGKILL`s the parent so a fresh daemon starts on the next connection. When
  28. * the parent exits normally the pipe closes and the child exits too (no
  29. * orphan).
  30. *
  31. * **Won't fire on real work.** Heavy parsing runs in the parse worker
  32. * (off-thread) and the daemon's indexing shells out to a child process, so the
  33. * daemon's main thread only ever does fast, bounded work. The default timeout
  34. * is ~300× the 5h #850 wedge shorter, yet far longer than any legitimate
  35. * main-thread block. Opt out with `CODEGRAPH_NO_WATCHDOG=1`; tune with
  36. * `CODEGRAPH_WATCHDOG_TIMEOUT_MS`.
  37. *
  38. * **Disk-progress deferral (`progressPaths`).** The CLI `index`/`init` path is
  39. * different: it runs the SQLite store on this thread, and one long synchronous
  40. * statement on severely degraded storage can block the loop past the timeout
  41. * with the process perfectly healthy (#1231: killed a valid index on a
  42. * 150-IOPS disk). Heartbeat silence alone cannot tell that apart from a wedge —
  43. * but the disk can: a wedged CPU loop makes no forward progress on the DB
  44. * files, while a slow store advances them. When the caller supplies
  45. * `progressPaths` (the SQLite DB + `-wal`), the child checks them at each
  46. * silent timeout: size/mtime advanced ⇒ defer the kill and keep watching;
  47. * unchanged ⇒ kill as before. Deferral is bounded by a hard cap
  48. * (`PROGRESS_CAP_MULTIPLIER` × timeout) of continuous silence, so a wedge
  49. * coinciding with unrelated file activity — or I/O hung beyond all reason —
  50. * still dies. A true wedge with no disk progress dies at the base timeout,
  51. * exactly as before.
  52. */
  53. import * as fs from 'fs';
  54. import * as os from 'os';
  55. import { spawn, ChildProcess } from 'child_process';
  56. /** Default: 60s — ~300× shorter than the 5h #850 wedge, far longer than any real main-thread block. */
  57. export const DEFAULT_WATCHDOG_TIMEOUT_MS = 60_000;
  58. /**
  59. * Hard cap on disk-progress deferral: after this many timeouts' worth of
  60. * CONTINUOUS heartbeat silence the process is killed even if the watched files
  61. * keep advancing (a wedge coinciding with unrelated file writes, or I/O hung
  62. * beyond any legitimate statement). 10× the 60s default ⇒ 10 minutes.
  63. */
  64. export const PROGRESS_CAP_MULTIPLIER = 10;
  65. /** `true` for `1/true/yes/on` (case-insensitive); `false` otherwise. */
  66. function isEnvTruthy(raw: string | undefined): boolean {
  67. if (!raw) return false;
  68. return ['1', 'true', 'yes', 'on'].includes(raw.trim().toLowerCase());
  69. }
  70. /** Parse the timeout env, falling back to the default for missing/invalid values. */
  71. export function parseWatchdogTimeoutMs(
  72. raw: string | undefined,
  73. fallback: number = DEFAULT_WATCHDOG_TIMEOUT_MS
  74. ): number {
  75. if (raw === undefined) return fallback;
  76. const n = Number(raw);
  77. return Number.isFinite(n) && n > 0 ? n : fallback;
  78. }
  79. /** Derive a heartbeat cadence that emits several beats inside the timeout window. */
  80. export function deriveCheckIntervalMs(timeoutMs: number): number {
  81. return Math.min(2000, Math.max(50, Math.round(timeoutMs / 5)));
  82. }
  83. /** Arming/teardown diagnostics, gated on the existing MCP debug switch. */
  84. function debug(msg: string): void {
  85. if (process.env.CODEGRAPH_MCP_DEBUG) {
  86. try { fs.writeSync(2, `[CodeGraph watchdog] ${msg}\n`); } catch { /* ignore */ }
  87. }
  88. }
  89. export interface WatchdogHandle {
  90. /** Stop heartbeating and shut the watchdog child down. Idempotent. */
  91. stop(): void;
  92. }
  93. /**
  94. * The watchdog child body, run via `node -e`. Inlined as a string (not a
  95. * shipped `.js`) so there is no dist-vs-src path to resolve — it runs
  96. * identically under `tsx` in tests and under the bundle in production. Reads its
  97. * target pid + timeout from argv; an MSG built once at startup (the child is
  98. * never wedged, so allocation here is fine).
  99. */
  100. const CHILD_SOURCE = `
  101. const fs = require('fs');
  102. const parentPid = Number(process.argv[1]);
  103. const timeoutMs = Number(process.argv[2]);
  104. const capMs = Number(process.argv[3]);
  105. const progressPaths = process.argv.slice(4);
  106. const secs = Math.round(timeoutMs / 1000);
  107. function kill(extra) {
  108. // Timestamped so daemon.log kills can be correlated with anything (#1431) —
  109. // computed here at kill time; this child process is never the wedged one.
  110. try { fs.writeSync(2, Buffer.from('[' + new Date().toISOString() + '] [CodeGraph] Main thread unresponsive for ~' + secs + 's' + (extra || '') + ' — killing the wedged process so a fresh one can start (#850). Disable with CODEGRAPH_NO_WATCHDOG=1.\\n')); } catch (e) {}
  111. try { process.kill(parentPid, 'SIGKILL'); } catch (e) {}
  112. process.exit(0);
  113. }
  114. // Fingerprint of the watched files (size + mtime). A change between checks is
  115. // forward disk progress — a slow synchronous SQLite statement, not a wedge.
  116. function snap() {
  117. let s = '';
  118. for (const p of progressPaths) {
  119. try { const st = fs.statSync(p); s += st.size + ':' + st.mtimeMs + ';'; } catch (e) { s += 'x;'; }
  120. }
  121. return s;
  122. }
  123. let lastSnap = progressPaths.length ? snap() : '';
  124. let lastSnapAt = Date.now();
  125. let silentSince = null; // start of the current continuous-silence episode
  126. function onTimeout() {
  127. if (!progressPaths.length) return kill('');
  128. const now = Date.now();
  129. if (silentSince === null) silentSince = now - timeoutMs; // silence began ~one timeout ago
  130. const cur = snap();
  131. if (cur !== lastSnap && now - silentSince < capMs) {
  132. // The event loop is blocked but the DB files are advancing: a legitimate
  133. // long store on slow storage. Defer, re-baseline, keep watching.
  134. lastSnap = cur;
  135. timer = setTimeout(onTimeout, timeoutMs);
  136. return;
  137. }
  138. kill(cur !== lastSnap ? ' despite ongoing disk activity (hard cap ' + Math.round(capMs / 1000) + 's reached)' : '');
  139. }
  140. let timer = setTimeout(onTimeout, timeoutMs);
  141. process.stdin.on('data', () => {
  142. silentSince = null;
  143. // Keep the baseline fresh while healthy (throttled — a stat per second).
  144. if (progressPaths.length) {
  145. const t = Date.now();
  146. if (t - lastSnapAt >= 1000) { lastSnap = snap(); lastSnapAt = t; }
  147. }
  148. clearTimeout(timer); timer = setTimeout(onTimeout, timeoutMs);
  149. });
  150. process.stdin.on('end', () => process.exit(0)); // parent closed the pipe (exited) -> no orphan
  151. process.stdin.on('error', () => process.exit(0)); // pipe broke -> parent gone
  152. process.stdin.resume();
  153. `;
  154. export interface WatchdogOptions {
  155. /**
  156. * Files whose size/mtime advancing counts as forward progress (the SQLite
  157. * DB + `-wal` for an in-process indexer). With paths supplied, a silent
  158. * timeout only kills when the files did NOT advance — see the header. Omit
  159. * for pure heartbeat behavior (the daemon, whose main thread never runs
  160. * long synchronous work).
  161. */
  162. progressPaths?: string[];
  163. }
  164. /**
  165. * Install the main-thread liveness watchdog for a long-lived process. Returns a
  166. * handle to stop it, or `null` when disabled or when the child can't be spawned
  167. * (degraded, never throws — a missing watchdog must never keep a process from
  168. * starting).
  169. */
  170. export function installMainThreadWatchdog(options: WatchdogOptions = {}): WatchdogHandle | null {
  171. if (isEnvTruthy(process.env.CODEGRAPH_NO_WATCHDOG)) return null;
  172. const timeoutMs = parseWatchdogTimeoutMs(process.env.CODEGRAPH_WATCHDOG_TIMEOUT_MS);
  173. const checkMs = deriveCheckIntervalMs(timeoutMs);
  174. const capMs = timeoutMs * PROGRESS_CAP_MULTIPLIER;
  175. const progressPaths = options.progressPaths ?? [];
  176. let child: ChildProcess;
  177. try {
  178. // No execArgv inheritance (unlike Worker), so the child carries none of our
  179. // V8 flags — it runs no WASM and needs none. stderr inherits the parent's
  180. // fd 2 so the kill notice lands wherever the parent logs (daemon.log).
  181. child = spawn(
  182. process.execPath,
  183. ['-e', CHILD_SOURCE, String(process.pid), String(timeoutMs), String(capMs), ...progressPaths],
  184. {
  185. stdio: ['pipe', 'ignore', 'inherit'],
  186. windowsHide: true,
  187. // The watchdog touches no files; keep its cwd off the project/temp dir
  188. // so it can't hold one open (Windows EPERM-on-cleanup, mirrors the
  189. // parse-worker quirk).
  190. cwd: os.tmpdir(),
  191. }
  192. );
  193. } catch (err) {
  194. debug(`spawn failed: ${err instanceof Error ? err.message : String(err)}`);
  195. return null;
  196. }
  197. const stdin = child.stdin;
  198. if (!stdin) {
  199. debug('child has no stdin pipe; not arming');
  200. try { child.kill(); } catch { /* ignore */ }
  201. return null;
  202. }
  203. // Writing after the child exits surfaces EPIPE on the stream — swallow it so
  204. // it can't escalate to the global handler (which now exits, #850).
  205. stdin.on('error', () => { /* child gone; heartbeat writes are best-effort */ });
  206. child.on('error', (err) => debug(`child error: ${err.message}`));
  207. // Heartbeat: a byte per tick. When the main thread wedges, these stop and the
  208. // child's timeout fires. unref'd so it never keeps the process alive itself.
  209. const heartbeat = setInterval(() => {
  210. try { stdin.write('\n'); } catch { /* child gone */ }
  211. }, checkMs);
  212. heartbeat.unref();
  213. // Neither the child nor its pipe should keep the parent alive past its work.
  214. child.unref();
  215. try { (stdin as unknown as { unref?: () => void }).unref?.(); } catch { /* ignore */ }
  216. debug(`armed (child pid ${child.pid ?? '?'}): timeoutMs=${timeoutMs} checkMs=${checkMs} progressPaths=${progressPaths.length}`);
  217. let stopped = false;
  218. return {
  219. stop(): void {
  220. if (stopped) return;
  221. stopped = true;
  222. clearInterval(heartbeat);
  223. try { stdin.end(); } catch { /* ignore */ } // EOF -> child exits cleanly
  224. try { child.kill(); } catch { /* ignore */ } // belt-and-suspenders
  225. },
  226. };
  227. }