Repository navigation
Fix #1040: read hook stdin in chunks, not one byte per syscall - #1045
Merged
Merged
Conversation
Red first: the shared reader does not exist yet, and on windows-latest a 1 MB payload piped to post-tool-hook.sh should exceed the 15 s bound. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
All four hooks read their payload with a read -t 1 line loop. bash reads a pipe unbuffered, so that was one read() and one select() per byte; on Cygwin/MSYS each goes through the Cygwin layer, and a 1 MB tool result cost about 25 s per PostToolUse call (reported and measured by @gwangyong-kim). scripts/lib-stdin.sh now does the read for all four, with read -N 1 MiB chunks on bash >= 4.1 and the old line loop on bash 3.2 (stock macOS) or when REMEMBER_NO_READ_N=1. The chunked path removes every newline, so the extractors and hooks.d listeners see the same string the line loop produced. It does so with one linear scan and an IFS split, because both obvious forms are quadratic in bash: a suffix strip on a payload with no trailing newline took 165 s for 1 MB, and a pattern substitution of the newlines in a pretty-printed 840 KB payload took 8 s (bash 5.3, measured). No new fork: the library is sourced, and compile_hooks inlines it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Its compiled form sits 37 bytes under the 120 KiB release budget that check_release_tree.py enforces, and the inlined library is about 1.2 KB, so routing it through lib-stdin.sh fails the release build (observed: 124079 bytes). Its payload never carries tool output or prompt text, so the byte-at-a-time cost there is milliseconds. The test pins the exception and runs its own loop, cut out of the hook, on small payloads. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On /bin/bash 3.2 the reader is the line loop, whose concatenation is itself quadratic in the number of lines (108 s for the pretty-printed shape, measured), as it always was. The check is about the chunked path only. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1040
Thanks to @gwangyong-kim for the report, the measurements, and the read -N fix it suggested: the root cause, the per-byte select() under Cygwin/MSYS, and the redirect-hides-it detail were all in the issue and all held up.
What changed
scripts/lib-stdin.sh:_remember_read_stdin_into VARreads stdin inread -N 1048576 -t 1chunks on bash 4.1 and later, and keeps the oldread -r -t 1line loop on bash 3.2 (stock macOS) or whenREMEMBER_NO_READ_N=1(the same kind of seam asREMEMBER_NO_PRINTF_T, so CI on bash 5 still runs the 3.2 path). A terminal on stdin is still never read, and each read still gives up after 1 s of silence.post-tool-hook.sh,user-prompt-hook.shandsession-end-hook.shread through it. The compiled release hooks carry it inlined (compile_hooks.pyhandles it like any other sourced library; checked in the built tree).session-start-hook.shkeeps the line loop, on purpose, with a comment saying why: its compiled form sits 37 bytes under the 120 KiB budget incheck_release_tree.py(122843 bytes on main), and the inlined library is about 1.2 KB, so routing it through the library failed the release check (124079 bytes, observed). Its payload is ids, paths and a source word, a few hundred bytes, so the per-byte cost there is milliseconds. A test pins this exception.The trailing newline, and what the extractors see
The old loop dropped every newline:
readstrips each line ending and the loop joined the lines bare.read -Nkeeps them. I chose to remove them again, all of them, so the string the JSON field extraction and the hooks.d listeners see is byte-for-byte what the old loop produced (a newline in JSON is whitespace between tokens, never inside a string value). The tests compare against exactly that, payload with newlines removed, for every hook and every reader path.Two obvious ways to remove them turned out quadratic in bash, found while writing this, so neither is used:
${buf%$NL}on a payload WITHOUT a trailing newline: 165 s for 1 MB (bash 5.3, macOS, measured).${buf//$NL/}on a pretty-printed 840 KB payload: 8 s under a UTF-8 locale.The library does one
[[ == *$NL* ]]scan and, only when that finds one, an IFS split andprintf -vjoin (0.03 s for the same 840 KB). A test bounds all three shapes.Tests
tests/test_hook_stdin_chunked_read_1040.py, red first (commit 067f507 pushed alone):subprocessstdin=PIPE, never a file redirect). Payloads: small, 400 KB of long lines, pretty-printed 300 KB, no trailing newline, trailing newline, multibyte UTF-8 (large and small), empty stdin. Readers: chunked, the line-loop fallback, and/bin/bash3.2 where the host has one.test_locale_ranges_695.py,lib-stdin.shadded to the stub plugins intest_save_session_gates.pyandtest_session_end_hook_345.py.Evidence, per platform
Observed:
transcript_path), so every PostToolUse call rescans the whole transcript #1034, post-tool-hook: offset-0 transcript scan still exceeds the 600 s host timeout on large transcripts (Windows, 0.43.1; follow-up to #1017 and #1034) #1036, Hook scripts fail with "Argument list too long" #266 stdin size, compile_hooks and compiled pins, locale ranges, scanner shapes) green, also against a locally compiled tree; release tree built from HEAD passescheck_release_tree.pyand the hook smoke (claude plugin validate skipped locally, it fails on main here too).Reasoned, not observed:
🤖 Generated with Claude Code
[AI-generated]