[BUG] Bash timeout silently hard-kills (exit 143) commands that fail an undocumented auto-background eligibility check

Status Open
Reported on v2.1.216
Maintainer reply None cached
Activity 1 comment · opened Jul 21, 2026

Summary

When a foreground Bash call reaches its timeout, Claude Code does one of two very different things:

  1. Auto-background: the process keeps running untouched, output goes to a task file, and the tool returns Command did not complete within its Ns timeout and was moved to the background (ID: …).
  2. Kill: the process group gets SIGTERM (exit 143) and the tool returns Error: Exit code 143 / Command timed out after Ns.

Which path fires is decided before spawn by a static analysis of the command string, and neither the behavior split nor its criteria are documented anywhere — not in the timeout parameter description, not in the tools reference, and not in the kill-path tool result. #77645 asks for the auto-background message to be documented; this issue is about the other branch: commands for which timeout is still a hard kill, and the undocumented heuristic that separates the two.

Observed rule (v2.1.216, Linux)

Auto-background on timeout requires all of the following (minified refs in the 2.1.216 bundle: Kay, gating shouldAutoBackground, backed by iHt/a7i/l7i, $Rt, Way):

  1. The bundled static shell analyzer (the same one used for permission/sandbox analysis) decomposes the command as kind === "simple". Everyday constructs that fail it and therefore get killed at timeout:
  • a $VAR (or backtick) in a redirect targetcmd > $S/out.jsonl 2> $S/err.log — even when the variable is assigned literally earlier in the same command;
  • a heredoc combined with a file redirectpython3 - <<'EOF' > out.txt … (heredoc alone is fine; literal-path redirects alone are fine).
  1. No subcommand in the chain is git (any git … anywhere → kill).
  2. The first statement's first word isn't sleep.

Tested and not relevant: explicit vs default timeout, timeout value, sandboxed vs dangerouslyDisableSandbox, CPU-busy vs idle process, cd prefixes, ;-chains, $?, whether output was produced before the timeout.

Repro (~5s each)

# auto-backgrounded at timeout:
Bash(command: 'python3 -c "import time; time.sleep(15)" > /tmp/x.out', timeout: 5000)

# killed at timeout (exit 143) — only difference is $S in the redirect target:
Bash(command: 'S=/tmp && python3 -c "import time; time.sleep(15)" > $S/x.out', timeout: 5000)

# killed at timeout — git subcommand anywhere in the chain:
Bash(command: 'git status --short > /dev/null; python3 -c "import time; time.sleep(15)"', timeout: 5000)

Why this bites

  • The model can't predict which class it's in. After a few benign timeouts it learns "timeouts are safe, the command just moves to background" — and then a realistic pipeline (tool … > $S/out.jsonl 2> $S/err) dies at timeout with partial results. The two commands look interchangeable from the model's side.
  • PreToolUse hooks that rewrite timeout can weaponize the kill path. We ran a hook that clamps long sync timeouts (>60s → 30s) on the reasonable-sounding assumption that timeout never kills since 2.1.21x. For non-"simple" commands that assumption is false, so the clamp converted a requested 240s allowance into a 30s SIGTERM — killing exactly the commands that look like real pipelines. (Hook since fixed to approximate the gate: https://github.com/Butanium/claude-code-hooks/blob/master/force_background_bash.py — but the asymmetry was invisible until we diffed the CLI bundle.)
  • The kill-path result gives no feedback. Command timed out after 30s doesn't mention that auto-backgrounding exists and was declined for this command, so neither models nor hook authors can learn the rule from observed behavior.

Ask

  1. Document the two-path timeout behavior and the gating criteria (at least the common triggers: variable/backtick redirect targets, heredoc+redirect, git, sleep) in the tools reference and/or the timeout parameter description.
  2. Ideally, have the kill-path tool result say why the command wasn't auto-backgroundable, e.g. Command timed out after 30s and was killed (not auto-backgroundable: redirect target contains a variable). Use run_in_background: true for long commands. One clause would let models adapt on the spot.

---
Investigated and written by Claude (Fable 5) in a Claude Code session, at the user's request: behavior probed live on v2.1.216 and cross-checked against the CLI bundle's decompiled source. (Co)-Authored-By: Claude Fable 5 <noreply@anthropic.com>

View original on GitHub ↗

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