(options: WatchdogOptions = {})
| 178 | * starting). |
| 179 | */ |
| 180 | export function installMainThreadWatchdog(options: WatchdogOptions = {}): WatchdogHandle | null { |
| 181 | if (isEnvTruthy(process.env.CODEGRAPH_NO_WATCHDOG)) return null; |
| 182 | |
| 183 | const timeoutMs = parseWatchdogTimeoutMs(process.env.CODEGRAPH_WATCHDOG_TIMEOUT_MS); |
| 184 | const checkMs = deriveCheckIntervalMs(timeoutMs); |
| 185 | const capMs = timeoutMs * PROGRESS_CAP_MULTIPLIER; |
| 186 | const progressPaths = options.progressPaths ?? []; |
| 187 | |
| 188 | let child: ChildProcess; |
| 189 | try { |
| 190 | // No execArgv inheritance (unlike Worker), so the child carries none of our |
| 191 | // V8 flags — it runs no WASM and needs none. stderr inherits the parent's |
| 192 | // fd 2 so the kill notice lands wherever the parent logs (daemon.log). |
| 193 | child = spawn( |
| 194 | process.execPath, |
| 195 | ['-e', CHILD_SOURCE, String(process.pid), String(timeoutMs), String(capMs), ...progressPaths], |
| 196 | { |
| 197 | stdio: ['pipe', 'ignore', 'inherit'], |
| 198 | windowsHide: true, |
| 199 | // The watchdog touches no files; keep its cwd off the project/temp dir |
| 200 | // so it can't hold one open (Windows EPERM-on-cleanup, mirrors the |
| 201 | // parse-worker quirk). |
| 202 | cwd: os.tmpdir(), |
| 203 | } |
| 204 | ); |
| 205 | } catch (err) { |
| 206 | debug(`spawn failed: ${err instanceof Error ? err.message : String(err)}`); |
| 207 | return null; |
| 208 | } |
| 209 | |
| 210 | const stdin = child.stdin; |
| 211 | if (!stdin) { |
| 212 | debug('child has no stdin pipe; not arming'); |
| 213 | try { child.kill(); } catch { /* ignore */ } |
| 214 | return null; |
| 215 | } |
| 216 | // Writing after the child exits surfaces EPIPE on the stream — swallow it so |
| 217 | // it can't escalate to the global handler (which now exits, #850). |
| 218 | stdin.on('error', () => { /* child gone; heartbeat writes are best-effort */ }); |
| 219 | child.on('error', (err) => debug(`child error: ${err.message}`)); |
| 220 | |
| 221 | // Heartbeat: a byte per tick. When the main thread wedges, these stop and the |
| 222 | // child's timeout fires. unref'd so it never keeps the process alive itself. |
| 223 | const heartbeat = setInterval(() => { |
| 224 | try { stdin.write('\n'); } catch { /* child gone */ } |
| 225 | }, checkMs); |
| 226 | heartbeat.unref(); |
| 227 | |
| 228 | // Neither the child nor its pipe should keep the parent alive past its work. |
| 229 | child.unref(); |
| 230 | try { (stdin as unknown as { unref?: () => void }).unref?.(); } catch { /* ignore */ } |
| 231 | |
| 232 | debug(`armed (child pid ${child.pid ?? '?'}): timeoutMs=${timeoutMs} checkMs=${checkMs} progressPaths=${progressPaths.length}`); |
| 233 | |
| 234 | let stopped = false; |
| 235 | return { |
| 236 | stop(): void { |
| 237 | if (stopped) return; |
no test coverage detected