Background processes leak as orphans (kill %1 silently fails in non-interactive shell)
Summary
Claude Code leaks orphaned background processes when executing commands containing & (background jobs) followed by a kill %1. The kill silently fails because Claude Code's non-interactive shell snapshot execution disables job control, so %1 is never a valid job spec. Long-running root-privilege tools like fs_usage then get reparented to PID 1 and burn CPU indefinitely.
In my case this leaked an fs_usage process that ran for 34 hours at 82-113% CPU before I noticed.
Environment
- Claude Code CLI (macOS host, zsh)
- Shell:
/bin/zshwith Claude Code shell snapshots
Reproduction
Claude generated a command shaped like this (real command from a session investigating a macOS proxy tampering issue):
sudo fs_usage -w -f filesys 2>/dev/null | grep preferences.plist & sleep 0; kill %1 2>/dev/null
Intent: start a background filesystem monitor, then immediately kill it after capturing a snapshot.
The command is executed via Claude Code's shell-snapshot mechanism:
/bin/zsh -c 'source /Users/.../.claude/shell-snapshots/snapshot-zsh-<ts>-<rand>.sh 2>/dev/null || true && setopt NO_EXTENDED_GLOB NO_BARE_GLOB_QUAL 2>/dev/null || true && eval '"'"'<the command>'"'"' < /dev/null && pwd -P >| /tmp/claude-<id>-cwd'
Root cause (double failure)
- Job control is disabled. The command runs under
zsh -c '...' < /dev/null— a non-interactive shell with stdin redirected from/dev/null. Non-interactive zsh hasMONITORoff by default, so%1is never a valid job specifier.kill %1produceskill: no such jobwhich is swallowed by2>/dev/null. Claude (the model) assumeskill %1works; under Claude Code's own execution wrapper it does not.
- Pipe rupture does not terminate the producer.
fs_usage | grep—grepexits aftersleep 0, the pipe breaks, butfs_usage(a root-privilege kernel-event stream tool) ignores SIGPIPE and keeps producing output into a closed pipe forever. It only stops on explicit SIGTERM/SIGKILL, which never arrives (see #1).
- Reparenting to init. The parent
zsh -cexits as soon as theevalcompletes. The orphanedfs_usage(and itssudowrapper) are reparented to PID 1 with no mechanism to reclaim them.
Impact
Every time Claude runs a "briefly observe fs/net events then stop" style command, a root process leaks at high CPU until manually discovered and killed. In my case: one fs_usage (82-113% CPU) + one ugrep (6-40% CPU) orphaned for 34 hours on a 16 GB Mac mini, contributing to sustained load avg ~5 and memory pressure.
Expected behavior
Claude Code should guarantee termination of any background process it starts. Concretely, one or more of:
- Run each command in its own process group (
setsid/setpgid) and kill the entire group (kill -- -PGID) on completion, so children cannot escape. - After the
evalblock returns, reap/terminate any child PIDs recorded for that command — not relying on shell job-control%Nspecifiers that are invalid in non-interactive mode. - Detect that the command contains
&and either reject thekill %1pattern or rewrite it to use a recorded PID (pid=$!; ...; kill "$pid"). - At minimum, wrap the command so that on shell exit a
trapkills all descendants.
Workaround for users
Manually find and kill the orphans:
pgrep -l fs_usage
sudo kill -9 <pid>
Related
This is a safety/cleanup gap in the shell-execution layer, not a model bug per se — but the model reliably generates the cmd & sleep 0; kill %1 idiom because it's correct in a normal interactive shell. Claude Code's execution wrapper makes that idiom silently broken, so the harness should either support it or prevent it.