Declared hook `timeout` is not enforced parent-side; a hook subprocess wedged during Node startup freezes the session permanently
Environment
| | |
|---|---|
| Product | Claude Code CLI |
| Version | 2.1.226 |
| OS | Windows 11 Pro, build 26200 |
| Node | v24.11.1 |
| Hook config | PreToolUse matcher Bash, 8 type: command hooks (Node scripts), each declaring "timeout": 2 |
Summary
hooks.json lets a hook declare "timeout": <seconds>. That timeout does not appear to be enforced by the parent process. It bounds a hook that runs long; it does not bound a hook that never begins executing.
Under a wide, concurrent hook batch, one of the spawned node subprocesses occasionally wedges during interpreter startup: the process exists in the process table, it accrues zero CPU ticks, and its JavaScript never runs. The parent's hook-batch await never settles. The tool call never receives its tool_result. The session is frozen permanently, at ~0% CPU, with no error surfaced.
This is a distinct process state from the more commonly reported variant where a hook's JS is running but is blocked reading stdin. The distinction matters for triage, because it removes the only workaround available to hook authors:
| | Hook blocked in stdin read | Hook wedged pre-main (this report) |
|---|---|---|
| Process state | JS running, blocked in stdin read | JS never runs |
| CPU | ~0 after the block | exactly 0, from spawn |
| Fixable by the hook author? | Yes — bound the read in-process | No. A fix written in JS cannot execute in a process whose JS never starts. |
| Declared timeout enforced? | No | No |
A user-space mitigation exists for the first and cannot exist for the second. That is why parent-side timeout enforcement is raised here as the primary ask rather than a hardening suggestion.
Reproduction
- Register a wide
PreToolUsehook batch on a hot matcher — 8 separatetype: commandhooks, each spawning a Node script, all on matcherBash, each declaring a shorttimeout:
``json``
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "node ./hooks/guard-1.js", "timeout": 2 },
{ "type": "command", "command": "node ./hooks/guard-2.js", "timeout": 2 }
// ... 8 total
]
}
]
}
}
- Run a long,
Bash-heavy session so the batch fires repeatedly under spawn contention. (Both of our occurrences were in long-running non-interactiveclaude -psessions; incidence is low per-invocation but the exposure accumulates over hours.)
- Eventually one
Bashtool call never returns. The CLI sits at ~0% CPU with no error and no diagnostic.
- Confirm the state with the census below. On a healthy host it reports 0 rows.
Actual behavior
The tool call never completes and the session is frozen permanently. There is no kill, no synthetic hook response, no hook_timeout event, and no message to the user. The only recovery is killing the CLI process.
Expected behavior
The declared timeout bounds the hook. When the hook does not produce a hook_response within it, the parent kills the child, settles the batch, and surfaces a diagnostic.
Evidence
A. Forensic signature (two signals, decisive only in combination)
1. An unmatched hook_started with no hook_response. With --include-hook-events, the frozen session's event stream ends with exactly one hook_started that never produces a matching hook_response or hook_progress. The other members of the same batch all resolve within milliseconds.
hook_started PreToolUse:Bash <8 hook ids>
hook_response <7 of the 8 ids>
-> the 8th id NEVER APPEARS AGAIN. Stream ends.
Ambiguous alone: a trailing unresolved hook_started is normal on a live session with a batch genuinely in flight.
2. A zero-CPU node.exe whose PPID is dead. At inspection time the missing hook's process is still resident:
- image
node.exe, command line pointing at the hook script, - CPU time 0 — not "low", zero: the process never executed an instruction,
- PPID refers to a process that no longer exists, and
- age vastly exceeding the declared
timeout(2s declared; hours observed).
Ambiguous alone: a zero-CPU node process could be momentarily new.
The pair is decisive. An unmatched hook_started whose hook process is still alive at zero CPU, minutes-to-hours later, against a declared timeout of seconds, is this defect and nothing else.
Census reproducer (PowerShell) — a healthy host reports 0 rows:
Get-CimInstance Win32_Process -Filter "Name='node.exe'" |
Where-Object { $_.CommandLine -match 'hooks' } |
ForEach-Object {
$p = Get-Process -Id $_.ProcessId -EA SilentlyContinue
if ($p -and $p.CPU -lt 0.05 -and ((Get-Date) - $p.StartTime).TotalMinutes -gt 5) {
[pscustomobject]@{
Pid = $_.ProcessId
Ppid = $_.ParentProcessId
PpidAlive = [bool](Get-Process -Id $_.ParentProcessId -EA SilentlyContinue)
AgeMin = [int]((Get-Date) - $p.StartTime).TotalMinutes
CpuSec = $p.CPU
Cmd = $_.CommandLine
}
}
} | Sort-Object AgeMin -Descending
Note for anyone reproducing this: the wedged processes are orphaned — their PPID points at an already-exited intermediate, so they are not children of the CLI process. A child-scoped process enumeration reports "no hook is alive" while the hook is very much alive, and a parent-tree kill does not clean them up. We lost several investigation cycles to exactly this, and the leaked processes survive the session that spawned them.
B. The declared timeout is demonstrably unenforced
The wedged hook declared "timeout": 2. Observed lifetimes of processes bearing that declaration run to hours. There is no kill, no synthetic hook response, and no error surfaced to the session.
An earlier probe of ours concluded "hook timeouts ARE enforced" — that probe measured a hook that sleeps, i.e. one whose JS is running normally. Anyone verifying a fix should be careful to reproduce the correct process state: a hook that runs and overruns is bounded; a hook that never runs is not.
C. Correlating condition: batch width
Both occurrences were in an 8-wide PreToolUse:Bash batch. We have not isolated the precise mechanism inside interpreter startup and make no claim about it — the observable is that concurrent spawn pressure correlates with the wedge, and that reducing batch width reduces incidence.
D. Possibly contributing, offered without a causal claim
The wedged hooks were loaded from a directory whose files carry Windows cloud-file attributes (0x80020, Archive + Pinned), meaning image load is mediated by a cloud-sync filter driver. Copies of the same hooks under a non-synced directory are plain Archive. We flag this as an environmental correlate a reproducer might find useful; we have not established that it is causal, and the requested fix does not depend on it.
Impact
Two long-running non-interactive sessions were lost on 2026-08-09, each frozen with no output and no diagnostic, each costing the remaining work of a multi-hour task. Both matched this signature.
For an interactive user this presents as the CLI hanging after a tool call with no diagnostic. For headless/non-interactive sessions it is worse: the process stays alive and continues to look healthy to external supervision, so a frozen session cannot be distinguished from a working one without process-level forensics.
The exposure scales with hook batch width.
Suggested fix
Enforce the declared timeout parent-side. When a hook subprocess has not produced a hook_response within its declared timeout:
- Hard-kill the child process (not a graceful signal — the target may never have run any code, so it has no handler to run and nothing to flush).
- Synthesize a hook response so the batch await settles, and continue the tool call under the documented disposition for a failed hook.
- Surface a diagnostic naming the hook and the timeout it exceeded.
The essential property is that the timeout must be owned by the parent. Any mechanism located inside the hook process is unreachable in a process whose JavaScript never begins.
Secondary asks (descending priority)
- Bound batch concurrency, or make it configurable. If concurrent spawn pressure is the trigger, a configurable ceiling on simultaneous hook spawns would reduce incidence for everyone.
- Reap orphaned hook subprocesses on session exit. A wedged hook currently outlives the session that spawned it and is not cleaned up by a parent-tree kill, because it has been reparented.
- Emit a
hook_timeoutevent. Today the only signal is an absence (a missinghook_response), which is why detection requires process-level forensics.
Workarounds available today (both partial)
- Reduce hook batch width — consolidate multiple hooks registered on the same matcher into a single process. We did this: five
Bash-matcher guards became one dispatcher, cutting the batch from 8 spawns to 4. This shrinks the target; it does not remove it, and it does nothing for a single-hook batch. - Reap orphans out-of-band — a periodic sweep that kills
nodehook processes with a dead PPID, zero CPU, and an age far past their declared timeout. This recovers the leaked processes; it does not unfreeze the session that was already lost.
Neither workaround addresses the freeze itself, because the freeze is the parent's un-settled await. Only the parent can end it.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗