Skip to content

Windows: timed-out/abandoned runs leak orphaned process trees (proc.kill() doesn't kill child Node tree) → host-wide resource exhaustion #241

Description

@tbertran

Summary

On Windows, timed-out or abandoned claude runs leak orphaned process trees. The daemon kills only the immediate subprocess handle, but on Windows that handle is a thin claude.exe launcher whose real work runs in a child Node process tree. The signal never reaches the children, so they survive indefinitely. Over hours/days of normal operation these orphans accumulate and exhaust OS resources (handles, sockets/ephemeral ports, pipes), until process creation and IPC stall machine-wide — at which point even launching cmd.exe times out and every claude invocation takes minutes. A reboot fully clears it; nothing in userland does.

This is a slow-burn reliability bug: the daemon degrades the host it runs on the longer it runs.

Affected code

src/runner.ts (v1.0.39), the timeout/abandon paths:

// runClaudeOnce — ~L454-456
try { proc.kill("SIGTERM"); } catch {}
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000);

The same pattern exists in the streaming runner (~L588-589). In both cases proc is the Bun.spawn(...) handle for CLAUDE_EXECUTABLE.

Why it leaks on Windows

  • CLAUDE_EXECUTABLE resolves to claude.exe, which is a launcher that spawns a child Node process (and that child may itself spawn subagent processes).
  • Bun.spawn(...).kill(signal) on Windows terminates only the top launcher process, not its descendants. Windows has no signal-propagation-to-tree semantics; killing a parent does not kill children.
  • Result: launcher dies, the Node worker (and any subagents) orphan and keep running. The 5s SIGKILL fallback targets the same already-dead launcher handle, so it does nothing for the survivors.

Second leak path: daemon restart/crash

When the daemon process itself exits (crash, manual restart, or the scheduled-task/VBS self-heal relaunch), every in-flight child is abandoned with no kill at all — the mainActiveProcs handles simply die with the parent. There is no shutdown reaper that tree-kills active runs before exit, so each restart strands whatever was running.

Symptoms / evidence observed

  • A trivial claude -p "reply pong" --max-turns 1 measured 4m15s wall-clock with ~0 CPU (user 0m0.000s, sys 0m0.015s) — i.e. blocked waiting on the OS to hand out a process/socket/handle, not computing. Plenty of free RAM (~49%), so not memory pressure.
  • Daemon log repeatedly showed Intent classifier error: spawnSync <...>\cmd.exe ETIMEDOUT (15s timeout) — spawning cmd.exe itself was timing out, which is never an app-level problem; it's OS process-creation starvation.
  • Get-CimInstance Win32_Process -Filter "Name='claude.exe'" showed many orphaned CLI claude.exe + child Node processes whose spawning daemon was long gone.
  • A full OS reboot restored normal (seconds, not minutes) behavior instantly.

Note for triage: on Windows the Claude desktop app (Electron, under ...\WindowsApps\Claude...\Claude.exe) also shows up as many claude.exe processes with --type=renderer/gpu-process/utility/crashpad-handler. Those are NOT the leak — they're the desktop app's normal helper swarm. The leak is the CLI claude.exe (from the user-bin install) and its child Node processes.

Suggested fix

  1. Tree-kill on timeout/abandon (per-OS):
    • Windows: taskkill /PID <pid> /T /F — the /T flag terminates the entire child tree. Requires capturing proc.pid.
    • POSIX: spawn the child in its own process group (detached: true) and kill the group (process.kill(-pid, "SIGTERM") then SIGKILL).
  2. Robust Windows option: launch each run inside a Win32 Job Object with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, so the whole tree dies automatically when the job/daemon closes — immune to launcher-vs-child mismatch.
  3. Shutdown reaper: on daemon SIGINT/SIGTERM/exit (and before the scheduled-task/VBS relaunch path), iterate mainActiveProcs and tree-kill each before exiting, so restarts don't strand in-flight runs.
  4. The existing 5s SIGKILL fallback should target the resolved tree/group, not the already-exited launcher handle.

Environment

  • OS: Windows 11
  • claudeclaw: 1.0.39
  • Runtime: Bun (daemon launched via a hidden VBS under a Windows Scheduled Task)
  • Claude Code CLI: 2.1.x
  • Channels in use: Discord + heartbeat (not channel-specific; the kill path is shared by all run types)

Repro

  1. On Windows, run the daemon with any channel/heartbeat that occasionally hits its timeout (default 5m), or restart the daemon a few times while runs are in flight.
  2. Inspect claude.exe + node.exe processes: orphans persist with dead parent PIDs.
  3. Over a long uptime, observe process creation slowing; eventually spawnSync cmd.exe times out and every claude call takes minutes.
  4. Reboot → normal behavior returns immediately (confirming OS-resource exhaustion, not config/model).

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