macOS: background Bash tasks orphaned at PID 1 with their .output files already unlinked — 30 spinning shells held 7.6/10 cores for 25h (2.1.245)

Status Open
Reported on v2.1.245
Maintainer reply None cached
Activity 1 comment · opened Aug 25, 2026

Summary

On macOS, harness-registered background Bash tasks (run_in_background: true) survived
their session and were adopted by launchd at PPID 1. Thirty spinning zsh processes held
~7.6 of 10 cores for 25 hours before I found them by accident.

The distinguishing detail: Claude Code had already deleted the tasks' .output files
while the processes were still alive and holding write fds on them. Some cleanup path ran and
reaped the bookkeeping without terminating, or even signalling, the processes it belonged to.

  • Claude Code 2.1.245
  • macOS 26.6.2 (Darwin 25.6.0), arm64, 10-core Apple Silicon

Forensics from the live incident

Thirty /bin/zsh processes, all PPID 1, in three groups of ten by start time
(2026-08-24 14:13:09, 14:15:15, 14:16:48 — matching three background tasks):

  PID  PPID  %CPU     ELAPSED COMMAND
92318     1  21.2 01-01:17:38 /bin/zsh
92319     1  21.6 01-01:17:38 /bin/zsh
...  (30 total, ~21-28% CPU each)

sample on one of them — 872 of 872 stack samples in a pure in-shell loop, no syscalls
beyond sigprocmask from zsh's own signal blocking. execstring means these are zsh -c
string bodies, i.e. tool-spawned, not interactive shells:

872 zsh_main + 1272
  872 init_misc + 152
    872 execstring + 132
      872 execode + 188
        ...
          872 execbuiltin + 1628
            ...
              872 execfor + 1072
                ...
                  440 execwhile + 464

lsof — fd 0 is /dev/null, fds 1 and 2 point at a task output file, ten processes per file
across exactly three files:

zsh 92318 user  cwd  DIR  /Users/<user>/code/<project>
zsh 92318 user   0r  CHR  /dev/null
zsh 92318 user   1w  REG  /private/tmp/claude-501/-Users-<user>-code-<project>/<session-uuid>/tasks/<taskid>.output
zsh 92318 user   2w  REG  /private/tmp/claude-501/-Users-<user>-code-<project>/<session-uuid>/tasks/<taskid>.output

Those three .output files no longer exist on disk. lsof still resolves the paths, but
ls returns No such file or directory for all three. They were unlinked while their writers
were running, so nothing could ever have read that output again — including a resumed session.

Aggregate cost while it ran: top reported 0.0% idle with 44 running threads on a 10-core
box; killing exactly these 30 processes dropped the 1-minute load average by ~30 and returned
the machine to ~67% idle.

One observation I cannot fully explain, included in case it points at the responsible path:
the owning session's transcript (~/.claude/projects/<slug>/<session-uuid>.jsonl) was last
written 2026-08-23 12:48, over a day before these processes started at 2026-08-24 14:13,
even though their fds point into that session id's task directory.

Why this is worth fixing beyond the wasted CPU

Silent, unbounded CPU consumption is the obvious cost. The second cost is that it corrupts
unrelated work on the same machine.

In my case a repository timing gate reads the 1-minute load average to decide whether
wall-clock measurements are trustworthy. With these 30 always-runnable threads in the mix it
read 4.7-6.9 per core against a 0.5 ceiling, and the investigation that produced those numbers
concluded that macOS load average is unusable as a signal on a working dev box — and opened
a ticket to redesign the gate around that false conclusion. Once the orphans were killed, the
same box measured 0.37-0.48 per core and the gate worked exactly as designed. A leak like this
does not just burn cores; it manufactures convincing evidence for wrong engineering decisions.

Relationship to existing issues

I checked the open orphan cluster and believe this is a distinct trigger:

  • #81462 (macOS, PID 1) is a foreground Bash call whose shell-level & child escapes.

Mine were registered background tasks with task IDs and output files — the runner knew about
them.

  • #81210 is Linux and crash-triggered, and correctly notes JS-layer exit handlers cannot

help on a hard crash. No crash was observed here, and the partial cleanup (output files
unlinked) is evidence that a cleanup path did run.

  • #80885 is scheduled-task sessions leaking claude process pairs, not Bash tool tasks.
  • #77593 is Windows.

The overlap suggests one underlying gap: background task teardown is not tied to task
deletion.
#81210's suggested primitives are the right shape — spawn each task into its own
process group and kill the group, and add a startup sweep that reaps tasks recorded by a
session whose PID is gone. On macOS the parent-death primitive is kqueue/NOTE_EXIT, since
PR_SET_PDEATHSIG is Linux-only.

At minimum: whatever unlinks a task's .output file should first terminate that task's
process group.
In this incident that single ordering change would have prevented all 30
orphans.

Repro

I did not cause this deliberately and have not established a reproduction — the forensics
above are from the live incident, and the processes are now killed. A plausible shape, offered
untested: start a long-running run_in_background Bash task that spawns child shells, end or
abandon the session without stopping the task, and check for PPID 1 survivors plus whether
the task's .output file was unlinked underneath them.

Happy to instrument a box and chase a deterministic repro if that would help.

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗