Declared hook timeout does not apply while a hook is blocked reading its stdin payload — hook survives ~300s and holds the tool call
Summary
A hook's declared timeout in hooks.json is enforced normally — but not while the hook is blocked reading its payload from stdin. In that state the declared timeout does not apply and the hook survives until a ~300-second backstop cancels it, holding the tool call behind it for the whole time.
Observed on Windows with Claude Code 2.1.229, auto permission mode.
Evidence from Claude Code's own logging
A third-party plugin's PostToolUse hook declares "timeout": 3 in its hooks.json. From ~/.claude/debug/<session>.txt:
14:07:39.571Z [DEBUG] Hook PostToolUse:Bash (PostToolUse) cancelled:
14:07:39.572Z [INFO] Slow PostToolUse hooks: 303244ms for Bash (1 hooks)
303,244ms against a declared 3,000ms — roughly 100× over, then cancelled. The hook's payload read was a bare cat > "$FILE", i.e. blocking until stdin closes.
The same shape appeared earlier in a PreToolUse hook declaring "timeout": 5, measured live at 235s and 182s via process age.
Control experiment: the timeout mechanism itself works
To rule out a general timeout failure, a hook that only computes — no stdin read at all:
.claude/settings.json:
{
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "bash \"$CLAUDE_PROJECT_DIR/slow-hook.sh\"", "timeout": 3 }]
}]
}
}
slow-hook.sh:
#!/usr/bin/env bash
LOG="$(dirname "$0")/hook-ran.log"
: > "$LOG"
for i in $(seq 1 60); do echo "alive ${i}s" >> "$LOG"; sleep 1; done
echo "finished-uninterrupted" >> "$LOG"
Then claude -p 'Run exactly this and nothing else: echo hooktest' --permission-mode dontAsk.
Result — killed exactly on time:
alive 1s
alive 2s
alive 3s
Three heartbeats, no finished-uninterrupted. So the declared timeout is enforced correctly for a hook that is executing. The 303s case is therefore specific to the stdin-read state, not a broken timer.
What I could not reproduce
I could not synthesise the triggering condition. A hook reading stdin normally returns immediately, because the harness writes the payload and closes the descriptor. Something intermittently left that descriptor open — but I have no reproduction for that, only for the consequence once it happens.
A plausible reading, offered as inference rather than fact: if the timeout clock starts once the payload has been handed over, then a hook whose payload delivery never completes is never inside the window the timeout governs. That would explain both observations — enforced at 3s when the hook runs, unbounded when it is still waiting to be fed.
Impact
The tool call waits for the hook, so a user sees the call sit at Running… with no indication anything is wrong. In one session, 24 of 292 Bash calls stalled between 303.9s and 313.3s — around two hours of dead time in one session.
The tight clustering is what makes it diagnosable: the stalled commands were unrelated to one another (a container exec, a Python script, a gh subcommand, an npm script), and heterogeneous commands do not land within 7 seconds of each other. The figure is the hook cancellation, not the work.
It is also very hard to attribute from the user's side. The visible UI text names the PreToolUse hooks that already completed, while the actual blocker is the PostToolUse hook of the previous tool call still holding the queue. I spent two days diagnosing this and blamed the permission classifier for most of it.
Questions
- Should the declared per-hook
timeoutcover the payload-delivery/read phase, not just execution after the payload arrives? As it stands, the one state where a hook can hang indefinitely is the one state the timeout does not govern. - Is the ~300s backstop documented anywhere? It is the only thing that ended these stalls, and it is 100× the declared value.
- Could a hook exceeding its declared timeout surface in the UI rather than only in
--debug?Slow PostToolUse hooks: 303244msis exactly the right message; it just isn't visible where the user is waiting. - Should the "Running…" indicator distinguish "waiting on a PostToolUse hook from the previous call" from "your command is executing"? That single distinction would have saved days here.
Workaround for plugin authors
Bound the payload read rather than relying on the declared timeout:
# instead of: INPUT=$(cat) / cat > "$PAYLOAD_FILE"
if command -v timeout >/dev/null 2>&1; then
INPUT="$(timeout 4 cat 2>/dev/null || true)"
elif command -v gtimeout >/dev/null 2>&1; then
INPUT="$(gtimeout 4 cat 2>/dev/null || true)"
else
INPUT="$(cat 2>/dev/null || true)"
fi
Note 2>/dev/null || true alone does not help — it handles a read that fails, not one that never returns. Note also that hooks which fail closed on an empty payload will begin blocking instead of hanging, which is a real behaviour change worth deciding deliberately.