Background processes leak as orphans (kill %1 silently fails in non-interactive shell)

Open 💬 0 comments Opened Jul 12, 2026 by slamdunk111

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/zsh with 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)

  1. 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 has MONITOR off by default, so %1 is never a valid job specifier. kill %1 produces kill: no such job which is swallowed by 2>/dev/null. Claude (the model) assumes kill %1 works; under Claude Code's own execution wrapper it does not.
  1. Pipe rupture does not terminate the producer. fs_usage | grepgrep exits after sleep 0, the pipe breaks, but fs_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).
  1. Reparenting to init. The parent zsh -c exits as soon as the eval completes. The orphaned fs_usage (and its sudo wrapper) 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 eval block returns, reap/terminate any child PIDs recorded for that command — not relying on shell job-control %N specifiers that are invalid in non-interactive mode.
  • Detect that the command contains & and either reject the kill %1 pattern or rewrite it to use a recorded PID (pid=$!; ...; kill "$pid").
  • At minimum, wrap the command so that on shell exit a trap kills 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.

View original on GitHub ↗