[Windows] Hook timeout doesn't release the tool call while an orphaned descendant still holds the hook's stdout
Environment
- Claude Code 2.1.281 (same on 2.1.280),
claude -p, Windows 11 Pro commandhook launched without a shell ("command": "py", "args": [...]); the shell form behaves the same
What happens
When a hook exceeds its timeout, Claude Code kills the hook's process tree, but the tool call does not proceed until the hook's stdout pipe reaches EOF. If a process outside the killed tree inherited that stdout handle (e.g. a grandchild whose parent already exited, so a tree kill can't reach it), the tool call waits for that process to exit, regardless of timeout.
Repro
.claude/settings.json:
{"hooks": {"PreToolUse": [{"matcher": "Bash", "hooks": [
{"type": "command", "command": "py",
"args": ["-3.12", "C:/path/to/holder.py", "holder", "60", "40"], "timeout": 5}
]}]}}
holder.py:
import os, subprocess, sys, time
LOG = os.path.join(os.path.dirname(os.path.abspath(__file__)), "hook.log")
def log(msg):
with open(LOG, "a") as f:
f.write("%.3f %s\n" % (time.time(), msg))
mode, hook_sleep, holder_s = sys.argv[1], float(sys.argv[2]), sys.argv[3]
sys.stdin.read()
log("start mode=%s" % mode)
if mode == "holder":
# The intermediate spawns the holder and exits at once: the holder's parent is dead,
# so a tree kill rooted at this hook can't reach it, but it inherited our stdout.
code = ("import subprocess,sys;"
"subprocess.Popen([sys.executable,'-c','import time; time.sleep(%s)'],"
"stdout=sys.stdout,stdin=subprocess.DEVNULL,stderr=subprocess.DEVNULL,"
"creationflags=0x08000000)" % holder_s)
subprocess.run([sys.executable, "-c", code], stdout=sys.stdout,
stdin=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
log("holder spawned for %ss" % holder_s)
sys.stdout.flush()
time.sleep(hook_sleep)
log("end")
print("{}")
Run:
claude -p "Run exactly this bash command once: echo hi. Then reply with the single word: done." --model haiku --dangerously-skip-permissions --output-format stream-json --verbose
and time the gap between the tool_use and tool_result events.
Measured (2.1.281)
| hook | timeout | tool_use → tool_result |
|---|---|---|
| sleeps 60 s, no holder (sleep mode) | 5 s | 8.5 s |
| sleeps 60 s + stdout holder alive 40 s (holder mode) | 5 s | 43.3 s |
hook.log never shows end, so the hook itself was killed at the timeout. The call returned only when the holder exited.
Why it matters
With shell-form hooks, a timeout kill of bash can leave suspended children (MSYS starts children suspended and resumes them after setup) that hold the inherited stdout forever. We saw Bash tool calls in subagents hang for 1.6–6.5 hours; they completed within seconds once those orphans were killed. The configured timeout gave no protection.
Expected
After timeout and the tree kill, stop waiting for EOF on the hook's pipes (e.g. close the read ends after a short grace period) and continue as with a cancelled hook.