Windows: hook processes are left SUSPENDED (never executed a single instruction), hanging the turn for 30-60+ minutes
Environment
- Claude Code on Windows 11 Pro (build 26200), Git Bash, PowerShell 5.1
- Hooks: Python scripts (
python.exe3.12), declared with"timeout": 2/"timeout": 3insettings.json
Summary
Claude Code sometimes leaves a hook process suspended and never resumed. The process never
executes a single instruction, so it never returns — and Claude Code waits on it, hanging the entire
turn for 30–60+ minutes. Escape does not cancel it; only restarting the session helps.
This is not a stdio/pipe deadlock and not a slow hook. The process is suspended, not blocked.
The signature (measured)
Live hook processes found alive 46–127 minutes despite a declared 2–3 s timeout:
| Property | Value |
|---|---|
| Thread count | 1 |
| Thread WaitReason | Suspended (kernel WrSuspended) |
| UserProcessorTime | 00:00:00 |
| Working set | ~1.9 MB |
| Parent process | dead |
Claude Code prints timed out - output discarded, but neither resumes nor kills the process.
Why this proves "never started", not "blocked on I/O"
WaitReason = Suspendedcannot be produced by a blocked read.ProcessThread.WaitReason
surfaces the kernel's KWAIT_REASON. A thread blocked in ReadFile on a pipe reports
UserRequest/Executive — never WrSuspended. These are distinct kernel codes.
- Zero CPU time really means zero instructions. Control experiment: 12
python.exeprocesses
that do start and then block on stdin were measured with GetProcessTimes after 3 s:
````
CPU times: [0.0312, 0.0312, 0.0312, 0.0312, 0.0156, 0.0312, ...]
processes reporting exactly 0.0 : 0 / 12 (min 0.0156, max 0.0312)
Python startup always crosses at least one 15.6 ms accounting quantum. So 0.0 is not a
granularity artifact — it discriminates "never ran" from "ran, then blocked".
- A single
ResumeThread()fixes it.OpenThread(THREAD_SUSPEND_RESUME)+ResumeThread()
returned previous suspend count = 1, and the process then completed its work and exited in
< 1 second (verified: it wrote its output file). Reproduced on 5 separate processes.
Since ResumeThread is a no-op returning 0 on a non-suspended thread, a return of 1 can only
mean the thread was genuinely suspended.
- The hook script is not at fault. Spawning the identical hook 120× via plain
subprocess.Popen (same stdin payload, same 3 s budget), outside Claude Code:
0 freezes, ~31 ms each. The freeze only occurs when Claude Code spawns the process.
- Windows Defender's Operational log contains zero events for
python.exe, and a plain
python -c pass spawn takes a stable ~65 ms — so AV scanning does not appear to be throttling
the spawn (though we cannot rule out AV interference at the kernel process-creation path).
Root cause: unknown
We deliberately do not claim a mechanism. Something either spawns these processes suspended or
calls SuspendThread on them and never resumes. For the record, we checked and ruled out the
obvious guess: in libuv/src/win/process.c, CREATE_SUSPENDED is set only underUV_PROCESS_DETACHED, and AssignProcessToJobObject sits in the mutually exclusive non-detached
branch — hooks are not detached, so that path is not it. We're reporting a reproducible signature and
a one-line remedy, and leaving root-cause to you.
Impact
- Turn hangs for tens of minutes on any tool (we observed it on a plain file
Read, not just Bash). Escapedoes not cancel the hung turn.- Silently corrupts hook semantics: a hook that never runs looks to Claude Code like a hook that
timed out. Side effects the user depends on (our session-edit manifest, guards, telemetry) simply
never happen.
Likely explains several open issues
This mechanism would account for reports that are currently filed separately:
- #34457 — Windows hook hangs (closed, not planned)
- #68970 — hooks fire at ~1.2% rate on Windows
- #76413 — PreToolUse hooks intermittently not invoked (Windows)
- #66151 — Stop hook orphan children (Windows)
- #67888 — Windows process-cleanup path hangs/accumulates
- #73546 — turn stuck on "running", timeout never fires
Suggested fixes
- On timeout, actually terminate the hook process. Today "output discarded" leaves it resident
forever. (See #67888 — the cleanup path itself is unreliable on Windows.)
- Resume before waiting / verify the child is runnable after spawn — a suspended child will
never produce output, so waiting on it is unbounded.
- Make
Escapecancel a turn that is blocked on a hook.
Workaround we are using
A watchdog that resumes (rather than kills) suspended hook processes: it polls every 5 s and
calls ResumeThread on hook processes with all threads suspended, zero CPU time, and age > 12 s.
The revived hook completes its work in < 1 s and exits, and the hung turn proceeds — no session
restart, no lost hook side effects.
Known limitation of the heuristic: a hook that legitimately blocks on a slow read also shows ~zero
CPU; only the age window protects it.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗