[Windows] Hook timeout doesn't release the tool call while an orphaned descendant still holds the hook's stdout

Status Open
Reported on v2.1.281
Maintainer reply None cached
Activity 0 comments · opened Sep 25, 2026

Environment

  • Claude Code 2.1.281 (same on 2.1.280), claude -p, Windows 11 Pro
  • command hook 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.

View original on GitHub ↗