[BUG] Bash teardown can SIGKILL an unrelated process after PID reuse
Summary
Claude Code's Bash teardown path can retain a descendant PID from an asynchronous
process-tree snapshot and later send SIGKILL to that numeric PID without checking
that it still identifies the same process.
Under process churn, the original descendant can exit and its PID can be reused by a
new, unrelated process before the delayed teardown branch runs. The unrelated process
is then killed immediately. I observed newly spawned Node processes die with SIGKILL
within 0–2 ms, before producing any output.
Static analysis of the exact incident build shows that the normal escalation path
already records and revalidates pid + lstart, but the late descendant-snapshot branch
bypasses that guard and calls process.kill(pid, "SIGKILL") directly.
Environment
- Incident version: Claude Code 2.1.275
- Exact artifact analyzed:
@anthropic-ai/claude-code-darwin-arm64@2.1.275 - Currently installed local CLI: Claude Code 2.1.259
- OS: macOS 26.5.1 (build 25F80)
- Architecture: arm64 / Apple Silicon
- Node.js used by the killed test processes: 24.14.1
Minimal reproduction
This is timing-dependent because it requires PID reuse during a delayed process-tree
snapshot. The minimal failing sequence for a regression test is:
- Start Bash-command teardown and begin its asynchronous descendant-PID snapshot.
- Delay completion of that snapshot beyond the 1,500 ms kill backstop.
- Let an original descendant exit.
- Make the same numeric PID refer to a sentinel process with a different start time.
- Resolve the delayed snapshot with the original descendant PID.
In 2.1.275, the late branch sends SIGKILL to the sentinel. A regression test should
mock process discovery and signaling so it can deterministically substitute a different
start identity for the same PID; it should not attempt to force real PID reuse.
The issue was observed probabilistically during several concurrent, process-intensive
test runs in Claude Code sessions. A wrapper around spawnSync(process.execPath, ...)
recorded failures before the child emitted output:
{"error":null,"signal":"SIGKILL","ms":1,"stdout":"","stderr":""}
The same signature appeared on otherwise clean code and across different test cases, so
it did not track a particular test revision. On one alternating stress series it occurred
in 3 of 10 full runs on one side and 0 of 10 on the other, then appeared later in a
different harness run.
Observed behavior
- A newly spawned, ordinary Node process exits with
status: nulland
signal: "SIGKILL" after 0–2 ms.
stdoutandstderrare empty; the process does not execute observable application
code.
- The parent test process survives.
- Frequency increases with concurrent process-heavy sessions.
- No corresponding macOS jetsam, AMFI, code-signing, process-limit, or file-descriptor
event was found at the incident timestamps.
Expected behavior
Claude Code must never signal a process based only on a previously captured numeric PID.
Immediately before every signal, it should verify that the PID still has the same process
start identity that was recorded when the target was discovered. If identity cannot be
verified, teardown should skip the signal and report the condition.
Evidence from the 2.1.275 artifact
The Bash harness has a 1,500 ms backstop. Its normal kill-escalation path obtains process
start times with the equivalent of:
ps -o pid= -o lstart=
and revalidates the stored start time before sending SIGKILL.
However, when the asynchronous descendant snapshot finishes only after the backstop has
started, the artifact takes a separate branch equivalent to:
if (backstopStarted) {
for (const pid of descendants) {
try {
process.kill(pid, "SIGKILL");
} catch {}
}
return;
}
That branch performs no start-time validation. The same teardown also contains a
process-group signal against the old PGID; the group leader's identity should likewise
be revalidated immediately before signaling the group.
Historical logs did not capture the sender PID, so they cannot prove which individual
Bash task sent each observed signal. The causal conclusion is based on the unsafe branch
in the exact incident version plus the distinctive observed signature. The same control
flow was also present in the locally installed 2.1.259 build.
Suggested fix
- Represent every signal target as at least
{ pid, startTime }, including the process
group leader.
- Remove the late direct
process.kill(pid, "SIGKILL")loop. Route every positive-PID
signal through the existing start-time revalidation path.
- Revalidate the group leader before every negative-PGID signal. Do not signal a group
if the leader's identity has changed or cannot be verified.
- If descendant discovery does not finish before the backstop, fail closed: log and leave
an unverified process alive rather than risk killing an unrelated process.
- Cancel teardown timers and polling immediately when the child exits.
- Add structured signal diagnostics: sender/session, reason, signal, target PID/PGID,
stored start time, and current start time.
- Add the deterministic delayed-snapshot/PID-identity-substitution regression test
described above.
Related issues checked
I did not find an existing issue for this specific stale-PID/start-time-bypass path.
These reports have adjacent symptoms but different triggers and kill paths:
- #87055 — background
Bash process group killed while a daemonizing CLI is running
- #84625 — long-running
background Bash tasks killed mid-run
- #83728 — background
process tree killed by a bg-pty/watchdog path
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗