Repository navigation
Fix #1036: count transcript lines with C tools, never in a shell variable - #1037
Merged
Merged
Conversation
…able _pt_scan_lines read everything after its resume offset into a bash variable and trimmed the trailing partial line with ## and % expansions. At offset 0 (no sidecar: every new session, every session after an upgrade) that was the whole transcript: @KRR-66 measured 1.5 s at 5 MB, 13.5 s at 20 MB, 92 s at 50 MB on Windows, and 247 MB / 1.33 GB transcripts hit the 600 s host timeout before the sidecar was written, so every later call stalled again. Now the bytes only stream through wc/tail/head: a one-byte read checks the resume newline (warm), wc -lc gives newlines and bytes, a one-byte read checks whether the last byte is a newline, and only for a pending partial line does head -n N | wc -c find where the last full line ends. Same OFFSET/LINES contract, same rewrite/shrink fallback. Forks per scan (PID delta, macOS bash 3.2 and 5.3): cold 2, warm 3, was 4. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
windows-latest failed the must-take-the-warm-path control: write_text turned the sidecar newline into CRLF, the LINES field read back with a trailing CR, the hook discarded the sidecar as malformed and recounted. The hook was right; the fixture was wrong. Same fix for the two must-recount twins, which were passing on Windows for the same wrong reason. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This was referenced Oct 18, 2026
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 #1036
Reported and measured by @KRR-66 -- the step-by-step timing table in the issue is what pointed straight at the offset-0 path. Thank you.
What was wrong
_pt_scan_lines(scripts/post-tool-hook.sh) read everything after its resume offset into a bash variable (_chunk=$(tail -c +OFFSET file; printf X)), cut the trailing partial line off it with##/%expansions, and counted newlines with awc -lherestring. At offset 0 -- no sidecar yet, which is the first call of every new session and of every existing session after an upgrade -- that is the whole transcript held in bash. On the reporter's Windows bash 5.2.37: 5 MB 1.5 s, 20 MB 13.5 s, 50 MB 92 s. 247 MB and 1.33 GB transcripts hit the 600 s PostToolUse timeout, the hook died before writing the sidecar, so every later call started from 0 again.A second, worse case turned up while writing the test: a multi-MB trailing partial line (a record still being written).
${_chunk%"$_partial"}with a 3 MB pattern did not finish in 120 s on macOS bash 5.3.The fix
The transcript never enters a shell variable now, on either path. Only one byte at a time does:
tail -c +OFFSETon fd 9, one byte read withread -r -d '' -n 1(bash reads a pipe byte by byte, so the rest stays in the pipe). It must be the newline OFFSET claims to follow, else full recount from 0 -- the same check and fallback as before.wc -lcover the rest (warm: the rest of fd 9; cold: the file itself) gives N newlines and B bytes.tail -c +END). Appends never change a written byte, so this is race-free against a growing file. Newline there: OFFSET = OFFSET + B, LINES += N. This is the common case.head -n N | wc -cgives the length through the Nth newline. Cold it reads the file directly, because macOStail -c +Nstreams at ~30 MB/s (27 MB in 0.9 s even to /dev/null) wherehead -n N filetook 0.014 s.Contract unchanged: PT_SCAN_OFFSET = byte offset just past the last
\n, PT_SCAN_LINES = newline count, a partial line is never counted and is re-read next call,local LC_ALL=Ckeepsread -n 1at one byte (#695). The long comment inside the function now describes this algorithm; the one above it no longer claims length/substring ops on the chunk.Lint fixes the repo's own scanners asked for: no
casestatement (directory scanner shape, #898) and10#on every count in$(( ))(#332).tests/test_locale_ranges_695.pyallowlist lines shifted +68 (1011->1079, 1129->1197); the new code adds no letter range.Forks
PID-delta measurement, macOS arm64, /bin/bash 3.2.57 and Homebrew bash 5.3.15, min of 10 (OBSERVED on macOS only):
$(tail ...; printf X)+$(wc -l <<< ...)<(exec tail ...)$(exec wc -lc < file)/$(exec wc -lc <&9)$(a | b)So per scan: cold 2 (was 4), warm with new bytes 3 (was 4), warm with nothing new 2 (was 2). A pending partial line adds 3 cold / 4 warm (the 4 is the same per-stage rule, REASONED, not measured). The explicit
execinside each substitution is what saves bash 3.2 the second fork a redirect otherwise costs. The Linux strace totals come from CI'stest_post_tool_total_processes_1011.py(its fixture is the cold path) -- see the CI comment below once it has run.Tests (written first, watched fail)
tests/test_pt_scan_cold_large_transcript_1036.pyruns the real hook throughresolve_bash(), with the #1034 harness, so it is meant to run on windows-latest too:Before / after, macOS arm64 (OBSERVED), seconds over the 10-line baseline:
Locally green (macOS, bash 5.3 and with
/bin/bash3.2 first on PATH for the new module and #1034): the new module, #913, #1017, #1034, #695, #330, #1021, the spawn tests, the #898/#332 scanners, and every test file that namespost-tool-hook(1645 passed, 55 skipped -- Windows-only and strace-only cases). The #1011 strace tests skip here (no strace on macOS).Per platform: observed vs reasoned
wc/tail/head, same bash code; the strace fork test will measure it.read -n 1on a pipe are standard bash behaviour there too, but this is exactly the claim CI has to confirm. The reporter's real 247 MB / 1.33 GB transcripts are not reproduced in CI; atwcspeed they should take seconds, not minutes -- reasoned, not measured.Not done: a PostToolUse
timeoutin hooks/hooks.json (the issue's suggestion 2)Decided against. A timeout that kills the hook mid-scan never writes the sidecar either, so it would only turn a 600 s stall on every call into an N s stall on every call -- and if N is below what a legitimate cold scan of a huge transcript on a slow disk needs, it turns a one-time cost into a permanent per-call cost that would otherwise heal after one slow call. With the scan now linear in C, the default 600 s is far from anything this code path reaches; picking a smaller number would trade a hypothetical future regression for a real risk of killing slow-but-finishing calls. Not clearly right, so not done.
🤖 Generated with Claude Code
[AI-generated]