Skip to content

Hook processes leak and busy-spin indefinitely, pinning the machine (load avg 180) #1560

Description

@benoitdepaire

Severity: High — degrades the whole machine, silently, over normal multi-day use.

Environment

  • LifeOS 6.0.5
  • bun 1.3.8
  • macOS 14.6.1 (Intel), launchd/GUI session

Summary

Over several days of normal use, PostToolUse / UserPromptSubmit hook processes
(bun ~/.claude/hooks/ToolActivityTracker.hook.ts and
~/.claude/hooks/SatisfactionCapture.hook.ts) fail to exit and actively burn CPU
for days
, accumulating one leaked, spinning process at a time. On my machine this
reached 11 stuck hook processes totalling ~900 CPU-hours, driving the 1-minute
load average to 180
(healthy is ~4–8). Killing the 11 processes immediately dropped
load back toward normal.

Crucially, these hooks have timeout set in settings.json (5s for
ToolActivityTracker, 20s for SatisfactionCapture) — yet the leaked processes had
elapsed times of 3–10 days. So whatever the timeout is meant to do, it is not
terminating the process
.

Evidence

ps -axo pid,etime,time,command for the leaked hooks (representative rows):

ETIME        CPUTIME        COMMAND
08-22:17:35  5826:41 (~97h) bun ~/.claude/hooks/ToolActivityTracker.hook.ts
10-09:54:38  7433:19 (~124h) bun ~/.claude/hooks/ToolActivityTracker.hook.ts
03-11:17:29  2051:46 (~34h) bun ~/.claude/hooks/SatisfactionCapture.hook.ts
...11 such processes, ~900 CPU-hours total...

CPU-time / elapsed ≈ 45% sustained CPU each — i.e. they are busy-spinning, not
idling on a blocked await (a hung await would sit near 0% CPU). Load average timeline
across the fix:

before kill:  load average: 180.03 150.82 147.79
after kill 11: load average:  34.39 106.46 130.86  (1-min avg falling)

What I ruled out (via source review)

The obvious suspect — the event-sourced work-registry write path
(bumpLastToolActivity → writeRegistry → foldToSnapshot, hooks/lib/work-events.ts) —
appears clean:

  • foldToSnapshot() is straight-line: acquireLock() returns on contention (no
    spin-wait), single heartbeat write, replay, tmp+rename, release. No loop, no
    setInterval.
  • parseEventLines() is a bounded for loop that always advances (no non-advancing
    parse loop on malformed input).
  • work-events.jsonl is only ~491K (compaction is working); no stuck *.lock dir.

So the spin is not in the work-registry fold.

ToolActivityTracker.hook.ts itself also looks correct in isolation: 2s stdin-read
timeout, wrapped in try/catch, ends in process.exit(0).

Confirmed secondary bug — duplicate hook registration

settings.json registers two hooks twice on UserPromptSubmit:

DUPLICATE x2: ~/.claude/hooks/PromptProcessing.hook.ts
DUPLICATE x2: ~/.claude/hooks/SatisfactionCapture.hook.ts

This doubles invocations (and therefore doubles the leak rate) on every prompt. It is
independently wrong and worth fixing regardless of the leak root cause.

Leading hypotheses (not yet definitively proven)

  1. Timeout does not reap the process. The timeout in settings.json is set but
    the leaked processes lived for days — so either the hook timeout doesn't SIGKILL the
    bun process, or it kills only a direct child and orphans others. Once orphaned
    (reparented away from Claude Code), nothing reaps them.

  2. Orphaned bun process busy-spins on its stdin pipe. Both leaking hooks attach
    process.stdin.on('data', …). When a hook is orphaned mid-run, the stdin pipe can
    enter a half-open/broken state; a readable stream on a broken fd can spin the event
    loop (data/readable in a tight loop) at high CPU without ever reaching
    process.exit(0). This matches the ~45%-CPU-forever signature and the fact that
    both hooks (very different bodies) leak identically — the common factor is stdin +
    orphaning, not the hook body.

  3. Self-amplifying via inference(). SatisfactionCapture calls
    inference() (LIFEOS/TOOLS/Inference.ts) on every prompt, which spawns
    claude --print — a heavy, slow subprocess (its own comment notes contention "for
    the same claude --print slot"). Under load these easily exceed the 20s hook timeout,
    which increases orphaning; the orphans raise load, which slows the next inference,
    which times out sooner… a feedback loop. Inference.ts on timeout does
    proc.kill('SIGTERM') on the direct child only (not the process group, no
    SIGKILL escalation), so a child that ignores SIGTERM or has grandchildren survives.

Reproduction / how to confirm

  1. Use a session heavily for a while (many tool calls / prompts) across days.
  2. Periodically: ps -axo pid,etime,time,command | grep 'hooks/.*\.hook\.ts' — watch
    for hook processes with elapsed/CPU-time far exceeding their configured timeout.
  3. Confirm they are spinning (high CPU) rather than blocked: top -pid <pid>.

Suggested fixes (for maintainers to consider)

  • Reap reliably: ensure hook processes are hard-killed (SIGKILL) when they exceed
    their timeout, and kill the whole process group (spawn hooks/inference with a new
    session/detached + kill(-pgid) on timeout) so spawned claude --print children
    don't orphan.
  • Guard the hook body with a self-timeout + hard exit: wrap each hook's main() in
    a top-level setTimeout(() => process.exit(0), N) (unref'd) as a belt-and-braces
    self-terminate, independent of Claude Code's timeout.
  • Harden stdin reads: after resolving readStdin (timer or end), explicitly
    process.stdin.destroy() / pause() and remove listeners so a broken pipe can't spin
    the loop; consider process.stdin.unref().
  • De-duplicate the settings.json UserPromptSubmit registrations for
    PromptProcessing and SatisfactionCapture.
  • Reconsider spawning claude --print synchronously from a per-prompt hook — it's
    the heaviest, most timeout-prone thing in the hot path and the most likely to orphan.

Workaround (until fixed)

# kill runaway hook processes with > ~10 min of accumulated CPU (a hook should take ms)
ps -axo pid,time,command | awk '/bun .*\.hook\.ts/ {
  split($2,t,":"); s=t[1]*60; if (length(t)==3) s=t[1]*3600+t[2]*60;
  if (s>600) print $1
}' | xargs -r kill -9

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