Skip to content

Fix #1040: read hook stdin in chunks, not one byte per syscall - #1045

Merged
fdaviddpt merged 4 commits into
mainfrom
fix/1040-stdin-chunked-read
Oct 19, 2026
Merged

fdaviddpt merged 4 commits into
mainfrom
fix/1040-stdin-chunked-read

Conversation

@fdaviddpt

@fdaviddpt fdaviddpt commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

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

  • New scripts/lib-stdin.sh: _remember_read_stdin_into VAR reads stdin in read -N 1048576 -t 1 chunks on bash 4.1 and later, and keeps the old read -r -t 1 line loop on bash 3.2 (stock macOS) or when REMEMBER_NO_READ_N=1 (the same kind of seam as REMEMBER_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.sh and session-end-hook.sh read through it. The compiled release hooks carry it inlined (compile_hooks.py handles it like any other sourced library; checked in the built tree).
  • session-start-hook.sh keeps the line loop, on purpose, with a comment saying why: its compiled form sits 37 bytes under the 120 KiB budget in check_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: read strips each line ending and the loop joined the lines bare. read -N keeps 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 and printf -v join (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):

  • Correctness per hook: each hook extractor, cut verbatim from the hook, fed what the reader produced from a real pipe (subprocess stdin=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/bash 3.2 where the host has one.
  • A 1 MiB+ multibyte payload under LC_ALL=C (chunk boundary inside a character) and UTF-8: byte-identical.
  • A pipe held open with nothing in it still returns in about 1 s, empty.
  • post-tool-hook.sh end to end through a pipe: session_id, transcript_path and cwd observed through the capture-alive marker, the post-tool-hook: ~1.2 s per tool call on Windows (0.33.0) - whole-transcript wc -l grows with the session; lib-slug spawns cygpath twice per call #913 scan sidecar, and the store landing in the payload cwd.
  • Timing: 1 MB piped to post-tool-hook.sh under 15 s, with a small-payload positive control under the same bound.
  • Existing tests adjusted: line-number allowlist in test_locale_ranges_695.py, lib-stdin.sh added to the stub plugins in test_save_session_gates.py and test_session_end_hook_345.py.

Evidence, per platform

Observed:

Reasoned, not observed:

  • The real-world Windows speedup for a user: the reporter measured 0.45 s for the chunked read of 1 MB on their machine; CI shows the whole hook well under the old cost, not a desktop timing.
  • bash 4.1 to 4.4 take the chunked path; no CI leg runs those versions.
  • A side finding: the old loop timeout is per line, so on Windows it fired roughly every 40 KB in the middle of a single long payload line. On macOS bash 5.3 under load, with the line loop forced, a timeout inside a multibyte character was seen to change a byte. The chunked read makes that far less likely on Windows; the bash 3.2 path keeps the old behaviour.

🤖 Generated with Claude Code

[AI-generated]

fdaviddpt and others added 4 commits October 19, 2026 21:10
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant