Skip to content

Fix #1029: remove three unconditional subshell forks per post-tool-hook.sh call - #1031

Merged
fdaviddpt merged 5 commits into
mainfrom
fix/1029
Oct 11, 2026
Merged

fdaviddpt merged 5 commits into
mainfrom
fix/1029

Conversation

@fdaviddpt

Copy link
Copy Markdown
Contributor

What

post-tool-hook.sh's _stdin_json_string extractor was invoked through command substitution at three unconditional sites -- REMEMBER_HOOK_CWD, STDIN_SESSION_ID, STDIN_TRANSCRIPT_PATH -- all three above the _REMEMBER_FAST if/else, so all three forked a subshell on EVERY hook invocation, cold and warm path alike, even though the function is pure parameter expansion.

This ports the fork-free _stdin_json_string_into form (printf -v into a named variable, no subshell) that session-start-hook.sh already carries for the identical extractor (#665/#660) -- same matching algorithm, same #344 first-occurrence-gap and #829 double-backslash-collapse behaviour, no behaviour change.

Also tightens the absolute-budget assertions in tests/test_post_tool_total_processes_1011.py from a less-than-or-equal-69 bound to a strict less-than-67 bound: the earlier bound was vacuous against the exact bad value the repo's own history calls already-regressed (v0.43.0 measured 69), caught by a reviewing auditor this round. The new bound is strictly below both the bad value and #1028's own prior total of 67.

This is a follow-up to #1011 and its round-2 fix in #1028: #1011/#1028 brought the whole-hook strace total down to 67 but did not identify this cut; #1029 asked for the remaining attribution and the cut this PR makes.

Testing

All local testing was on macOS, where strace is unavailable, so the two tests that actually measure this issue's claimed fork count (tests/test_post_tool_total_processes_1011.py) run SKIPPED locally, both before and after the fix -- never seen green or red locally. This is flagged, not hidden: the fork-count claim rests on (a) static reading of the old forking vs new fork-free extractor forms, (b) the identical pattern already proven in session-start-hook.sh's own history, and (c) this PR's own CI run, which must be checked before merge to confirm the new budget actually holds.

116 other tests passed locally (0 failed, 2 skipped -- the strace-gated ones above), covering the exact stdin-extraction call sites changed, the fast and slow path split, cooldown, basename and session-id handling, and the byte-budget pin in tests/test_locale_ranges_695.py (five line pins shifted plus four each for the new lines added above them).

The changelog assembly check passes with the new changelog.d/1029.fixed.md fragment.

Scope note

scripts/session-end-hook.sh still carries the OLD forking _stdin_json_string extractor -- same opportunity, same fix available -- but is NOT touched here, since #1029 is scoped to post-tool-hook.sh specifically and touching a third hook file would widen blast radius beyond what was briefed. Filed as a follow-up candidate rather than rolled into this PR.

Closes #1029.

Self-review note

One below-bar reviewer finding on the new absolute-budget assertions: the changelog fragment and commit message both explicitly describe the absolute-budget assertions as existing so a regression is caught even on a no-network CI leg -- exactly the narrow case the reviewer confirmed it still covers correctly.

🤖 Generated with Claude Code

[AI-generated]

fdaviddpt and others added 5 commits October 11, 2026 15:29
…s cold-start-only, not a per-call cost

#1011/#1028's own CI measurement (strace -f -e trace=clone,clone3,fork,vfork,
whole-hook total for post-tool-hook.sh's no-save path, minimum of 9 runs) is
67 on HEAD, down from v0.43.0's 69 and v0.42.4's 68 -- but still on the order
of several dozen processes for one no-save PostToolUse call, per #1029.

Traced to the test's own fixture: tests/test_post_tool_total_processes_1011.py
creates a brand-new tmp_path (HOME, PROJECT, REMEMBER_DIR, TMPDIR all freshly
created) for every one of the n repeated runs, so every measured run takes
the slow/cold path -- _remember_env_cache_load misses, so resolve-paths.sh,
detect-tools.sh, bootstrap-dirs.sh and log.sh all get sourced, pulling in
lib-memory-dir.sh's worktree-detection forks, _config_load's jq read, and
several mktemp+mv atomic cache publishes. On the warm path -- any call after
the first in a real session, the common case once a project has run once --
_remember_env_cache_load succeeds and none of those four files are sourced
at all; the only forks left are _pt_scan_lines's own tail+wc pair, which the
code's own comment directly above the wc call already documents as
unavoidable without reintroducing #1017's quadratic bash-expansion
regression.

No source change lands in this commit: every candidate #1029 named
(bootstrap-dirs.sh's directory creation, the mktemp+mv atomic-write pattern,
the jq config read, _pt_scan_lines's two forks) traced to either
already-gated cold-path-only work (#230/#666), work inside the byte-pinned
lib-memory-dir.sh (tests/test_case_divergence_298.py forbids touching it), or
a fork already proven, in-repo, unavoidable without a worse regression.
Forcing a cut here would mean either reaching into the pinned file or
reintroducing one of #1013/#1017/#1021's own fixed regressions, neither of
which is a safe trade for a performance-only, non-blocking issue.

tests/test_post_tool_total_processes_1011.py now asserts an absolute budget
(69 -- the measured 67 plus 2 of slack) for both the below-threshold and
cooldown paths, in addition to the existing relative tag-comparisons, so a
real future reduction or regression on this cold path is still caught even
on a CI leg where neither tagged comparison tree can be materialized (no
network reaching origin). Adds a changelog fragment documenting the
corrected attribution with the CI-measured totals.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
….sh, removing three unconditional subshell forks per call

Self-review (Explore + oss:auditor review of commit 3116fcf) found a real,
low-risk cut this round's investigation missed: _stdin_json_string in
scripts/post-tool-hook.sh was called through X=$(...) command substitution
at three sites (REMEMBER_HOOK_CWD, STDIN_SESSION_ID, STDIN_TRANSCRIPT_PATH),
all above the _REMEMBER_FAST if/else, so all three ran unconditionally on
EVERY invocation of this hook -- cold-start and warm path alike -- each
forking a subshell even though the function itself is pure parameter
expansion with no external exec at all. session-start-hook.sh already
carries a fork-free twin of this exact extractor, _stdin_json_string_into
(#665, part of #660), written into VARNAME via printf -v instead of printed
through a subshell.

Ports that same function (renamed in place, same matching algorithm: same
first-occurrence scan, same #344 nested-key gap, same #829 double-backslash
collapse) into post-tool-hook.sh and switches all three call sites to it.
No behaviour change: tests/test_hook_cwd_end_to_end_411.py,
tests/test_codex_stdin_session_id_468.py and
tests/test_session_end_hook_345.py, which all exercise this extraction
end-to-end, pass unchanged, along with 47 other post-tool-hook.sh tests run
locally (test_post_tool_* family, test_hot_path_cost_pin_330,
test_pt_scan_lines_wall_clock_1017, test_post_tool_fast_path_350) and
tests/test_release_branch_check_900.py's byte-budget pin.

tests/test_locale_ranges_695.py's five post-tool-hook.sh line pins shift by
+4 for the lines this change adds above them. Tightens
tests/test_post_tool_total_processes_1011.py's new absolute-budget
assertions (added in 3116fcf) from <69 to <67 -- strictly below #1028's own
prior total, reflecting this cut -- for both the below-threshold and
cooldown paths; the exact new total is reasoned, not re-measured locally
(this file's own strace gate needs Linux, unavailable here), and this pull
request's own CI run will confirm it. Updates the changelog fragment and
this file's own module docstring to describe the fix landed rather than
"no further cut was found."

Co-Authored-By: Claude Sonnet 5 <[email protected]>
…_json_string_into

00f26b7 ported the fork-free printf-v extractor into post-tool-hook.sh, renaming
_stdin_json_string to _stdin_json_string_into there (same shape as
session-start-hook.sh's own #665/#660 port). The #447 characterization tests
still looked for the old name by string match, so all 7 post-tool/* parametrized
cases failed collection-time assertions on every CI leg ("_stdin_json_string not
found in scripts/post-tool-hook.sh"), not a behaviour regression.

Confirmed the new extractor keeps the same first-occurrence scan, the same
documented nested-before/after gap, and the same #829 double-backslash collapse
by reading its body directly (post-tool-hook.sh:212-230): identical mechanism to
_stdin_json_string, just printf -v into a named variable instead of printed and
captured via command substitution. No product fix needed -- only the test's
CASES table, which already supported the "_into" calling convention via
session-start-hook.sh's own rows in the same table.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
The previous commit fixed test_stdin_extractor_top_level_wins_447.py's own
CASES table but missed this file's separate EXTRACTORS table, which also
names post-tool-hook.sh's extractor by the old "_stdin_json_string" /
keyed=True / into=False shape. CI's first re-run surfaced the gap directly:
test_windows_cwd_is_decoded[post-tool], test_unc_prefix_decodes_pairwise[post-tool]
and test_posix_cwd_is_unchanged[post-tool] all failed the same
"_stdin_json_string not found in scripts/post-tool-hook.sh" assertion.

Same fix as before: the function is now _stdin_json_string_into (printf -v,
no subshell), so this row flips to into=True, matching session-start-hook.sh's
own "session-start/into" row in the same table.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
printf -v resolves against the innermost local, so the port left a latent
collision: a future call site passing a VARNAME of value, dq, rest, raw,
field or prefix would write the function own local, not the caller.
session-start-hook.sh copy already prefixes every local for this reason.
Same line count, so the locale-range pins do not move.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
@fdaviddpt
fdaviddpt merged commit 56a7f9b into main Oct 11, 2026
19 checks passed
@fdaviddpt
fdaviddpt deleted the fix/1029 branch October 11, 2026 21:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

post-tool-hook creates ~67 processes per no-save tool call

1 participant