Orphaned Bash-tool subprocesses (tsc/vitest) outlive a terminated session and run unsupervised for hours

Status Open
Maintainer reply None cached
Activity 3 comments · opened Sep 13, 2026

Description

When a Claude Code session is stopped, crashes, or is cleared/closed while it has a long-running Bash-tool subprocess in flight (e.g. tsc --noEmit, vitest run), that subprocess is not terminated. It gets reparented to PID 1 (standard Unix orphaning) and keeps running to completion, uninterrupted, consuming CPU/RAM for as long as it takes.

On a repo with a large/growing test suite, a single such run can legitimately take a long time, which widens the window in which this matters. In practice this has now shown up two days in a row on the same machine:

  • A tsc --noEmit process ran for 5 hours 32 minutes (472+ CPU-minutes), orphaned (PPID=1), from a session whose session list entry was marked isRunning: false (i.e. the owning session was no longer active).
  • A day earlier, a similar orphaned tsc process pushed the machine's swap usage to 23.2 of 24.5 GB, making the machine "extremely hot and slow" for the user.

Steps to reproduce

  1. Start a Claude Code session in a git worktree.
  2. Have it run a long-lived Bash command (e.g. npm exec tsc --noEmit on a large codebase, or vitest run on a large suite).
  3. While that command is still running, stop/kill/crash the session (e.g. close the app, kill the underlying process, or otherwise terminate it abnormally) rather than letting the tool call finish normally.
  4. Observe with ps -axo pid,ppid,etime,command that the spawned subprocess (or its npm exec wrapper) is still running, with PPID=1, indefinitely.

Expected behavior

When a session's own process terminates (for any reason), the full process tree of any Bash-tool subprocess it spawned should be terminated as well (e.g. by running commands in their own process group and sending that group a signal on session teardown), not just detached and left to run to completion untracked.

Actual behavior

The subprocess survives session termination, gets reparented to PID 1, and runs unsupervised — sometimes for hours — consuming machine resources with no owner left to notice or stop it.

Environment

  • macOS (Darwin 27.0.0)
  • Repro involved multiple concurrent worktree-based sessions on the same machine

Workaround

We've added a conservative, narrowly-scoped cleanup step to our own repo's SessionStart hook that detects and kills orphaned (PPID=1) tsc/vitest one-shot processes rooted in one of our own worktree paths, older than 30 minutes, only when the worktree's git status is clean. This mitigates the symptom on our end but doesn't address the root cause, which is in the harness's process lifecycle management.

View original on GitHub ↗

3 Comments

anik8das · 1 month ago

Adding a macOS data point with much longer durations than the ones in the report, same failure mode.

Two orphans on this machine, both reparented to PPID=1, both with stdout still pointed at a Claude Code task output file under /private/tmp/claude-501/.../tasks/*.output:

| process | age when found | originating cwd |
|---|---|---|
| sh -c while :; do sleep 0.2; done (3 instances) | 9 days 4 hours | $TMPDIR/crq-review-313-1_*/src (since deleted) |
| tail -4 | 12 days 3 hours | a worktree under ~/coding/.worktrees/ |

The three shell loops are the part I would draw attention to. They are not finite jobs like tsc or vitest, which at least terminate on their own eventually, so the orphan window in your report is bounded by however long the build takes. These are a busy-wait that never completes. Each one wakes five times a second, so together they burned roughly 11.6 million wakeups over nine days, and they would still be running now had I not killed them by hand.

What I could establish about them:

  • The session that spawned them no longer existed. Their parent was launchd.
  • Their cwd was a temp directory that had already been deleted, so nothing on the filesystem connected them to live work.
  • Their stdout was still a task output file. The harness had a handle on the task and never reaped it.
  • kill -TERM was enough for the three shell loops. I did not test signals against the tail -4 before it exited on its own, so I can only report the loops.

Environment: macOS, Darwin 25.5.0, arm64. Claude Code 2.1.236 at time of writing, machine up 14 days with 8 concurrent sessions. Both orphans were created before the installed version, on 2026-09-01 and 2026-09-04, so I cannot say whether 2.1.270 still reproduces them. I have not retested since upgrading.

Why it mattered here rather than being cosmetic: these accumulated alongside about a hundred other leaked helper processes from other tools, and the machine reached swap 6.2 of 7 GB with load average 9.5. That is what surfaced it, as a "your system has run out of application memory" dialog. Individually these are 1.6 MB each and would never be noticed. Collectively they are part of what pushes a 24 GB box over.

If it would help, I can instrument the teardown path or run a build with process accounting on and share the tree, since I still have the reproduction environment.

Roy3838 · 1 month ago

Hey! A process quietly running for 5+ hours with no one the wiser is such a specific pain, built a free/FOSS tool for it:
https://github.com/Roy3838/Observer
It runs a tiny local agent watching your terminal/screen so even an "orphaned" job that the session lost track of still gets noticed, and it'll ping or call you when it actually finishes. Runs locally, nothing leaves your machine. Any questions feel free to reach out :D

zzwong · 29 days ago

Another macOS data point, with a different path to the orphan: the session never ended. The background task was marked finished while its real process kept running.

Environment: Claude Code 2.1.269/2.1.270 (no fix in the changelogs through 2.1.273), macOS 15 (Darwin 24.6), Python 3.11, headless background session.

Chain, reconstructed from the session transcript:

  1. The model ran python3 - <<'EOF' ... EOF, and the script had an infinite loop.
  2. After 120s the Bash tool moved it to a background task.
  3. A later Bash call ran pkill -f 'python3 -' to clean up.
  4. As in #93607, the wrapper shell's argv contains the full command text (/bin/zsh -c "source ... && eval '<command>' ..."), so the pattern matched the background task's wrapper. macOS pkill excludes its own ancestors by default (man pgrep, -a), so the calling shell was not killed and that call succeeded. That is the difference from the Linux self-kill in #93607.
  5. python3 execs .../Python.app/Contents/MacOS/Python -, so the case-sensitive pattern never matched the real interpreter.
  6. The task notification reported failed, exit code 144. The interpreter was reparented to PID 1, with stdout/stderr still open on /private/tmp/claude-501/.../tasks/<id>.output.
  7. The session finished normally. The process ran at 100% CPU for about 4 days (68 CPU-hours) until it was killed by hand.

From Claude Code's side the task had ended, so cleanup at session end had nothing left to stop. The fix proposed here, killing the task's whole process group or tree, would cover this if it also runs whenever a background task's wrapper exits for any reason, not only at session teardown. Bash tool shells already lead their own process group (the wrapper's PGID equals its PID), and the interpreter was a plain child with no setsid, so a group kill when the wrapper exits should have caught it.

Related: #93607 (pkill/pgrep matching wrapper argv), #90672 (timeout doesn't kill the child tree).