You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
perf(guardrails): block-exported-msys-pathconv.sh averages 87.5s and timed out 15x - reduce process spawns #3519
Child of #3508 (Windows process-creation tax). This issue covers one hook: block-exported-msys-pathconv.sh in the guardrails plugin.
Measured
Plugin
guardrails
Hook
block-exported-msys-pathconv.sh
Event(s)
PreToolUse:Bash + PreToolUse:PowerShell
Timeouts in window
15
Average duration
87.5s
Worst observed
112.6s
Configured timeout
15 s
Every recorded run exceeded the 15 s timeout, so this guard is killed before it renders a verdict. It currently costs wall-clock without providing protection on this platform.
Context from the parent
Process creation on the affected host costs 0.3-0.9 s per spawn (bash -c true = 661 ms, jq -n 1 = 908 ms, cmd.exe /c exit = 872 ms, against ~3-5 ms on a healthy host). User CPU time is ~1% of wall-clock, so the fix is reducing the number of process spawns, not optimizing script logic. See #3508 for the full measurement and environment detail.
Specific guidance
18 external call sites. Windows-specific guard that is itself pathologically slow on Windows.
Acceptance criteria
This hook makes no more than 2 external process spawns on its common path (its own shell plus at most one jq).
Subprocess calls to grep/sed/cut/tr/basename/dirname on the hot path are replaced with bash builtins.
Where the guard only applies to a subset of commands, the matcher or an early guard clause exits before any spawn for non-matching invocations.
Existing behavioural tests still pass; the guard still blocks what it blocked before.
Measured on a Windows host, a single run of this hook completes in under 2 s.
Method
Counts from ~/.claude/projects/**/*.jsonlhook_cancelled attachments with timedOut: true, across 236 sessions, 2026-08-23 to 2026-08-31.
Shared correction (applies to every child of #3508)
1. The "15 s" timeout in the table above is wrong for most of these hooks. Timeouts are declared per hook in each plugin hooks/hooks.json, and they vary widely. Verified against the installed manifests and cross-checked against the timeoutMs field the harness records in transcripts:
Observed timeoutMs distribution across the transcript corpus confirms it: 1,365 PreToolUse:Bash timeouts at 15,000 ms (source-control), 246 at 60,000 ms and 384 PreToolUse:PowerShell at 60,000 ms (guardrails), Stop at 10,000 / 15,000 / 20,000 ms.
This makes the guardrails hooks worse than reported, not better — each one burns a full 60 seconds before being killed, not 15.
2. The acceptance criteria should cite the repo's existing budget, not the ad-hoc ones I wrote.docs/conventions/hook-budget/README.md (adopted in #1809, surfaced by .claude/rules/hook-budget.md on plugins/*/hooks/**) already defines the binding ceiling:
Surface
Budget
Per tool call (PreToolUse + PostToolUse for one matcher)
<= 1 s typical, <= 2 s worst-case
Per turn (Stop / notification-shaped)
<= 500 ms
Measured as parallel wall time, not the sum. Rule 1 of that doc requires a plugin adding or widening an always-on hook to state its measured share in its README; Rule 2 states the budget never relaxes to absorb an overage. Use those numbers as the bar. My "under 2 s on a Windows host" was an accidental near-restatement of the per-tool-call worst case; the per-turn 500 ms figure I did not have at all, and it binds every Stop-event child.
3. Prior art exists and should not be re-derived.#1403 (closed, needs-human) captures the spawn-reduction work from the unmerged PR #1385, including:
hook::jq_fields batched field extraction in lib/hook-utils.sh, replacing per-field jq calls across eight guards
a strip_quoted_spans shell-builtin replacement for a printf | sed | sed pipeline
a deferred git rev-parse --is-inside-work-tree probe in hardcoded-path-check.sh
a census of 65 process spawns per Bash tool call, and a measured 437 ms per-spawn unit price
It also records why #1385 did not merge: a fresh-context verification found four contract-test regressions, two of which failed open. Any revival must clear those first.
4. hook-utils.sh is synced, not per-plugin. It exists in 17 copies across plugins/*/hooks/hook-utils.sh, delivered by scripts/sync-hook-utils.sh. A change there must go through that script with all copies verified identical — editing one plugin's copy will be reverted or will diverge the fleet.
5. Validate with scripts/affected-tests.sh --run (add --explain to see suite selection), per the repo AGENTS.md. A changed file mapping to zero suites is an error, not "nothing to run".
6. Reference baseline for the host. The budget doc's reference measurements (2026-07-31, Windows 11 + Git Bash) are bash -c : ~ 80 ms and python3 -c pass ~ 160 ms, with the per-Bash-call always-on set at **~ 5.9 s parallel wall**. The host in #3508 measures a bimodal no-op spawn at 180-2,841 ms (median 1,108 ms) at 501 concurrent processes — 2-35x worse than that reference. Report the spawn baseline alongside any timing so a result on a healthy box is not mistaken for a pass on a contended one.
Static call-site counts do not reproduce — treat them as indicative only. An independent pass on shipped 0.29.24 using the stated pattern yields, for block-dangerous-git.sh, 184 with comments / 163 lines-with-match / 44 comment-stripped — none of which is the published 127. The ordering survives under every method (block-dangerous-git.sh is highest), so #3529's headline stands, but the absolute numbers should not be quoted without pinning the exact command and version. #3508's Method section needs that pin.
The spawn-count criterion is the one that matters and it is unaffected: it is measured directly by PATH shim on the executed path, not inferred from source greps.
Read-and-report pass over plugins/guardrails/hooks/block-exported-msys-pathconv.sh at origin/main (a34a96bc). This lane does not close issues; the recommendation below is for the triage lane.
Method
Guards no longer run as standalone hooks. Every guard in the Bash|PowerShell matcher is sourced in-process by run-guards.sh, which calls hook::buffer_stdin once for the whole batch and primes the jq field cache once. Verified on current main by reading the dispatcher and by measurement.
Counted with strace -f -e trace=clone,clone3,fork,vfork,execve, two separate counters: successful execve (PATH-visible program images, failed PATH probes discarded) and clone/fork (process creations including pure subshells, which is what an MSYS2 fork costs on the affected host). No xtrace command-position count: #3520 showed it reads 2 where the kernel makes 8, and the plugin's own budget test (run-guards.test.sh:168-215) uses PATH shims whose comment concedes forks are invisible to them.
The Windows lane was forced with OSTYPE=msys, as the contract suite does. HOOK_TELEMETRY_SINK unset. Linux, bash 5.2.21, jq present. Per-guard figures are deltas against a dispatcher run carrying a single no-op guard.
What the issue claimed vs what main has
Named overhead
State on main
18 external call sites, "Windows-specific guard that is itself pathologically slow on Windows"
Runtime figures below; static counts were withdrawn in the maintainer addendum on #3514.
Gone. Lines 97-100 are _HOOK_SELF="${BASH_SOURCE[0]%/*}" with the bare-filename fallback.
<= 2 external process spawns on the common path
Met on both counters. 0 execve, 1 process creation. This is the cheapest of the five guards in this campaign slice.
grep/sed/cut/tr/basename/dirname on the hot path
Met. Zero.
Early exit before any spawn for non-matching invocations
Met on execve. See the note below on the OSTYPE gate's position.
Behavioural tests still pass
Met.block-exported-msys-pathconv.test.sh PASS=127 FAIL=0; export MSYS_NO_PATHCONV=1 still exits 2 under OSTYPE=msys.
Under 2 s on a Windows host
Unverified. Measured on Linux.
Which commit changed it
a5f5e799 (#3781, "drop dirname and sed execs from the always-on path"). #3788 (5101f5a2) does not touch this file — confirmed against its file list, refuting the "fixed by #3788" framing for this shard. 273c20fe (#3820) does not touch it either.
Batch-level history on git status --short (whole Bash|PowerShell matcher, all eight guards), for context on where this guard's share sits:
On git status --short and on export MSYS_NO_PATHCONV=1, inside the dispatcher, on both a Linux host and with OSTYPE=msys:
execve contributed: 0. The batch's 2 jq are the dispatcher's, paid once for all eight guards.
Process creations contributed: 1, attributed with a BASHPID-logging DEBUG trap to block-exported-msys-pathconv.sh:111INPUT=$(hook::buffer_stdin).
That single fork is the shared hook::buffer_stdin capture pattern, present in all eight guards. There is nothing else in this file on the common path.
Residual: one small, optional in-file item
I checked the two shapes the scout could have missed:
The source "$(dirname …)" fork: already the ${BASH_SOURCE[0]%/*} form, matching plugins/source-control/hooks/worktree-create-gate.sh.
In-substitution redirects and pipes: none. No $( … 2>/dev/null ) or $( … | … ) double-fork shapes anywhere in this file.
The one thing left worth naming: the case "${OSTYPE:-}" host gate sits at line 144, after hook::buffer_stdin (111), hook::require_jq_blocking (119) and hook::jq_fields (122). Its sibling block-windows-drive-tmp.sh puts the same gate at lines 98-101, ahead of all three, and so costs literally zero on a non-Windows host. Moving this gate earlier would remove the last fork for Linux and macOS users. Note before anyone does it: this ordering is deliberate and documented in block-windows-drive-tmp.sh lines 96-99, which explicitly say the two files keep opposite orderings on purpose and "Do not 'fix the inconsistency' by aligning them". The reason given is blast radius: block-windows-drive-tmp.sh also matches Write|Edit|MultiEdit|NotebookEdit, where require_jq_blocking firing before the host gate would deny every file edit on a jq-less Linux box; this guard only matches Bash|PowerShell, where the #2146 fail-closed posture is accepted. A worker picking this up must preserve the fail-closed posture, not just move the case.
Fleet item worth its own issue: hook::buffer_stdin_to <var> in lib/hook-utils.sh (synced to 17 copies via scripts/sync-hook-utils.sh). Under the dispatcher the payload is already in RUN_GUARDS_INPUT, and INPUT=$(hook::buffer_stdin) copies it back out through a fork and a pipe. 1 fork per guard, 8 of the 36 process creations per Bash tool call. That is this guard's entire remaining cost and the largest remaining win in the plugin.
Recommendation
Close. Every overhead this issue names is gone, and at 0 execve and 1 process creation this is the cheapest guard in the slice, comfortably inside its own "<= 2 spawns" bar. The scout's read was correct. Two caveats for the record: the "under 2 s on a Windows host" criterion was not verified in this lane, and the binding budget is the docs/conventions/hook-budget/README.md ceiling cited in the shared correction above, not the ad-hoc figure in the issue body.
Activity
kyle-sexton commentedon Aug 31, 2026
Shared correction (applies to every child of #3508)
1. The "15 s" timeout in the table above is wrong for most of these hooks. Timeouts are declared per hook in each plugin
hooks/hooks.json, and they vary widely. Verified against the installed manifests and cross-checked against thetimeoutMsfield the harness records in transcripts:Observed
timeoutMsdistribution across the transcript corpus confirms it: 1,365PreToolUse:Bashtimeouts at 15,000 ms (source-control), 246 at 60,000 ms and 384PreToolUse:PowerShellat 60,000 ms (guardrails), Stop at 10,000 / 15,000 / 20,000 ms.This makes the guardrails hooks worse than reported, not better — each one burns a full 60 seconds before being killed, not 15.
2. The acceptance criteria should cite the repo's existing budget, not the ad-hoc ones I wrote.
docs/conventions/hook-budget/README.md(adopted in #1809, surfaced by.claude/rules/hook-budget.mdonplugins/*/hooks/**) already defines the binding ceiling:PreToolUse+PostToolUsefor one matcher)Stop/ notification-shaped)Measured as parallel wall time, not the sum. Rule 1 of that doc requires a plugin adding or widening an always-on hook to state its measured share in its README; Rule 2 states the budget never relaxes to absorb an overage. Use those numbers as the bar. My "under 2 s on a Windows host" was an accidental near-restatement of the per-tool-call worst case; the per-turn 500 ms figure I did not have at all, and it binds every Stop-event child.
3. Prior art exists and should not be re-derived. #1403 (closed,
needs-human) captures the spawn-reduction work from the unmerged PR #1385, including:hook::jq_fieldsbatched field extraction inlib/hook-utils.sh, replacing per-fieldjqcalls across eight guardsstrip_quoted_spansshell-builtin replacement for aprintf | sed | sedpipelinegit rev-parse --is-inside-work-treeprobe inhardcoded-path-check.shIt also records why #1385 did not merge: a fresh-context verification found four contract-test regressions, two of which failed open. Any revival must clear those first.
4.
hook-utils.shis synced, not per-plugin. It exists in 17 copies acrossplugins/*/hooks/hook-utils.sh, delivered byscripts/sync-hook-utils.sh. A change there must go through that script with all copies verified identical — editing one plugin's copy will be reverted or will diverge the fleet.5. Validate with
scripts/affected-tests.sh --run(add--explainto see suite selection), per the repo AGENTS.md. A changed file mapping to zero suites is an error, not "nothing to run".6. Reference baseline for the host. The budget doc's reference measurements (2026-07-31, Windows 11 + Git Bash) are
bash -c :~ 80 ms andpython3 -c pass~ 160 ms, with the per-Bash-call always-on set at **~ 5.9 s parallel wall**. The host in #3508 measures a bimodal no-op spawn at 180-2,841 ms (median 1,108 ms) at 501 concurrent processes — 2-35x worse than that reference. Report the spawn baseline alongside any timing so a result on a healthy box is not mistaken for a pass on a contended one.kyle-sexton commentedon Aug 31, 2026
Addendum: hook file paths, matcher corrections, and two withdrawn comparative claims.
Several children named no script file, which left a pickup agent to hunt. Verified paths:
hooks/pr-body-linkage-gate.sh(+pr-linkage-mcp-gate.shon the^mcp__github__(create|update)_pull_request$matcher; sharedpr-linkage-validator.sh)hooks/worktree-add-containment-gate.sh(PostToolUse siblingworktree-add-claim-gate.sh)hooks/hook-failure-audit.shhooks/lane-stop-gate.shhooks/zone-crossing-inject.shMatcher corrections:
zone-crossing-inject.shalso fires onUserPromptSubmit, not onlyPostToolBatch. That changes its cost model: it is both a per-tool-batch and a per-turn cost, so it draws on the <= 500 ms per-turn ceiling as well.PreToolUse:Edit. The actual matcher isWrite|Edit|NotebookEditat timeout 60.block-windows-drive-tmp.shis registered onWrite|Edit|MultiEdit|NotebookEditin addition to the Bash/PowerShell matcher. The issue accounted for only the latter. See also block-windows-drive-tmp: non-Windows host gate leaves large payloads undrained on stdin #3504, which touches the same file (stdin drain).Two comparative claims withdrawn:
hook-utils.shat 113.Static call-site counts do not reproduce — treat them as indicative only. An independent pass on shipped 0.29.24 using the stated pattern yields, for
block-dangerous-git.sh, 184 with comments / 163 lines-with-match / 44 comment-stripped — none of which is the published 127. The ordering survives under every method (block-dangerous-git.shis highest), so #3529's headline stands, but the absolute numbers should not be quoted without pinning the exact command and version. #3508's Method section needs that pin.The spawn-count criterion is the one that matters and it is unaffected: it is measured directly by PATH shim on the executed path, not inferred from source greps.
kyle-sexton commentedon Sep 6, 2026
Verification: FULLY FIXED
Read-and-report pass over
plugins/guardrails/hooks/block-exported-msys-pathconv.shatorigin/main(a34a96bc). This lane does not close issues; the recommendation below is for the triage lane.Method
Guards no longer run as standalone hooks. Every guard in the
Bash|PowerShellmatcher issourced in-process byrun-guards.sh, which callshook::buffer_stdinonce for the whole batch and primes the jq field cache once. Verified on current main by reading the dispatcher and by measurement.Counted with
strace -f -e trace=clone,clone3,fork,vfork,execve, two separate counters: successful execve (PATH-visible program images, failed PATH probes discarded) and clone/fork (process creations including pure subshells, which is what an MSYS2 fork costs on the affected host). No xtrace command-position count: #3520 showed it reads 2 where the kernel makes 8, and the plugin's own budget test (run-guards.test.sh:168-215) uses PATH shims whose comment concedes forks are invisible to them.The Windows lane was forced with
OSTYPE=msys, as the contract suite does.HOOK_TELEMETRY_SINKunset. Linux, bash 5.2.21, jq present. Per-guard figures are deltas against a dispatcher run carrying a single no-op guard.What the issue claimed vs what main has
source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh"_HOOK_SELF="${BASH_SOURCE[0]%/*}"with the bare-filename fallback.<= 2 external process spawns on the common pathblock-exported-msys-pathconv.test.shPASS=127 FAIL=0;export MSYS_NO_PATHCONV=1still exits 2 underOSTYPE=msys.Which commit changed it
a5f5e799(#3781, "drop dirname and sed execs from the always-on path"). #3788 (5101f5a2) does not touch this file — confirmed against its file list, refuting the "fixed by #3788" framing for this shard.273c20fe(#3820) does not touch it either.Batch-level history on
git status --short(wholeBash|PowerShellmatcher, all eight guards), for context on where this guard's share sits:a5f5e799^(pre-#3781)a5f5e799(#3781)5101f5a2(#3788)273c20fe(#3820)Measured counts for this guard
On
git status --shortand onexport MSYS_NO_PATHCONV=1, inside the dispatcher, on both a Linux host and withOSTYPE=msys:BASHPID-logging DEBUG trap toblock-exported-msys-pathconv.sh:111INPUT=$(hook::buffer_stdin).That single fork is the shared
hook::buffer_stdincapture pattern, present in all eight guards. There is nothing else in this file on the common path.Residual: one small, optional in-file item
I checked the two shapes the scout could have missed:
source "$(dirname …)"fork: already the${BASH_SOURCE[0]%/*}form, matchingplugins/source-control/hooks/worktree-create-gate.sh.$( … 2>/dev/null )or$( … | … )double-fork shapes anywhere in this file.The one thing left worth naming: the
case "${OSTYPE:-}"host gate sits at line 144, afterhook::buffer_stdin(111),hook::require_jq_blocking(119) andhook::jq_fields(122). Its siblingblock-windows-drive-tmp.shputs the same gate at lines 98-101, ahead of all three, and so costs literally zero on a non-Windows host. Moving this gate earlier would remove the last fork for Linux and macOS users. Note before anyone does it: this ordering is deliberate and documented inblock-windows-drive-tmp.shlines 96-99, which explicitly say the two files keep opposite orderings on purpose and "Do not 'fix the inconsistency' by aligning them". The reason given is blast radius:block-windows-drive-tmp.shalso matchesWrite|Edit|MultiEdit|NotebookEdit, whererequire_jq_blockingfiring before the host gate would deny every file edit on a jq-less Linux box; this guard only matchesBash|PowerShell, where the #2146 fail-closed posture is accepted. A worker picking this up must preserve the fail-closed posture, not just move thecase.Fleet item worth its own issue:
hook::buffer_stdin_to <var>inlib/hook-utils.sh(synced to 17 copies viascripts/sync-hook-utils.sh). Under the dispatcher the payload is already inRUN_GUARDS_INPUT, andINPUT=$(hook::buffer_stdin)copies it back out through a fork and a pipe. 1 fork per guard, 8 of the 36 process creations per Bash tool call. That is this guard's entire remaining cost and the largest remaining win in the plugin.Recommendation
Close. Every overhead this issue names is gone, and at 0 execve and 1 process creation this is the cheapest guard in the slice, comfortably inside its own "<= 2 spawns" bar. The scout's read was correct. Two caveats for the record: the "under 2 s on a Windows host" criterion was not verified in this lane, and the binding budget is the
docs/conventions/hook-budget/README.mdceiling cited in the shared correction above, not the ad-hoc figure in the issue body.Generated by Claude Code
perf(guardrails): drop the forks that never exec in block-noncanonica…
kyle-sexton commentedon Sep 26, 2026
Closing in the backlog-drain close sweep: obsolete. Evidence: PR #3781; 2026-09-06 verification comment: "FULLY FIXED", 0 execve