Skip to content

perf(hooks): Windows process-creation tax makes the hook set unusable - 1,778 timeouts / 20.6 hours blocked over 9 days #3508

Description

@kyle-sexton

Warning

Three claims in the original body are superseded. See the correction comments. Do not work from the original acceptance criteria — they conflict with the marketplace hook budget and with the child issues.

Superseded: the acceptance criteria

The original body proposed "median PreToolUse hook-stack wall-clock under 2 s" and "no hook exceeds 5 external process spawns", while every child issue says "no more than 2 spawns". Three different bars coexisted. The binding bar is docs/conventions/hook-budget/README.md (adopted #1809, surfaced via .claude/rules/hook-budget.md):

Surface Budget
Per tool call (PreToolUse + PostToolUse, one matcher) <= 1 s typical, <= 2 s worst-case
Per turn (Stop / notification-shaped) <= 500 ms

Measured as parallel wall time, not the sum. Note the original "under 2 s per hook" let a single hook consume the entire worst-case budget for the whole set — with 8 guardrails hooks on the Bash matcher, all could "pass" while the set failed the convention several times over. The Stop-event children were 4x over the per-turn ceiling under the old criterion.

Use instead, for every child:

  • Spawn count on the common path, reported before and after, measured by PATH shim (the drift-immune criterion).
  • The hook's measured share, by the convention's method (EPOCHREALTIME around direct invocation, >= 10 runs, concurrent launch), stated in the plugin README per hook-budget Rule 1.
  • The always-on set stays within <= 1 s typical / <= 2 s worst-case per tool call, or <= 500 ms per turn, reported alongside the spawn-cost baseline at measurement time.

Matcher-narrowing work should additionally cite docs/conventions/hook-precision/README.md, which owns what a hook fires on.

Superseded: "they serialize"

The original body said hooks "serialize" on one tool call. The convention states Claude Code runs matching hooks in parallel — "the wall is the max of the set under spawn contention, not the sum." The transcript evidence (up to 8 hooks timing out against one toolUseID) shows they contend, which is not the same as serializing. The corrected reading: parallel dispatch, with wall-clock set by the slowest hook plus contention among them. That is also why hook cost must never be presented additively.

Superseded: the flat spawn cost and the timeout

  • Spawn cost is bimodal (180-2,841 ms, 15.7x spread, at 501 concurrent processes), not the flat 661/872/908 ms quoted in the body. Never quote a spawn figure without concurrent_processes_at_sample.
  • "Against a 15 s timeout" is wrong as a universal. Actual configured timeouts: source-control 15 s, guardrails 60 s, context-guard 60 s, disk-hygiene Stop 20 s, claude-ops Stop 10 s, autonomy Stop 15 s.

Method pin (was missing)

Transcript counts: hook_cancelled attachment records with timedOut: true, reading durationMs / timeoutMs / hookName / command, across 236 session files under ~/.claude/projects/**/*.jsonl, 2026-08-23 to 2026-08-31.

The static call-site counts in the original body do not reproduce and should be treated as indicative only. An independent pass on shipped guardrails 0.29.24 yields, for block-dangerous-git.sh, 184 (with comments) / 163 (lines-with-match) / 44 (comment-stripped) — none equal to the published 127. The ordering holds under every method, so #3529's headline stands, but absolute counts need the exact command and version pinned before being quoted.

Prior art, and the delivery constraint


Original body — SUPERSEDED by the block above. Kept for the record; do not work from its numbers or checklists.

Summary

On a Windows host, the marketplace hook set blocks the agent loop for hours per day. Over a 9-day window on one machine: 1,778 hook timeouts totalling 74,123 s (20.6 hours) of blocked wall-clock, mean 41.7 s each against a 15 s timeout.

The scripts are not the problem. Process creation on this host costs 0.3-0.9 s per spawn. Every hook pays that toll once for its own shell and again for each external command it invokes.

This is the parent issue for the root cause. Per-hook children are listed at the bottom.

Measured: process spawn cost

bash -c true                    x10 ->  6.614 s real  = 661 ms/spawn   (user time 0.090 s TOTAL)
bash --noprofile --norc -c true x10 ->  2.775 s real  = 277 ms/spawn
jq -n 1                         x10 ->  9.083 s real  = 908 ms/spawn
cmd.exe /c exit                 x10 ->  8.719 s real  = 872 ms/spawn

Two things to read off this:

  1. User CPU time for 10 bash spawns is 0.090 s. ~99% of wall-clock is process-creation overhead, not script work. Optimizing script logic cannot fix this.
  2. cmd.exe is as slow as bash. This is not MSYS/Git-Bash fork() emulation. It is native Windows process creation.

Healthy reference on Linux/macOS is ~3-5 ms/spawn. This host is ~150-200x slower.

Cost model for any hook: 0.66 s (its own bash) + N x 0.91 s (each jq). A hook making 5 jq calls costs ~5.2 s before executing a single line of its own logic.

Measured: fan-out per tool call

59 hook commands are registered across 16 events by 22 enabled plugins - 17 on PreToolUse, 22 on PostToolUse. They serialize:

hooks timing out on a single tool call:
  1 hook  -> 113 tool calls
  2 hooks -> 327
  3 hooks -> 146
  4 hooks ->  30
  5 hooks ->  34
  6 hooks ->  15
  7 hooks ->  12
  8 hooks ->   3

Every Bash, PowerShell, and Edit call pays the whole stack.

Measured: worst offenders

hookName || command, by timeout count, from transcript hook_cancelled attachments with timedOut: true:

  423x PreToolUse:Bash        PR body / pr-issue-linkage gate           avg 24.7s  max 115.3s
  418x PreToolUse:Bash        git worktree add nesting invariant        avg 26.0s  max 115.3s
  126x PreToolUse:Bash        commit subject / PR title convention      avg 70.5s  max 162.8s
  113x Stop                   unsurfaced hook failures                  avg 21.9s  max  74.1s
   82x PreToolUse:PowerShell  hook-bypass attempts                      avg 75.1s  max 134.1s
   81x PreToolUse:PowerShell  commit subject / PR title convention      avg 81.8s  max 146.5s
   73x Stop                   lane-stop gate                            avg 27.6s  max  66.8s
   68x PreToolUse:PowerShell  commit-message convention                 avg 78.0s  max 134.1s
   55x PreToolUse:PowerShell  dangerous git commands                    avg 75.9s  max 134.1s
   52x Stop                   silent destructive-guard failures         avg 31.8s  max  57.9s
   43x PreToolUse:PowerShell  --no-verify bypass                        avg 76.1s  max 134.1s
   27x PreToolUse:Bash        Windows drive-root /tmp writes            avg 73.4s  max 112.6s
   10x PostToolBatch          context-zone crossing                     avg 73.0s  max  96.0s

Note every duration is 2-8x past the 15 s timeout. The guard is killed mid-run and never renders a verdict - the user pays 15-160 s for a check that does not complete. These guards are currently pure cost with zero protective value on this platform.

Static call-site counts (indicative, not runtime)

External-command call sites per script (jq|grep|sed|awk|date|git|python|realpath|dirname|basename|cut|tr). Many sit in branches, so this is an upper bound, not an executed-spawn count:

 127  block-dangerous-git.sh
 113  hook-utils.sh
 110  block-noncanonical-commit.sh
  52  block-hook-bypass.sh
  38  stale-path-verify.sh
  30  block-no-verify.sh
  23  block-convention-violation.sh
  22  skill-reference-verify.sh
  22  cli-flag-verify.sh
  21  hardcoded-path-check.sh

hook-utils.sh is sourced by every guardrails hook, so its 113 call sites are paid on every hook run that reaches them.

Environment / what is NOT the cause

  • Windows 11 Pro 26200, AzureAD-joined. Claude Code 2.1.251 (native install). Git Bash (MSYS).
  • Defender is not the cause. WinDefend service is Stopped (StartType Manual); Get-MpPreference and Get-MpComputerStatus both fail with "Provider load failure".
  • No AppLocker policy. AppInit_DLLs empty. Smart App Control off.
  • Prime suspect: Win32_DeviceGuard reports CodeIntegrityPolicyEnforcementStatus = 2 (WDAC kernel-mode policy enforced) with VirtualizationBasedSecurityStatus = 2. A WDAC policy in enforcement mode validates code integrity on every process launch, which fits 300-900 ms spawns - especially for unsigned MSYS binaries that miss the signature cache.
  • Not yet checked: fltmc filters requires elevation. Enumerating minifilter drivers is the remaining unknown.

Proposed work, in value order

1. Cut spawn COUNT, not script runtime. This is the whole game. Concretely:

  • Collapse multi-jq call sites into a single jq invocation per hook (hook::jq_fields already does this in places - apply it everywhere).
  • Replace grep/sed/cut/tr/basename/dirname subprocess calls with bash builtins (parameter expansion, [[ =~ ]]). Each one removed saves ~0.9 s on this class of host.
  • Avoid sourcing the full hook-utils.sh when a hook needs two helpers.

2. Consolidate hooks per event. 17 PreToolUse commands is 17 shells. A single dispatcher process that runs the guards in-process and returns one verdict would cut PreToolUse cost by roughly 17x on the spawn axis alone.

3. Narrow matchers. Several guards fire on every Bash call but only care about git/gh invocations. A matcher that excludes non-git commands removes most of the fan-out for free.

4. Add a platform escape hatch. A documented switch to disable the shell-based guard set on Windows hosts where spawn cost is pathological, so the fleet degrades to "unprotected but usable" instead of "protected in theory, unusable in practice".

5. Raise or make configurable the 15 s hook timeout - secondary. Currently every one of these runs is killed, so the guard provides no protection while still costing the full wall-clock.

Out of scope for this repo

The 872 ms native process-creation cost is a machine/IT problem (likely the enforced WDAC policy). Worth pursuing in parallel with IT - a code-integrity exclusion for the toolchain would recover most of the 20.6 hours without any change here. This issue covers making the hook set resilient to slow hosts regardless.

Per-hook children

To be filed separately so a Windows host can work them independently:

  • source-control: pr-issue-linkage gate (423 timeouts - highest single offender)
  • source-control: git worktree nesting invariant (418)
  • guardrails: block-convention-violation.sh commit subject / PR title (207 across Bash + PowerShell)
  • guardrails: Stop - unsurfaced hook failures (113)
  • guardrails: block-hook-bypass.sh perf (105) - correctness tracked separately in fix(guardrails): block-hook-bypass.sh fails CLOSED on a Windows stdin read stall, blocking valid tool calls with 'hook stdin is not valid JSON' #3507
  • guardrails: block-noncanonical-commit.sh (91)
  • autonomy: Stop - lane-stop gate (73)
  • guardrails: block-dangerous-git.sh (59)
  • guardrails: Stop - silent destructive-guard failures (52)
  • guardrails: block-windows-drive-tmp.sh (49)
  • guardrails: block-no-verify.sh (44)
  • guardrails: block-exported-msys-pathconv.sh (15)
  • context-guard: PostToolBatch context-zone crossing (10)
  • guardrails: secret-pattern-detection.sh / hardcoded-path-check.sh (PreToolUse:Edit, ~60 s each observed)

Acceptance criteria

  • Median PreToolUse hook-stack wall-clock on a Windows host is under 2 s.
  • No hook in the set exceeds 5 external process spawns on its common path.
  • A documented way to disable the shell-guard set per-platform exists.
  • Re-measuring the transcript corpus after the change shows a >90% reduction in timedOut: true hook attachments.

Method

Counts derived from ~/.claude/projects/**/*.jsonl hook_cancelled attachment records (timedOut: true, durationMs, timeoutMs) across 236 sessions, 2026-08-23 to 2026-08-31. Spawn timings from time over 10 iterations each.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    agent-readyFully specified and briefed; eligible for autonomous pickup from the frontier.priority: criticalProduction broken, data loss, active exploit, or release blocker.work-class: structuralRefactors, migrations, contract changes; cross-cutting and hard to reverse.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions