Declared hook `timeout` is not enforced parent-side; a hook subprocess wedged during Node startup freezes the session permanently

Status Open
Reported on v2.1.226
Maintainer reply None cached
Activity 1 comment · opened Aug 9, 2026

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

  1. Register a wide PreToolUse hook batch on a hot matcher — 8 separate type: command hooks, each spawning a Node script, all on matcher Bash, each declaring a short timeout:

``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
]
}
]
}
}
``

  1. Run a long, Bash-heavy session so the batch fires repeatedly under spawn contention. (Both of our occurrences were in long-running non-interactive claude -p sessions; incidence is low per-invocation but the exposure accumulates over hours.)
  1. Eventually one Bash tool call never returns. The CLI sits at ~0% CPU with no error and no diagnostic.
  1. 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:

  1. 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).
  2. Synthesize a hook response so the batch await settles, and continue the tool call under the documented disposition for a failed hook.
  3. 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_timeout event. Today the only signal is an absence (a missing hook_response), which is why detection requires process-level forensics.

Workarounds available today (both partial)

  1. 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.
  2. Reap orphans out-of-band — a periodic sweep that kills node hook 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.

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗