()
| 106 | * starting). |
| 107 | */ |
| 108 | export function installMainThreadWatchdog(): WatchdogHandle | null { |
| 109 | if (isEnvTruthy(process.env.CODEGRAPH_NO_WATCHDOG)) return null; |
| 110 | |
| 111 | const timeoutMs = parseWatchdogTimeoutMs(process.env.CODEGRAPH_WATCHDOG_TIMEOUT_MS); |
| 112 | const checkMs = deriveCheckIntervalMs(timeoutMs); |
| 113 | |
| 114 | let child: ChildProcess; |
| 115 | try { |
| 116 | // No execArgv inheritance (unlike Worker), so the child carries none of our |
| 117 | // V8 flags — it runs no WASM and needs none. stderr inherits the parent's |
| 118 | // fd 2 so the kill notice lands wherever the parent logs (daemon.log). |
| 119 | child = spawn( |
| 120 | process.execPath, |
| 121 | ['-e', CHILD_SOURCE, String(process.pid), String(timeoutMs)], |
| 122 | { |
| 123 | stdio: ['pipe', 'ignore', 'inherit'], |
| 124 | windowsHide: true, |
| 125 | // The watchdog touches no files; keep its cwd off the project/temp dir |
| 126 | // so it can't hold one open (Windows EPERM-on-cleanup, mirrors the |
| 127 | // parse-worker quirk). |
| 128 | cwd: os.tmpdir(), |
| 129 | } |
| 130 | ); |
| 131 | } catch (err) { |
| 132 | debug(`spawn failed: ${err instanceof Error ? err.message : String(err)}`); |
| 133 | return null; |
| 134 | } |
| 135 | |
| 136 | const stdin = child.stdin; |
| 137 | if (!stdin) { |
| 138 | debug('child has no stdin pipe; not arming'); |
| 139 | try { child.kill(); } catch { /* ignore */ } |
| 140 | return null; |
| 141 | } |
| 142 | // Writing after the child exits surfaces EPIPE on the stream — swallow it so |
| 143 | // it can't escalate to the global handler (which now exits, #850). |
| 144 | stdin.on('error', () => { /* child gone; heartbeat writes are best-effort */ }); |
| 145 | child.on('error', (err) => debug(`child error: ${err.message}`)); |
| 146 | |
| 147 | // Heartbeat: a byte per tick. When the main thread wedges, these stop and the |
| 148 | // child's timeout fires. unref'd so it never keeps the process alive itself. |
| 149 | const heartbeat = setInterval(() => { |
| 150 | try { stdin.write('\n'); } catch { /* child gone */ } |
| 151 | }, checkMs); |
| 152 | heartbeat.unref(); |
| 153 | |
| 154 | // Neither the child nor its pipe should keep the parent alive past its work. |
| 155 | child.unref(); |
| 156 | try { (stdin as unknown as { unref?: () => void }).unref?.(); } catch { /* ignore */ } |
| 157 | |
| 158 | debug(`armed (child pid ${child.pid ?? '?'}): timeoutMs=${timeoutMs} checkMs=${checkMs}`); |
| 159 | |
| 160 | let stopped = false; |
| 161 | return { |
| 162 | stop(): void { |
| 163 | if (stopped) return; |
| 164 | stopped = true; |
| 165 | clearInterval(heartbeat); |
no test coverage detected