[BUG] Shell snapshot captures mise functions without `__MISE_EXE` variable, causing infinite fork recursion and OOM

Resolved 💬 5 comments Opened Apr 14, 2026 by rolfwr Closed May 23, 2026

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

  1. Install mise and add to .bashrc:

``bash
eval "$(mise activate bash)"
``

  1. Start a new Claude Code session (claude)
  2. Any Bash tool call that happens to trigger the captured mise() function or command_not_found_handle() will begin the fork recursion. Even ordinary tool calls can trigger it if any command-not-found occurs during shell initialization.
  3. 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:

  1. Bash tool sources snapshot containing mise() function (with empty $__MISE_EXE) and command_not_found_handle()
  2. Any invocation of mise() calls command "$__MISE_EXE" ... which resolves to command "" ...
  3. command "" fails and invokes command_not_found_handle("")
  4. CNFH checks "" != "mise" (true), calls mise hook-not-found -s bash -- ""
  5. mise() calls command "" "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

  1. Capture non-exported shell variables that captured functions reference (at least __MISE_EXE, __MISE_HOOK_ENABLED, __MISE_FLAGS).
  2. Don't capture command_not_found_handle — it's dangerous in a non-interactive shell context and enables recursion when other functions are broken.
  3. 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

View original on GitHub ↗

This issue has 5 comments on GitHub. Read the full discussion on GitHub ↗