Skip to content

perf(guardrails): block-exported-msys-pathconv.sh averages 87.5s and timed out 15x - reduce process spawns #3519

Closed

Description

@kyle-sexton

Summary

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/**/*.jsonl hook_cancelled attachments with timedOut: true, across 236 sessions, 2026-08-23 to 2026-08-31.

Activity

  1. added
    priority: highSignificant impact, or blocks an imminent release; staff this cycle.
    agent-readyFully specified and briefed; eligible for autonomous pickup from the frontier.
    work-class: scopedA briefed fix or small feature; blast radius bounded by the brief, tests exist.
    on Aug 31, 2026
  2. kyle-sexton commented on Aug 31, 2026

    @kyle-sexton
    ContributorAuthor

    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:

    source-control  PreToolUse Bash                    timeout=15
    guardrails      PreToolUse Bash|PowerShell         timeout=60
    guardrails      PreToolUse Write|Edit|NotebookEdit timeout=60
    guardrails      PostToolUse Write|Edit             timeout=30
    context-guard   PreToolUse / PostToolBatch         timeout=60
    disk-hygiene    PreToolUse Bash|PowerShell         timeout=60 ; Stop timeout=20
    claude-ops      Stop timeout=10 ; StopFailure timeout=5
    autonomy        Stop timeout=15
    

    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.

  3. kyle-sexton commented on Aug 31, 2026

    @kyle-sexton
    ContributorAuthor

    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:

    Issue Plugin Hook file(s)
    #3509 source-control hooks/pr-body-linkage-gate.sh (+ pr-linkage-mcp-gate.sh on the ^mcp__github__(create|update)_pull_request$ matcher; shared pr-linkage-validator.sh)
    #3510 source-control hooks/worktree-add-containment-gate.sh (PostToolUse sibling worktree-add-claim-gate.sh)
    #3512 claude-ops hooks/hook-failure-audit.sh
    #3515 autonomy hooks/lane-stop-gate.sh
    #3520 context-guard hooks/zone-crossing-inject.sh

    Matcher corrections:

    Two comparative claims withdrawn:

    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.

  4. kyle-sexton commented on Sep 6, 2026

    @kyle-sexton
    ContributorAuthor

    Verification: FULLY FIXED

    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.
    source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh" 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:

    tree execve process creations batch p50 (n=25)
    a5f5e799^ (pre-#3781) 16 (3 bash, 7 dirname, 3 git, 2 jq, 1 sed) 65 97 ms
    a5f5e799 (#3781) 8 (3 bash, 3 git, 2 jq) 54 78 ms
    5101f5a2 (#3788) 8 54 67 ms
    273c20fe (#3820) 3 (1 bash, 2 jq) 36 49 ms

    Measured counts for this guard

    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:111 INPUT=$(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.


    Generated by Claude Code

  5. kyle-sexton commented on Sep 26, 2026

    @kyle-sexton
    ContributorAuthor

    Closing in the backlog-drain close sweep: obsolete. Evidence: PR #3781; 2026-09-06 verification comment: "FULLY FIXED", 0 execve

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: highSignificant impact, or blocks an imminent release; staff this cycle.work-class: scopedA briefed fix or small feature; blast radius bounded by the brief, tests exist.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions