Skip to content

Watchdog kill->respawn loop under memory exhaustion exhausts system commit and crashes unrelated apps (Windows, v1.2.0) #3137

Open

Description

@sageri

Summary

On Windows, the codegraph serve --mcp long-running worker (the --liftoff-only child) repeatedly ballooned to 38–41 GB of commit charge, wedged, was killed by the watchdog added for #850, and was respawned — then ballooned again. The result was a kill→respawn→re-blowup loop (9 different node.exe PIDs in ~22 minutes) that exhausted the system commit limit (49.7 GB) and crashed unrelated applications. The watchdog fixed the "wedged forever" CPU failure mode of #850, but it exposed a worse one under memory pressure: the respawn loop itself takes the whole machine down, because every fresh worker re-attempts the same work that blew it up in the first place.

This happened on v1.2.0; we have not yet reproduced on current versions. Filed because the loop dynamic (no respawn backoff, no memory guard, no client-visible error) is version-independent as long as the watchdog design stays as-is, and none of the open memory issues (#1553/#1583, #3027) cover it.

Environment

Item Value
codegraph @colbymchenry/codegraph 1.2.0 (latest is 1.6.2; several memory fixes landed in between — #900, #2111, #2006)
Invocation MCP stdio server: cmd /c codegraph serve --mcp → node.exe --liftoff-only .../lib/dist/bin/codegraph.js serve --mcp (bundled Node 24.16.0)
Client Codex CLI / Codex desktop, each session spawning its own MCP stack (including a fresh codegraph worker)
OS Windows 11 (build 26200), 16 GB RAM, commit limit 49.7 GB (auto-managed pagefile)
Index target Private monorepo working tree, 28,592 files / 234 MB, many JSON files (largest ~370–720 KB)
Date 2026-10-31, 16:46–17:08 (UTC+9)

What happened (timeline)

  1. Five client sessions were started within ~25 minutes while the target repo was being actively edited; each session start spawned a fresh codegraph worker.

  2. Windows Resource-Exhaustion-Detector (System log, event 2004) fired 11 times between 16:46:07 and 17:05:42. Every event names node.exe as the top committer, each time a different PID except the last, at 38–41 GB commit each:

    Time PID Commit (bytes)
    16:46:07 38796 38,849,732,608
    16:47:50 22568 40,931,942,400
    16:50:03 13300 40,550,477,824
    16:52:47 33408 40,462,024,704
    16:56:39 31140 39,835,422,720
    16:58:35 20260 40,462,966,784
    17:01:45 / 17:02:29 / 17:03:13 / 17:05:42 37132 ~38–41 GB (same PID across 4 minutes, 4 events)
  3. The worker's watchdog child (node -e, the codegraph cause 100% CPU #850 mitigation) SIGKILLs the main process once the stdin heartbeat stops for 60 s, "so a fresh one can start". The PID churn above is consistent with that kill→respawn cycle repeating. (Process-creation auditing was not enabled, so PID→binary mapping is inferred, not logged — codegraph was, however, the only node.exe that matches all three constraints: freshly spawned inside the window, equipped with the kill-and-respawn watchdog, and holding a large in-memory index.)

  4. Commit exhaustion took unrelated applications down with it: the MCP client itself crashed twice (16:47:51, 16:50:04), Microsoft Edge crashed twice, dwm.exe crashed once (desktop-wide UI stalls), PowerPoint hung, and even the Windows Error Reporting Service terminated with an error. To the user this looked like "the whole PC is extremely slow with fans pegged".

  5. The storm stopped only when the client-side retry loop stopped respawning sessions. Surviving codegraph instances then idled at 33–86 MB, so the blow-up appears to happen during active (re)indexing, not at rest.

Notably this incident did not look like the #850 CPU-peg (system CPU stayed modest throughout); it manifested as unbounded commit growth instead.

Why the watchdog alone can't cover this failure mode

The watchdog turns "wedged forever" into "wedged, killed, respawned" — strictly better for a CPU wedge. But for a memory-blowup wedge it produces a loop: every fresh worker starts (re)indexing from scratch, balloons again within ~1–4 minutes, gets killed again. When the MCP client auto-respawns the server (or the user retries), this repeats indefinitely, and every cycle degrades the whole OS — commit pressure makes unrelated processes fail allocations and crash. A single misbehaving index target ends up crashing the user's browser, shell, and window manager.

Suggestions

  1. Memory guard in the worker: cap the worker's heap (e.g. --max-old-space-size for the --liftoff-only child) and monitor external/buffer memory; abort indexing with a clear error to the client instead of growing until the OS commit limit is hit.
  2. Respawn backoff / circuit breaker: after N watchdog kills within M minutes, stop respawning and surface an error to the client, instead of looping. (This is the piece that would have capped yesterday's incident at one crash instead of nine.)
  3. Incremental index persistence: if a fresh worker rebuilds from scratch, persist enough state that a respawn doesn't redo the full pass that triggered the blow-up.
  4. Surface watchdog kills: emit the kill reason (and the last exception, if any) to stderr so MCP clients can log it. We had no local trace of the root cause — only the Resource-Exhaustion events.
  5. Possibly: documented ignore/scoping globs for the indexer, so users can exclude pathological inputs.

Happy to enable process auditing and reproduce with a profiler attached (or retest on 1.6.2 first and report back) — just tell me what data would help.

Related: #850 (the watchdog origin), #2111 (OOM during index rebuild, closed), #900 (large-repo indexing OOMs, closed), #1553/#1583 (in-flight memory bounding), #3027 (worker pool growth / every worker opens every project).

Activity

  1. johnhldiver-beep commented on Oct 31, 2026

    @johnhldiver-beep

    I dug into this against current main (2008e2f) because I cannot reproduce the Windows commit exhaustion on Linux: a 38-41 GB commit charge is a Windows commit-accounting outcome, and the report is against v1.2.0. What I can establish from the code and the incident timeline (9 node.exe PIDs in ~22 minutes) is the loop itself, and it is still present on current main.

    How the loop exists today

    • src/mcp/liveness-watchdog.ts is the codegraph cause 100% CPU #850 watchdog. Its child process writes one stderr line, then process.kill(parentPid, 'SIGKILL') when the event loop stops turning. It records nothing anywhere, so the next process knows nothing about the kill.
    • src/mcp/index.ts is what restarts the worker: the MCP client starts a fresh codegraph serve --mcp (the local-handshake proxy), and its getDaemonSocket() probes for a daemon, then spawnDetachedDaemon()s a new shared daemon when none answers. Nothing on that path consults the fact that this project's daemon was just killed by the watchdog.
    • The same file arms the watchdog for direct mode and for the detached daemon; src/mcp/proxy.ts arms one for the proxy. None reads a kill history, so every fresh process repeats the same work with no memory of the previous kill.
    • Consequence: work that drives the process's memory up until the event loop wedges gets killed, the client reconnects, a fresh daemon repeats it, and each restart re-wedges. That dynamic is version-independent for as long as the kill-and-restart design stays as it is, which matches the report that current versions can still show the loop even though v1.2.0's particular growth was not reproduced.

    Planned fix (no public API change)
    A small persisted kill record that a fresh process can see, plus a circuit breaker on the automatic restart path:

    1. The watchdog child appends a timestamp to <project>/.codegraph/watchdog-kills.jsonl immediately before it SIGKILLs, but only when its parent is armed for a project. Kill conditions themselves are untouched, so a real CPU wedge still dies exactly as codegraph cause 100% CPU #850 requires.
    2. A shared helper counts kills in a sliding window (default 3 kills in 10 minutes). When the window trips, an automatically spawned daemon refuses to start and logs why, and the proxy does not spawn another: it answers tool calls with one clear, client-visible refusal that names the repeated watchdog kills and the log to inspect, instead of re-running the same heavy work. A session already attached to a healthy daemon is unaffected; the breaker gates only the automatic restart/fallback path.
    3. Once the window passes with no further kills the record ages out and the next attempt starts normally, so this is a cooldown rather than a permanent disable. CODEGRAPH_NO_WATCHDOG=1 and the existing timeout and disk-progress deferral behavior are unchanged.

    The regression tests will prove on Linux that the child writes a parseable kill record when it kills; that the window accounting trips and expires; that a tripped launcher does not spawn a daemon and a tripped proxy returns the refusal; and that a real wedge is still killed. What they cannot prove here is the Windows commit-accounting behavior itself, which I will state plainly in the PR.

    Does this align with the project design?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions