[BUG] Shell snapshot captures mise functions without `__MISE_EXE` variable, causing infinite fork recursion and OOM
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
When mise is activated in .bashrc, the shell snapshot captures mise's mise() and command_not_found_handle() functions but does not capture the non-exported shell variable __MISE_EXE=/usr/bin/mise. This creates an infinite fork recursion: command "" (empty __MISE_EXE) triggers command_not_found_handle, which calls mise() again, which calls command "" again, ad infinitum. Each recursion level forks a new bash process.
This silently builds up thousands of bash processes in the background. In two separate incidents on a 64 GB machine, the process count reached 2,171 and 5,168 respectively, exhausting all RAM and triggering the kernel OOM killer. The first incident required a hard reboot (no earlyoom/systemd-oomd was installed). The second was caught by earlyoom but still killed the Claude Code session and Slack.
Related to #25824 (snapshot drops __-prefixed functions, breaking cd) and #25398 (snapshot doesn't capture typeset declarations). Those were broken-function bugs; this is the same root cause escalated to a fork bomb.
What Should Happen?
The snapshot should capture non-exported shell variables that captured functions depend on (at minimum __MISE_EXE). Alternatively, the snapshot should not capture functions whose dependencies are incomplete, or should detect and break the command_not_found_handle recursion.
Error Messages/Logs
Kernel OOM killer output from first incident:
Apr 14 14:55:28 kernel: oom-kill:constraint=CONSTRAINT_NONE,nodemask=(null),cpuset=/,mems_allowed=0,global_oom,task_memcg=/user.slice/user-1000.slice/user@1000.service/app.slice/app-gnome-google\x2dchrome-8886.scope/117372,task=chrome,pid=117372,uid=1000
Apr 14 14:55:28 kernel: Out of memory: Killed process 117372 (chrome) total-vm:1460424488kB, anon-rss:589164kB
Apr 14 14:55:42 kernel: Out of memory: Killed process 17249 (slack) total-vm:1476983760kB, anon-rss:480192kB
The OOM dump showed 2,171 bash processes (PIDs 179425-182872), all children of the Claude Code process, forming deeply nested parent-child chains. `pstree` during the second incident confirmed the same deep nesting.
The recursion trace (reproduced with debug output):
MISE_FUNC: calling command 'UNSET' '--version'
CNFH: called with ''
MISE_FUNC: calling command 'UNSET' 'hook-not-found' -s bash --
CNFH: called with ''
MISE_FUNC: calling command 'UNSET' 'hook-not-found' -s bash --
CNFH: called with ''
... (infinite)
Steps to Reproduce
Minimal reproduction (no mise required)
This simulates exactly what the snapshot produces when mise activate bash was in .bashrc:
#!/usr/bin/env bash
# repro-shell-snapshot-fork-bomb.sh
#
# Reproduces a Claude Code bug where the shell snapshot captures mise's
# shell functions without the non-exported __MISE_EXE variable, creating
# an infinite fork recursion that leaks thousands of bash processes.
#
# What this script does:
# 1. Creates an isolated $HOME with a .bashrc that activates mise-like
# shell hooks (the same functions `eval "$(mise activate bash)"` installs)
# 2. Copies Claude Code auth so the isolated env can reach the API
# 3. Control test: runs `mise --version` in a plain bash shell to prove
# the functions work correctly when __MISE_EXE is set
# 4. Tests each locally available Claude Code version against the bug
# 5. Reports a summary showing which versions are affected
#
# Requirements:
# - Claude Code CLI (`claude`) installed and authenticated
# - No other software required (does NOT need mise installed)
#
# Safety:
# - Runs in an isolated HOME — does not modify your real config
# - Kills all leaked processes on exit
# - Hard timeout per version prevents runaway resource consumption
#
# Usage:
# chmod +x repro-shell-snapshot-fork-bomb.sh
# ./repro-shell-snapshot-fork-bomb.sh # test all available versions
# ./repro-shell-snapshot-fork-bomb.sh 2.1.68 2.1.71 # test specific versions only
#
set -uo pipefail
REAL_HOME="$HOME"
FAKE_HOME=""
VERSIONS_DIR="$REAL_HOME/.local/share/claude/versions"
NPM_VERSIONS_DIR="/tmp/cc-versions"
VERSION_FILTER=("$@") # command-line arguments: version numbers or ranges
# ------------------------------------------------------------------
# Cleanup: freeze-then-kill to defeat the self-sustaining fork chain
# ------------------------------------------------------------------
kill_fork_chain() {
local pattern="$1"
# Phase 1: Freeze. Loop SIGSTOP until nothing matching is still running.
# A single pkill pass sends signals one-at-a-time; while it works through
# the list, unfrozen processes fork new children. So we loop.
local freeze_attempts=0
while true; do
pkill -STOP -f "$pattern" 2>/dev/null
local running stopped still_running
running=$(pgrep -cf "$pattern" 2>/dev/null || true)
stopped=$(ps -u "$USER" -o stat=,args= 2>/dev/null \
| grep -v grep | grep "$pattern" | grep -c '^T' || true)
still_running=$((running - stopped))
if [ "$still_running" -le 0 ]; then break; fi
freeze_attempts=$((freeze_attempts + 1))
if [ "$freeze_attempts" -gt 50 ]; then
echo " WARNING: Could not freeze all processes after 50 SIGSTOP passes" >&2
break
fi
done
# Phase 2: Kill. Everything is frozen — nothing can fork.
while pkill -9 -f "$pattern" 2>/dev/null; do true; done
}
cleanup() {
if [ -n "$FAKE_HOME" ]; then
kill_fork_chain "$(basename "$FAKE_HOME")"
rm -rf "$FAKE_HOME"
fi
}
trap cleanup EXIT
# ------------------------------------------------------------------
# Test a single Claude Code binary against the bug.
# Arguments: $1 = path to claude binary, $2 = version label
# Returns: 0 = bug reproduced, 1 = not reproduced, 2 = inconclusive
# ------------------------------------------------------------------
test_version() {
local claude_bin="$1"
local version_label="$2"
local test_claude_pid="" test_monitor_pid="" test_timeout_pid=""
local detected=0 timed_out=0
# Fresh snapshot for this version
rm -rf "$FAKE_HOME/.claude/shell-snapshots" 2>/dev/null
# Wait for process count to stabilize after previous test's cleanup.
# Stragglers from kill_fork_chain may still be exiting.
local prev_count=0 curr_count=999 settle_attempts=0
while [ "$curr_count" -ne "$prev_count" ]; do
prev_count=$curr_count
sleep 0.5
curr_count=$(ps -u "$USER" -o comm= 2>/dev/null | grep -c '^bash$')
settle_attempts=$((settle_attempts + 1))
if [ "$settle_attempts" -gt 20 ]; then break; fi
done
# Record baseline
local baseline
baseline=$(ps -u "$USER" -o comm= 2>/dev/null | grep -c '^bash$')
# Background monitor: detect leak
(
while true; do
local count leaked
count=$(ps -u "$USER" -o comm= 2>/dev/null | grep -c '^bash$')
leaked=$((count - baseline))
if [ "$leaked" -gt 50 ]; then
echo ""
echo " FORK BOMB DETECTED: $leaked leaked bash processes"
echo " Sample chain (pid -> ppid):"
ps -u "$USER" -o pid=,ppid=,comm= 2>/dev/null \
| grep 'bash$' | grep -v "$$" | tail -5 \
| while read pid ppid comm; do echo " $ppid -> $pid"; done
kill -USR1 $$ 2>/dev/null
exit 0
fi
sleep 0.5
done
) &
test_monitor_pid=$!
# Hard timeout
(
sleep 30
kill -USR2 $$ 2>/dev/null
) &
test_timeout_pid=$!
trap 'detected=1' USR1
trap 'timed_out=1' USR2
# Run this version
HOME="$FAKE_HOME" "$claude_bin" -p \
--dangerously-skip-permissions \
--allowedTools Bash \
--no-session-persistence \
"Use the Bash tool to run the command: mise --version" \
>/dev/null 2>&1 &
test_claude_pid=$!
# Wait for detection, timeout, or exit
while true; do
if [ "$detected" -eq 1 ] || [ "$timed_out" -eq 1 ]; then break; fi
if ! kill -0 "$test_claude_pid" 2>/dev/null; then
sleep 1 # grace period for monitor to catch late leak
break
fi
sleep 0.5
done
# Stop claude and monitor
kill "$test_claude_pid" 2>/dev/null
kill "$test_monitor_pid" 2>/dev/null
kill "$test_timeout_pid" 2>/dev/null
wait "$test_claude_pid" 2>/dev/null
wait "$test_monitor_pid" 2>/dev/null
wait "$test_timeout_pid" 2>/dev/null
# Measure leak
local final leaked
final=$(ps -u "$USER" -o comm= 2>/dev/null | grep -c '^bash$')
leaked=$((final - baseline))
# Clean up fork chain before returning
kill_fork_chain "$(basename "$FAKE_HOME")"
# Reset signal handlers for next iteration
trap 'cleanup; exit' EXIT
trap '' USR1 USR2
# Verdict
if [ "$detected" -eq 1 ] || [ "$leaked" -gt 20 ]; then
echo " Result: LEAKED $leaked bash processes"
return 0 # bug reproduced
elif [ "$timed_out" -eq 1 ]; then
echo " Result: INCONCLUSIVE (timed out, leaked $leaked)"
return 2
else
echo " Result: OK (leaked $leaked)"
return 1 # not reproduced
fi
}
# ==================================================================
# Main
# ==================================================================
echo "Bash version: ${BASH_VERSION}"
echo ""
# ------------------------------------------------------------------
# 1. Discover available Claude Code versions
#
# Looks in two places:
# - Standalone binaries in ~/.local/share/claude/versions/
# - npm-installed versions in /tmp/cc-versions/*/node_modules/.bin/claude
# (install with: npm install --prefix /tmp/cc-versions/X.Y.Z @anthropic-ai/claude-code@X.Y.Z)
# ------------------------------------------------------------------
declare -a VERSION_BINS=()
declare -a VERSION_LABELS=()
# Standalone binaries
if [ -d "$VERSIONS_DIR" ]; then
while IFS= read -r bin; do
label="$(basename "$bin")"
VERSION_BINS+=("$bin")
VERSION_LABELS+=("$label")
done < <(find "$VERSIONS_DIR" -maxdepth 1 -type f -executable 2>/dev/null | sort -V)
fi
# npm-installed versions
if [ -d "$NPM_VERSIONS_DIR" ]; then
while IFS= read -r verdir; do
bin="$verdir/node_modules/.bin/claude"
if [ -x "$bin" ]; then
label="$(basename "$verdir")"
VERSION_BINS+=("$bin")
VERSION_LABELS+=("$label")
fi
done < <(find "$NPM_VERSIONS_DIR" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort -V)
fi
# Deduplicate and sort by version
if [ ${#VERSION_BINS[@]} -gt 1 ]; then
declare -a SORTED_BINS=() SORTED_LABELS=()
while IFS=$'\t' read -r label bin; do
SORTED_BINS+=("$bin")
SORTED_LABELS+=("$label")
done < <(
for i in "${!VERSION_LABELS[@]}"; do
printf '%s\t%s\n' "${VERSION_LABELS[$i]}" "${VERSION_BINS[$i]}"
done | sort -t. -k1,1n -k2,2n -k3,3n | awk -F'\t' '!seen[$1]++ {print}'
)
VERSION_BINS=("${SORTED_BINS[@]}")
VERSION_LABELS=("${SORTED_LABELS[@]}")
fi
# Filter to specific versions if arguments were given
if [ ${#VERSION_FILTER[@]} -gt 0 ]; then
declare -a FILTERED_BINS=() FILTERED_LABELS=()
for i in "${!VERSION_LABELS[@]}"; do
for arg in "${VERSION_FILTER[@]}"; do
if [ "${VERSION_LABELS[$i]}" = "$arg" ]; then
FILTERED_BINS+=("${VERSION_BINS[$i]}")
FILTERED_LABELS+=("${VERSION_LABELS[$i]}")
fi
done
done
VERSION_BINS=("${FILTERED_BINS[@]}")
VERSION_LABELS=("${FILTERED_LABELS[@]}")
fi
if [ ${#VERSION_BINS[@]} -eq 0 ]; then
if [ ${#VERSION_FILTER[@]} -gt 0 ]; then
echo "ERROR: No installed versions match: ${VERSION_FILTER[*]}" >&2
exit 1
elif command -v claude >/dev/null 2>&1; then
VERSION_BINS+=("$(command -v claude)")
VERSION_LABELS+=("$(claude --version 2>/dev/null | head -1)")
else
echo "ERROR: No Claude Code binaries found." >&2
echo "Install versions with:" >&2
echo " npm install --prefix /tmp/cc-versions/X.Y.Z @anthropic-ai/claude-code@X.Y.Z" >&2
exit 1
fi
fi
echo "Found ${#VERSION_BINS[@]} Claude Code version(s):"
for i in "${!VERSION_LABELS[@]}"; do
echo " ${VERSION_LABELS[$i]} (${VERSION_BINS[$i]})"
done
echo ""
# ------------------------------------------------------------------
# 2. Create isolated HOME
# ------------------------------------------------------------------
FAKE_HOME="$(mktemp -d "${TMPDIR:-/tmp}/cc-repro-XXXXXX")"
echo "Isolated HOME: $FAKE_HOME"
mkdir -p "$FAKE_HOME/.claude"
for f in .credentials.json settings.json policy-limits.json; do
cp "$REAL_HOME/.claude/$f" "$FAKE_HOME/.claude/" 2>/dev/null || true
done
# ------------------------------------------------------------------
# 3. Create .bashrc with mise-like activation
# ------------------------------------------------------------------
cat > "$FAKE_HOME/.bash_profile" << 'EOF'
[ -f ~/.bashrc ] && source ~/.bashrc
EOF
cat > "$FAKE_HOME/.bashrc" << 'BASHRC'
# Minimal reproduction of `eval "$(mise activate bash)"`.
# The critical detail: __MISE_EXE is a non-exported shell variable.
__MISE_EXE=/bin/true
mise() {
local command="${1:-}"
if [ "$#" = 0 ]; then command "$__MISE_EXE"; return; fi
shift
command "$__MISE_EXE" "$command" "$@"
}
command_not_found_handle() {
if [[ $1 != "mise" && $1 != "mise-"* ]] && mise hook-not-found -s bash -- "$1"; then
_mise_hook
"$@"
else
echo "bash: command not found: $1" >&2
return 127
fi
}
BASHRC
# ------------------------------------------------------------------
# 4. Control test: verify functions work when __MISE_EXE is set
# ------------------------------------------------------------------
echo ""
echo "=== Control test: mise functions in a plain bash shell ==="
control_before=$(ps -u "$USER" -o comm= 2>/dev/null | grep -c '^bash$')
HOME="$FAKE_HOME" timeout 3 bash -l -c 'mise --version 2>/dev/null; echo "mise() exit code: $?"' 2>/dev/null
control_after=$(ps -u "$USER" -o comm= 2>/dev/null | grep -c '^bash$')
control_leaked=$((control_after - control_before))
if [ "$control_leaked" -le 0 ]; then
echo "PASS: no leaked processes ($control_before -> $control_after)"
else
echo "UNEXPECTED: $control_leaked leaked processes without Claude Code"
fi
# ------------------------------------------------------------------
# 5. Test each version
# ------------------------------------------------------------------
echo ""
echo "=== Testing Claude Code versions ==="
echo ""
declare -a RESULTS=()
for i in "${!VERSION_BINS[@]}"; do
label="${VERSION_LABELS[$i]}"
bin="${VERSION_BINS[$i]}"
echo "--- Version $label ---"
test_version "$bin" "$label"
rc=$?
case $rc in
0) RESULTS+=("$label VULNERABLE") ;;
1) RESULTS+=("$label OK") ;;
2) RESULTS+=("$label INCONCLUSIVE") ;;
esac
echo ""
done
# ------------------------------------------------------------------
# 6. Summary
# ------------------------------------------------------------------
echo "==========================================="
echo " Summary"
echo "==========================================="
for r in "${RESULTS[@]}"; do
echo " $r"
done
echo "==========================================="
echo ""
# Check if any version was vulnerable
any_vulnerable=false
for r in "${RESULTS[@]}"; do
if [[ "$r" == *VULNERABLE* ]]; then any_vulnerable=true; break; fi
done
if $any_vulnerable; then
echo "At least one version has the fork bomb bug."
exit 0
else
echo "No version triggered the bug."
exit 1
fi
Expected output: Leaked bash processes: 0
Actual output: Leaked bash processes: ~500 (in 2 seconds)
Real-world reproduction
- Install mise and add to
.bashrc:
``bash``
eval "$(mise activate bash)"
- Start a new Claude Code session (
claude) - Any Bash tool call that happens to trigger the captured
mise()function orcommand_not_found_handle()will begin the fork recursion. Even ordinary tool calls can trigger it if any command-not-found occurs during shell initialization. - Bash processes accumulate silently in the background, consuming ~4 MB each. On a 64 GB machine, OOM occurs within minutes.
Workaround
Guard mise activation in .bashrc:
if [[ -z "${CLAUDECODE:-}" ]]; then
eval "$(mise activate bash)"
fi
Claude Model
Opus
Is this a regression?
Yes, this worked in a previous version
Last Working Version
2.1.68
Claude Code Version
2.1.107 (Claude Code)
Platform
Anthropic API
Operating System
Other Linux
Terminal/Shell
Other
Additional Information
Version bisection
Bisection across installed versions shows the bug was introduced twice, with a brief fix in between, all on the same day:
| Version | Date | Result |
|---------|------|--------|
| 2.1.50 | Feb 20 | OK |
| 2.1.55 | Feb 25 | OK |
| 2.1.64 | Mar 3 | OK |
| 2.1.66 | Mar 4 | VULNERABLE (introduced) |
| 2.1.67 | Mar 4 | OK (fixed) |
| 2.1.68 | Mar 4 | OK |
| 2.1.69 | Mar 4 | VULNERABLE (regressed) |
| 2.1.71 | Mar 6 | VULNERABLE |
| 2.1.73 | Mar 11 | VULNERABLE |
| 2.1.75 | Mar 13 | VULNERABLE |
| 2.1.85 | Mar 26 | VULNERABLE |
| 2.1.97 | Apr 2 | VULNERABLE |
| 2.1.107 | Apr 14 | VULNERABLE |
The fix in 2.1.67-2.1.68 was likely the response to #25824 (filed 2026-02-24, "snapshot drops __-prefixed functions, breaking cd when used with mise"). That fix briefly restored correct behavior, but whatever landed in 2.1.69 re-broke the same surface area. The change between 2.1.68 and 2.1.69 is the one to look at.
Why this is worse than #25824 and #25398
Those bugs caused error messages (command not found: __zsh_like_cd, assignment to invalid subscript range). This bug causes silent exponential process growth leading to a system-wide OOM. There is no visible error — the bash processes accumulate in the background while the session appears to work normally.
Root cause analysis
The recursion cycle:
- Bash tool sources snapshot containing
mise()function (with empty$__MISE_EXE) andcommand_not_found_handle() - Any invocation of
mise()callscommand "$__MISE_EXE" ...which resolves tocommand "" ... command ""fails and invokescommand_not_found_handle("")- CNFH checks
"" != "mise"(true), callsmise hook-not-found -s bash -- "" mise()callscommand "" "hook-not-found" ...— back to step 3
Each cycle forks a child bash process. pstree shows this as a deeply nested single-child chain, not flat accumulation.
What the snapshot captures vs. what it should
| Item | Captured? | Needed by captured functions? |
|------|-----------|------------------------------|
| mise() function | Yes | Needs $__MISE_EXE |
| command_not_found_handle() | Yes | Calls mise() and _mise_hook() |
| cd() / pushd() / popd() | Yes | Calls __zsh_like_cd() |
| __zsh_like_cd() | Yes (fixed in #25824) | OK |
| __MISE_EXE=/usr/bin/mise | No (non-exported variable) | Required |
| _mise_hook() | No | Referenced by CNFH |
| PROMPT_COMMAND | No (shell variable) | Contains _mise_hook_prompt_command |
| chpwd_functions array | No | Contains _mise_hook_chpwd |
Reproduction script output
<details>
<summary>Full output of ./repro-shell-snapshot-fork-bomb.sh 2.1.66 2.1.67 2.1.68 2.1.69 2.1.107`</summary>
Bash version: 5.3.0(1)-release
Found 5 Claude Code version(s):
2.1.66 (/tmp/cc-versions/2.1.66/node_modules/.bin/claude)
2.1.67 (/tmp/cc-versions/2.1.67/node_modules/.bin/claude)
2.1.68 (/tmp/cc-versions/2.1.68/node_modules/.bin/claude)
2.1.69 (/tmp/cc-versions/2.1.69/node_modules/.bin/claude)
2.1.107 (/home/rolfwr/.local/share/claude/versions/2.1.107)
Isolated HOME: /tmp/cc-repro-tXBm8L
=== Control test: mise functions in a plain bash shell ===
true (GNU coreutils) 9.7
Copyright (C) 2025 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <https://gnu.org/licenses/gpl.html>.
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
Written by Jim Meyering.
mise() exit code: 0
PASS: no leaked processes (5 -> 5)
=== Testing Claude Code versions ===
--- Version 2.1.66 ---
FORK BOMB DETECTED: 71 leaked bash processes
Sample chain (pid -> ppid):
173086 -> 173087
173087 -> 173088
173088 -> 173093
172777 -> 173094
173093 -> 173095
Result: LEAKED 465 bash processes
--- Version 2.1.67 ---
Result: OK (leaked 0)
--- Version 2.1.68 ---
Result: OK (leaked 0)
--- Version 2.1.69 ---
FORK BOMB DETECTED: 283 leaked bash processes
Sample chain (pid -> ppid):
174641 -> 174642
174642 -> 174643
174643 -> 174644
174644 -> 174645
174092 -> 174650
Result: LEAKED 539 bash processes
--- Version 2.1.107 ---
FORK BOMB DETECTED: 336 leaked bash processes
Sample chain (pid -> ppid):
176207 -> 176208
176208 -> 176209
176209 -> 176210
175631 -> 176215
176210 -> 176216
Result: LEAKED 546 bash processes
===========================================
Summary
===========================================
2.1.66 VULNERABLE
2.1.67 OK
2.1.68 OK
2.1.69 VULNERABLE
2.1.107 VULNERABLE
===========================================
At least one version has the fork bomb bug.
</details>
Suggested fix directions
- Capture non-exported shell variables that captured functions reference (at least
__MISE_EXE,__MISE_HOOK_ENABLED,__MISE_FLAGS). - Don't capture
command_not_found_handle— it's dangerous in a non-interactive shell context and enables recursion when other functions are broken. - Detect recursion — if the Bash tool's spawned process creates child processes beyond a threshold, kill the tree and report an error.
mise version: 2026.4.10
This issue has 5 comments on GitHub. Read the full discussion on GitHub ↗