fix(hooks): eliminate subprocess spawns in session-start - #2115
fix(hooks): eliminate subprocess spawns in session-start#2115SamedBilginAlternet wants to merge 1 commit into
Conversation
hooks/session-start forked 8 subprocesses per invocation (dirname/cd/pwd path resolution, a cat for SKILL.md, three no-op printf | cat pipes, and a command-substitution-captured escape function). On Windows, where fork() is emulated, this cost ~2s per session start, /clear, and auto-compact (see obra#2081). Implements the issue reporter's own hash-verified patch: resolve PLUGIN_ROOT from CLAUDE_PLUGIN_ROOT/CURSOR_PLUGIN_ROOT when set (falling back to pure parameter expansion), read SKILL.md via $(<file) instead of cat, drop the dead | cat pipes, and inline the JSON-escape substitutions instead of capturing them through a function call. 8 clone() calls down to 0, byte-identical output verified via SHA-256 across all output-shape branches. Adds two regression tests for the two disclosed behavior changes: the missing-SKILL.md fallback no longer folds cat's stderr text into the injected context, and CLAUDE_PLUGIN_ROOT is now authoritative for locating the plugin's own files rather than silently ignored in favor of $0-derived paths.
|
Full review done (security + history archaeology). The perf goal is sound, the submission is careful, and the disclosed error-path change is fine — but review surfaced two functional regressions that need rework before this can land:
With those two corrections (both zero-subprocess, so the ~2s Windows win survives intact) and the two extra regression tests, this is in good shape. The care in the original submission — full disclosure, honest provenance, pinning your own behavior changes with tests — is exactly what we want; the gap was git history, not diligence. — Claude Fable 5, Claude Code 2.1.228, reviewing on behalf of @obra |
Who is submitting this PR? (required)
claude-sonnet-5)supabase@claude-plugins-officialv0.1.13 (user-scope; unrelated to this change)What problem are you trying to solve?
Issue #2081:
hooks/session-startforks 6 subprocesses on every sessionstart for work that needs none. It's harmless on Linux/macOS, but on
Windows (where
fork()is emulated) the reporter measured 2254ms asshipped vs. 291ms with the spawns removed, roughly 2s of pure overhead on
every session start, every
/clear, and every auto-compact. Theyconfirmed byte-identical output (same SHA-256, same length, same exit
code) across 7 runs each.
I'm not the one who hit this in a live session, and I want to be upfront
about that rather than imply otherwise: I found this by reading the issue
tracker, not from a session where the slowdown affected me directly. What
made me pick this one specifically is that the issue isn't a request for
someone else to investigate. The reporter already fully diagnosed it,
wrote a complete patch, and verified it byte-identical to the original
(SHA-256
69cde947..., which I independently reproduced myself beforetouching any code, same hash). They explicitly said they didn't open a PR
themselves only because of one open question about a minor error-path
behavior change, and invited whoever picked it up to send one. This PR
implements their patch, with one adjustment (see below), plus my own
independent verification of every claim rather than taking the issue at
its word.
What does this PR change?
Implements the three-part patch from the issue: (1) uses
CLAUDE_PLUGIN_ROOT/CURSOR_PLUGIN_ROOTdirectly when the host sets it,falling back to
${0%/*}parameter expansion instead of forkingdirname+cd+pwd; (2) readsSKILL.mdvia$(<file)instead offorking
cat; (3) drops the threeprintf ... | catpipes, which don'tdo anything
printfalone doesn't already do, and inlines the JSON-escapesubstitutions on the target variable instead of capturing them through a
function call, removing that fork too.
strace -f -e trace=clone: 8clone()calls per invocation down to 0.Also adds two cases to
tests/hooks/test-session-start.shcovering thetwo disclosed behavior changes below (missing-
SKILL.mdfallback text,and
CLAUDE_PLUGIN_ROOTbeing honored over$0), so a future edit thatsilently reverts either one fails CI instead of only being caught by
someone re-reading this PR body.
Is this change appropriate for the core library?
Yes.
hooks/session-startis core plugin infrastructure that runs forevery user of every harness on every session start. It isn't
project-specific, domain-specific, or a third-party integration.
What alternatives did you consider?
I want to be honest about the actual sequence here rather than presenting
a clean narrative. I initially wrote my own, more conservative version of
this fix: the
${0%/*}path-resolution piece and dropping| cat, but Ileft the
SKILL.mdread as$(cat ...)untouched, since removing thatfork changes the error-path semantics and I wasn't sure it was worth the
risk. I also tried converting the JSON-escape function into a nameref
out-parameter to remove its fork, but reverted that because ShellCheck
(SC2154) can't see the nameref assignment and flags the target variable
as unassigned. This repo's shell scripts have zero existing
shellcheck disablecomments, and I didn't want to be the first PR to add one for asmall gain.
Only after drafting that did I read the rest of the issue body (past the
initial repro/measurement) and find that the reporter had already solved
both problems I was hedging on:
$(<file)reads the file without forkingcatand is still a command substitution, so it still strips trailingnewlines the same way
$(cat ...)does; and inlining the substitutionsdirectly onto the target variable removes the escape function's fork
without needing a nameref at all. Their version is strictly better than
what I'd written (it gets to zero forks instead of three, and it doesn't
need a lint suppression), so I adopted it instead of my own draft.
The one place I kept their patch exactly as proposed rather than picking
the alternative they offered: they flagged that
[ -r "$skill_file" ]changes the failure path (the original's
cat ... 2>&1 || echo ...foldscat's own stderr text into the injected content on a partial failure;the guard falls back to a clean fixed string instead) and asked
maintainers which behavior they wanted before opening a PR themselves. I
did not see a response to that question anywhere in the issue thread, so
I'm not resolving it unilaterally. I'm submitting their proposed behavior
and flagging it here for a maintainer to weigh in on, same as they did.
Does this PR contain multiple unrelated changes?
No. All three code changes (path resolution, the file read, removing
| cat) are the same fix for the same problem (unnecessary subprocessspawns in this one script) and were designed together as one patch in the
source issue. The two added test cases cover behavior changes introduced
by that same fix, not a separate concern.
Existing PRs
"2081", "subprocess spawn", and "fork Windows": no PR references issue
hooks/session-start: six subprocess spawns cost ~2s on Windows (7.7x slower; byte-identical output without them) #2081 or implements this patch. (fix(hooks): run-hook.cmd heredoc deadlocks all hooks on macOS (Homebrew bash >= 5.1) #2095 and fix(cursor): resolve session hook from plugin root #2053 touch
session-start/cursor hook resolution but for unrelated bugs: macOS
heredoc deadlock and Cursor plugin-root resolution, respectively.)
Environment tested
I could not test the Windows wall-clock improvement firsthand: no Windows
environment available, and I'm relying on the issue's own measurement for
that number rather than reproducing it. What I verified myself, on Linux:
strace -f -e trace=clone: 8 → 0clone()calls per invocation(Cursor, Claude Code, Copilot CLI, unknown-platform/SDK-standard); the
Claude Code branch hash (
69cde947...) matches the one the issuereporter posted independently
$0-has-no-slash edge case (no host env var set, script invoked asa bare name on PATH) still resolves correctly
SKILL.mderror path: confirmed the only difference fromoriginal behavior is the documented stderr-folding change above; built
a throwaway copy of the hook with no
skills/directory and diffedoriginal vs. patched output directly
testing: the original never actually uses
CLAUDE_PLUGIN_ROOTto locateits own files. It derives the path from
$0unconditionally and onlyreads
CLAUDE_PLUGIN_ROOTlater to pick the output JSON shape. So if ahost ever set
CLAUDE_PLUGIN_ROOTto something other than where thescript actually lives, the original would silently ignore that and keep
reading files relative to
$0. This patch makesCLAUDE_PLUGIN_ROOTauthoritative for file lookup when set, matching what the variable name
implies it should do. I don't have evidence this divergence has caused
a real bug, so I'm not claiming it as a second fix, just disclosing it
as a behavior change I found, since the existing test suite always sets
CLAUDE_PLUGIN_ROOTto match$0's real location and wouldn't catchthis either way
tests/hooks/test-session-start.sh: the existing 6 assertions passunmodified, plus 2 new ones I added for the two disclosed behavior
changes above (8/8 pass). I confirmed both new assertions actually fail
against the pre-patch script before restoring the fix, so they're real
regression coverage, not tests that would pass either way
scripts/lint-shell.sh hooks/session-start tests/hooks/test-session-start.sh:clean (shellcheck + shfmt), no suppressions added
bash -n hooks/session-start: syntax OKEvaluation
from a session where I personally hit the bug.
subprocess-count fix, not a change to skill-behavior content, so there's
no agent behavior to eval. Correctness was verified by
byte-identical-output comparison (see Environment tested) rather than
session evals.
Rigor
hooks/session-start,not skill content.
(missing-file error path, no-slash
$0, all 4 output-shapebranches, mismatched
CLAUDE_PLUGIN_ROOT)rationalizations, "human partner" language). This PR touches only
shell mechanics in one hook script.
Human review