[BUG] Bash timeout silently hard-kills (exit 143) commands that fail an undocumented auto-background eligibility check
Summary
When a foreground Bash call reaches its timeout, Claude Code does one of two very different things:
- 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: …). - 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):
- 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 target —cmd > $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 redirect —
python3 - <<'EOF' > out.txt …(heredoc alone is fine; literal-path redirects alone are fine).
- No subcommand in the chain is
git(anygit …anywhere → kill). - 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
timeoutcan 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 30sdoesn'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
- 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
timeoutparameter description. - 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>
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗