Skip to content

Fix #1047: bound what SessionStart keeps from stdin, not just how it reads it - #1049

Open
CNSeniorious000 wants to merge 3 commits into
Digital-Process-Tools:mainfrom
CNSeniorious000:fix/1047
Open

CNSeniorious000 wants to merge 3 commits into
Digital-Process-Tools:mainfrom
CNSeniorious000:fix/1047

Conversation

@CNSeniorious000

@CNSeniorious000 CNSeniorious000 commented Oct 20, 2026 •

Copy link
Copy Markdown

Closes #1047.

What

session-start-hook.sh crops each side of the stdin join to 4 KB, so the payload its extractors see cannot exceed 8 KB whatever the host sends — and the four extractions over it (cwd, session_id, transcript_path, source) stay linear-size.

    while IFS= read -r -t 1 _line || [ -n "$_line" ]; do
        HOOK_STDIN="${HOOK_STDIN:0:4096}${_line:0:4096}"
        _line=""
    done

Why

_stdin_json_string_into opens with ${_sjsi_raw#*\"$_sjsi_key\"}, and a leading * makes bash retry the matcher at every offset — quadratic. Measured on bash 5.2.15: 0.28 s at 12.5 KB, 0.95 s at 25 KB, 3.55 s at 50 KB, 15.2 s at 100 KB, against 2-6 ms for the case one line below it on the same strings. Dropping the leading * is not an option: docs/releasing.md already records that both ${x#*P} and ${x%%P*} are quadratic on a miss.

The loop's own HOOK_STDIN="$HOOK_STDIN$_line" is the second quadratic, for many-line input rather than one huge line (6 MB across 5,000 lines: 63-91 s, against 0.4-0.7 s cropped). That is why the bound belongs in the read loop rather than only in the extractor: one 18-byte crop closes both.

The #1040 comment this sits under already says why the line loop itself is fine ("a few hundred bytes: milliseconds even at that rate"). That is a claim about a payload the host chooses, and nothing enforced it. Full numbers, and the 27 orphaned processes this was found on, are in #1047.

Red, then green

Red — with scripts/session-start-hook.sh reverted to main (tests, docs and fragment left in place), on Linux / bash 5.2.15, the same transcript-shaped payload the third test uses (1 MB over 64 lines):

pre-fix rc=124 elapsed=60s published=NONE      # timeout(1), never finished
post-fix rc=0  elapsed=605ms published=4096

and on macOS / bash 3.2, the deterministic test:

E   AssertionError: the hook published a 16419-byte payload; the bound is 8192
1 failed, 1 passed, 1 skipped

Green — this branch:

macOS, bash 3.2 (resolve_bash here):   2 passed, 1 skipped
macOS: tests/test_locale_ranges_695.py 5 passed
adjacent SessionStart modules:         123 passed, 13 skipped

The positive control is green in BOTH runs on purpose: a bound that cropped everything would pass the first assertion too.

The three tests

test what it is evidence of
..._a_16_kb_stdin_is_cropped_before_the_hook_publishes_it deterministic, counted by execution rather than by a clock: a hooks.d/ listener reports how many bytes the hook published in REMEMBER_HOOK_STDIN_FILE
..._a_real_payload_arrives_byte_for_byte the positive control, same fixture, same assertion surface (the trailing newline is stripped, as it always was — #1040)
..._a_transcript_sized_stdin_does_not_hang_the_hook the reported shape under a hang ceiling; 1 MB over 64 lines

Two fixture constraints came out of measuring bash 3.2, and both are in the module docstring:

  • 16 KB, not 64 KB+, for the deterministic test, because bash 3.2's read -r -t 1 returns EMPTY — not a partial line — once a payload needs more than its second to fetch (observed: a 16 KB single line arrives, a 64 KB one and an 8-line 131 KB one do not). A larger fixture would leave the hook with nothing to crop and the test green for the wrong reason.
  • The 1 MB case skips on bash 3.x rather than passing vacuously on it. On bash 5 that same payload is what produced the 605 ms / 60 s numbers above.

The listener is named 99-record-stdin.sh, not 50-git-restore.sh: a lone bundled listener is dispatched deferred (& disown, #660) and the published file is deleted on the way out, so a deferred copy would race that cleanup. An unknown script keeps the foreground dispatch, which is why nothing here polls.

Budget

built=122861 budget=122880 margin=19      # main: built=122843 margin=37
check_release_tree: OK -- 80 files, 704943 bytes

The comment block costs nothing (the build strips comments); the code change is the +18.

Not done here

  • /simplify findings that did not fit: an early break once the accumulator reaches the bound, or read -n, would cut the pathological loop from ~894 ms to ~4 ms — but costs 44-45 compiled bytes against a 19-byte margin, and would stop draining stdin (EPIPE for the writer). Left as a deliberate non-change.
  • The same ${raw#*...} operator lives in post-tool-hook.sh, session-end-hook.sh and user-prompt-hook.sh, whose payloads lib-stdin.sh also reads unbounded. Those are not affected by this fix and this PR does not touch them; noted on session-start-hook: a transcript-sized stdin makes field extraction quadratic — the hook spins for hours and ignores SIGTERM #1047 instead.

Observed, and reasoned

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.

session-start-hook: a transcript-sized stdin makes field extraction quadratic — the hook spins for hours and ignores SIGTERM

1 participant