Orphaned Bash-tool subprocesses (tsc/vitest) outlive a terminated session and run unsupervised for hours
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 --noEmitprocess ran for 5 hours 32 minutes (472+ CPU-minutes), orphaned (PPID=1), from a session whose session list entry was markedisRunning: false(i.e. the owning session was no longer active). - A day earlier, a similar orphaned
tscprocess 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
- Start a Claude Code session in a git worktree.
- Have it run a long-lived Bash command (e.g.
npm exec tsc --noEmiton a large codebase, orvitest runon a large suite). - 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.
- Observe with
ps -axo pid,ppid,etime,commandthat the spawned subprocess (or itsnpm execwrapper) is still running, withPPID=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.
3 Comments
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
tscorvitest, 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:
kill -TERMwas enough for the three shell loops. I did not test signals against thetail -4before 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.
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
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:
python3 - <<'EOF' ... EOF, and the script had an infinite loop.pkill -f 'python3 -'to clean up./bin/zsh -c "source ... && eval '<command>' ..."), so the pattern matched the background task's wrapper. macOSpkillexcludes 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.python3execs.../Python.app/Contents/MacOS/Python -, so the case-sensitive pattern never matched the real interpreter.failed,exit code 144. The interpreter was reparented to PID 1, with stdout/stderr still open on/private/tmp/claude-501/.../tasks/<id>.output.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).