fix(#4492): index the suffix window instead of pattern-matching it (#4539)

* fix(#4492): index the suffix window instead of pattern-matching it

`MSG_SUFFIX="${CMD#*"$MSG_MATCH"}"` is quadratic in the -m message. bash tries
every prefix length and compares the whole matched literal at each, and
MSG_MATCH is BASH_REMATCH[0] — the entire `-m "..."` — so the cost grows with
the thing being scanned. Measured on the real hook: 10.0s at 64KB, 22.0s at
96KB, 30.2s at 112KB, 40.1s at 128KB. `bash -x` with an EPOCHREALTIME PS4
attributes 10.116s of a 10.2s run to that one expansion, which computes an
empty string. Conforming and non-conforming cost the same, so this is the path
every commit takes, and Claude Code blocks on PreToolUse hooks.

MSG_PREFIX on the line above has already located the match, so the suffix is
arithmetic rather than a search. Same first-occurrence assumption both
expansions always made — MSG_MATCH is a literal substring of CMD by
construction. Equivalence checked across 480 comparisons on bash 3.2.57 and
5.3.15 under C, UTF-8 and SJIS locales, including multibyte text, repeated
matches, metacharacters and invalid bytes.

Three regression rows, all deliberately on the RESOLVE=1 path so they pin the
suffix scan alone and do not depend on the separate #4429 SIGPIPE fix:
non-conforming and conforming 112KB heredocs, plus a suffix-window row whose
padding sits before the heredoc opener's newline so the COMMAND is large while
the message stays small. Red against the true base — all three killed at the
10s bound with the head -1 sites still present — and green with only this
change.

Fixture sizes stay under Linux MAX_ARG_STRLEN (131072 on a 4KB-page kernel).
Above it execve fails, the classifier cannot launch and the hook fails open, so
a larger fixture measures the argument limit rather than the suffix scan; an
earlier 131225-byte draft passed on base AND head for exactly that reason.
Every row asserts empty stderr, which is what separates "validated" from
"failed open".

The bound is enforced by killing the process GROUP, not the direct child: the
hook spawns a node classifier that inherits stdout, so killing only bash can
leave the pipe open and `close` never arrives. `local/no-elapsed-assertion`
forbids asserting on elapsed time, and `{ timeout }` is inert on a synchronous
body, so the rows are async and the kill is the signal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZw5UhR474YLyE4knjHrte

* chore(#4492): add changeset

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZw5UhR474YLyE4knjHrte

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
Behruz Nassre Esfahani
2026-09-08 22:48:17 -07:00
committed by GitHub
parent 42c02a00c0
commit 137f3115a1
3 changed files with 136 additions and 1 deletions

View File

@@ -218,7 +218,24 @@ if [ "$CLASSIFY_STATUS" = "0" ]; then
# the message — the window a guard must use when the token it scans for
# is also legal English inside a commit message, but may legally appear
# on EITHER side of the message on the command line.
MSG_SUFFIX="${CMD#*"$MSG_MATCH"}"
# Indexed, not searched (#4492). `${CMD#*"$MSG_MATCH"}` is quadratic in
# the message: bash walks every prefix length and compares the whole
# matched literal at each one, and MSG_MATCH is BASH_REMATCH[0] — the
# entire `-m "..."` — so the cost grows with the thing being scanned.
# Measured on the path EVERY commit takes (conforming and non-conforming
# cost the same): 10.0 s at a 64 KB message, 22.0 s at 96 KB, 30.2 s at
# 112 KB. Sizes stop there deliberately — a single argument above Linux's
# MAX_ARG_STRLEN (131072 on a 4 KB-page kernel) never reaches this code
# at all, because execve fails and the hook fails open, so a larger
# "measurement" would be timing the wrong thing.
#
# MSG_PREFIX above has already located the match, so the suffix is
# arithmetic rather than a search: skip the prefix and the match. This
# removes the quadratic SEARCH; the expansion still counts characters and
# materialises a substring, so it is linear in the command, not O(1).
# Same first-occurrence assumption both expansions here always made —
# MSG_MATCH is a literal substring of CMD by construction.
MSG_SUFFIX="${CMD:$(( ${#MSG_PREFIX} + ${#MSG_MATCH} ))}"
# LINE CONTINUATIONS ARE NOT SEPARATORS (review of #3816, rounds 8 and 9).
# `git commit \` newline ` -m "$(cat <<'EOF' …` is an ordinary way to
# spread an invocation over lines, and every guard below reads a newline in