Magic Tools
Pitfall NotesBy CooconSeptember 26, 20266 views9 min read

Claude Code "Command timed out after 2m 0s": Two Timeout Paths, BASH_DEFAULT_TIMEOUT_MS and run_in_background Tested

Background

Ask Claude Code to run something slow — a cold npm install, a full test suite, docker build, a sleep-based poll — and after two minutes the tool result turns into:

Exit code 143
Command timed out after 2m 0s

That is the Bash tool's default timeout of 120000 ms. The usual advice is "raise the timeout", but that leaves at least three questions open:

  • When the command is killed, is the output it already printed kept? Do child processes survive as orphans?
  • When a script runs claude -p, how do you even notice that a command timed out?
  • When should you use BASH_DEFAULT_TIMEOUT_MS, BASH_MAX_TIMEOUT_MS, a per-command timeout, or run_in_background?

Every error string and number below comes from 19 real claude -p sessions run on Claude Code 2.1.280 on 2026-09-26, copied verbatim.

Analysis

I first read the relevant parts of the 2.1.280 binary (claude.exe, 217,254,576 bytes):

  • Defaults: var p=120000,d=600000. BASH_DEFAULT_TIMEOUT_MS is only honored when !isNaN(o)&&o>0, otherwise it falls back to 120000. BASH_MAX_TIMEOUT_MS is Math.max(max, default), so the ceiling is never lower than the default.
  • Error template: Command timed out after ${zt(this.#u)}.
  • A per-command timeout becomes Math.min(requested||default, ceiling, …). When it gets clamped, only a timeout_clamped telemetry event is emitted — the model never sees it.
  • Auto-backgrounding on timeout: when !or&&Ee===void 0&&kYr(Be) holds, a timed-out command is not killed but moved to the background. kYr takes the first word of the command and passes unless it is in gYr=["sleep"]. or is true when, among other things, CLAUDE_CODE_DISABLE_BACKGROUND_TASKS is set.

That last one was the surprise. In 2.1.280 a Bash timeout takes one of two completely different paths:

Path When Tool result is_error
Killed First word is sleep, or CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1 Exit code 143\nCommand timed out after 2m 0s true
Backgrounded Anything else, e.g. echo …; sleep 300, (sleep 301; echo X) | cat Command did not complete within its 120s timeout and was moved to the background (ID: …) false

So in 2.1.280 you only see "Command timed out after 2m 0s" in the first case. The experiments below use sleep 300 to reproduce it reliably.

Options

When two minutes is not enough there are three ways around it, each with a different cost:

Option Cost Good for
BASH_DEFAULT_TIMEOUT_MS env var (plus BASH_MAX_TIMEOUT_MS) Applies to every command, so a genuinely hung command also waits the full new limit; 0 or non-numeric values silently fall back to 120s; the ceiling defaults to 600000 ms, so going past 10 minutes needs MAX raised too Sessions that are all long builds; headless CI-style jobs
Per-command timeout parameter (capped at BASH_MAX_TIMEOUT_MS, default 600000) Relies on the model passing it; anything above the ceiling is silently clamped A few known slow commands, e.g. one long test run
run_in_background Returns immediately, but the completion notification carries no output, so you must read the file; in -p mode it is killed 5s after the final reply; in my run the model fabricated a completion notification Interactive long tasks; under -p the model must poll in the foreground until done

Ruled out:

  • Wrapping the command in timeout 600 …. GNU timeout can only end a command earlier; it cannot extend Claude Code's own 120-second timer (inferred from the code, not tested this round).
  • Throwing the process away with nohup … &. In the sleep 305 & wait run, the & child shared a process group with the outer shell and was killed with it on timeout (B2c below). Even if you escape the group, the model never gets the result.
  • CLAUDE_CODE_AUTO_BACKGROUND_TIMEOUT_MS is out of scope. It exists in the binary (it shortens the timeout for backgroundable commands) but I did not test it, so no advice here.

Test Process

Every session used the same invocation, with its own work/ directory and an isolated config directory:

cd <run>/work
CLAUDE_CONFIG_DIR=<lab>/claude-config claude -p '<prompt>' \
  --allowedTools Bash --model haiku --setting-sources user \
  --debug-file <run>/debug.log --output-format text < /dev/null

Verbatim tool results come from each session's transcript.jsonl. "Tool time" is the timestamp gap between tool_use and tool_result in the transcript.

A. Default timeout: error text, exit code, timing

claude -p running sleep 300: stdout holds the model's retelling of the error, stderr is 0 bytes, exit code 0

A1 (text output):

Item Reading
Wall clock 127.39 s
Tool time 120.177 s
claude exit code 0
stderr 0 bytes
Tool result (transcript) Exit code 143\nCommand timed out after 2m 0s, is_error=true
  • Exit code 143 = 128 + 15: killed by SIGTERM.
  • The tool result has no trailing newline after 2m 0s. The code block in stdout is the model's own formatting, not something the CLI printed.
  • Under claude -p this error is in neither stdout nor stderr — it is an internal tool_result. Whether stdout shows it depends entirely on how the model retells it.

A2 ran the same prompt with --output-format json:

Field Value
Top-level is_error / subtype false / success
num_turns 2
duration_ms 126196
duration_api_ms 6021
total_cost_usd 0.0115758

The JSON top level does not reveal the timeout either. duration_ms includes the 120-second tool wait and duration_api_ms does not, so the difference tells you whether time went into Bash. This is the opposite of the MCP connection timeout case, where the wait happens before the first API request and is not counted in duration_ms.

Stability: 6 runs with sleep 300 (B2c used sleep 305 & wait) and default env all produced the same tool result, with tool time between 120.149 and 120.189 s and wall clock 125.48–127.39 s; the difference is two rounds of model inference. In A3-r3 the model copied the tool result into stdout together with a surrounding <error>…</error> wrapper and a <system-reminder>, which shows that the model receives the timeout wrapped in <error> tags.

B. Partial output and leftover processes

B1: echo PARTIAL_OUTPUT_START; sleep 300 (default env). No "Command timed out" here — the first word is echo, so it took the background path:

Command did not complete within its 120s timeout and was moved to the background (ID: b1lixbsks). Output is being written to: /private/tmp/claude-501/…/tasks/b1lixbsks.output. You will be notified when it completes. To check interim output, use Read on that file path.

is_error=false; toolUseResult has stdout = "" and "timedOutAfterMs": 120000. The already-printed PARTIAL_OUTPUT_START did not make it into the tool result; it only went to the background output file, which at session end contained PARTIAL_OUTPUT_START\n\n[killed]\n (31 bytes).

B1b: same command with CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1 and BASH_DEFAULT_TIMEOUT_MS=10000 to force the kill path. Tool result:

Exit code 143
Command timed out after 10s
PARTIAL_OUTPUT_START

The kill path keeps existing stdout, appended after the two error lines. (Two variables changed at once; the same combination at the default 120s was not verified separately.)

B2: do child processes survive? Every command is wrapped as /bin/zsh -c source …/shell-snapshots/snapshot-zsh-….sh … && eval '<cmd>' < /dev/null && pwd -P >| /tmp/claude-XXXX-cwd, and that zsh is the process group leader.

Process trees for both paths: a sleep-first command is killed as a group on timeout; a pipeline is backgrounded and killed at wind-down

Run Command Path Processes while running After claude exit +0 / +10 / +60 s
B2-r2 sleep 300 Killed zsh(64768) → sleep(64772), same PGID none / none / none
B2c sleep 305 & wait Killed zsh(65537) → sleep(65543), same PGID none / none / none
B2d (sleep 301; echo CHAIN_DONE) | cat Backgrounded 4 processes, same PGID 66172 none / none / none
  • On the kill path the & child disappeared too; the whole process group is killed.
  • On the background path the command keeps running after the timeout until claude -p winds down. debug.log: print wind-down: killing background shell bdpcaw0cb ("Run sleep and echo command") after 5000ms grace — exactly 5 seconds after the model's final reply. CHAIN_DONE was never written.
  • None of the 19 valid runs left files in work/. Three sampled /tmp/claude-XXXX-cwd files did not exist, because the kill path never reaches && pwd -P. The only leftovers are /private/tmp/claude-501/<project>/…/tasks/*.output, left by 5 runs.

C. Environment variable overrides

Measured tool time and error for the default, BASH_DEFAULT_TIMEOUT_MS, BASH_MAX_TIMEOUT_MS and an explicit timeout

Setting Command Tool time Tool result
BASH_DEFAULT_TIMEOUT_MS=8000 sleep 30 8.166 s Command timed out after 8s
BASH_DEFAULT_TIMEOUT_MS=20000 sleep 30 20.167 s Command timed out after 20s
DEFAULT=5000 MAX=15000, model passed "timeout": 120000 sleep 60 15.119 s Command timed out after 15s
BASH_DEFAULT_TIMEOUT_MS=0 sleep 125 120.334 s Command timed out after 2m 0s
BASH_DEFAULT_TIMEOUT_MS=abc sleep 125 120.167 s Command timed out after 2m 0s

(Each tool result is preceded by an Exit code 143 line.)

  • Durations are printed as 8s, 15s, 2m 0s, not milliseconds.
  • In C2 the model really did pass {"command": "sleep 60", "timeout": 120000}; it was killed at 15 seconds and the tool result says nothing about clamping.
  • 0 and abc both fall back to 120 seconds silently — no error, no warning.
  • If the default is larger than the ceiling, the ceiling is raised to the default: read from Math.max(max, default) in the code only, not tested.

D. Workarounds

D2: explicit per-command timeout. The model passed {"command": "sleep 130", "timeout": 300000}; tool time 130.285 s, result (Bash completed with no output), not cut at 120 seconds; wall clock 135.34 s.

D1: run_in_background only, prompt tells the model to "wait for the notification". The tool returned in 0.118 s and the whole session took 18.59 s — sleep 45 never finished. debug.log shows the background shell killed 5 seconds after the final reply; the output file contained \n[killed]\n.

Worse, haiku wrote a completion notification itself in its final reply:

<task-notification from="bg8oxu8iz" type="completed">
Completed at 2026-09-26T20:51:45Z

and claimed the output was BG_DONE. The transcript contains no real notification, and the timestamp is made up.

D1b: start in the background, then poll the output file in the foreground. The second Bash call, while ! grep -q BG_DONE <file>; do sleep 5; done; cat <file>, returned BG_DONE\n\n[exited with code 0]; wall clock 53.99 s, num_turns 3, cost $0.01930. The real notification looks like this:

<task-notification>
<task-id>bhotgd0u5</task-id>
<tool-use-id>toolu_01KkFoDKJgtufk46BCyZehk5</tool-use-id>
<output-file>/private/tmp/claude-501/…/tasks/bhotgd0u5.output</output-file>
<status>completed</status>
<summary>Background command "Background sleep and echo task" completed (exit code 0)</summary>
</task-notification>

The real notification carries no output — just status, exit code and the file path.

run_in_background: foreground polling gets BG_DONE, while "just wait" gets a notification the model made up

Results

What the readings translate into:

1. When scripting claude -p, don't trust the exit code. On timeout claude still exits 0 with empty stderr, and the JSON top level says is_error=false / success. One way to detect it:

claude -p "$PROMPT" --output-format json < /dev/null > out.json
jq '.duration_ms - .duration_api_ms' out.json

In A2 the difference was 126196 − 6021 = 120175, i.e. one full 120-second timeout. For a precise answer, parse the transcript for tool_results with is_error=true, or for records carrying timedOutAfterMs (background path).

2. If the whole session is slow, raise the default at launch. All env vars in this test were passed as a command-line prefix:

BASH_DEFAULT_TIMEOUT_MS=600000 BASH_MAX_TIMEOUT_MS=1200000 claude

The value must be a positive integer; 0 or 10m silently falls back to 120s. To go past 600000, raise BASH_MAX_TIMEOUT_MS as well.

3. If only a few commands are slow, have the model pass timeout. timeout: 300000 let a 130-second command finish. Going over the ceiling raises no error, it just gets clamped quietly — keep requests within the ceiling.

4. With run_in_background in -p mode, poll in the foreground until it finishes. If the model just "waits for the notification", the session winds down and kills the background task 5 seconds after the final reply — and the model may invent a result.

5. Seeing moved to the background instead of timed out is expected. That is 2.1.280's default for commands whose first word isn't sleep. The command didn't fail; it just didn't finish. Under -p it is still killed at wind-down.

Total API spend for the 19 valid sessions: $0.4152.

Pitfalls

  • grep on this machine is aliased to ugrep. Searching the 217 MB binary with a .{0,N} regex failed with exceeds complexity limits. Switching to /usr/bin/grep, a leading .{0,255} then ran for over 600 seconds — and the Bash command in my own driving session got auto-backgrounded, a first-hand preview of what this article measures. I ended up slicing by offset with python mmap.
  • API gateway 503. The first batch of 6 concurrent runs all hit 503 No available accounts, exhausted 11 retries and cost $0. They were moved to _invalid_503/ and rerun with concurrency lowered to 2–3. The first B2 run's "while running" process snapshot was missed because of the 503 delay.
  • The repo's hook caught my own commands. The output of commands I used to inspect readings contained Exit code 143 / timed out, and the repository's PostToolUse hook logged them as failed commands, appending 10 lines to tasks/lessons-inbox.md (18→28). After confirming it was a pure tail append, I truncated it back to the first 18 lines. Worth remembering when you experiment on timeouts inside a repo with such a hook.
  • The model fabricated a background completion notification (D1). Seen once with haiku; other models and the repeat rate are not tested. The takeaway holds anyway: don't trust a "completed" notification in the reply — read the output file.
  • Limited polling precision. Attributing processes by cwd via lsof is slow; the real polling interval was 8–25 seconds, so "the processes vanished at the moment of timeout" can only be stated as "they were gone when claude exited".

Not verified: the source of the zt() duration formatter (format taken from measurements only); CLAUDE_CODE_AUTO_BACKGROUND_TIMEOUT_MS and the sleep≥25 check (code read only); the kill path for non-sleep commands with background tasks disabled at the default 120s; behavior of backgrounded commands in interactive (non--p) sessions; the ceiling being raised when DEFAULT exceeds MAX.

Related Articles

Claude Code "Error: Reached max turns (1)": when the headless guardrail fires, your file may already be written

Claude Code "Error: Reached max turns (1)": when the headless guardrail fires, your file may already be written

claude -p guardrails stop with Error: Reached max turns (1) or Error: Exceeded USD budget (0.01), exit 1. On 2.1.270 and 2.1.280 they are 28 and 33 bytes on stdout, not stderr, with no newline. In json mode the result key is missing, so jq -r .result prints null with jq exit 0. Failure does not mean nothing happened: --max-turns 2 errors after out.txt is written, and the budget is checked after each call, so a $0.05 cap spent $0.0517, finished the task and still exited 1.

claude-codeautomation+5
hands-onSep 25, 202611 min
45
Claude Code MCP server Failed to connect: CONNECTION_CLOSED, connection timed out after 30000ms and ENOENT, reproduced one by one

Claude Code MCP server Failed to connect: CONNECTION_CLOSED, connection timed out after 30000ms and ENOENT, reproduced one by one

Six causes behind Claude Code's MCP "Failed to connect", reproduced on 2.1.280: mcp list exits 0 on failure, CONNECTION_CLOSED hides two causes that only --debug-file reveals, and one server that never handshakes pushes claude -p wall time 4.63s → 36.21s, duration_ms only 4323.5 → 5930.

mcpclaude-code+4
pitfallsSep 24, 202610 min
72

DeepSeek Says 1M, Claude Code Says 200K: I Measured Both and Neither Number Is the Real Limit

DeepSeek advertises a 1M context window. Point Claude Code at it and Claude Code reports contextWindow 200000 for the same model. I measured what actually happens. DeepSeek's real ceiling is 1,048,576 tokens — literally 2^20, not one million — and it covers input plus your max_tokens budget, proven with a controlled pair. A needle planted at position zero was retrieved correctly at 1,039,744 tokens. Claude Code refuses client-side long before that, in 25ms with zero API calls, and its gate is not on tokens at all: it fires at roughly 480,000 characters. Feed it high-entropy text and 478,000 characters sails through carrying 309,567 real tokens — 55% past the 200K window it just claimed. And in ordinary use you reach none of these, because Bash output over exactly 30,000 characters never enters context at all.

claude-codelong-context+5
hands-onSep 20, 20267 min
108

Running Claude Code on DeepSeek: Everything Works, But the Cost Readout Lies by 38x

DeepSeek ships an Anthropic-format endpoint, so you can point Claude Code at it with three environment variables. I ran the whole thing on a real machine: every local tool (Read / Write / Bash / Glob / Edit / subagents) works and produces real side effects, so the short answer is yes, it works. The long answer is the part nobody measured — Claude Code bills DeepSeek tokens at Claude Sonnet rates. Ten identical turns: Claude Code reported $1.71, DeepSeek's actual balance dropped ¥0.32 (≈$0.045). That is a 38x over-report, measured against the invoice, not a price list. Also inside: the official docs are wrong about unknown model names (they 400, they don't fall back), v4-pro returns thinking blocks by default so a small max_tokens looks like an empty reply, and one failure that looks like DeepSeek's fault but isn't.

claude-codedeepseek+5
hands-onSep 20, 20268 min
95

Published by Magic Tools