From 3c7354e5258c38a3b9d3d19dda313679887a9608 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:15:36 -0400 Subject: [PATCH 1/7] fix(hook-utils): read hook stdin in chunks so a large payload is not blocked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hook::buffer_stdin read the payload with `read -d ''`, which consumes a pipe one byte at a time (~32 KB/s on Git Bash). The stdin_read_timeout bound was therefore a ~64 KB THROUGHPUT ceiling, not the stall detector it was written to be: past it the read returned a truncated payload and rc 2, and the seven fail-closed guardrails guards mapped that to exit 2 — blocking a legitimate write whose content was never scanned. Every other caller uses `|| exit 0`, so on a large payload those hooks silently did not run at all. Reproduced end-to-end: a benign Write payload (nothing in the content is a violation) piped into hardcoded-path-check.sh — 50 KB passes at 2857 ms, 100 KB and 200 KB both exit 2 with the BLOCKED stdin diagnostic. In isolation the read itself takes 1564 ms at 50 KB (78% of the bound) and times out past 100 KB. Read in 64 KB chunks with `read -N`, which bash satisfies with block reads, and take the stall verdict from read's own exit status (EOF returns 1, an exceeded -t returns >128) instead of inferring it from elapsed-time arithmetic — which drops two awk subprocesses per hook invocation. The bound becomes an IDLE bound armed per chunk: a read still making progress is never cut off, a pipe silent for stdin_read_timeout seconds still fails closed. Claude Code's own default command hook timeout is 600 s (https://code.claude.com/docs/en/hooks), so the bound was not tracking a harness limit. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. After the fix all four e2e payloads up to 200 KB return rc 0. The fail-closed posture is unchanged and now tested three ways: a stalled pipe still yields rc 2 (existing Test 18 plus the new large-payload and late-EOF cases in lib/hook-utils.test.sh), a payload containing a violation is still blocked, and a violation at the very END of a 200 KB payload is now CAUGHT rather than swept into a content-blind block. The jq completeness check stays as the backstop so the Win32 late-EOF case — a complete payload on a pipe that never closes — still succeeds rather than blocking. Also declares stdin_read_timeout in guardrails' userConfig. Its hooks already read CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT through the shared lib but never declared the option, so consumers had no supported way to set it; actionlint and claude-ops set the precedent. The effective default with nothing configured remains the shell-level `:-2` fallback in hook-utils.sh. Synced to all 14 carrying plugins via scripts/sync-hook-utils.sh, each bumped with a matching changelog entry per the coupled sync/changelog-parity gates. Closes #1563 Co-Authored-By: Claude Fable 5 --- lib/hook-utils.sh | 55 ++++++++++++++----- lib/hook-utils.test.sh | 53 ++++++++++++++++++ plugins/actionlint/.claude-plugin/plugin.json | 2 +- plugins/actionlint/CHANGELOG.md | 16 ++++++ plugins/actionlint/hooks/hook-utils.sh | 55 ++++++++++++++----- plugins/autonomy/.claude-plugin/plugin.json | 2 +- plugins/autonomy/CHANGELOG.md | 16 ++++++ plugins/autonomy/hooks/hook-utils.sh | 55 ++++++++++++++----- .../bash-format/.claude-plugin/plugin.json | 2 +- plugins/bash-format/CHANGELOG.md | 16 ++++++ plugins/bash-format/hooks/hook-utils.sh | 55 ++++++++++++++----- .../biome-format/.claude-plugin/plugin.json | 2 +- plugins/biome-format/CHANGELOG.md | 16 ++++++ plugins/biome-format/hooks/hook-utils.sh | 55 ++++++++++++++----- plugins/claude-ops/.claude-plugin/plugin.json | 2 +- plugins/claude-ops/CHANGELOG.md | 16 ++++++ plugins/claude-ops/hooks/hook-utils.sh | 55 ++++++++++++++----- .../.claude-plugin/plugin.json | 2 +- plugins/desktop-notification/CHANGELOG.md | 16 ++++++ .../desktop-notification/hooks/hook-utils.sh | 55 ++++++++++++++----- .../eol-normalizer/.claude-plugin/plugin.json | 2 +- plugins/eol-normalizer/CHANGELOG.md | 16 ++++++ plugins/eol-normalizer/hooks/hook-utils.sh | 55 ++++++++++++++----- plugins/go-format/.claude-plugin/plugin.json | 2 +- plugins/go-format/CHANGELOG.md | 16 ++++++ plugins/go-format/hooks/hook-utils.sh | 55 ++++++++++++++----- plugins/guardrails/.claude-plugin/plugin.json | 9 ++- plugins/guardrails/CHANGELOG.md | 31 +++++++++++ plugins/guardrails/README.md | 10 ++++ plugins/guardrails/hooks/hook-utils.sh | 55 ++++++++++++++----- .../.claude-plugin/plugin.json | 2 +- plugins/markdown-format/CHANGELOG.md | 16 ++++++ plugins/markdown-format/hooks/hook-utils.sh | 55 ++++++++++++++----- .../.claude-plugin/plugin.json | 2 +- plugins/powershell-format/CHANGELOG.md | 16 ++++++ plugins/powershell-format/hooks/hook-utils.sh | 55 ++++++++++++++----- .../.claude-plugin/plugin.json | 2 +- plugins/rate-limit-guard/CHANGELOG.md | 16 ++++++ plugins/rate-limit-guard/hooks/hook-utils.sh | 55 ++++++++++++++----- .../ruff-format/.claude-plugin/plugin.json | 2 +- plugins/ruff-format/CHANGELOG.md | 16 ++++++ plugins/ruff-format/hooks/hook-utils.sh | 55 ++++++++++++++----- .../typos-format/.claude-plugin/plugin.json | 2 +- plugins/typos-format/CHANGELOG.md | 16 ++++++ plugins/typos-format/hooks/hook-utils.sh | 55 ++++++++++++++----- 45 files changed, 938 insertions(+), 224 deletions(-) diff --git a/lib/hook-utils.sh b/lib/hook-utils.sh index 411c4a673..858e644dc 100644 --- a/lib/hook-utils.sh +++ b/lib/hook-utils.sh @@ -231,27 +231,54 @@ hook::repo_root() { # Buffer a complete JSON payload from stdin, tolerating Windows Win32-pipe # late-EOF stalls via a bounded read on the inherited fd0. Returns the payload # on success; returns 1 on empty/incomplete stdin (caller skips), or 2 when the -# read timed out before a complete JSON payload arrived (caller may block). -# Bound is the stdin_read_timeout userConfig option in seconds (read via -# CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2). jq (when present) -# distinguishes a truncated read from a genuinely small-but-complete payload; a -# missing/broken jq (exit 127) fails open like absent jq. +# read stalled before a complete JSON payload arrived (caller may block). +# +# The bound (stdin_read_timeout userConfig option, in seconds, read via +# CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2) is an IDLE bound, not a +# total one: it arms per chunk, so a read that keeps making progress is never +# cut off, while a pipe that goes silent for that long still fails closed. The +# distinction is load-bearing. `read -d ''` consumes a pipe one byte at a time +# (~32 KB/s on Git Bash), so a total bound was really a ~64 KB throughput +# ceiling — every larger payload tripped the timeout branch and every +# fail-closed caller blocked a legitimate write. `read -N` lets bash satisfy +# the read in blocks instead: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from +# ~6800 ms to ~85 ms. Claude Code's own default `command` hook timeout is 600 s +# (https://code.claude.com/docs/en/hooks), so the idle bound is not tracking +# any harness limit. +# +# `read` reports which stop condition it hit — EOF returns 1, an exceeded -t +# returns >128 — and assigns whatever it did read either way, so the loop reads +# the stall verdict off $? instead of inferring it from elapsed-time arithmetic. +# jq (when present) is still the completeness backstop: a stall that neverthe- +# less delivered a complete payload is the Win32 late-EOF case this function +# exists for and must succeed, not block. A missing/broken jq (exit 127) fails +# open like absent jq. Requires Bash 4.1+ for `read -N` (this library already +# requires 4.0+ for the `${var^}` case operators in hook::normalize_path). # INPUT=$(hook::buffer_stdin) || exit 0 hook::buffer_stdin() { - local input="" read_status=0 read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" start_epoch elapsed_ms timeout_ms - start_epoch=${EPOCHREALTIME:-} - IFS= read -r -d '' -t "$read_timeout" input || read_status=$? + local input="" chunk="" read_rc=0 stalled=0 + local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + while :; do + chunk="" + read_rc=0 + IFS= read -r -t "$read_timeout" -N 65536 chunk || read_rc=$? + input+="$chunk" + if ((read_rc == 0)); then + continue # a full chunk — more may still be coming + fi + if ((read_rc > 128)); then + stalled=1 + fi + break # EOF (rc 1), a stall (rc >128), or a read error + done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 local jq_rc=0 - if [[ "$read_status" -ne 0 ]] && command -v jq >/dev/null 2>&1; then + if command -v jq >/dev/null 2>&1; then jq -e . >/dev/null 2>&1 <<<"$input" || jq_rc=$? fi - if [[ "$read_status" -ne 0 && "$jq_rc" -ne 0 && "$jq_rc" -ne 127 ]]; then - elapsed_ms=$(awk -v start="$start_epoch" -v end="$EPOCHREALTIME" 'BEGIN { printf "%.0f", (end - start) * 1000 }') - timeout_ms=$(awk -v timeout="$read_timeout" 'BEGIN { printf "%.0f", timeout * 1000 }') - if [[ "$elapsed_ms" =~ ^[0-9]+$ && "$timeout_ms" =~ ^[0-9]+$ ]] && - ((elapsed_ms + 100 >= timeout_ms)); then + if ((jq_rc != 0 && jq_rc != 127)); then + if ((stalled)); then echo "BLOCKED: hook stdin timed out before a complete JSON payload arrived." >&2 return 2 fi diff --git a/lib/hook-utils.test.sh b/lib/hook-utils.test.sh index d2dfacc37..d6e44192a 100755 --- a/lib/hook-utils.test.sh +++ b/lib/hook-utils.test.sh @@ -836,6 +836,59 @@ else fi rm -f "$bs_rc_file" "$bs_err_file" +# --- Test 18b: hook::buffer_stdin — stall AFTER a complete payload succeeds --- +# The Win32-pipe late-EOF case the bounded read exists for: the producer emits a +# COMPLETE JSON payload and then holds the pipe open past the read timeout. The +# read still stalls, but jq confirms the payload is whole, so the function must +# return it (rc 0) rather than block. This is the half of the contract that keeps +# the chunked read from turning every slow producer into a blocked tool call. +bs_rc_file="$(mktemp)" +bs_out_file="$(mktemp)" +{ printf '{"complete":true}'; sleep 1; } | { + CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT=0.4 hook::buffer_stdin >"$bs_out_file" 2>/dev/null + echo "$?" >"$bs_rc_file" +} +bs_rc=$(cat "$bs_rc_file") +if [[ "$bs_rc" == "0" ]] && [[ "$(cat "$bs_out_file")" == '{"complete":true}' ]]; then + ok "buffer_stdin: complete JSON on a pipe held open past timeout → rc 0 + payload" +else + fail "buffer_stdin late-EOF: rc=$bs_rc out=$(cat "$bs_out_file")" +fi +rm -f "$bs_rc_file" "$bs_out_file" + +# --- Test 18c: hook::buffer_stdin — a large payload is neither blocked nor slow - +# Regression for the defect the chunked read fixes: `read -d ''` consumed a pipe +# byte-at-a-time, so the timeout was a throughput ceiling and a ~100 KB payload — +# an ordinary full-file Write of a long document — tripped the stall branch and +# every fail-closed caller blocked it. Drives a 256 KB payload (comfortably past +# the old ~64 KB ceiling and past the 64 KB chunk size, so the read loop iterates) +# through the real function on a real pipe, at the DEFAULT timeout: it must come +# back whole, with rc 0. Asserted on content, not on wall-clock, so a loaded CI +# runner cannot flake it — but if the byte-at-a-time read ever returns, this case +# fails outright rather than merely slowing down. +bs_big_file="$(mktemp)" +bs_payload_file="$(mktemp)" +bs_rc_file="$(mktemp)" +bs_out_file="$(mktemp)" +yes 'portable content with no delimiters of interest' | head -c 262144 >"$bs_big_file" +jq -cn --rawfile c "$bs_big_file" \ + '{hook_event_name:"PreToolUse",tool_name:"Write",tool_input:{file_path:"/tmp/big.md",content:$c}}' \ + >"$bs_payload_file" +# shellcheck disable=SC2002 # `cat |` is the point: it forces a real pipe on fd 0. +cat "$bs_payload_file" | { + hook::buffer_stdin >"$bs_out_file" 2>/dev/null + echo "$?" >"$bs_rc_file" +} +bs_rc=$(cat "$bs_rc_file") +bs_len=$(wc -c <"$bs_out_file") +bs_content_len=$(jq -r '.tool_input.content | length' "$bs_out_file" 2>/dev/null || echo 0) +if [[ "$bs_rc" == "0" ]] && ((bs_content_len == 262144)); then + ok "buffer_stdin: 256 KB payload on a pipe → rc 0, payload intact ($bs_len bytes)" +else + fail "buffer_stdin large payload: rc=$bs_rc content_len=$bs_content_len buffered=$bs_len bytes" +fi +rm -f "$bs_big_file" "$bs_payload_file" "$bs_rc_file" "$bs_out_file" + # --- Test 19: hook::emit_telemetry — EPOCHREALTIME-absent (Bash < 5.0) skip --- # On Bash < 5.0 EPOCHREALTIME is unset, so the caller's `start=${EPOCHREALTIME:-}` # snapshot is empty. emit_telemetry must then skip fail-open (return 0, emit no diff --git a/plugins/actionlint/.claude-plugin/plugin.json b/plugins/actionlint/.claude-plugin/plugin.json index 592adbf33..64b478bb9 100644 --- a/plugins/actionlint/.claude-plugin/plugin.json +++ b/plugins/actionlint/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "actionlint", - "version": "0.7.3", + "version": "0.7.4", "description": "Lint GitHub Actions workflow files on edit via actionlint, surfacing findings as advisory context.", "author": { "name": "Melodic Software", diff --git a/plugins/actionlint/CHANGELOG.md b/plugins/actionlint/CHANGELOG.md index c43915974..ec8dbe506 100644 --- a/plugins/actionlint/CHANGELOG.md +++ b/plugins/actionlint/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to the `actionlint` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.7.4] + +### Fixed + +- **Shared `hook-utils.sh`: a large tool payload no longer makes this plugin's hooks silently + skip (#1563).** `hook::buffer_stdin` read the hook payload with `read -d ''`, which consumes a + pipe one byte at a time (~32 KB/s on Git Bash), so the `stdin_read_timeout` bound was really a + ~64 KB throughput ceiling rather than the stall detector it was written to be. Past that ceiling + the read returned a truncated payload and rc 1, and this plugin's hooks took their `|| exit 0` + branch — the hook did not run at all, with no diagnostic, on exactly the large writes it was + most wanted for. The read is now chunked (`read -N`), which bash satisfies with block reads, and + the bound became an idle bound that arms per chunk: a read still making progress is never cut + off, while a pipe that goes silent for `stdin_read_timeout` seconds still stops the read the + same way. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. Synced + from `lib/hook-utils.sh`; this plugin's own hook behavior is otherwise unchanged. + ## [0.7.3] ### Changed diff --git a/plugins/actionlint/hooks/hook-utils.sh b/plugins/actionlint/hooks/hook-utils.sh index 411c4a673..858e644dc 100644 --- a/plugins/actionlint/hooks/hook-utils.sh +++ b/plugins/actionlint/hooks/hook-utils.sh @@ -231,27 +231,54 @@ hook::repo_root() { # Buffer a complete JSON payload from stdin, tolerating Windows Win32-pipe # late-EOF stalls via a bounded read on the inherited fd0. Returns the payload # on success; returns 1 on empty/incomplete stdin (caller skips), or 2 when the -# read timed out before a complete JSON payload arrived (caller may block). -# Bound is the stdin_read_timeout userConfig option in seconds (read via -# CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2). jq (when present) -# distinguishes a truncated read from a genuinely small-but-complete payload; a -# missing/broken jq (exit 127) fails open like absent jq. +# read stalled before a complete JSON payload arrived (caller may block). +# +# The bound (stdin_read_timeout userConfig option, in seconds, read via +# CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2) is an IDLE bound, not a +# total one: it arms per chunk, so a read that keeps making progress is never +# cut off, while a pipe that goes silent for that long still fails closed. The +# distinction is load-bearing. `read -d ''` consumes a pipe one byte at a time +# (~32 KB/s on Git Bash), so a total bound was really a ~64 KB throughput +# ceiling — every larger payload tripped the timeout branch and every +# fail-closed caller blocked a legitimate write. `read -N` lets bash satisfy +# the read in blocks instead: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from +# ~6800 ms to ~85 ms. Claude Code's own default `command` hook timeout is 600 s +# (https://code.claude.com/docs/en/hooks), so the idle bound is not tracking +# any harness limit. +# +# `read` reports which stop condition it hit — EOF returns 1, an exceeded -t +# returns >128 — and assigns whatever it did read either way, so the loop reads +# the stall verdict off $? instead of inferring it from elapsed-time arithmetic. +# jq (when present) is still the completeness backstop: a stall that neverthe- +# less delivered a complete payload is the Win32 late-EOF case this function +# exists for and must succeed, not block. A missing/broken jq (exit 127) fails +# open like absent jq. Requires Bash 4.1+ for `read -N` (this library already +# requires 4.0+ for the `${var^}` case operators in hook::normalize_path). # INPUT=$(hook::buffer_stdin) || exit 0 hook::buffer_stdin() { - local input="" read_status=0 read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" start_epoch elapsed_ms timeout_ms - start_epoch=${EPOCHREALTIME:-} - IFS= read -r -d '' -t "$read_timeout" input || read_status=$? + local input="" chunk="" read_rc=0 stalled=0 + local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + while :; do + chunk="" + read_rc=0 + IFS= read -r -t "$read_timeout" -N 65536 chunk || read_rc=$? + input+="$chunk" + if ((read_rc == 0)); then + continue # a full chunk — more may still be coming + fi + if ((read_rc > 128)); then + stalled=1 + fi + break # EOF (rc 1), a stall (rc >128), or a read error + done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 local jq_rc=0 - if [[ "$read_status" -ne 0 ]] && command -v jq >/dev/null 2>&1; then + if command -v jq >/dev/null 2>&1; then jq -e . >/dev/null 2>&1 <<<"$input" || jq_rc=$? fi - if [[ "$read_status" -ne 0 && "$jq_rc" -ne 0 && "$jq_rc" -ne 127 ]]; then - elapsed_ms=$(awk -v start="$start_epoch" -v end="$EPOCHREALTIME" 'BEGIN { printf "%.0f", (end - start) * 1000 }') - timeout_ms=$(awk -v timeout="$read_timeout" 'BEGIN { printf "%.0f", timeout * 1000 }') - if [[ "$elapsed_ms" =~ ^[0-9]+$ && "$timeout_ms" =~ ^[0-9]+$ ]] && - ((elapsed_ms + 100 >= timeout_ms)); then + if ((jq_rc != 0 && jq_rc != 127)); then + if ((stalled)); then echo "BLOCKED: hook stdin timed out before a complete JSON payload arrived." >&2 return 2 fi diff --git a/plugins/autonomy/.claude-plugin/plugin.json b/plugins/autonomy/.claude-plugin/plugin.json index e3eafe782..7956e8804 100644 --- a/plugins/autonomy/.claude-plugin/plugin.json +++ b/plugins/autonomy/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "autonomy", - "version": "0.11.2", + "version": "0.11.3", "description": "Governed autonomous agent operation: role-topology, binding-seam, wiring-vs-advisor, telemetry, return-accounting, trigger-dispatch, per-work-class guardrail-matrix, standing-routine-catalog, and design-only runner-charter contracts for climbing the AI-adoption ladder, plus a guided-setup skill that discovers an adopting org's state, writes its schema-versioned binding, wires standards-pinned OTLP emission with a zero-cost file-artifact default, wires human-attested return capture at the task boundary, wires signal adapters with one governed dispatch entrypoint, binds the five-class guardrail matrix to an org's isolation substrates with an in-boundary live-validation probe before recording each fail-closed binding, and stands up standing-routine-catalog classes as scheduled temporal signal adapters behind the one governed queue with free scheduling defaults wired as reviewable changes and each routine's work-class mapping homed on the security surface.", "author": { "name": "Melodic Software", diff --git a/plugins/autonomy/CHANGELOG.md b/plugins/autonomy/CHANGELOG.md index 86b228bf1..fb482cdea 100644 --- a/plugins/autonomy/CHANGELOG.md +++ b/plugins/autonomy/CHANGELOG.md @@ -6,6 +6,22 @@ All notable changes to the `autonomy` plugin are documented here. Format follows Versions 0.1.0–0.7.0 predate this file (introduced with 0.7.1); their history lives in the merged work-package PRs (#333, #343, #356, #372, #377, #600, #676). +## [0.11.3] + +### Fixed + +- **Shared `hook-utils.sh`: a large tool payload no longer makes this plugin's hooks silently + skip (#1563).** `hook::buffer_stdin` read the hook payload with `read -d ''`, which consumes a + pipe one byte at a time (~32 KB/s on Git Bash), so the `stdin_read_timeout` bound was really a + ~64 KB throughput ceiling rather than the stall detector it was written to be. Past that ceiling + the read returned a truncated payload and rc 1, and this plugin's hooks took their `|| exit 0` + branch — the hook did not run at all, with no diagnostic, on exactly the large writes it was + most wanted for. The read is now chunked (`read -N`), which bash satisfies with block reads, and + the bound became an idle bound that arms per chunk: a read still making progress is never cut + off, while a pipe that goes silent for `stdin_read_timeout` seconds still stops the read the + same way. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. Synced + from `lib/hook-utils.sh`; this plugin's own hook behavior is otherwise unchanged. + ## [0.11.2] ### Changed diff --git a/plugins/autonomy/hooks/hook-utils.sh b/plugins/autonomy/hooks/hook-utils.sh index 411c4a673..858e644dc 100644 --- a/plugins/autonomy/hooks/hook-utils.sh +++ b/plugins/autonomy/hooks/hook-utils.sh @@ -231,27 +231,54 @@ hook::repo_root() { # Buffer a complete JSON payload from stdin, tolerating Windows Win32-pipe # late-EOF stalls via a bounded read on the inherited fd0. Returns the payload # on success; returns 1 on empty/incomplete stdin (caller skips), or 2 when the -# read timed out before a complete JSON payload arrived (caller may block). -# Bound is the stdin_read_timeout userConfig option in seconds (read via -# CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2). jq (when present) -# distinguishes a truncated read from a genuinely small-but-complete payload; a -# missing/broken jq (exit 127) fails open like absent jq. +# read stalled before a complete JSON payload arrived (caller may block). +# +# The bound (stdin_read_timeout userConfig option, in seconds, read via +# CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2) is an IDLE bound, not a +# total one: it arms per chunk, so a read that keeps making progress is never +# cut off, while a pipe that goes silent for that long still fails closed. The +# distinction is load-bearing. `read -d ''` consumes a pipe one byte at a time +# (~32 KB/s on Git Bash), so a total bound was really a ~64 KB throughput +# ceiling — every larger payload tripped the timeout branch and every +# fail-closed caller blocked a legitimate write. `read -N` lets bash satisfy +# the read in blocks instead: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from +# ~6800 ms to ~85 ms. Claude Code's own default `command` hook timeout is 600 s +# (https://code.claude.com/docs/en/hooks), so the idle bound is not tracking +# any harness limit. +# +# `read` reports which stop condition it hit — EOF returns 1, an exceeded -t +# returns >128 — and assigns whatever it did read either way, so the loop reads +# the stall verdict off $? instead of inferring it from elapsed-time arithmetic. +# jq (when present) is still the completeness backstop: a stall that neverthe- +# less delivered a complete payload is the Win32 late-EOF case this function +# exists for and must succeed, not block. A missing/broken jq (exit 127) fails +# open like absent jq. Requires Bash 4.1+ for `read -N` (this library already +# requires 4.0+ for the `${var^}` case operators in hook::normalize_path). # INPUT=$(hook::buffer_stdin) || exit 0 hook::buffer_stdin() { - local input="" read_status=0 read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" start_epoch elapsed_ms timeout_ms - start_epoch=${EPOCHREALTIME:-} - IFS= read -r -d '' -t "$read_timeout" input || read_status=$? + local input="" chunk="" read_rc=0 stalled=0 + local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + while :; do + chunk="" + read_rc=0 + IFS= read -r -t "$read_timeout" -N 65536 chunk || read_rc=$? + input+="$chunk" + if ((read_rc == 0)); then + continue # a full chunk — more may still be coming + fi + if ((read_rc > 128)); then + stalled=1 + fi + break # EOF (rc 1), a stall (rc >128), or a read error + done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 local jq_rc=0 - if [[ "$read_status" -ne 0 ]] && command -v jq >/dev/null 2>&1; then + if command -v jq >/dev/null 2>&1; then jq -e . >/dev/null 2>&1 <<<"$input" || jq_rc=$? fi - if [[ "$read_status" -ne 0 && "$jq_rc" -ne 0 && "$jq_rc" -ne 127 ]]; then - elapsed_ms=$(awk -v start="$start_epoch" -v end="$EPOCHREALTIME" 'BEGIN { printf "%.0f", (end - start) * 1000 }') - timeout_ms=$(awk -v timeout="$read_timeout" 'BEGIN { printf "%.0f", timeout * 1000 }') - if [[ "$elapsed_ms" =~ ^[0-9]+$ && "$timeout_ms" =~ ^[0-9]+$ ]] && - ((elapsed_ms + 100 >= timeout_ms)); then + if ((jq_rc != 0 && jq_rc != 127)); then + if ((stalled)); then echo "BLOCKED: hook stdin timed out before a complete JSON payload arrived." >&2 return 2 fi diff --git a/plugins/bash-format/.claude-plugin/plugin.json b/plugins/bash-format/.claude-plugin/plugin.json index 80a8b3561..c89c464b6 100644 --- a/plugins/bash-format/.claude-plugin/plugin.json +++ b/plugins/bash-format/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "bash-format", - "version": "0.6.5", + "version": "0.6.6", "description": "Auto-format and lint shell scripts on edit via shfmt + ShellCheck, using the consuming repo's own .editorconfig and .shellcheckrc.", "author": { "name": "Melodic Software", diff --git a/plugins/bash-format/CHANGELOG.md b/plugins/bash-format/CHANGELOG.md index 1ea5d0cff..b95b7340b 100644 --- a/plugins/bash-format/CHANGELOG.md +++ b/plugins/bash-format/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to the `bash-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.6.6] + +### Fixed + +- **Shared `hook-utils.sh`: a large tool payload no longer makes this plugin's hooks silently + skip (#1563).** `hook::buffer_stdin` read the hook payload with `read -d ''`, which consumes a + pipe one byte at a time (~32 KB/s on Git Bash), so the `stdin_read_timeout` bound was really a + ~64 KB throughput ceiling rather than the stall detector it was written to be. Past that ceiling + the read returned a truncated payload and rc 1, and this plugin's hooks took their `|| exit 0` + branch — the hook did not run at all, with no diagnostic, on exactly the large writes it was + most wanted for. The read is now chunked (`read -N`), which bash satisfies with block reads, and + the bound became an idle bound that arms per chunk: a read still making progress is never cut + off, while a pipe that goes silent for `stdin_read_timeout` seconds still stops the read the + same way. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. Synced + from `lib/hook-utils.sh`; this plugin's own hook behavior is otherwise unchanged. + ## [0.6.5] ### Changed diff --git a/plugins/bash-format/hooks/hook-utils.sh b/plugins/bash-format/hooks/hook-utils.sh index 411c4a673..858e644dc 100644 --- a/plugins/bash-format/hooks/hook-utils.sh +++ b/plugins/bash-format/hooks/hook-utils.sh @@ -231,27 +231,54 @@ hook::repo_root() { # Buffer a complete JSON payload from stdin, tolerating Windows Win32-pipe # late-EOF stalls via a bounded read on the inherited fd0. Returns the payload # on success; returns 1 on empty/incomplete stdin (caller skips), or 2 when the -# read timed out before a complete JSON payload arrived (caller may block). -# Bound is the stdin_read_timeout userConfig option in seconds (read via -# CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2). jq (when present) -# distinguishes a truncated read from a genuinely small-but-complete payload; a -# missing/broken jq (exit 127) fails open like absent jq. +# read stalled before a complete JSON payload arrived (caller may block). +# +# The bound (stdin_read_timeout userConfig option, in seconds, read via +# CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2) is an IDLE bound, not a +# total one: it arms per chunk, so a read that keeps making progress is never +# cut off, while a pipe that goes silent for that long still fails closed. The +# distinction is load-bearing. `read -d ''` consumes a pipe one byte at a time +# (~32 KB/s on Git Bash), so a total bound was really a ~64 KB throughput +# ceiling — every larger payload tripped the timeout branch and every +# fail-closed caller blocked a legitimate write. `read -N` lets bash satisfy +# the read in blocks instead: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from +# ~6800 ms to ~85 ms. Claude Code's own default `command` hook timeout is 600 s +# (https://code.claude.com/docs/en/hooks), so the idle bound is not tracking +# any harness limit. +# +# `read` reports which stop condition it hit — EOF returns 1, an exceeded -t +# returns >128 — and assigns whatever it did read either way, so the loop reads +# the stall verdict off $? instead of inferring it from elapsed-time arithmetic. +# jq (when present) is still the completeness backstop: a stall that neverthe- +# less delivered a complete payload is the Win32 late-EOF case this function +# exists for and must succeed, not block. A missing/broken jq (exit 127) fails +# open like absent jq. Requires Bash 4.1+ for `read -N` (this library already +# requires 4.0+ for the `${var^}` case operators in hook::normalize_path). # INPUT=$(hook::buffer_stdin) || exit 0 hook::buffer_stdin() { - local input="" read_status=0 read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" start_epoch elapsed_ms timeout_ms - start_epoch=${EPOCHREALTIME:-} - IFS= read -r -d '' -t "$read_timeout" input || read_status=$? + local input="" chunk="" read_rc=0 stalled=0 + local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + while :; do + chunk="" + read_rc=0 + IFS= read -r -t "$read_timeout" -N 65536 chunk || read_rc=$? + input+="$chunk" + if ((read_rc == 0)); then + continue # a full chunk — more may still be coming + fi + if ((read_rc > 128)); then + stalled=1 + fi + break # EOF (rc 1), a stall (rc >128), or a read error + done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 local jq_rc=0 - if [[ "$read_status" -ne 0 ]] && command -v jq >/dev/null 2>&1; then + if command -v jq >/dev/null 2>&1; then jq -e . >/dev/null 2>&1 <<<"$input" || jq_rc=$? fi - if [[ "$read_status" -ne 0 && "$jq_rc" -ne 0 && "$jq_rc" -ne 127 ]]; then - elapsed_ms=$(awk -v start="$start_epoch" -v end="$EPOCHREALTIME" 'BEGIN { printf "%.0f", (end - start) * 1000 }') - timeout_ms=$(awk -v timeout="$read_timeout" 'BEGIN { printf "%.0f", timeout * 1000 }') - if [[ "$elapsed_ms" =~ ^[0-9]+$ && "$timeout_ms" =~ ^[0-9]+$ ]] && - ((elapsed_ms + 100 >= timeout_ms)); then + if ((jq_rc != 0 && jq_rc != 127)); then + if ((stalled)); then echo "BLOCKED: hook stdin timed out before a complete JSON payload arrived." >&2 return 2 fi diff --git a/plugins/biome-format/.claude-plugin/plugin.json b/plugins/biome-format/.claude-plugin/plugin.json index 923e950e3..46dc6edf7 100644 --- a/plugins/biome-format/.claude-plugin/plugin.json +++ b/plugins/biome-format/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "biome-format", - "version": "0.5.4", + "version": "0.5.5", "description": "Auto-format and lint JS/TS/JSX/JSON on edit via Biome, only when a biome.json governs the repo — using the consuming repo's own Biome config.", "author": { "name": "Melodic Software", diff --git a/plugins/biome-format/CHANGELOG.md b/plugins/biome-format/CHANGELOG.md index 27db375f9..3f7ce6b13 100644 --- a/plugins/biome-format/CHANGELOG.md +++ b/plugins/biome-format/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to the `biome-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.5.5] + +### Fixed + +- **Shared `hook-utils.sh`: a large tool payload no longer makes this plugin's hooks silently + skip (#1563).** `hook::buffer_stdin` read the hook payload with `read -d ''`, which consumes a + pipe one byte at a time (~32 KB/s on Git Bash), so the `stdin_read_timeout` bound was really a + ~64 KB throughput ceiling rather than the stall detector it was written to be. Past that ceiling + the read returned a truncated payload and rc 1, and this plugin's hooks took their `|| exit 0` + branch — the hook did not run at all, with no diagnostic, on exactly the large writes it was + most wanted for. The read is now chunked (`read -N`), which bash satisfies with block reads, and + the bound became an idle bound that arms per chunk: a read still making progress is never cut + off, while a pipe that goes silent for `stdin_read_timeout` seconds still stops the read the + same way. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. Synced + from `lib/hook-utils.sh`; this plugin's own hook behavior is otherwise unchanged. + ## [0.5.4] ### Changed diff --git a/plugins/biome-format/hooks/hook-utils.sh b/plugins/biome-format/hooks/hook-utils.sh index 411c4a673..858e644dc 100644 --- a/plugins/biome-format/hooks/hook-utils.sh +++ b/plugins/biome-format/hooks/hook-utils.sh @@ -231,27 +231,54 @@ hook::repo_root() { # Buffer a complete JSON payload from stdin, tolerating Windows Win32-pipe # late-EOF stalls via a bounded read on the inherited fd0. Returns the payload # on success; returns 1 on empty/incomplete stdin (caller skips), or 2 when the -# read timed out before a complete JSON payload arrived (caller may block). -# Bound is the stdin_read_timeout userConfig option in seconds (read via -# CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2). jq (when present) -# distinguishes a truncated read from a genuinely small-but-complete payload; a -# missing/broken jq (exit 127) fails open like absent jq. +# read stalled before a complete JSON payload arrived (caller may block). +# +# The bound (stdin_read_timeout userConfig option, in seconds, read via +# CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2) is an IDLE bound, not a +# total one: it arms per chunk, so a read that keeps making progress is never +# cut off, while a pipe that goes silent for that long still fails closed. The +# distinction is load-bearing. `read -d ''` consumes a pipe one byte at a time +# (~32 KB/s on Git Bash), so a total bound was really a ~64 KB throughput +# ceiling — every larger payload tripped the timeout branch and every +# fail-closed caller blocked a legitimate write. `read -N` lets bash satisfy +# the read in blocks instead: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from +# ~6800 ms to ~85 ms. Claude Code's own default `command` hook timeout is 600 s +# (https://code.claude.com/docs/en/hooks), so the idle bound is not tracking +# any harness limit. +# +# `read` reports which stop condition it hit — EOF returns 1, an exceeded -t +# returns >128 — and assigns whatever it did read either way, so the loop reads +# the stall verdict off $? instead of inferring it from elapsed-time arithmetic. +# jq (when present) is still the completeness backstop: a stall that neverthe- +# less delivered a complete payload is the Win32 late-EOF case this function +# exists for and must succeed, not block. A missing/broken jq (exit 127) fails +# open like absent jq. Requires Bash 4.1+ for `read -N` (this library already +# requires 4.0+ for the `${var^}` case operators in hook::normalize_path). # INPUT=$(hook::buffer_stdin) || exit 0 hook::buffer_stdin() { - local input="" read_status=0 read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" start_epoch elapsed_ms timeout_ms - start_epoch=${EPOCHREALTIME:-} - IFS= read -r -d '' -t "$read_timeout" input || read_status=$? + local input="" chunk="" read_rc=0 stalled=0 + local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + while :; do + chunk="" + read_rc=0 + IFS= read -r -t "$read_timeout" -N 65536 chunk || read_rc=$? + input+="$chunk" + if ((read_rc == 0)); then + continue # a full chunk — more may still be coming + fi + if ((read_rc > 128)); then + stalled=1 + fi + break # EOF (rc 1), a stall (rc >128), or a read error + done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 local jq_rc=0 - if [[ "$read_status" -ne 0 ]] && command -v jq >/dev/null 2>&1; then + if command -v jq >/dev/null 2>&1; then jq -e . >/dev/null 2>&1 <<<"$input" || jq_rc=$? fi - if [[ "$read_status" -ne 0 && "$jq_rc" -ne 0 && "$jq_rc" -ne 127 ]]; then - elapsed_ms=$(awk -v start="$start_epoch" -v end="$EPOCHREALTIME" 'BEGIN { printf "%.0f", (end - start) * 1000 }') - timeout_ms=$(awk -v timeout="$read_timeout" 'BEGIN { printf "%.0f", timeout * 1000 }') - if [[ "$elapsed_ms" =~ ^[0-9]+$ && "$timeout_ms" =~ ^[0-9]+$ ]] && - ((elapsed_ms + 100 >= timeout_ms)); then + if ((jq_rc != 0 && jq_rc != 127)); then + if ((stalled)); then echo "BLOCKED: hook stdin timed out before a complete JSON payload arrived." >&2 return 2 fi diff --git a/plugins/claude-ops/.claude-plugin/plugin.json b/plugins/claude-ops/.claude-plugin/plugin.json index 483cff522..040bc1448 100644 --- a/plugins/claude-ops/.claude-plugin/plugin.json +++ b/plugins/claude-ops/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "claude-ops", - "version": "0.21.5", + "version": "0.21.6", "description": "Claude Code operations toolkit. Seven skills: observability (read locally captured telemetry — OTEL store, collector, hook-event JSONL, ccusage — with trend reports and store pruning), known-issues (search known Claude product GitHub bugs, check service health, maintain a persistent tracked-issue registry), changelog (ingest Claude Code changelog entries and integrate them into the current repo), plugins (bring a machine's plugin fleet current on demand — marketplace refresh, effective-scope updates including in-repo project/local installs, new-plugin install per policy, scope-divergence detection and explicit convergence), morning-brief (read-only gh-based operator morning view — queue-label counts, merge-ready PRs, parked decisions with their RECOMMENDED lines, and loop-lane telemetry freshness), lanes (start/restart/stop/status loop lanes as named background Claude Code sessions seeded from canonical prompt files, with per-lane model/effort and a repo-pull + marketplace-refresh launch step), and a re-runnable setup action that settles where the known-issues registry lives. Plus a family of seven advisory *-audit telemetry-emitter hooks (API errors, config changes, instruction loads, permission denials, pre-compaction, skill usage, tool failures) that emit the shared hook-telemetry envelope, and a reference sink that maps envelopes into the hook-events.jsonl the observability skill reads.", "author": { "name": "Melodic Software", diff --git a/plugins/claude-ops/CHANGELOG.md b/plugins/claude-ops/CHANGELOG.md index bbc283303..1d32f332f 100644 --- a/plugins/claude-ops/CHANGELOG.md +++ b/plugins/claude-ops/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to the `claude-ops` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.21.6] + +### Fixed + +- **Shared `hook-utils.sh`: a large tool payload no longer makes this plugin's hooks silently + skip (#1563).** `hook::buffer_stdin` read the hook payload with `read -d ''`, which consumes a + pipe one byte at a time (~32 KB/s on Git Bash), so the `stdin_read_timeout` bound was really a + ~64 KB throughput ceiling rather than the stall detector it was written to be. Past that ceiling + the read returned a truncated payload and rc 1, and this plugin's hooks took their `|| exit 0` + branch — the hook did not run at all, with no diagnostic, on exactly the large writes it was + most wanted for. The read is now chunked (`read -N`), which bash satisfies with block reads, and + the bound became an idle bound that arms per chunk: a read still making progress is never cut + off, while a pipe that goes silent for `stdin_read_timeout` seconds still stops the read the + same way. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. Synced + from `lib/hook-utils.sh`; this plugin's own hook behavior is otherwise unchanged. + ## [0.21.5] ### Fixed diff --git a/plugins/claude-ops/hooks/hook-utils.sh b/plugins/claude-ops/hooks/hook-utils.sh index 411c4a673..858e644dc 100644 --- a/plugins/claude-ops/hooks/hook-utils.sh +++ b/plugins/claude-ops/hooks/hook-utils.sh @@ -231,27 +231,54 @@ hook::repo_root() { # Buffer a complete JSON payload from stdin, tolerating Windows Win32-pipe # late-EOF stalls via a bounded read on the inherited fd0. Returns the payload # on success; returns 1 on empty/incomplete stdin (caller skips), or 2 when the -# read timed out before a complete JSON payload arrived (caller may block). -# Bound is the stdin_read_timeout userConfig option in seconds (read via -# CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2). jq (when present) -# distinguishes a truncated read from a genuinely small-but-complete payload; a -# missing/broken jq (exit 127) fails open like absent jq. +# read stalled before a complete JSON payload arrived (caller may block). +# +# The bound (stdin_read_timeout userConfig option, in seconds, read via +# CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2) is an IDLE bound, not a +# total one: it arms per chunk, so a read that keeps making progress is never +# cut off, while a pipe that goes silent for that long still fails closed. The +# distinction is load-bearing. `read -d ''` consumes a pipe one byte at a time +# (~32 KB/s on Git Bash), so a total bound was really a ~64 KB throughput +# ceiling — every larger payload tripped the timeout branch and every +# fail-closed caller blocked a legitimate write. `read -N` lets bash satisfy +# the read in blocks instead: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from +# ~6800 ms to ~85 ms. Claude Code's own default `command` hook timeout is 600 s +# (https://code.claude.com/docs/en/hooks), so the idle bound is not tracking +# any harness limit. +# +# `read` reports which stop condition it hit — EOF returns 1, an exceeded -t +# returns >128 — and assigns whatever it did read either way, so the loop reads +# the stall verdict off $? instead of inferring it from elapsed-time arithmetic. +# jq (when present) is still the completeness backstop: a stall that neverthe- +# less delivered a complete payload is the Win32 late-EOF case this function +# exists for and must succeed, not block. A missing/broken jq (exit 127) fails +# open like absent jq. Requires Bash 4.1+ for `read -N` (this library already +# requires 4.0+ for the `${var^}` case operators in hook::normalize_path). # INPUT=$(hook::buffer_stdin) || exit 0 hook::buffer_stdin() { - local input="" read_status=0 read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" start_epoch elapsed_ms timeout_ms - start_epoch=${EPOCHREALTIME:-} - IFS= read -r -d '' -t "$read_timeout" input || read_status=$? + local input="" chunk="" read_rc=0 stalled=0 + local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + while :; do + chunk="" + read_rc=0 + IFS= read -r -t "$read_timeout" -N 65536 chunk || read_rc=$? + input+="$chunk" + if ((read_rc == 0)); then + continue # a full chunk — more may still be coming + fi + if ((read_rc > 128)); then + stalled=1 + fi + break # EOF (rc 1), a stall (rc >128), or a read error + done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 local jq_rc=0 - if [[ "$read_status" -ne 0 ]] && command -v jq >/dev/null 2>&1; then + if command -v jq >/dev/null 2>&1; then jq -e . >/dev/null 2>&1 <<<"$input" || jq_rc=$? fi - if [[ "$read_status" -ne 0 && "$jq_rc" -ne 0 && "$jq_rc" -ne 127 ]]; then - elapsed_ms=$(awk -v start="$start_epoch" -v end="$EPOCHREALTIME" 'BEGIN { printf "%.0f", (end - start) * 1000 }') - timeout_ms=$(awk -v timeout="$read_timeout" 'BEGIN { printf "%.0f", timeout * 1000 }') - if [[ "$elapsed_ms" =~ ^[0-9]+$ && "$timeout_ms" =~ ^[0-9]+$ ]] && - ((elapsed_ms + 100 >= timeout_ms)); then + if ((jq_rc != 0 && jq_rc != 127)); then + if ((stalled)); then echo "BLOCKED: hook stdin timed out before a complete JSON payload arrived." >&2 return 2 fi diff --git a/plugins/desktop-notification/.claude-plugin/plugin.json b/plugins/desktop-notification/.claude-plugin/plugin.json index eb4138fcb..2d5af35d4 100644 --- a/plugins/desktop-notification/.claude-plugin/plugin.json +++ b/plugins/desktop-notification/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "desktop-notification", - "version": "0.5.5", + "version": "0.5.6", "description": "Alert you when Claude Code needs input — an audible terminal bell, an OSC 9 terminal notification, and an OS-native toast (macOS/Linux) on permission and idle prompts.", "author": { "name": "Melodic Software", diff --git a/plugins/desktop-notification/CHANGELOG.md b/plugins/desktop-notification/CHANGELOG.md index bf54d6151..385ba4427 100644 --- a/plugins/desktop-notification/CHANGELOG.md +++ b/plugins/desktop-notification/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to the `desktop-notification` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.5.6] + +### Fixed + +- **Shared `hook-utils.sh`: a large tool payload no longer makes this plugin's hooks silently + skip (#1563).** `hook::buffer_stdin` read the hook payload with `read -d ''`, which consumes a + pipe one byte at a time (~32 KB/s on Git Bash), so the `stdin_read_timeout` bound was really a + ~64 KB throughput ceiling rather than the stall detector it was written to be. Past that ceiling + the read returned a truncated payload and rc 1, and this plugin's hooks took their `|| exit 0` + branch — the hook did not run at all, with no diagnostic, on exactly the large writes it was + most wanted for. The read is now chunked (`read -N`), which bash satisfies with block reads, and + the bound became an idle bound that arms per chunk: a read still making progress is never cut + off, while a pipe that goes silent for `stdin_read_timeout` seconds still stops the read the + same way. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. Synced + from `lib/hook-utils.sh`; this plugin's own hook behavior is otherwise unchanged. + ## [0.5.5] ### Changed diff --git a/plugins/desktop-notification/hooks/hook-utils.sh b/plugins/desktop-notification/hooks/hook-utils.sh index 411c4a673..858e644dc 100644 --- a/plugins/desktop-notification/hooks/hook-utils.sh +++ b/plugins/desktop-notification/hooks/hook-utils.sh @@ -231,27 +231,54 @@ hook::repo_root() { # Buffer a complete JSON payload from stdin, tolerating Windows Win32-pipe # late-EOF stalls via a bounded read on the inherited fd0. Returns the payload # on success; returns 1 on empty/incomplete stdin (caller skips), or 2 when the -# read timed out before a complete JSON payload arrived (caller may block). -# Bound is the stdin_read_timeout userConfig option in seconds (read via -# CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2). jq (when present) -# distinguishes a truncated read from a genuinely small-but-complete payload; a -# missing/broken jq (exit 127) fails open like absent jq. +# read stalled before a complete JSON payload arrived (caller may block). +# +# The bound (stdin_read_timeout userConfig option, in seconds, read via +# CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2) is an IDLE bound, not a +# total one: it arms per chunk, so a read that keeps making progress is never +# cut off, while a pipe that goes silent for that long still fails closed. The +# distinction is load-bearing. `read -d ''` consumes a pipe one byte at a time +# (~32 KB/s on Git Bash), so a total bound was really a ~64 KB throughput +# ceiling — every larger payload tripped the timeout branch and every +# fail-closed caller blocked a legitimate write. `read -N` lets bash satisfy +# the read in blocks instead: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from +# ~6800 ms to ~85 ms. Claude Code's own default `command` hook timeout is 600 s +# (https://code.claude.com/docs/en/hooks), so the idle bound is not tracking +# any harness limit. +# +# `read` reports which stop condition it hit — EOF returns 1, an exceeded -t +# returns >128 — and assigns whatever it did read either way, so the loop reads +# the stall verdict off $? instead of inferring it from elapsed-time arithmetic. +# jq (when present) is still the completeness backstop: a stall that neverthe- +# less delivered a complete payload is the Win32 late-EOF case this function +# exists for and must succeed, not block. A missing/broken jq (exit 127) fails +# open like absent jq. Requires Bash 4.1+ for `read -N` (this library already +# requires 4.0+ for the `${var^}` case operators in hook::normalize_path). # INPUT=$(hook::buffer_stdin) || exit 0 hook::buffer_stdin() { - local input="" read_status=0 read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" start_epoch elapsed_ms timeout_ms - start_epoch=${EPOCHREALTIME:-} - IFS= read -r -d '' -t "$read_timeout" input || read_status=$? + local input="" chunk="" read_rc=0 stalled=0 + local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + while :; do + chunk="" + read_rc=0 + IFS= read -r -t "$read_timeout" -N 65536 chunk || read_rc=$? + input+="$chunk" + if ((read_rc == 0)); then + continue # a full chunk — more may still be coming + fi + if ((read_rc > 128)); then + stalled=1 + fi + break # EOF (rc 1), a stall (rc >128), or a read error + done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 local jq_rc=0 - if [[ "$read_status" -ne 0 ]] && command -v jq >/dev/null 2>&1; then + if command -v jq >/dev/null 2>&1; then jq -e . >/dev/null 2>&1 <<<"$input" || jq_rc=$? fi - if [[ "$read_status" -ne 0 && "$jq_rc" -ne 0 && "$jq_rc" -ne 127 ]]; then - elapsed_ms=$(awk -v start="$start_epoch" -v end="$EPOCHREALTIME" 'BEGIN { printf "%.0f", (end - start) * 1000 }') - timeout_ms=$(awk -v timeout="$read_timeout" 'BEGIN { printf "%.0f", timeout * 1000 }') - if [[ "$elapsed_ms" =~ ^[0-9]+$ && "$timeout_ms" =~ ^[0-9]+$ ]] && - ((elapsed_ms + 100 >= timeout_ms)); then + if ((jq_rc != 0 && jq_rc != 127)); then + if ((stalled)); then echo "BLOCKED: hook stdin timed out before a complete JSON payload arrived." >&2 return 2 fi diff --git a/plugins/eol-normalizer/.claude-plugin/plugin.json b/plugins/eol-normalizer/.claude-plugin/plugin.json index 282cfa2e1..a705df4ca 100644 --- a/plugins/eol-normalizer/.claude-plugin/plugin.json +++ b/plugins/eol-normalizer/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "eol-normalizer", - "version": "0.5.4", + "version": "0.5.5", "description": "Normalize a written file's working-tree line endings to its .gitattributes eol value on edit — symmetric CRLF/LF driven by git check-attr, advisory and never blocking.", "author": { "name": "Melodic Software", diff --git a/plugins/eol-normalizer/CHANGELOG.md b/plugins/eol-normalizer/CHANGELOG.md index d2a9b97f9..c2624b094 100644 --- a/plugins/eol-normalizer/CHANGELOG.md +++ b/plugins/eol-normalizer/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to the `eol-normalizer` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.5.5] + +### Fixed + +- **Shared `hook-utils.sh`: a large tool payload no longer makes this plugin's hooks silently + skip (#1563).** `hook::buffer_stdin` read the hook payload with `read -d ''`, which consumes a + pipe one byte at a time (~32 KB/s on Git Bash), so the `stdin_read_timeout` bound was really a + ~64 KB throughput ceiling rather than the stall detector it was written to be. Past that ceiling + the read returned a truncated payload and rc 1, and this plugin's hooks took their `|| exit 0` + branch — the hook did not run at all, with no diagnostic, on exactly the large writes it was + most wanted for. The read is now chunked (`read -N`), which bash satisfies with block reads, and + the bound became an idle bound that arms per chunk: a read still making progress is never cut + off, while a pipe that goes silent for `stdin_read_timeout` seconds still stops the read the + same way. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. Synced + from `lib/hook-utils.sh`; this plugin's own hook behavior is otherwise unchanged. + ## [0.5.4] ### Changed diff --git a/plugins/eol-normalizer/hooks/hook-utils.sh b/plugins/eol-normalizer/hooks/hook-utils.sh index 411c4a673..858e644dc 100644 --- a/plugins/eol-normalizer/hooks/hook-utils.sh +++ b/plugins/eol-normalizer/hooks/hook-utils.sh @@ -231,27 +231,54 @@ hook::repo_root() { # Buffer a complete JSON payload from stdin, tolerating Windows Win32-pipe # late-EOF stalls via a bounded read on the inherited fd0. Returns the payload # on success; returns 1 on empty/incomplete stdin (caller skips), or 2 when the -# read timed out before a complete JSON payload arrived (caller may block). -# Bound is the stdin_read_timeout userConfig option in seconds (read via -# CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2). jq (when present) -# distinguishes a truncated read from a genuinely small-but-complete payload; a -# missing/broken jq (exit 127) fails open like absent jq. +# read stalled before a complete JSON payload arrived (caller may block). +# +# The bound (stdin_read_timeout userConfig option, in seconds, read via +# CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2) is an IDLE bound, not a +# total one: it arms per chunk, so a read that keeps making progress is never +# cut off, while a pipe that goes silent for that long still fails closed. The +# distinction is load-bearing. `read -d ''` consumes a pipe one byte at a time +# (~32 KB/s on Git Bash), so a total bound was really a ~64 KB throughput +# ceiling — every larger payload tripped the timeout branch and every +# fail-closed caller blocked a legitimate write. `read -N` lets bash satisfy +# the read in blocks instead: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from +# ~6800 ms to ~85 ms. Claude Code's own default `command` hook timeout is 600 s +# (https://code.claude.com/docs/en/hooks), so the idle bound is not tracking +# any harness limit. +# +# `read` reports which stop condition it hit — EOF returns 1, an exceeded -t +# returns >128 — and assigns whatever it did read either way, so the loop reads +# the stall verdict off $? instead of inferring it from elapsed-time arithmetic. +# jq (when present) is still the completeness backstop: a stall that neverthe- +# less delivered a complete payload is the Win32 late-EOF case this function +# exists for and must succeed, not block. A missing/broken jq (exit 127) fails +# open like absent jq. Requires Bash 4.1+ for `read -N` (this library already +# requires 4.0+ for the `${var^}` case operators in hook::normalize_path). # INPUT=$(hook::buffer_stdin) || exit 0 hook::buffer_stdin() { - local input="" read_status=0 read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" start_epoch elapsed_ms timeout_ms - start_epoch=${EPOCHREALTIME:-} - IFS= read -r -d '' -t "$read_timeout" input || read_status=$? + local input="" chunk="" read_rc=0 stalled=0 + local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + while :; do + chunk="" + read_rc=0 + IFS= read -r -t "$read_timeout" -N 65536 chunk || read_rc=$? + input+="$chunk" + if ((read_rc == 0)); then + continue # a full chunk — more may still be coming + fi + if ((read_rc > 128)); then + stalled=1 + fi + break # EOF (rc 1), a stall (rc >128), or a read error + done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 local jq_rc=0 - if [[ "$read_status" -ne 0 ]] && command -v jq >/dev/null 2>&1; then + if command -v jq >/dev/null 2>&1; then jq -e . >/dev/null 2>&1 <<<"$input" || jq_rc=$? fi - if [[ "$read_status" -ne 0 && "$jq_rc" -ne 0 && "$jq_rc" -ne 127 ]]; then - elapsed_ms=$(awk -v start="$start_epoch" -v end="$EPOCHREALTIME" 'BEGIN { printf "%.0f", (end - start) * 1000 }') - timeout_ms=$(awk -v timeout="$read_timeout" 'BEGIN { printf "%.0f", timeout * 1000 }') - if [[ "$elapsed_ms" =~ ^[0-9]+$ && "$timeout_ms" =~ ^[0-9]+$ ]] && - ((elapsed_ms + 100 >= timeout_ms)); then + if ((jq_rc != 0 && jq_rc != 127)); then + if ((stalled)); then echo "BLOCKED: hook stdin timed out before a complete JSON payload arrived." >&2 return 2 fi diff --git a/plugins/go-format/.claude-plugin/plugin.json b/plugins/go-format/.claude-plugin/plugin.json index cc5e8f3bb..a0b798ae4 100644 --- a/plugins/go-format/.claude-plugin/plugin.json +++ b/plugins/go-format/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "go-format", - "version": "0.2.4", + "version": "0.2.5", "description": "Auto-fix Go formatting and import management on edit via goimports — runs unconditionally (no consumer-config gate), skipping generated files.", "author": { "name": "Melodic Software", diff --git a/plugins/go-format/CHANGELOG.md b/plugins/go-format/CHANGELOG.md index 891ce5435..6a2973333 100644 --- a/plugins/go-format/CHANGELOG.md +++ b/plugins/go-format/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to the `go-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.2.5] + +### Fixed + +- **Shared `hook-utils.sh`: a large tool payload no longer makes this plugin's hooks silently + skip (#1563).** `hook::buffer_stdin` read the hook payload with `read -d ''`, which consumes a + pipe one byte at a time (~32 KB/s on Git Bash), so the `stdin_read_timeout` bound was really a + ~64 KB throughput ceiling rather than the stall detector it was written to be. Past that ceiling + the read returned a truncated payload and rc 1, and this plugin's hooks took their `|| exit 0` + branch — the hook did not run at all, with no diagnostic, on exactly the large writes it was + most wanted for. The read is now chunked (`read -N`), which bash satisfies with block reads, and + the bound became an idle bound that arms per chunk: a read still making progress is never cut + off, while a pipe that goes silent for `stdin_read_timeout` seconds still stops the read the + same way. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. Synced + from `lib/hook-utils.sh`; this plugin's own hook behavior is otherwise unchanged. + ## [0.2.4] ### Changed diff --git a/plugins/go-format/hooks/hook-utils.sh b/plugins/go-format/hooks/hook-utils.sh index 411c4a673..858e644dc 100644 --- a/plugins/go-format/hooks/hook-utils.sh +++ b/plugins/go-format/hooks/hook-utils.sh @@ -231,27 +231,54 @@ hook::repo_root() { # Buffer a complete JSON payload from stdin, tolerating Windows Win32-pipe # late-EOF stalls via a bounded read on the inherited fd0. Returns the payload # on success; returns 1 on empty/incomplete stdin (caller skips), or 2 when the -# read timed out before a complete JSON payload arrived (caller may block). -# Bound is the stdin_read_timeout userConfig option in seconds (read via -# CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2). jq (when present) -# distinguishes a truncated read from a genuinely small-but-complete payload; a -# missing/broken jq (exit 127) fails open like absent jq. +# read stalled before a complete JSON payload arrived (caller may block). +# +# The bound (stdin_read_timeout userConfig option, in seconds, read via +# CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2) is an IDLE bound, not a +# total one: it arms per chunk, so a read that keeps making progress is never +# cut off, while a pipe that goes silent for that long still fails closed. The +# distinction is load-bearing. `read -d ''` consumes a pipe one byte at a time +# (~32 KB/s on Git Bash), so a total bound was really a ~64 KB throughput +# ceiling — every larger payload tripped the timeout branch and every +# fail-closed caller blocked a legitimate write. `read -N` lets bash satisfy +# the read in blocks instead: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from +# ~6800 ms to ~85 ms. Claude Code's own default `command` hook timeout is 600 s +# (https://code.claude.com/docs/en/hooks), so the idle bound is not tracking +# any harness limit. +# +# `read` reports which stop condition it hit — EOF returns 1, an exceeded -t +# returns >128 — and assigns whatever it did read either way, so the loop reads +# the stall verdict off $? instead of inferring it from elapsed-time arithmetic. +# jq (when present) is still the completeness backstop: a stall that neverthe- +# less delivered a complete payload is the Win32 late-EOF case this function +# exists for and must succeed, not block. A missing/broken jq (exit 127) fails +# open like absent jq. Requires Bash 4.1+ for `read -N` (this library already +# requires 4.0+ for the `${var^}` case operators in hook::normalize_path). # INPUT=$(hook::buffer_stdin) || exit 0 hook::buffer_stdin() { - local input="" read_status=0 read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" start_epoch elapsed_ms timeout_ms - start_epoch=${EPOCHREALTIME:-} - IFS= read -r -d '' -t "$read_timeout" input || read_status=$? + local input="" chunk="" read_rc=0 stalled=0 + local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + while :; do + chunk="" + read_rc=0 + IFS= read -r -t "$read_timeout" -N 65536 chunk || read_rc=$? + input+="$chunk" + if ((read_rc == 0)); then + continue # a full chunk — more may still be coming + fi + if ((read_rc > 128)); then + stalled=1 + fi + break # EOF (rc 1), a stall (rc >128), or a read error + done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 local jq_rc=0 - if [[ "$read_status" -ne 0 ]] && command -v jq >/dev/null 2>&1; then + if command -v jq >/dev/null 2>&1; then jq -e . >/dev/null 2>&1 <<<"$input" || jq_rc=$? fi - if [[ "$read_status" -ne 0 && "$jq_rc" -ne 0 && "$jq_rc" -ne 127 ]]; then - elapsed_ms=$(awk -v start="$start_epoch" -v end="$EPOCHREALTIME" 'BEGIN { printf "%.0f", (end - start) * 1000 }') - timeout_ms=$(awk -v timeout="$read_timeout" 'BEGIN { printf "%.0f", timeout * 1000 }') - if [[ "$elapsed_ms" =~ ^[0-9]+$ && "$timeout_ms" =~ ^[0-9]+$ ]] && - ((elapsed_ms + 100 >= timeout_ms)); then + if ((jq_rc != 0 && jq_rc != 127)); then + if ((stalled)); then echo "BLOCKED: hook stdin timed out before a complete JSON payload arrived." >&2 return 2 fi diff --git a/plugins/guardrails/.claude-plugin/plugin.json b/plugins/guardrails/.claude-plugin/plugin.json index 6fdfbb83d..c4e7b7fd9 100644 --- a/plugins/guardrails/.claude-plugin/plugin.json +++ b/plugins/guardrails/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "guardrails", - "version": "0.17.3", + "version": "0.18.0", "description": "Twelve safety guards that block secret/credential writes, hardcoded machine-specific paths, git hook-bypass attempts, irreversible git operations (force-push, reset --hard, worktree-wide checkout/restore discards), Bash file-write workarounds that circumvent Write/Edit hooks, commit subjects and gh pr create titles that violate the repo's tracked team convention (when one is declared in .claude/source-control.md), (advisory) hallucinated CLI flags, (advisory) /plugin:skill references that do not resolve, (advisory) markdown citing a repo path the repo's own history shows was removed, (advisory) un-throttled Workflow fan-out that risks burst 529s, and (advisory) direct git commit/gh pr create calls bypassing this marketplace's own commit/pull-request skills — each independently toggleable.", "author": { "name": "Melodic Software", @@ -121,6 +121,13 @@ "title": "block-no-verify hook-manager prefixes", "description": "Comma-separated hook-manager env-var name prefixes block-no-verify treats as a bypass when set to 0/false (e.g. lefthook,husky); empty uses the built-in default set (lefthook, husky, pre_commit, simple_git_hooks)", "default": "" + }, + "stdin_read_timeout": { + "type": "number", + "title": "Hook stdin read timeout (seconds)", + "description": "Idle bound on reading the hook payload from stdin — how long a silent pipe is tolerated before a blocking guard fails closed", + "default": 2, + "min": 1 } } } diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index 06076ccdc..c014ad91e 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -3,6 +3,37 @@ All notable changes to the `guardrails` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.18.0] + +### Fixed + +- **A large but legitimate `Write` is no longer BLOCKED by the stdin read bound (#1563).** + `hook::buffer_stdin` read the hook payload with `read -d ''`, which consumes a pipe one byte at a + time (~32 KB/s on Git Bash), so the `stdin_read_timeout` bound was really a **~64 KB throughput + ceiling** rather than the stall detector it was written to be. Past that ceiling the read returned + a truncated payload and returned rc 2, and all seven blocking guards here — `hardcoded-path-check`, + `secret-pattern-detection`, `block-no-verify`, `block-dangerous-git`, `block-hook-bypass`, + `block-noncanonical-commit`, `block-convention-violation` — mapped that to `exit 2`, blocking a + write whose content was never even scanned. Observed in the field as a full-file write of an + 844-line document being blocked repeatedly, forcing the author to write it in five chunks; + reproduced here end-to-end with a benign 100 KB payload. + The read is now chunked (`read -N`), which bash satisfies with block reads, and the bound became an + **idle** bound that arms per chunk: a read still making progress is never cut off, while a pipe + that goes silent for `stdin_read_timeout` seconds still fails closed with rc 2 exactly as before. + Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. + **The fail-closed posture is unchanged** — a stalled pipe still yields rc 2 (regression test in + `lib/hook-utils.test.sh`), a payload containing a violation is still blocked, and a violation + sitting at the very end of a 200 KB payload is now *caught* rather than swept up in a + content-blind block. Synced from `lib/hook-utils.sh`. + +### Added + +- **`stdin_read_timeout` userConfig option.** This plugin's hooks already read + `CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT` through the shared library but never declared the option, + so consumers had no supported way to set it — `actionlint` and `claude-ops` both declare it. + Declaring it exposes the same knob here. The effective default when a consumer sets nothing remains + the shell-level `:-2` fallback inside `hook-utils.sh`. + ## [0.17.3] ### Changed diff --git a/plugins/guardrails/README.md b/plugins/guardrails/README.md index c21d2b9a4..136a9136b 100644 --- a/plugins/guardrails/README.md +++ b/plugins/guardrails/README.md @@ -128,6 +128,16 @@ These options are user-scoped (stored in your user settings, not the project's). To turn guards off for a single repository, disable the whole plugin in that project's `enabledPlugins` instead. +One further option tunes the hooks' shared plumbing rather than a single guard: + +- **`stdin_read_timeout`** (number, default `2`, minimum `1`) — idle bound in + seconds on reading the hook payload from stdin. It arms per chunk, not over + the whole read, so a large payload that keeps arriving is never cut off; it + fires only when the pipe goes silent for that long, at which point a blocking + guard fails **closed** (`exit 2` with a `BLOCKED:` reason) rather than letting + an unscanned tool call through. Raise it only if a genuinely slow producer + trips it. + ## Consumer seams The guards scope and tune themselves to **your** repository — they ship no diff --git a/plugins/guardrails/hooks/hook-utils.sh b/plugins/guardrails/hooks/hook-utils.sh index 411c4a673..858e644dc 100644 --- a/plugins/guardrails/hooks/hook-utils.sh +++ b/plugins/guardrails/hooks/hook-utils.sh @@ -231,27 +231,54 @@ hook::repo_root() { # Buffer a complete JSON payload from stdin, tolerating Windows Win32-pipe # late-EOF stalls via a bounded read on the inherited fd0. Returns the payload # on success; returns 1 on empty/incomplete stdin (caller skips), or 2 when the -# read timed out before a complete JSON payload arrived (caller may block). -# Bound is the stdin_read_timeout userConfig option in seconds (read via -# CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2). jq (when present) -# distinguishes a truncated read from a genuinely small-but-complete payload; a -# missing/broken jq (exit 127) fails open like absent jq. +# read stalled before a complete JSON payload arrived (caller may block). +# +# The bound (stdin_read_timeout userConfig option, in seconds, read via +# CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2) is an IDLE bound, not a +# total one: it arms per chunk, so a read that keeps making progress is never +# cut off, while a pipe that goes silent for that long still fails closed. The +# distinction is load-bearing. `read -d ''` consumes a pipe one byte at a time +# (~32 KB/s on Git Bash), so a total bound was really a ~64 KB throughput +# ceiling — every larger payload tripped the timeout branch and every +# fail-closed caller blocked a legitimate write. `read -N` lets bash satisfy +# the read in blocks instead: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from +# ~6800 ms to ~85 ms. Claude Code's own default `command` hook timeout is 600 s +# (https://code.claude.com/docs/en/hooks), so the idle bound is not tracking +# any harness limit. +# +# `read` reports which stop condition it hit — EOF returns 1, an exceeded -t +# returns >128 — and assigns whatever it did read either way, so the loop reads +# the stall verdict off $? instead of inferring it from elapsed-time arithmetic. +# jq (when present) is still the completeness backstop: a stall that neverthe- +# less delivered a complete payload is the Win32 late-EOF case this function +# exists for and must succeed, not block. A missing/broken jq (exit 127) fails +# open like absent jq. Requires Bash 4.1+ for `read -N` (this library already +# requires 4.0+ for the `${var^}` case operators in hook::normalize_path). # INPUT=$(hook::buffer_stdin) || exit 0 hook::buffer_stdin() { - local input="" read_status=0 read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" start_epoch elapsed_ms timeout_ms - start_epoch=${EPOCHREALTIME:-} - IFS= read -r -d '' -t "$read_timeout" input || read_status=$? + local input="" chunk="" read_rc=0 stalled=0 + local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + while :; do + chunk="" + read_rc=0 + IFS= read -r -t "$read_timeout" -N 65536 chunk || read_rc=$? + input+="$chunk" + if ((read_rc == 0)); then + continue # a full chunk — more may still be coming + fi + if ((read_rc > 128)); then + stalled=1 + fi + break # EOF (rc 1), a stall (rc >128), or a read error + done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 local jq_rc=0 - if [[ "$read_status" -ne 0 ]] && command -v jq >/dev/null 2>&1; then + if command -v jq >/dev/null 2>&1; then jq -e . >/dev/null 2>&1 <<<"$input" || jq_rc=$? fi - if [[ "$read_status" -ne 0 && "$jq_rc" -ne 0 && "$jq_rc" -ne 127 ]]; then - elapsed_ms=$(awk -v start="$start_epoch" -v end="$EPOCHREALTIME" 'BEGIN { printf "%.0f", (end - start) * 1000 }') - timeout_ms=$(awk -v timeout="$read_timeout" 'BEGIN { printf "%.0f", timeout * 1000 }') - if [[ "$elapsed_ms" =~ ^[0-9]+$ && "$timeout_ms" =~ ^[0-9]+$ ]] && - ((elapsed_ms + 100 >= timeout_ms)); then + if ((jq_rc != 0 && jq_rc != 127)); then + if ((stalled)); then echo "BLOCKED: hook stdin timed out before a complete JSON payload arrived." >&2 return 2 fi diff --git a/plugins/markdown-format/.claude-plugin/plugin.json b/plugins/markdown-format/.claude-plugin/plugin.json index bac9e67c1..23d3ac31c 100644 --- a/plugins/markdown-format/.claude-plugin/plugin.json +++ b/plugins/markdown-format/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "markdown-format", - "version": "0.7.1", + "version": "0.7.2", "description": "Auto-format and lint Markdown on edit via markdownlint-cli2, using the consuming repo's own markdownlint config.", "author": { "name": "Melodic Software", diff --git a/plugins/markdown-format/CHANGELOG.md b/plugins/markdown-format/CHANGELOG.md index 609e3bfee..e87416d53 100644 --- a/plugins/markdown-format/CHANGELOG.md +++ b/plugins/markdown-format/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to the `markdown-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.7.2] + +### Fixed + +- **Shared `hook-utils.sh`: a large tool payload no longer makes this plugin's hooks silently + skip (#1563).** `hook::buffer_stdin` read the hook payload with `read -d ''`, which consumes a + pipe one byte at a time (~32 KB/s on Git Bash), so the `stdin_read_timeout` bound was really a + ~64 KB throughput ceiling rather than the stall detector it was written to be. Past that ceiling + the read returned a truncated payload and rc 1, and this plugin's hooks took their `|| exit 0` + branch — the hook did not run at all, with no diagnostic, on exactly the large writes it was + most wanted for. The read is now chunked (`read -N`), which bash satisfies with block reads, and + the bound became an idle bound that arms per chunk: a read still making progress is never cut + off, while a pipe that goes silent for `stdin_read_timeout` seconds still stops the read the + same way. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. Synced + from `lib/hook-utils.sh`; this plugin's own hook behavior is otherwise unchanged. + ## [0.7.1] ### Changed diff --git a/plugins/markdown-format/hooks/hook-utils.sh b/plugins/markdown-format/hooks/hook-utils.sh index 411c4a673..858e644dc 100644 --- a/plugins/markdown-format/hooks/hook-utils.sh +++ b/plugins/markdown-format/hooks/hook-utils.sh @@ -231,27 +231,54 @@ hook::repo_root() { # Buffer a complete JSON payload from stdin, tolerating Windows Win32-pipe # late-EOF stalls via a bounded read on the inherited fd0. Returns the payload # on success; returns 1 on empty/incomplete stdin (caller skips), or 2 when the -# read timed out before a complete JSON payload arrived (caller may block). -# Bound is the stdin_read_timeout userConfig option in seconds (read via -# CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2). jq (when present) -# distinguishes a truncated read from a genuinely small-but-complete payload; a -# missing/broken jq (exit 127) fails open like absent jq. +# read stalled before a complete JSON payload arrived (caller may block). +# +# The bound (stdin_read_timeout userConfig option, in seconds, read via +# CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2) is an IDLE bound, not a +# total one: it arms per chunk, so a read that keeps making progress is never +# cut off, while a pipe that goes silent for that long still fails closed. The +# distinction is load-bearing. `read -d ''` consumes a pipe one byte at a time +# (~32 KB/s on Git Bash), so a total bound was really a ~64 KB throughput +# ceiling — every larger payload tripped the timeout branch and every +# fail-closed caller blocked a legitimate write. `read -N` lets bash satisfy +# the read in blocks instead: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from +# ~6800 ms to ~85 ms. Claude Code's own default `command` hook timeout is 600 s +# (https://code.claude.com/docs/en/hooks), so the idle bound is not tracking +# any harness limit. +# +# `read` reports which stop condition it hit — EOF returns 1, an exceeded -t +# returns >128 — and assigns whatever it did read either way, so the loop reads +# the stall verdict off $? instead of inferring it from elapsed-time arithmetic. +# jq (when present) is still the completeness backstop: a stall that neverthe- +# less delivered a complete payload is the Win32 late-EOF case this function +# exists for and must succeed, not block. A missing/broken jq (exit 127) fails +# open like absent jq. Requires Bash 4.1+ for `read -N` (this library already +# requires 4.0+ for the `${var^}` case operators in hook::normalize_path). # INPUT=$(hook::buffer_stdin) || exit 0 hook::buffer_stdin() { - local input="" read_status=0 read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" start_epoch elapsed_ms timeout_ms - start_epoch=${EPOCHREALTIME:-} - IFS= read -r -d '' -t "$read_timeout" input || read_status=$? + local input="" chunk="" read_rc=0 stalled=0 + local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + while :; do + chunk="" + read_rc=0 + IFS= read -r -t "$read_timeout" -N 65536 chunk || read_rc=$? + input+="$chunk" + if ((read_rc == 0)); then + continue # a full chunk — more may still be coming + fi + if ((read_rc > 128)); then + stalled=1 + fi + break # EOF (rc 1), a stall (rc >128), or a read error + done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 local jq_rc=0 - if [[ "$read_status" -ne 0 ]] && command -v jq >/dev/null 2>&1; then + if command -v jq >/dev/null 2>&1; then jq -e . >/dev/null 2>&1 <<<"$input" || jq_rc=$? fi - if [[ "$read_status" -ne 0 && "$jq_rc" -ne 0 && "$jq_rc" -ne 127 ]]; then - elapsed_ms=$(awk -v start="$start_epoch" -v end="$EPOCHREALTIME" 'BEGIN { printf "%.0f", (end - start) * 1000 }') - timeout_ms=$(awk -v timeout="$read_timeout" 'BEGIN { printf "%.0f", timeout * 1000 }') - if [[ "$elapsed_ms" =~ ^[0-9]+$ && "$timeout_ms" =~ ^[0-9]+$ ]] && - ((elapsed_ms + 100 >= timeout_ms)); then + if ((jq_rc != 0 && jq_rc != 127)); then + if ((stalled)); then echo "BLOCKED: hook stdin timed out before a complete JSON payload arrived." >&2 return 2 fi diff --git a/plugins/powershell-format/.claude-plugin/plugin.json b/plugins/powershell-format/.claude-plugin/plugin.json index 27459a5c2..306c072d7 100644 --- a/plugins/powershell-format/.claude-plugin/plugin.json +++ b/plugins/powershell-format/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "powershell-format", - "version": "0.6.1", + "version": "0.6.2", "description": "Auto-format and lint PowerShell on edit via PSScriptAnalyzer, only when a PSScriptAnalyzerSettings.psd1 governs the repo — using the consuming repo's own analyzer settings.", "author": { "name": "Melodic Software", diff --git a/plugins/powershell-format/CHANGELOG.md b/plugins/powershell-format/CHANGELOG.md index 772e5aec7..5776b1c2f 100644 --- a/plugins/powershell-format/CHANGELOG.md +++ b/plugins/powershell-format/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to the `powershell-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.6.2] + +### Fixed + +- **Shared `hook-utils.sh`: a large tool payload no longer makes this plugin's hooks silently + skip (#1563).** `hook::buffer_stdin` read the hook payload with `read -d ''`, which consumes a + pipe one byte at a time (~32 KB/s on Git Bash), so the `stdin_read_timeout` bound was really a + ~64 KB throughput ceiling rather than the stall detector it was written to be. Past that ceiling + the read returned a truncated payload and rc 1, and this plugin's hooks took their `|| exit 0` + branch — the hook did not run at all, with no diagnostic, on exactly the large writes it was + most wanted for. The read is now chunked (`read -N`), which bash satisfies with block reads, and + the bound became an idle bound that arms per chunk: a read still making progress is never cut + off, while a pipe that goes silent for `stdin_read_timeout` seconds still stops the read the + same way. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. Synced + from `lib/hook-utils.sh`; this plugin's own hook behavior is otherwise unchanged. + ## [0.6.1] ### Changed diff --git a/plugins/powershell-format/hooks/hook-utils.sh b/plugins/powershell-format/hooks/hook-utils.sh index 411c4a673..858e644dc 100644 --- a/plugins/powershell-format/hooks/hook-utils.sh +++ b/plugins/powershell-format/hooks/hook-utils.sh @@ -231,27 +231,54 @@ hook::repo_root() { # Buffer a complete JSON payload from stdin, tolerating Windows Win32-pipe # late-EOF stalls via a bounded read on the inherited fd0. Returns the payload # on success; returns 1 on empty/incomplete stdin (caller skips), or 2 when the -# read timed out before a complete JSON payload arrived (caller may block). -# Bound is the stdin_read_timeout userConfig option in seconds (read via -# CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2). jq (when present) -# distinguishes a truncated read from a genuinely small-but-complete payload; a -# missing/broken jq (exit 127) fails open like absent jq. +# read stalled before a complete JSON payload arrived (caller may block). +# +# The bound (stdin_read_timeout userConfig option, in seconds, read via +# CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2) is an IDLE bound, not a +# total one: it arms per chunk, so a read that keeps making progress is never +# cut off, while a pipe that goes silent for that long still fails closed. The +# distinction is load-bearing. `read -d ''` consumes a pipe one byte at a time +# (~32 KB/s on Git Bash), so a total bound was really a ~64 KB throughput +# ceiling — every larger payload tripped the timeout branch and every +# fail-closed caller blocked a legitimate write. `read -N` lets bash satisfy +# the read in blocks instead: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from +# ~6800 ms to ~85 ms. Claude Code's own default `command` hook timeout is 600 s +# (https://code.claude.com/docs/en/hooks), so the idle bound is not tracking +# any harness limit. +# +# `read` reports which stop condition it hit — EOF returns 1, an exceeded -t +# returns >128 — and assigns whatever it did read either way, so the loop reads +# the stall verdict off $? instead of inferring it from elapsed-time arithmetic. +# jq (when present) is still the completeness backstop: a stall that neverthe- +# less delivered a complete payload is the Win32 late-EOF case this function +# exists for and must succeed, not block. A missing/broken jq (exit 127) fails +# open like absent jq. Requires Bash 4.1+ for `read -N` (this library already +# requires 4.0+ for the `${var^}` case operators in hook::normalize_path). # INPUT=$(hook::buffer_stdin) || exit 0 hook::buffer_stdin() { - local input="" read_status=0 read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" start_epoch elapsed_ms timeout_ms - start_epoch=${EPOCHREALTIME:-} - IFS= read -r -d '' -t "$read_timeout" input || read_status=$? + local input="" chunk="" read_rc=0 stalled=0 + local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + while :; do + chunk="" + read_rc=0 + IFS= read -r -t "$read_timeout" -N 65536 chunk || read_rc=$? + input+="$chunk" + if ((read_rc == 0)); then + continue # a full chunk — more may still be coming + fi + if ((read_rc > 128)); then + stalled=1 + fi + break # EOF (rc 1), a stall (rc >128), or a read error + done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 local jq_rc=0 - if [[ "$read_status" -ne 0 ]] && command -v jq >/dev/null 2>&1; then + if command -v jq >/dev/null 2>&1; then jq -e . >/dev/null 2>&1 <<<"$input" || jq_rc=$? fi - if [[ "$read_status" -ne 0 && "$jq_rc" -ne 0 && "$jq_rc" -ne 127 ]]; then - elapsed_ms=$(awk -v start="$start_epoch" -v end="$EPOCHREALTIME" 'BEGIN { printf "%.0f", (end - start) * 1000 }') - timeout_ms=$(awk -v timeout="$read_timeout" 'BEGIN { printf "%.0f", timeout * 1000 }') - if [[ "$elapsed_ms" =~ ^[0-9]+$ && "$timeout_ms" =~ ^[0-9]+$ ]] && - ((elapsed_ms + 100 >= timeout_ms)); then + if ((jq_rc != 0 && jq_rc != 127)); then + if ((stalled)); then echo "BLOCKED: hook stdin timed out before a complete JSON payload arrived." >&2 return 2 fi diff --git a/plugins/rate-limit-guard/.claude-plugin/plugin.json b/plugins/rate-limit-guard/.claude-plugin/plugin.json index be11c3117..8fb91f2af 100644 --- a/plugins/rate-limit-guard/.claude-plugin/plugin.json +++ b/plugins/rate-limit-guard/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "rate-limit-guard", - "version": "0.3.2", + "version": "0.3.3", "description": "Shared rate-limit guard for loop lanes: a statusline wrapper tees the subscription rate-limit windows to a fixed machine-scope file, a StopFailure hook records rate-limit stops reactively, and a reader contract fixes how consuming sessions pause and resume.", "author": { "name": "Melodic Software", diff --git a/plugins/rate-limit-guard/CHANGELOG.md b/plugins/rate-limit-guard/CHANGELOG.md index b0bd50755..257ad42ca 100644 --- a/plugins/rate-limit-guard/CHANGELOG.md +++ b/plugins/rate-limit-guard/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to the `rate-limit-guard` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.3.3] + +### Fixed + +- **Shared `hook-utils.sh`: a large tool payload no longer makes this plugin's hooks silently + skip (#1563).** `hook::buffer_stdin` read the hook payload with `read -d ''`, which consumes a + pipe one byte at a time (~32 KB/s on Git Bash), so the `stdin_read_timeout` bound was really a + ~64 KB throughput ceiling rather than the stall detector it was written to be. Past that ceiling + the read returned a truncated payload and rc 1, and this plugin's hooks took their `|| exit 0` + branch — the hook did not run at all, with no diagnostic, on exactly the large writes it was + most wanted for. The read is now chunked (`read -N`), which bash satisfies with block reads, and + the bound became an idle bound that arms per chunk: a read still making progress is never cut + off, while a pipe that goes silent for `stdin_read_timeout` seconds still stops the read the + same way. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. Synced + from `lib/hook-utils.sh`; this plugin's own hook behavior is otherwise unchanged. + ## [0.3.2] ### Fixed diff --git a/plugins/rate-limit-guard/hooks/hook-utils.sh b/plugins/rate-limit-guard/hooks/hook-utils.sh index 411c4a673..858e644dc 100755 --- a/plugins/rate-limit-guard/hooks/hook-utils.sh +++ b/plugins/rate-limit-guard/hooks/hook-utils.sh @@ -231,27 +231,54 @@ hook::repo_root() { # Buffer a complete JSON payload from stdin, tolerating Windows Win32-pipe # late-EOF stalls via a bounded read on the inherited fd0. Returns the payload # on success; returns 1 on empty/incomplete stdin (caller skips), or 2 when the -# read timed out before a complete JSON payload arrived (caller may block). -# Bound is the stdin_read_timeout userConfig option in seconds (read via -# CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2). jq (when present) -# distinguishes a truncated read from a genuinely small-but-complete payload; a -# missing/broken jq (exit 127) fails open like absent jq. +# read stalled before a complete JSON payload arrived (caller may block). +# +# The bound (stdin_read_timeout userConfig option, in seconds, read via +# CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2) is an IDLE bound, not a +# total one: it arms per chunk, so a read that keeps making progress is never +# cut off, while a pipe that goes silent for that long still fails closed. The +# distinction is load-bearing. `read -d ''` consumes a pipe one byte at a time +# (~32 KB/s on Git Bash), so a total bound was really a ~64 KB throughput +# ceiling — every larger payload tripped the timeout branch and every +# fail-closed caller blocked a legitimate write. `read -N` lets bash satisfy +# the read in blocks instead: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from +# ~6800 ms to ~85 ms. Claude Code's own default `command` hook timeout is 600 s +# (https://code.claude.com/docs/en/hooks), so the idle bound is not tracking +# any harness limit. +# +# `read` reports which stop condition it hit — EOF returns 1, an exceeded -t +# returns >128 — and assigns whatever it did read either way, so the loop reads +# the stall verdict off $? instead of inferring it from elapsed-time arithmetic. +# jq (when present) is still the completeness backstop: a stall that neverthe- +# less delivered a complete payload is the Win32 late-EOF case this function +# exists for and must succeed, not block. A missing/broken jq (exit 127) fails +# open like absent jq. Requires Bash 4.1+ for `read -N` (this library already +# requires 4.0+ for the `${var^}` case operators in hook::normalize_path). # INPUT=$(hook::buffer_stdin) || exit 0 hook::buffer_stdin() { - local input="" read_status=0 read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" start_epoch elapsed_ms timeout_ms - start_epoch=${EPOCHREALTIME:-} - IFS= read -r -d '' -t "$read_timeout" input || read_status=$? + local input="" chunk="" read_rc=0 stalled=0 + local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + while :; do + chunk="" + read_rc=0 + IFS= read -r -t "$read_timeout" -N 65536 chunk || read_rc=$? + input+="$chunk" + if ((read_rc == 0)); then + continue # a full chunk — more may still be coming + fi + if ((read_rc > 128)); then + stalled=1 + fi + break # EOF (rc 1), a stall (rc >128), or a read error + done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 local jq_rc=0 - if [[ "$read_status" -ne 0 ]] && command -v jq >/dev/null 2>&1; then + if command -v jq >/dev/null 2>&1; then jq -e . >/dev/null 2>&1 <<<"$input" || jq_rc=$? fi - if [[ "$read_status" -ne 0 && "$jq_rc" -ne 0 && "$jq_rc" -ne 127 ]]; then - elapsed_ms=$(awk -v start="$start_epoch" -v end="$EPOCHREALTIME" 'BEGIN { printf "%.0f", (end - start) * 1000 }') - timeout_ms=$(awk -v timeout="$read_timeout" 'BEGIN { printf "%.0f", timeout * 1000 }') - if [[ "$elapsed_ms" =~ ^[0-9]+$ && "$timeout_ms" =~ ^[0-9]+$ ]] && - ((elapsed_ms + 100 >= timeout_ms)); then + if ((jq_rc != 0 && jq_rc != 127)); then + if ((stalled)); then echo "BLOCKED: hook stdin timed out before a complete JSON payload arrived." >&2 return 2 fi diff --git a/plugins/ruff-format/.claude-plugin/plugin.json b/plugins/ruff-format/.claude-plugin/plugin.json index 5872b1e54..9904f0dce 100644 --- a/plugins/ruff-format/.claude-plugin/plugin.json +++ b/plugins/ruff-format/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "ruff-format", - "version": "0.5.4", + "version": "0.5.5", "description": "Auto-format and lint Python on edit via Ruff, only when a Ruff config governs the repo — using the consuming repo's own Ruff config.", "author": { "name": "Melodic Software", diff --git a/plugins/ruff-format/CHANGELOG.md b/plugins/ruff-format/CHANGELOG.md index 8acf62cae..1738b9a2a 100644 --- a/plugins/ruff-format/CHANGELOG.md +++ b/plugins/ruff-format/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to the `ruff-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.5.5] + +### Fixed + +- **Shared `hook-utils.sh`: a large tool payload no longer makes this plugin's hooks silently + skip (#1563).** `hook::buffer_stdin` read the hook payload with `read -d ''`, which consumes a + pipe one byte at a time (~32 KB/s on Git Bash), so the `stdin_read_timeout` bound was really a + ~64 KB throughput ceiling rather than the stall detector it was written to be. Past that ceiling + the read returned a truncated payload and rc 1, and this plugin's hooks took their `|| exit 0` + branch — the hook did not run at all, with no diagnostic, on exactly the large writes it was + most wanted for. The read is now chunked (`read -N`), which bash satisfies with block reads, and + the bound became an idle bound that arms per chunk: a read still making progress is never cut + off, while a pipe that goes silent for `stdin_read_timeout` seconds still stops the read the + same way. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. Synced + from `lib/hook-utils.sh`; this plugin's own hook behavior is otherwise unchanged. + ## [0.5.4] ### Changed diff --git a/plugins/ruff-format/hooks/hook-utils.sh b/plugins/ruff-format/hooks/hook-utils.sh index 411c4a673..858e644dc 100644 --- a/plugins/ruff-format/hooks/hook-utils.sh +++ b/plugins/ruff-format/hooks/hook-utils.sh @@ -231,27 +231,54 @@ hook::repo_root() { # Buffer a complete JSON payload from stdin, tolerating Windows Win32-pipe # late-EOF stalls via a bounded read on the inherited fd0. Returns the payload # on success; returns 1 on empty/incomplete stdin (caller skips), or 2 when the -# read timed out before a complete JSON payload arrived (caller may block). -# Bound is the stdin_read_timeout userConfig option in seconds (read via -# CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2). jq (when present) -# distinguishes a truncated read from a genuinely small-but-complete payload; a -# missing/broken jq (exit 127) fails open like absent jq. +# read stalled before a complete JSON payload arrived (caller may block). +# +# The bound (stdin_read_timeout userConfig option, in seconds, read via +# CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2) is an IDLE bound, not a +# total one: it arms per chunk, so a read that keeps making progress is never +# cut off, while a pipe that goes silent for that long still fails closed. The +# distinction is load-bearing. `read -d ''` consumes a pipe one byte at a time +# (~32 KB/s on Git Bash), so a total bound was really a ~64 KB throughput +# ceiling — every larger payload tripped the timeout branch and every +# fail-closed caller blocked a legitimate write. `read -N` lets bash satisfy +# the read in blocks instead: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from +# ~6800 ms to ~85 ms. Claude Code's own default `command` hook timeout is 600 s +# (https://code.claude.com/docs/en/hooks), so the idle bound is not tracking +# any harness limit. +# +# `read` reports which stop condition it hit — EOF returns 1, an exceeded -t +# returns >128 — and assigns whatever it did read either way, so the loop reads +# the stall verdict off $? instead of inferring it from elapsed-time arithmetic. +# jq (when present) is still the completeness backstop: a stall that neverthe- +# less delivered a complete payload is the Win32 late-EOF case this function +# exists for and must succeed, not block. A missing/broken jq (exit 127) fails +# open like absent jq. Requires Bash 4.1+ for `read -N` (this library already +# requires 4.0+ for the `${var^}` case operators in hook::normalize_path). # INPUT=$(hook::buffer_stdin) || exit 0 hook::buffer_stdin() { - local input="" read_status=0 read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" start_epoch elapsed_ms timeout_ms - start_epoch=${EPOCHREALTIME:-} - IFS= read -r -d '' -t "$read_timeout" input || read_status=$? + local input="" chunk="" read_rc=0 stalled=0 + local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + while :; do + chunk="" + read_rc=0 + IFS= read -r -t "$read_timeout" -N 65536 chunk || read_rc=$? + input+="$chunk" + if ((read_rc == 0)); then + continue # a full chunk — more may still be coming + fi + if ((read_rc > 128)); then + stalled=1 + fi + break # EOF (rc 1), a stall (rc >128), or a read error + done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 local jq_rc=0 - if [[ "$read_status" -ne 0 ]] && command -v jq >/dev/null 2>&1; then + if command -v jq >/dev/null 2>&1; then jq -e . >/dev/null 2>&1 <<<"$input" || jq_rc=$? fi - if [[ "$read_status" -ne 0 && "$jq_rc" -ne 0 && "$jq_rc" -ne 127 ]]; then - elapsed_ms=$(awk -v start="$start_epoch" -v end="$EPOCHREALTIME" 'BEGIN { printf "%.0f", (end - start) * 1000 }') - timeout_ms=$(awk -v timeout="$read_timeout" 'BEGIN { printf "%.0f", timeout * 1000 }') - if [[ "$elapsed_ms" =~ ^[0-9]+$ && "$timeout_ms" =~ ^[0-9]+$ ]] && - ((elapsed_ms + 100 >= timeout_ms)); then + if ((jq_rc != 0 && jq_rc != 127)); then + if ((stalled)); then echo "BLOCKED: hook stdin timed out before a complete JSON payload arrived." >&2 return 2 fi diff --git a/plugins/typos-format/.claude-plugin/plugin.json b/plugins/typos-format/.claude-plugin/plugin.json index bd67ff288..6adbf8f76 100644 --- a/plugins/typos-format/.claude-plugin/plugin.json +++ b/plugins/typos-format/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "typos-format", - "version": "0.3.4", + "version": "0.3.5", "description": "Auto-fix spelling typos on edit via typos-cli, unconditionally — honoring the consuming repo's own typos configuration when one is present.", "author": { "name": "Melodic Software", diff --git a/plugins/typos-format/CHANGELOG.md b/plugins/typos-format/CHANGELOG.md index 7a721396e..2361ce53b 100644 --- a/plugins/typos-format/CHANGELOG.md +++ b/plugins/typos-format/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to the `typos-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.3.5] + +### Fixed + +- **Shared `hook-utils.sh`: a large tool payload no longer makes this plugin's hooks silently + skip (#1563).** `hook::buffer_stdin` read the hook payload with `read -d ''`, which consumes a + pipe one byte at a time (~32 KB/s on Git Bash), so the `stdin_read_timeout` bound was really a + ~64 KB throughput ceiling rather than the stall detector it was written to be. Past that ceiling + the read returned a truncated payload and rc 1, and this plugin's hooks took their `|| exit 0` + branch — the hook did not run at all, with no diagnostic, on exactly the large writes it was + most wanted for. The read is now chunked (`read -N`), which bash satisfies with block reads, and + the bound became an idle bound that arms per chunk: a read still making progress is never cut + off, while a pipe that goes silent for `stdin_read_timeout` seconds still stops the read the + same way. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. Synced + from `lib/hook-utils.sh`; this plugin's own hook behavior is otherwise unchanged. + ## [0.3.4] ### Changed diff --git a/plugins/typos-format/hooks/hook-utils.sh b/plugins/typos-format/hooks/hook-utils.sh index 411c4a673..858e644dc 100644 --- a/plugins/typos-format/hooks/hook-utils.sh +++ b/plugins/typos-format/hooks/hook-utils.sh @@ -231,27 +231,54 @@ hook::repo_root() { # Buffer a complete JSON payload from stdin, tolerating Windows Win32-pipe # late-EOF stalls via a bounded read on the inherited fd0. Returns the payload # on success; returns 1 on empty/incomplete stdin (caller skips), or 2 when the -# read timed out before a complete JSON payload arrived (caller may block). -# Bound is the stdin_read_timeout userConfig option in seconds (read via -# CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2). jq (when present) -# distinguishes a truncated read from a genuinely small-but-complete payload; a -# missing/broken jq (exit 127) fails open like absent jq. +# read stalled before a complete JSON payload arrived (caller may block). +# +# The bound (stdin_read_timeout userConfig option, in seconds, read via +# CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2) is an IDLE bound, not a +# total one: it arms per chunk, so a read that keeps making progress is never +# cut off, while a pipe that goes silent for that long still fails closed. The +# distinction is load-bearing. `read -d ''` consumes a pipe one byte at a time +# (~32 KB/s on Git Bash), so a total bound was really a ~64 KB throughput +# ceiling — every larger payload tripped the timeout branch and every +# fail-closed caller blocked a legitimate write. `read -N` lets bash satisfy +# the read in blocks instead: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from +# ~6800 ms to ~85 ms. Claude Code's own default `command` hook timeout is 600 s +# (https://code.claude.com/docs/en/hooks), so the idle bound is not tracking +# any harness limit. +# +# `read` reports which stop condition it hit — EOF returns 1, an exceeded -t +# returns >128 — and assigns whatever it did read either way, so the loop reads +# the stall verdict off $? instead of inferring it from elapsed-time arithmetic. +# jq (when present) is still the completeness backstop: a stall that neverthe- +# less delivered a complete payload is the Win32 late-EOF case this function +# exists for and must succeed, not block. A missing/broken jq (exit 127) fails +# open like absent jq. Requires Bash 4.1+ for `read -N` (this library already +# requires 4.0+ for the `${var^}` case operators in hook::normalize_path). # INPUT=$(hook::buffer_stdin) || exit 0 hook::buffer_stdin() { - local input="" read_status=0 read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" start_epoch elapsed_ms timeout_ms - start_epoch=${EPOCHREALTIME:-} - IFS= read -r -d '' -t "$read_timeout" input || read_status=$? + local input="" chunk="" read_rc=0 stalled=0 + local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + while :; do + chunk="" + read_rc=0 + IFS= read -r -t "$read_timeout" -N 65536 chunk || read_rc=$? + input+="$chunk" + if ((read_rc == 0)); then + continue # a full chunk — more may still be coming + fi + if ((read_rc > 128)); then + stalled=1 + fi + break # EOF (rc 1), a stall (rc >128), or a read error + done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 local jq_rc=0 - if [[ "$read_status" -ne 0 ]] && command -v jq >/dev/null 2>&1; then + if command -v jq >/dev/null 2>&1; then jq -e . >/dev/null 2>&1 <<<"$input" || jq_rc=$? fi - if [[ "$read_status" -ne 0 && "$jq_rc" -ne 0 && "$jq_rc" -ne 127 ]]; then - elapsed_ms=$(awk -v start="$start_epoch" -v end="$EPOCHREALTIME" 'BEGIN { printf "%.0f", (end - start) * 1000 }') - timeout_ms=$(awk -v timeout="$read_timeout" 'BEGIN { printf "%.0f", timeout * 1000 }') - if [[ "$elapsed_ms" =~ ^[0-9]+$ && "$timeout_ms" =~ ^[0-9]+$ ]] && - ((elapsed_ms + 100 >= timeout_ms)); then + if ((jq_rc != 0 && jq_rc != 127)); then + if ((stalled)); then echo "BLOCKED: hook stdin timed out before a complete JSON payload arrived." >&2 return 2 fi From 33f95e0266bd06ee038d4a1889893c2e317c9063 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:39:24 -0400 Subject: [PATCH 2/7] fix(hook-utils): restore Bash 3.2 support and make the stdin bound truly idle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses both review findings on #1587, plus the portability lint. P1 — Bash 3.2. The previous commit used `read -N` unconditionally. That option is Bash 4.1+, and nine plugin READMEs document Bash 3.2+ support (macOS system bash). On 3.2 the invalid option would leave the payload empty, buffer_stdin would return 1, and every synced hook would silently exit 0 — disabling the whole fleet, guardrails included. The claim in the previous commit that the library already required 4.0+ was WRONG: hook::normalize_path's `${var^}` case operators sit behind an OSTYPE msys/cygwin branch that never executes on macOS. The `-N` availability guard now matches the existing in-repo idiom in plugins/context-guard/scripts/statusline-tee.sh, falling back to the delimiter read inside the same loop, so 3.2 keeps the new progress semantics. P2 — the bound was not actually idle. `read -t` is a deadline for the WHOLE requested read, not an inactivity timer, so a producer making steady progress but slower than one chunk per window still tripped it. Verified against the previous commit: a payload trickled one character per 100 ms against a 300 ms timeout returned rc 2 even though the pipe was never idle, and even when the producer then closed cleanly with complete JSON. `read` assigns whatever it received even on timeout, so a timed-out read that returned bytes is now treated as progress — the partial chunk is kept and a fresh window armed. Only a window that delivers nothing at all is a stall. Consequence, stated in the comment: a producer trickling indefinitely is never cut off here; the harness's 600 s command-hook cap is the outer bound. The guard predicate is split into hook::read_supports_nchars purely so the pre-4.1 path stays reachable in tests — BASH_VERSINFO is readonly and cannot be shadowed, so the first attempt at a 3.2 test was silently vacuous. It is not a consumer seam; nothing reads it from the environment. Tests (lib/hook-utils.test.sh, now 103 passing): - 18d: a steady trickle far slower than one chunk per window returns rc 0 with the whole payload — the idle-bound regression test. - 18d': that same trickle then going SILENT mid-payload still returns rc 2, so re-arming on progress did not become "never time out". - 18e: asserts the guard override really flips the branch (so the cases below are not vacuous), then that the pre-4.1 delimiter read buffers a 128 KB payload and still fails closed on a stalled pipe. Fail-closed re-verified end-to-end after the change: a violation still blocks, a violation at the end of a 200 KB payload still blocks, and a stalled pipe still exits 2. Note the stall must exceed one full window before EOF; a truncated payload that goes quiet and then closes reaches the pre-existing EOF branch (rc 1, skip), which this change does not alter. Also annotates two pre-existing shell-portability false positives that only surfaced because this change put all 14 synced copies into the changed set: a bash glob bracket class `[\<\>]` and a literal `a\b` path fixture, neither a GNU regex construct. Marked with the gate's own `portability-ok:` hatch rather than touching the synced linter. Refs #1563 Co-Authored-By: Claude Fable 5 --- lib/hook-utils.sh | 80 +++++++++---- lib/hook-utils.test.sh | 108 +++++++++++++++++- plugins/actionlint/CHANGELOG.md | 9 +- plugins/actionlint/hooks/hook-utils.sh | 80 +++++++++---- plugins/autonomy/CHANGELOG.md | 9 +- plugins/autonomy/hooks/hook-utils.sh | 80 +++++++++---- plugins/bash-format/CHANGELOG.md | 9 +- plugins/bash-format/hooks/hook-utils.sh | 80 +++++++++---- plugins/biome-format/CHANGELOG.md | 9 +- plugins/biome-format/hooks/hook-utils.sh | 80 +++++++++---- plugins/claude-ops/CHANGELOG.md | 9 +- plugins/claude-ops/hooks/hook-utils.sh | 80 +++++++++---- plugins/desktop-notification/CHANGELOG.md | 9 +- .../desktop-notification/hooks/hook-utils.sh | 80 +++++++++---- plugins/eol-normalizer/CHANGELOG.md | 9 +- plugins/eol-normalizer/hooks/hook-utils.sh | 80 +++++++++---- plugins/go-format/CHANGELOG.md | 9 +- plugins/go-format/hooks/hook-utils.sh | 80 +++++++++---- plugins/guardrails/CHANGELOG.md | 13 ++- plugins/guardrails/README.md | 12 +- plugins/guardrails/hooks/hook-utils.sh | 80 +++++++++---- plugins/markdown-format/CHANGELOG.md | 9 +- plugins/markdown-format/hooks/hook-utils.sh | 80 +++++++++---- plugins/powershell-format/CHANGELOG.md | 9 +- plugins/powershell-format/hooks/hook-utils.sh | 80 +++++++++---- plugins/rate-limit-guard/CHANGELOG.md | 9 +- plugins/rate-limit-guard/hooks/hook-utils.sh | 80 +++++++++---- plugins/ruff-format/CHANGELOG.md | 9 +- plugins/ruff-format/hooks/hook-utils.sh | 80 +++++++++---- plugins/typos-format/CHANGELOG.md | 9 +- plugins/typos-format/hooks/hook-utils.sh | 80 +++++++++---- 31 files changed, 1085 insertions(+), 365 deletions(-) diff --git a/lib/hook-utils.sh b/lib/hook-utils.sh index 858e644dc..52398f67e 100644 --- a/lib/hook-utils.sh +++ b/lib/hook-utils.sh @@ -235,41 +235,79 @@ hook::repo_root() { # # The bound (stdin_read_timeout userConfig option, in seconds, read via # CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2) is an IDLE bound, not a -# total one: it arms per chunk, so a read that keeps making progress is never -# cut off, while a pipe that goes silent for that long still fails closed. The -# distinction is load-bearing. `read -d ''` consumes a pipe one byte at a time -# (~32 KB/s on Git Bash), so a total bound was really a ~64 KB throughput -# ceiling — every larger payload tripped the timeout branch and every -# fail-closed caller blocked a legitimate write. `read -N` lets bash satisfy -# the read in blocks instead: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from -# ~6800 ms to ~85 ms. Claude Code's own default `command` hook timeout is 600 s -# (https://code.claude.com/docs/en/hooks), so the idle bound is not tracking -# any harness limit. +# total one: only a window in which NOTHING arrives ends the read. A single +# `read -d ''` bounded by -t was a total bound, and because bash consumes +# `read -d ''` on a pipe one byte at a time (~32 KB/s on Git Bash) that made it +# a ~64 KB THROUGHPUT ceiling — every larger payload tripped the timeout branch, +# so fail-closed callers blocked a legitimate write and fail-open callers +# skipped silently. Two things together make the bound mean what it says: +# +# * The read is chunked. `read -N` lets bash satisfy it in blocks instead of +# byte-at-a-time: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms +# to ~85 ms. +# * The timer re-arms on progress. `read -t` is a deadline for the WHOLE +# requested read, not an inactivity timer, so a producer that keeps +# delivering but slower than one chunk per window would still trip it. `read` +# assigns whatever it did receive even when it times out, so a timed-out read +# that returned bytes is progress, not a stall: the loop keeps that partial +# chunk and arms a fresh window. Only a window that delivers nothing at all +# is a stall. +# +# The trade this makes: a producer trickling bytes indefinitely is never cut off +# here. That is deliberate — the harness already caps a `command` hook at 600 s +# by default (https://code.claude.com/docs/en/hooks), and blocking a live +# producer is exactly the failure this function had. # # `read` reports which stop condition it hit — EOF returns 1, an exceeded -t -# returns >128 — and assigns whatever it did read either way, so the loop reads -# the stall verdict off $? instead of inferring it from elapsed-time arithmetic. -# jq (when present) is still the completeness backstop: a stall that neverthe- -# less delivered a complete payload is the Win32 late-EOF case this function -# exists for and must succeed, not block. A missing/broken jq (exit 127) fails -# open like absent jq. Requires Bash 4.1+ for `read -N` (this library already -# requires 4.0+ for the `${var^}` case operators in hook::normalize_path). +# returns >128 — so the loop takes the verdict off $? rather than inferring it +# from elapsed-time arithmetic. jq (when present) is still the completeness +# backstop: a stall that nevertheless delivered a complete payload is the Win32 +# late-EOF case this function exists for and must succeed, not block. A +# missing/broken jq (exit 127) fails open like absent jq. +# +# `read -N` is Bash 4.1+; macOS ships Bash 3.2 and these hooks document 3.2+ +# support, so the pre-4.1 branch falls back to the delimiter read, which already +# reads to EOF and is fast enough on native POSIX pipes. Same guard and same +# rationale as plugins/context-guard/scripts/statusline-tee.sh. The re-arming +# loop wraps both forms, so 3.2 gets the progress semantics too — just in +# byte-at-a-time-sized steps. # INPUT=$(hook::buffer_stdin) || exit 0 + +# The `read -N` availability guard, split out as its own predicate so the +# pre-4.1 path stays reachable in tests on a modern host: BASH_VERSINFO is +# readonly, so it cannot be shadowed, but a test can override this function +# after sourcing. Not a consumer seam — nothing reads it from the environment. +hook::read_supports_nchars() { + ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))) +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + local -a read_opts=(-r -t "$read_timeout") + if hook::read_supports_nchars; then + read_opts+=(-N 65536) + else + read_opts+=(-d '') + fi while :; do chunk="" read_rc=0 - IFS= read -r -t "$read_timeout" -N 65536 chunk || read_rc=$? + # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array + IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" if ((read_rc == 0)); then - continue # a full chunk — more may still be coming + continue # a full chunk (or a delimiter) — more may still be coming fi if ((read_rc > 128)); then + # Timed out. Bytes in this window mean the producer is alive: keep them + # and re-arm. An empty window is the stall this guard exists to catch. + if [[ -n "$chunk" ]]; then + continue + fi stalled=1 fi - break # EOF (rc 1), a stall (rc >128), or a read error + break # EOF (rc 1), an empty timed-out window, or a read error done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 @@ -1138,7 +1176,7 @@ hook::bash_parse_segments() { # the '(' separator splits it into a segment that gets scanned. skipnext=0 else - while ((i + 1 < n)) && [[ "${chars[i + 1]}" == [\<\>] ]]; do ((i++)); done + while ((i + 1 < n)) && [[ "${chars[i + 1]}" == [\<\>] ]]; do ((i++)); done # portability-ok: bash glob bracket class matching a literal < or > character, not a GNU grep \< \> word boundary if ((i + 1 < n)) && [[ "${chars[i + 1]}" == '&' ]]; then ((i++)) if ((i + 1 < n)) && [[ "${chars[i + 1]}" == [0-9-] ]]; then diff --git a/lib/hook-utils.test.sh b/lib/hook-utils.test.sh index d6e44192a..e882cc2bc 100755 --- a/lib/hook-utils.test.sh +++ b/lib/hook-utils.test.sh @@ -332,7 +332,7 @@ assert_norm cygwin "/c/Repo" "C:/repo" "cygwin folds like msys" assert_norm linux-gnu "/c/Repo" "/c/Repo" "linux leaves /c/Repo unchanged" assert_norm linux-gnu "/c/repo" "/c/repo" "linux leaves /c/repo unchanged" assert_norm linux-gnu "/opt/App/Sub" "/opt/App/Sub" "linux leaves normal path unchanged" -assert_norm linux-gnu 'a\b' "a/b" "linux still converts backslashes" +assert_norm linux-gnu 'a\b' "a/b" "linux still converts backslashes" # portability-ok: literal backslash in a path fixture, not a GNU grep \b word boundary # Explicit guard: on POSIX the two casings must NOT collapse to one value. if [[ "$(norm_as linux-gnu /c/Repo)" != "$(norm_as linux-gnu /c/repo)" ]]; then @@ -889,6 +889,112 @@ else fi rm -f "$bs_big_file" "$bs_payload_file" "$bs_rc_file" "$bs_out_file" +# --- Test 18d: hook::buffer_stdin — the bound is IDLE, not per-read total ----- +# `read -t` is a deadline for the whole requested read, not an inactivity timer, +# so a producer that keeps delivering but slower than one chunk per window would +# trip it even though the pipe is never idle. buffer_stdin must keep a timed-out +# read's partial bytes and re-arm. Producer emits one character every 100 ms +# against a 300 ms timeout — far too slow to fill a chunk in any single window — +# then closes. Expect the whole payload back with rc 0. +bs_rc_file="$(mktemp)" +bs_out_file="$(mktemp)" +{ + printf '{"a":"' + for _ in 1 2 3 4 5 6 7 8 9 10; do + sleep 0.1 + printf 'x' + done + printf '"}' +} | { + CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT=0.3 hook::buffer_stdin >"$bs_out_file" 2>/dev/null + echo "$?" >"$bs_rc_file" +} +bs_rc=$(cat "$bs_rc_file") +if [[ "$bs_rc" == "0" ]] && [[ "$(cat "$bs_out_file")" == '{"a":"xxxxxxxxxx"}' ]]; then + ok "buffer_stdin: steady trickle slower than one chunk per window → rc 0 (idle bound)" +else + fail "buffer_stdin trickle: rc=$bs_rc out=$(cat "$bs_out_file")" +fi + +# And the other side of the same contract: a trickle that then goes SILENT with +# an incomplete payload must still fail closed. Re-arming on progress must not +# become "never time out". +: >"$bs_out_file" +{ + printf '{"a":"' + for _ in 1 2 3 4 5; do + sleep 0.1 + printf 'x' + done + sleep 2 +} | { + CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT=0.3 hook::buffer_stdin >"$bs_out_file" 2>/dev/null + echo "$?" >"$bs_rc_file" +} +bs_rc=$(cat "$bs_rc_file") +if [[ "$bs_rc" == "2" ]]; then + ok "buffer_stdin: trickle that then goes silent mid-payload → still rc 2" +else + fail "buffer_stdin trickle-then-stall: rc=$bs_rc out=$(cat "$bs_out_file")" +fi +rm -f "$bs_rc_file" "$bs_out_file" + +# --- Test 18e: hook::buffer_stdin — the pre-4.1 (`read -d ''`) branch --------- +# `read -N` is Bash 4.1+; macOS ships 3.2 and these hooks document 3.2+ support, +# so the function falls back to the delimiter read below 4.1. CI and this host +# run a modern bash, and BASH_VERSINFO is readonly so it cannot be shadowed — +# hence the guard lives in its own predicate, which the child shell overrides to +# select the fallback path FOR REAL rather than simulating it. First assert the +# override actually flips the branch (otherwise these cases would be vacuous), +# then that the fallback buffers a whole large payload and still fails closed. +bs_big_file="$(mktemp)" +bs_payload_file="$(mktemp)" +bs_rc_file="$(mktemp)" +bs_out_file="$(mktemp)" +# shellcheck disable=SC2016 # $1 is deliberately the CHILD shell's positional, not this one's +force_legacy='source "$1"; hook::read_supports_nchars() { return 1; }; ' + +bs_branch=$(bash -c "${force_legacy}"'hook::read_supports_nchars && echo modern || echo legacy' \ + _ "$HOOK_DIR/hook-utils.sh") +if [[ "$bs_branch" == "legacy" ]]; then + ok "buffer_stdin: pre-4.1 guard override selects the delimiter-read branch" +else + fail "pre-4.1 override did not flip the branch (got '$bs_branch'); cases below are vacuous" +fi + +yes 'portable content with no delimiters of interest' | head -c 131072 >"$bs_big_file" +jq -cn --rawfile c "$bs_big_file" \ + '{hook_event_name:"PreToolUse",tool_name:"Write",tool_input:{file_path:"/tmp/big.md",content:$c}}' \ + >"$bs_payload_file" +# shellcheck disable=SC2002 # `cat |` is the point: it forces a real pipe on fd 0. +cat "$bs_payload_file" | { + bash -c "${force_legacy}"'hook::buffer_stdin' _ "$HOOK_DIR/hook-utils.sh" \ + >"$bs_out_file" 2>/dev/null + echo "$?" >"$bs_rc_file" +} +bs_rc=$(cat "$bs_rc_file") +bs_content_len=$(jq -r '.tool_input.content | length' "$bs_out_file" 2>/dev/null || echo 0) +if [[ "$bs_rc" == "0" ]] && ((bs_content_len == 131072)); then + ok "buffer_stdin: pre-4.1 delimiter-read fallback buffers a 128 KB payload (rc 0)" +else + fail "buffer_stdin pre-4.1 fallback: rc=$bs_rc content_len=$bs_content_len" +fi + +: >"$bs_out_file" +{ printf '{"incomplete":'; sleep 2; } | { + CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT=0.4 \ + bash -c "${force_legacy}"'hook::buffer_stdin' _ "$HOOK_DIR/hook-utils.sh" \ + >"$bs_out_file" 2>/dev/null + echo "$?" >"$bs_rc_file" +} +bs_rc=$(cat "$bs_rc_file") +if [[ "$bs_rc" == "2" ]]; then + ok "buffer_stdin: pre-4.1 fallback still fails closed on a stalled pipe (rc 2)" +else + fail "buffer_stdin pre-4.1 stall: rc=$bs_rc" +fi +rm -f "$bs_big_file" "$bs_payload_file" "$bs_rc_file" "$bs_out_file" + # --- Test 19: hook::emit_telemetry — EPOCHREALTIME-absent (Bash < 5.0) skip --- # On Bash < 5.0 EPOCHREALTIME is unset, so the caller's `start=${EPOCHREALTIME:-}` # snapshot is empty. emit_telemetry must then skip fail-open (return 0, emit no diff --git a/plugins/actionlint/CHANGELOG.md b/plugins/actionlint/CHANGELOG.md index ec8dbe506..ff2f0dee7 100644 --- a/plugins/actionlint/CHANGELOG.md +++ b/plugins/actionlint/CHANGELOG.md @@ -14,9 +14,12 @@ All notable changes to the `actionlint` plugin are documented here. Format follo the read returned a truncated payload and rc 1, and this plugin's hooks took their `|| exit 0` branch — the hook did not run at all, with no diagnostic, on exactly the large writes it was most wanted for. The read is now chunked (`read -N`), which bash satisfies with block reads, and - the bound became an idle bound that arms per chunk: a read still making progress is never cut - off, while a pipe that goes silent for `stdin_read_timeout` seconds still stops the read the - same way. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. Synced + the bound became a true idle bound: `read -t` is a deadline for the whole requested read rather + than an inactivity timer, so a timed-out read that nevertheless returned bytes is now treated as + progress — its partial chunk is kept and a fresh window is armed. Only a window that delivers + nothing at all is a stall. `read -N` is Bash 4.1+, and these hooks support Bash 3.2+ (macOS + system bash), so the pre-4.1 path falls back to the delimiter read inside the same re-arming + loop. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. Synced from `lib/hook-utils.sh`; this plugin's own hook behavior is otherwise unchanged. ## [0.7.3] diff --git a/plugins/actionlint/hooks/hook-utils.sh b/plugins/actionlint/hooks/hook-utils.sh index 858e644dc..52398f67e 100644 --- a/plugins/actionlint/hooks/hook-utils.sh +++ b/plugins/actionlint/hooks/hook-utils.sh @@ -235,41 +235,79 @@ hook::repo_root() { # # The bound (stdin_read_timeout userConfig option, in seconds, read via # CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2) is an IDLE bound, not a -# total one: it arms per chunk, so a read that keeps making progress is never -# cut off, while a pipe that goes silent for that long still fails closed. The -# distinction is load-bearing. `read -d ''` consumes a pipe one byte at a time -# (~32 KB/s on Git Bash), so a total bound was really a ~64 KB throughput -# ceiling — every larger payload tripped the timeout branch and every -# fail-closed caller blocked a legitimate write. `read -N` lets bash satisfy -# the read in blocks instead: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from -# ~6800 ms to ~85 ms. Claude Code's own default `command` hook timeout is 600 s -# (https://code.claude.com/docs/en/hooks), so the idle bound is not tracking -# any harness limit. +# total one: only a window in which NOTHING arrives ends the read. A single +# `read -d ''` bounded by -t was a total bound, and because bash consumes +# `read -d ''` on a pipe one byte at a time (~32 KB/s on Git Bash) that made it +# a ~64 KB THROUGHPUT ceiling — every larger payload tripped the timeout branch, +# so fail-closed callers blocked a legitimate write and fail-open callers +# skipped silently. Two things together make the bound mean what it says: +# +# * The read is chunked. `read -N` lets bash satisfy it in blocks instead of +# byte-at-a-time: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms +# to ~85 ms. +# * The timer re-arms on progress. `read -t` is a deadline for the WHOLE +# requested read, not an inactivity timer, so a producer that keeps +# delivering but slower than one chunk per window would still trip it. `read` +# assigns whatever it did receive even when it times out, so a timed-out read +# that returned bytes is progress, not a stall: the loop keeps that partial +# chunk and arms a fresh window. Only a window that delivers nothing at all +# is a stall. +# +# The trade this makes: a producer trickling bytes indefinitely is never cut off +# here. That is deliberate — the harness already caps a `command` hook at 600 s +# by default (https://code.claude.com/docs/en/hooks), and blocking a live +# producer is exactly the failure this function had. # # `read` reports which stop condition it hit — EOF returns 1, an exceeded -t -# returns >128 — and assigns whatever it did read either way, so the loop reads -# the stall verdict off $? instead of inferring it from elapsed-time arithmetic. -# jq (when present) is still the completeness backstop: a stall that neverthe- -# less delivered a complete payload is the Win32 late-EOF case this function -# exists for and must succeed, not block. A missing/broken jq (exit 127) fails -# open like absent jq. Requires Bash 4.1+ for `read -N` (this library already -# requires 4.0+ for the `${var^}` case operators in hook::normalize_path). +# returns >128 — so the loop takes the verdict off $? rather than inferring it +# from elapsed-time arithmetic. jq (when present) is still the completeness +# backstop: a stall that nevertheless delivered a complete payload is the Win32 +# late-EOF case this function exists for and must succeed, not block. A +# missing/broken jq (exit 127) fails open like absent jq. +# +# `read -N` is Bash 4.1+; macOS ships Bash 3.2 and these hooks document 3.2+ +# support, so the pre-4.1 branch falls back to the delimiter read, which already +# reads to EOF and is fast enough on native POSIX pipes. Same guard and same +# rationale as plugins/context-guard/scripts/statusline-tee.sh. The re-arming +# loop wraps both forms, so 3.2 gets the progress semantics too — just in +# byte-at-a-time-sized steps. # INPUT=$(hook::buffer_stdin) || exit 0 + +# The `read -N` availability guard, split out as its own predicate so the +# pre-4.1 path stays reachable in tests on a modern host: BASH_VERSINFO is +# readonly, so it cannot be shadowed, but a test can override this function +# after sourcing. Not a consumer seam — nothing reads it from the environment. +hook::read_supports_nchars() { + ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))) +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + local -a read_opts=(-r -t "$read_timeout") + if hook::read_supports_nchars; then + read_opts+=(-N 65536) + else + read_opts+=(-d '') + fi while :; do chunk="" read_rc=0 - IFS= read -r -t "$read_timeout" -N 65536 chunk || read_rc=$? + # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array + IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" if ((read_rc == 0)); then - continue # a full chunk — more may still be coming + continue # a full chunk (or a delimiter) — more may still be coming fi if ((read_rc > 128)); then + # Timed out. Bytes in this window mean the producer is alive: keep them + # and re-arm. An empty window is the stall this guard exists to catch. + if [[ -n "$chunk" ]]; then + continue + fi stalled=1 fi - break # EOF (rc 1), a stall (rc >128), or a read error + break # EOF (rc 1), an empty timed-out window, or a read error done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 @@ -1138,7 +1176,7 @@ hook::bash_parse_segments() { # the '(' separator splits it into a segment that gets scanned. skipnext=0 else - while ((i + 1 < n)) && [[ "${chars[i + 1]}" == [\<\>] ]]; do ((i++)); done + while ((i + 1 < n)) && [[ "${chars[i + 1]}" == [\<\>] ]]; do ((i++)); done # portability-ok: bash glob bracket class matching a literal < or > character, not a GNU grep \< \> word boundary if ((i + 1 < n)) && [[ "${chars[i + 1]}" == '&' ]]; then ((i++)) if ((i + 1 < n)) && [[ "${chars[i + 1]}" == [0-9-] ]]; then diff --git a/plugins/autonomy/CHANGELOG.md b/plugins/autonomy/CHANGELOG.md index fb482cdea..3b8c06287 100644 --- a/plugins/autonomy/CHANGELOG.md +++ b/plugins/autonomy/CHANGELOG.md @@ -17,9 +17,12 @@ merged work-package PRs (#333, #343, #356, #372, #377, #600, #676). the read returned a truncated payload and rc 1, and this plugin's hooks took their `|| exit 0` branch — the hook did not run at all, with no diagnostic, on exactly the large writes it was most wanted for. The read is now chunked (`read -N`), which bash satisfies with block reads, and - the bound became an idle bound that arms per chunk: a read still making progress is never cut - off, while a pipe that goes silent for `stdin_read_timeout` seconds still stops the read the - same way. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. Synced + the bound became a true idle bound: `read -t` is a deadline for the whole requested read rather + than an inactivity timer, so a timed-out read that nevertheless returned bytes is now treated as + progress — its partial chunk is kept and a fresh window is armed. Only a window that delivers + nothing at all is a stall. `read -N` is Bash 4.1+, and these hooks support Bash 3.2+ (macOS + system bash), so the pre-4.1 path falls back to the delimiter read inside the same re-arming + loop. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. Synced from `lib/hook-utils.sh`; this plugin's own hook behavior is otherwise unchanged. ## [0.11.2] diff --git a/plugins/autonomy/hooks/hook-utils.sh b/plugins/autonomy/hooks/hook-utils.sh index 858e644dc..52398f67e 100644 --- a/plugins/autonomy/hooks/hook-utils.sh +++ b/plugins/autonomy/hooks/hook-utils.sh @@ -235,41 +235,79 @@ hook::repo_root() { # # The bound (stdin_read_timeout userConfig option, in seconds, read via # CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2) is an IDLE bound, not a -# total one: it arms per chunk, so a read that keeps making progress is never -# cut off, while a pipe that goes silent for that long still fails closed. The -# distinction is load-bearing. `read -d ''` consumes a pipe one byte at a time -# (~32 KB/s on Git Bash), so a total bound was really a ~64 KB throughput -# ceiling — every larger payload tripped the timeout branch and every -# fail-closed caller blocked a legitimate write. `read -N` lets bash satisfy -# the read in blocks instead: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from -# ~6800 ms to ~85 ms. Claude Code's own default `command` hook timeout is 600 s -# (https://code.claude.com/docs/en/hooks), so the idle bound is not tracking -# any harness limit. +# total one: only a window in which NOTHING arrives ends the read. A single +# `read -d ''` bounded by -t was a total bound, and because bash consumes +# `read -d ''` on a pipe one byte at a time (~32 KB/s on Git Bash) that made it +# a ~64 KB THROUGHPUT ceiling — every larger payload tripped the timeout branch, +# so fail-closed callers blocked a legitimate write and fail-open callers +# skipped silently. Two things together make the bound mean what it says: +# +# * The read is chunked. `read -N` lets bash satisfy it in blocks instead of +# byte-at-a-time: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms +# to ~85 ms. +# * The timer re-arms on progress. `read -t` is a deadline for the WHOLE +# requested read, not an inactivity timer, so a producer that keeps +# delivering but slower than one chunk per window would still trip it. `read` +# assigns whatever it did receive even when it times out, so a timed-out read +# that returned bytes is progress, not a stall: the loop keeps that partial +# chunk and arms a fresh window. Only a window that delivers nothing at all +# is a stall. +# +# The trade this makes: a producer trickling bytes indefinitely is never cut off +# here. That is deliberate — the harness already caps a `command` hook at 600 s +# by default (https://code.claude.com/docs/en/hooks), and blocking a live +# producer is exactly the failure this function had. # # `read` reports which stop condition it hit — EOF returns 1, an exceeded -t -# returns >128 — and assigns whatever it did read either way, so the loop reads -# the stall verdict off $? instead of inferring it from elapsed-time arithmetic. -# jq (when present) is still the completeness backstop: a stall that neverthe- -# less delivered a complete payload is the Win32 late-EOF case this function -# exists for and must succeed, not block. A missing/broken jq (exit 127) fails -# open like absent jq. Requires Bash 4.1+ for `read -N` (this library already -# requires 4.0+ for the `${var^}` case operators in hook::normalize_path). +# returns >128 — so the loop takes the verdict off $? rather than inferring it +# from elapsed-time arithmetic. jq (when present) is still the completeness +# backstop: a stall that nevertheless delivered a complete payload is the Win32 +# late-EOF case this function exists for and must succeed, not block. A +# missing/broken jq (exit 127) fails open like absent jq. +# +# `read -N` is Bash 4.1+; macOS ships Bash 3.2 and these hooks document 3.2+ +# support, so the pre-4.1 branch falls back to the delimiter read, which already +# reads to EOF and is fast enough on native POSIX pipes. Same guard and same +# rationale as plugins/context-guard/scripts/statusline-tee.sh. The re-arming +# loop wraps both forms, so 3.2 gets the progress semantics too — just in +# byte-at-a-time-sized steps. # INPUT=$(hook::buffer_stdin) || exit 0 + +# The `read -N` availability guard, split out as its own predicate so the +# pre-4.1 path stays reachable in tests on a modern host: BASH_VERSINFO is +# readonly, so it cannot be shadowed, but a test can override this function +# after sourcing. Not a consumer seam — nothing reads it from the environment. +hook::read_supports_nchars() { + ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))) +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + local -a read_opts=(-r -t "$read_timeout") + if hook::read_supports_nchars; then + read_opts+=(-N 65536) + else + read_opts+=(-d '') + fi while :; do chunk="" read_rc=0 - IFS= read -r -t "$read_timeout" -N 65536 chunk || read_rc=$? + # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array + IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" if ((read_rc == 0)); then - continue # a full chunk — more may still be coming + continue # a full chunk (or a delimiter) — more may still be coming fi if ((read_rc > 128)); then + # Timed out. Bytes in this window mean the producer is alive: keep them + # and re-arm. An empty window is the stall this guard exists to catch. + if [[ -n "$chunk" ]]; then + continue + fi stalled=1 fi - break # EOF (rc 1), a stall (rc >128), or a read error + break # EOF (rc 1), an empty timed-out window, or a read error done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 @@ -1138,7 +1176,7 @@ hook::bash_parse_segments() { # the '(' separator splits it into a segment that gets scanned. skipnext=0 else - while ((i + 1 < n)) && [[ "${chars[i + 1]}" == [\<\>] ]]; do ((i++)); done + while ((i + 1 < n)) && [[ "${chars[i + 1]}" == [\<\>] ]]; do ((i++)); done # portability-ok: bash glob bracket class matching a literal < or > character, not a GNU grep \< \> word boundary if ((i + 1 < n)) && [[ "${chars[i + 1]}" == '&' ]]; then ((i++)) if ((i + 1 < n)) && [[ "${chars[i + 1]}" == [0-9-] ]]; then diff --git a/plugins/bash-format/CHANGELOG.md b/plugins/bash-format/CHANGELOG.md index b95b7340b..19637e2e5 100644 --- a/plugins/bash-format/CHANGELOG.md +++ b/plugins/bash-format/CHANGELOG.md @@ -14,9 +14,12 @@ All notable changes to the `bash-format` plugin are documented here. Format foll the read returned a truncated payload and rc 1, and this plugin's hooks took their `|| exit 0` branch — the hook did not run at all, with no diagnostic, on exactly the large writes it was most wanted for. The read is now chunked (`read -N`), which bash satisfies with block reads, and - the bound became an idle bound that arms per chunk: a read still making progress is never cut - off, while a pipe that goes silent for `stdin_read_timeout` seconds still stops the read the - same way. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. Synced + the bound became a true idle bound: `read -t` is a deadline for the whole requested read rather + than an inactivity timer, so a timed-out read that nevertheless returned bytes is now treated as + progress — its partial chunk is kept and a fresh window is armed. Only a window that delivers + nothing at all is a stall. `read -N` is Bash 4.1+, and these hooks support Bash 3.2+ (macOS + system bash), so the pre-4.1 path falls back to the delimiter read inside the same re-arming + loop. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. Synced from `lib/hook-utils.sh`; this plugin's own hook behavior is otherwise unchanged. ## [0.6.5] diff --git a/plugins/bash-format/hooks/hook-utils.sh b/plugins/bash-format/hooks/hook-utils.sh index 858e644dc..52398f67e 100644 --- a/plugins/bash-format/hooks/hook-utils.sh +++ b/plugins/bash-format/hooks/hook-utils.sh @@ -235,41 +235,79 @@ hook::repo_root() { # # The bound (stdin_read_timeout userConfig option, in seconds, read via # CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2) is an IDLE bound, not a -# total one: it arms per chunk, so a read that keeps making progress is never -# cut off, while a pipe that goes silent for that long still fails closed. The -# distinction is load-bearing. `read -d ''` consumes a pipe one byte at a time -# (~32 KB/s on Git Bash), so a total bound was really a ~64 KB throughput -# ceiling — every larger payload tripped the timeout branch and every -# fail-closed caller blocked a legitimate write. `read -N` lets bash satisfy -# the read in blocks instead: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from -# ~6800 ms to ~85 ms. Claude Code's own default `command` hook timeout is 600 s -# (https://code.claude.com/docs/en/hooks), so the idle bound is not tracking -# any harness limit. +# total one: only a window in which NOTHING arrives ends the read. A single +# `read -d ''` bounded by -t was a total bound, and because bash consumes +# `read -d ''` on a pipe one byte at a time (~32 KB/s on Git Bash) that made it +# a ~64 KB THROUGHPUT ceiling — every larger payload tripped the timeout branch, +# so fail-closed callers blocked a legitimate write and fail-open callers +# skipped silently. Two things together make the bound mean what it says: +# +# * The read is chunked. `read -N` lets bash satisfy it in blocks instead of +# byte-at-a-time: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms +# to ~85 ms. +# * The timer re-arms on progress. `read -t` is a deadline for the WHOLE +# requested read, not an inactivity timer, so a producer that keeps +# delivering but slower than one chunk per window would still trip it. `read` +# assigns whatever it did receive even when it times out, so a timed-out read +# that returned bytes is progress, not a stall: the loop keeps that partial +# chunk and arms a fresh window. Only a window that delivers nothing at all +# is a stall. +# +# The trade this makes: a producer trickling bytes indefinitely is never cut off +# here. That is deliberate — the harness already caps a `command` hook at 600 s +# by default (https://code.claude.com/docs/en/hooks), and blocking a live +# producer is exactly the failure this function had. # # `read` reports which stop condition it hit — EOF returns 1, an exceeded -t -# returns >128 — and assigns whatever it did read either way, so the loop reads -# the stall verdict off $? instead of inferring it from elapsed-time arithmetic. -# jq (when present) is still the completeness backstop: a stall that neverthe- -# less delivered a complete payload is the Win32 late-EOF case this function -# exists for and must succeed, not block. A missing/broken jq (exit 127) fails -# open like absent jq. Requires Bash 4.1+ for `read -N` (this library already -# requires 4.0+ for the `${var^}` case operators in hook::normalize_path). +# returns >128 — so the loop takes the verdict off $? rather than inferring it +# from elapsed-time arithmetic. jq (when present) is still the completeness +# backstop: a stall that nevertheless delivered a complete payload is the Win32 +# late-EOF case this function exists for and must succeed, not block. A +# missing/broken jq (exit 127) fails open like absent jq. +# +# `read -N` is Bash 4.1+; macOS ships Bash 3.2 and these hooks document 3.2+ +# support, so the pre-4.1 branch falls back to the delimiter read, which already +# reads to EOF and is fast enough on native POSIX pipes. Same guard and same +# rationale as plugins/context-guard/scripts/statusline-tee.sh. The re-arming +# loop wraps both forms, so 3.2 gets the progress semantics too — just in +# byte-at-a-time-sized steps. # INPUT=$(hook::buffer_stdin) || exit 0 + +# The `read -N` availability guard, split out as its own predicate so the +# pre-4.1 path stays reachable in tests on a modern host: BASH_VERSINFO is +# readonly, so it cannot be shadowed, but a test can override this function +# after sourcing. Not a consumer seam — nothing reads it from the environment. +hook::read_supports_nchars() { + ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))) +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + local -a read_opts=(-r -t "$read_timeout") + if hook::read_supports_nchars; then + read_opts+=(-N 65536) + else + read_opts+=(-d '') + fi while :; do chunk="" read_rc=0 - IFS= read -r -t "$read_timeout" -N 65536 chunk || read_rc=$? + # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array + IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" if ((read_rc == 0)); then - continue # a full chunk — more may still be coming + continue # a full chunk (or a delimiter) — more may still be coming fi if ((read_rc > 128)); then + # Timed out. Bytes in this window mean the producer is alive: keep them + # and re-arm. An empty window is the stall this guard exists to catch. + if [[ -n "$chunk" ]]; then + continue + fi stalled=1 fi - break # EOF (rc 1), a stall (rc >128), or a read error + break # EOF (rc 1), an empty timed-out window, or a read error done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 @@ -1138,7 +1176,7 @@ hook::bash_parse_segments() { # the '(' separator splits it into a segment that gets scanned. skipnext=0 else - while ((i + 1 < n)) && [[ "${chars[i + 1]}" == [\<\>] ]]; do ((i++)); done + while ((i + 1 < n)) && [[ "${chars[i + 1]}" == [\<\>] ]]; do ((i++)); done # portability-ok: bash glob bracket class matching a literal < or > character, not a GNU grep \< \> word boundary if ((i + 1 < n)) && [[ "${chars[i + 1]}" == '&' ]]; then ((i++)) if ((i + 1 < n)) && [[ "${chars[i + 1]}" == [0-9-] ]]; then diff --git a/plugins/biome-format/CHANGELOG.md b/plugins/biome-format/CHANGELOG.md index 3f7ce6b13..a0beab6e1 100644 --- a/plugins/biome-format/CHANGELOG.md +++ b/plugins/biome-format/CHANGELOG.md @@ -14,9 +14,12 @@ All notable changes to the `biome-format` plugin are documented here. Format fol the read returned a truncated payload and rc 1, and this plugin's hooks took their `|| exit 0` branch — the hook did not run at all, with no diagnostic, on exactly the large writes it was most wanted for. The read is now chunked (`read -N`), which bash satisfies with block reads, and - the bound became an idle bound that arms per chunk: a read still making progress is never cut - off, while a pipe that goes silent for `stdin_read_timeout` seconds still stops the read the - same way. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. Synced + the bound became a true idle bound: `read -t` is a deadline for the whole requested read rather + than an inactivity timer, so a timed-out read that nevertheless returned bytes is now treated as + progress — its partial chunk is kept and a fresh window is armed. Only a window that delivers + nothing at all is a stall. `read -N` is Bash 4.1+, and these hooks support Bash 3.2+ (macOS + system bash), so the pre-4.1 path falls back to the delimiter read inside the same re-arming + loop. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. Synced from `lib/hook-utils.sh`; this plugin's own hook behavior is otherwise unchanged. ## [0.5.4] diff --git a/plugins/biome-format/hooks/hook-utils.sh b/plugins/biome-format/hooks/hook-utils.sh index 858e644dc..52398f67e 100644 --- a/plugins/biome-format/hooks/hook-utils.sh +++ b/plugins/biome-format/hooks/hook-utils.sh @@ -235,41 +235,79 @@ hook::repo_root() { # # The bound (stdin_read_timeout userConfig option, in seconds, read via # CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2) is an IDLE bound, not a -# total one: it arms per chunk, so a read that keeps making progress is never -# cut off, while a pipe that goes silent for that long still fails closed. The -# distinction is load-bearing. `read -d ''` consumes a pipe one byte at a time -# (~32 KB/s on Git Bash), so a total bound was really a ~64 KB throughput -# ceiling — every larger payload tripped the timeout branch and every -# fail-closed caller blocked a legitimate write. `read -N` lets bash satisfy -# the read in blocks instead: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from -# ~6800 ms to ~85 ms. Claude Code's own default `command` hook timeout is 600 s -# (https://code.claude.com/docs/en/hooks), so the idle bound is not tracking -# any harness limit. +# total one: only a window in which NOTHING arrives ends the read. A single +# `read -d ''` bounded by -t was a total bound, and because bash consumes +# `read -d ''` on a pipe one byte at a time (~32 KB/s on Git Bash) that made it +# a ~64 KB THROUGHPUT ceiling — every larger payload tripped the timeout branch, +# so fail-closed callers blocked a legitimate write and fail-open callers +# skipped silently. Two things together make the bound mean what it says: +# +# * The read is chunked. `read -N` lets bash satisfy it in blocks instead of +# byte-at-a-time: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms +# to ~85 ms. +# * The timer re-arms on progress. `read -t` is a deadline for the WHOLE +# requested read, not an inactivity timer, so a producer that keeps +# delivering but slower than one chunk per window would still trip it. `read` +# assigns whatever it did receive even when it times out, so a timed-out read +# that returned bytes is progress, not a stall: the loop keeps that partial +# chunk and arms a fresh window. Only a window that delivers nothing at all +# is a stall. +# +# The trade this makes: a producer trickling bytes indefinitely is never cut off +# here. That is deliberate — the harness already caps a `command` hook at 600 s +# by default (https://code.claude.com/docs/en/hooks), and blocking a live +# producer is exactly the failure this function had. # # `read` reports which stop condition it hit — EOF returns 1, an exceeded -t -# returns >128 — and assigns whatever it did read either way, so the loop reads -# the stall verdict off $? instead of inferring it from elapsed-time arithmetic. -# jq (when present) is still the completeness backstop: a stall that neverthe- -# less delivered a complete payload is the Win32 late-EOF case this function -# exists for and must succeed, not block. A missing/broken jq (exit 127) fails -# open like absent jq. Requires Bash 4.1+ for `read -N` (this library already -# requires 4.0+ for the `${var^}` case operators in hook::normalize_path). +# returns >128 — so the loop takes the verdict off $? rather than inferring it +# from elapsed-time arithmetic. jq (when present) is still the completeness +# backstop: a stall that nevertheless delivered a complete payload is the Win32 +# late-EOF case this function exists for and must succeed, not block. A +# missing/broken jq (exit 127) fails open like absent jq. +# +# `read -N` is Bash 4.1+; macOS ships Bash 3.2 and these hooks document 3.2+ +# support, so the pre-4.1 branch falls back to the delimiter read, which already +# reads to EOF and is fast enough on native POSIX pipes. Same guard and same +# rationale as plugins/context-guard/scripts/statusline-tee.sh. The re-arming +# loop wraps both forms, so 3.2 gets the progress semantics too — just in +# byte-at-a-time-sized steps. # INPUT=$(hook::buffer_stdin) || exit 0 + +# The `read -N` availability guard, split out as its own predicate so the +# pre-4.1 path stays reachable in tests on a modern host: BASH_VERSINFO is +# readonly, so it cannot be shadowed, but a test can override this function +# after sourcing. Not a consumer seam — nothing reads it from the environment. +hook::read_supports_nchars() { + ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))) +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + local -a read_opts=(-r -t "$read_timeout") + if hook::read_supports_nchars; then + read_opts+=(-N 65536) + else + read_opts+=(-d '') + fi while :; do chunk="" read_rc=0 - IFS= read -r -t "$read_timeout" -N 65536 chunk || read_rc=$? + # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array + IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" if ((read_rc == 0)); then - continue # a full chunk — more may still be coming + continue # a full chunk (or a delimiter) — more may still be coming fi if ((read_rc > 128)); then + # Timed out. Bytes in this window mean the producer is alive: keep them + # and re-arm. An empty window is the stall this guard exists to catch. + if [[ -n "$chunk" ]]; then + continue + fi stalled=1 fi - break # EOF (rc 1), a stall (rc >128), or a read error + break # EOF (rc 1), an empty timed-out window, or a read error done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 @@ -1138,7 +1176,7 @@ hook::bash_parse_segments() { # the '(' separator splits it into a segment that gets scanned. skipnext=0 else - while ((i + 1 < n)) && [[ "${chars[i + 1]}" == [\<\>] ]]; do ((i++)); done + while ((i + 1 < n)) && [[ "${chars[i + 1]}" == [\<\>] ]]; do ((i++)); done # portability-ok: bash glob bracket class matching a literal < or > character, not a GNU grep \< \> word boundary if ((i + 1 < n)) && [[ "${chars[i + 1]}" == '&' ]]; then ((i++)) if ((i + 1 < n)) && [[ "${chars[i + 1]}" == [0-9-] ]]; then diff --git a/plugins/claude-ops/CHANGELOG.md b/plugins/claude-ops/CHANGELOG.md index 1d32f332f..102298619 100644 --- a/plugins/claude-ops/CHANGELOG.md +++ b/plugins/claude-ops/CHANGELOG.md @@ -14,9 +14,12 @@ All notable changes to the `claude-ops` plugin are documented here. Format follo the read returned a truncated payload and rc 1, and this plugin's hooks took their `|| exit 0` branch — the hook did not run at all, with no diagnostic, on exactly the large writes it was most wanted for. The read is now chunked (`read -N`), which bash satisfies with block reads, and - the bound became an idle bound that arms per chunk: a read still making progress is never cut - off, while a pipe that goes silent for `stdin_read_timeout` seconds still stops the read the - same way. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. Synced + the bound became a true idle bound: `read -t` is a deadline for the whole requested read rather + than an inactivity timer, so a timed-out read that nevertheless returned bytes is now treated as + progress — its partial chunk is kept and a fresh window is armed. Only a window that delivers + nothing at all is a stall. `read -N` is Bash 4.1+, and these hooks support Bash 3.2+ (macOS + system bash), so the pre-4.1 path falls back to the delimiter read inside the same re-arming + loop. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. Synced from `lib/hook-utils.sh`; this plugin's own hook behavior is otherwise unchanged. ## [0.21.5] diff --git a/plugins/claude-ops/hooks/hook-utils.sh b/plugins/claude-ops/hooks/hook-utils.sh index 858e644dc..52398f67e 100644 --- a/plugins/claude-ops/hooks/hook-utils.sh +++ b/plugins/claude-ops/hooks/hook-utils.sh @@ -235,41 +235,79 @@ hook::repo_root() { # # The bound (stdin_read_timeout userConfig option, in seconds, read via # CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2) is an IDLE bound, not a -# total one: it arms per chunk, so a read that keeps making progress is never -# cut off, while a pipe that goes silent for that long still fails closed. The -# distinction is load-bearing. `read -d ''` consumes a pipe one byte at a time -# (~32 KB/s on Git Bash), so a total bound was really a ~64 KB throughput -# ceiling — every larger payload tripped the timeout branch and every -# fail-closed caller blocked a legitimate write. `read -N` lets bash satisfy -# the read in blocks instead: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from -# ~6800 ms to ~85 ms. Claude Code's own default `command` hook timeout is 600 s -# (https://code.claude.com/docs/en/hooks), so the idle bound is not tracking -# any harness limit. +# total one: only a window in which NOTHING arrives ends the read. A single +# `read -d ''` bounded by -t was a total bound, and because bash consumes +# `read -d ''` on a pipe one byte at a time (~32 KB/s on Git Bash) that made it +# a ~64 KB THROUGHPUT ceiling — every larger payload tripped the timeout branch, +# so fail-closed callers blocked a legitimate write and fail-open callers +# skipped silently. Two things together make the bound mean what it says: +# +# * The read is chunked. `read -N` lets bash satisfy it in blocks instead of +# byte-at-a-time: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms +# to ~85 ms. +# * The timer re-arms on progress. `read -t` is a deadline for the WHOLE +# requested read, not an inactivity timer, so a producer that keeps +# delivering but slower than one chunk per window would still trip it. `read` +# assigns whatever it did receive even when it times out, so a timed-out read +# that returned bytes is progress, not a stall: the loop keeps that partial +# chunk and arms a fresh window. Only a window that delivers nothing at all +# is a stall. +# +# The trade this makes: a producer trickling bytes indefinitely is never cut off +# here. That is deliberate — the harness already caps a `command` hook at 600 s +# by default (https://code.claude.com/docs/en/hooks), and blocking a live +# producer is exactly the failure this function had. # # `read` reports which stop condition it hit — EOF returns 1, an exceeded -t -# returns >128 — and assigns whatever it did read either way, so the loop reads -# the stall verdict off $? instead of inferring it from elapsed-time arithmetic. -# jq (when present) is still the completeness backstop: a stall that neverthe- -# less delivered a complete payload is the Win32 late-EOF case this function -# exists for and must succeed, not block. A missing/broken jq (exit 127) fails -# open like absent jq. Requires Bash 4.1+ for `read -N` (this library already -# requires 4.0+ for the `${var^}` case operators in hook::normalize_path). +# returns >128 — so the loop takes the verdict off $? rather than inferring it +# from elapsed-time arithmetic. jq (when present) is still the completeness +# backstop: a stall that nevertheless delivered a complete payload is the Win32 +# late-EOF case this function exists for and must succeed, not block. A +# missing/broken jq (exit 127) fails open like absent jq. +# +# `read -N` is Bash 4.1+; macOS ships Bash 3.2 and these hooks document 3.2+ +# support, so the pre-4.1 branch falls back to the delimiter read, which already +# reads to EOF and is fast enough on native POSIX pipes. Same guard and same +# rationale as plugins/context-guard/scripts/statusline-tee.sh. The re-arming +# loop wraps both forms, so 3.2 gets the progress semantics too — just in +# byte-at-a-time-sized steps. # INPUT=$(hook::buffer_stdin) || exit 0 + +# The `read -N` availability guard, split out as its own predicate so the +# pre-4.1 path stays reachable in tests on a modern host: BASH_VERSINFO is +# readonly, so it cannot be shadowed, but a test can override this function +# after sourcing. Not a consumer seam — nothing reads it from the environment. +hook::read_supports_nchars() { + ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))) +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + local -a read_opts=(-r -t "$read_timeout") + if hook::read_supports_nchars; then + read_opts+=(-N 65536) + else + read_opts+=(-d '') + fi while :; do chunk="" read_rc=0 - IFS= read -r -t "$read_timeout" -N 65536 chunk || read_rc=$? + # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array + IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" if ((read_rc == 0)); then - continue # a full chunk — more may still be coming + continue # a full chunk (or a delimiter) — more may still be coming fi if ((read_rc > 128)); then + # Timed out. Bytes in this window mean the producer is alive: keep them + # and re-arm. An empty window is the stall this guard exists to catch. + if [[ -n "$chunk" ]]; then + continue + fi stalled=1 fi - break # EOF (rc 1), a stall (rc >128), or a read error + break # EOF (rc 1), an empty timed-out window, or a read error done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 @@ -1138,7 +1176,7 @@ hook::bash_parse_segments() { # the '(' separator splits it into a segment that gets scanned. skipnext=0 else - while ((i + 1 < n)) && [[ "${chars[i + 1]}" == [\<\>] ]]; do ((i++)); done + while ((i + 1 < n)) && [[ "${chars[i + 1]}" == [\<\>] ]]; do ((i++)); done # portability-ok: bash glob bracket class matching a literal < or > character, not a GNU grep \< \> word boundary if ((i + 1 < n)) && [[ "${chars[i + 1]}" == '&' ]]; then ((i++)) if ((i + 1 < n)) && [[ "${chars[i + 1]}" == [0-9-] ]]; then diff --git a/plugins/desktop-notification/CHANGELOG.md b/plugins/desktop-notification/CHANGELOG.md index 385ba4427..29c8c11fb 100644 --- a/plugins/desktop-notification/CHANGELOG.md +++ b/plugins/desktop-notification/CHANGELOG.md @@ -14,9 +14,12 @@ All notable changes to the `desktop-notification` plugin are documented here. Fo the read returned a truncated payload and rc 1, and this plugin's hooks took their `|| exit 0` branch — the hook did not run at all, with no diagnostic, on exactly the large writes it was most wanted for. The read is now chunked (`read -N`), which bash satisfies with block reads, and - the bound became an idle bound that arms per chunk: a read still making progress is never cut - off, while a pipe that goes silent for `stdin_read_timeout` seconds still stops the read the - same way. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. Synced + the bound became a true idle bound: `read -t` is a deadline for the whole requested read rather + than an inactivity timer, so a timed-out read that nevertheless returned bytes is now treated as + progress — its partial chunk is kept and a fresh window is armed. Only a window that delivers + nothing at all is a stall. `read -N` is Bash 4.1+, and these hooks support Bash 3.2+ (macOS + system bash), so the pre-4.1 path falls back to the delimiter read inside the same re-arming + loop. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. Synced from `lib/hook-utils.sh`; this plugin's own hook behavior is otherwise unchanged. ## [0.5.5] diff --git a/plugins/desktop-notification/hooks/hook-utils.sh b/plugins/desktop-notification/hooks/hook-utils.sh index 858e644dc..52398f67e 100644 --- a/plugins/desktop-notification/hooks/hook-utils.sh +++ b/plugins/desktop-notification/hooks/hook-utils.sh @@ -235,41 +235,79 @@ hook::repo_root() { # # The bound (stdin_read_timeout userConfig option, in seconds, read via # CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2) is an IDLE bound, not a -# total one: it arms per chunk, so a read that keeps making progress is never -# cut off, while a pipe that goes silent for that long still fails closed. The -# distinction is load-bearing. `read -d ''` consumes a pipe one byte at a time -# (~32 KB/s on Git Bash), so a total bound was really a ~64 KB throughput -# ceiling — every larger payload tripped the timeout branch and every -# fail-closed caller blocked a legitimate write. `read -N` lets bash satisfy -# the read in blocks instead: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from -# ~6800 ms to ~85 ms. Claude Code's own default `command` hook timeout is 600 s -# (https://code.claude.com/docs/en/hooks), so the idle bound is not tracking -# any harness limit. +# total one: only a window in which NOTHING arrives ends the read. A single +# `read -d ''` bounded by -t was a total bound, and because bash consumes +# `read -d ''` on a pipe one byte at a time (~32 KB/s on Git Bash) that made it +# a ~64 KB THROUGHPUT ceiling — every larger payload tripped the timeout branch, +# so fail-closed callers blocked a legitimate write and fail-open callers +# skipped silently. Two things together make the bound mean what it says: +# +# * The read is chunked. `read -N` lets bash satisfy it in blocks instead of +# byte-at-a-time: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms +# to ~85 ms. +# * The timer re-arms on progress. `read -t` is a deadline for the WHOLE +# requested read, not an inactivity timer, so a producer that keeps +# delivering but slower than one chunk per window would still trip it. `read` +# assigns whatever it did receive even when it times out, so a timed-out read +# that returned bytes is progress, not a stall: the loop keeps that partial +# chunk and arms a fresh window. Only a window that delivers nothing at all +# is a stall. +# +# The trade this makes: a producer trickling bytes indefinitely is never cut off +# here. That is deliberate — the harness already caps a `command` hook at 600 s +# by default (https://code.claude.com/docs/en/hooks), and blocking a live +# producer is exactly the failure this function had. # # `read` reports which stop condition it hit — EOF returns 1, an exceeded -t -# returns >128 — and assigns whatever it did read either way, so the loop reads -# the stall verdict off $? instead of inferring it from elapsed-time arithmetic. -# jq (when present) is still the completeness backstop: a stall that neverthe- -# less delivered a complete payload is the Win32 late-EOF case this function -# exists for and must succeed, not block. A missing/broken jq (exit 127) fails -# open like absent jq. Requires Bash 4.1+ for `read -N` (this library already -# requires 4.0+ for the `${var^}` case operators in hook::normalize_path). +# returns >128 — so the loop takes the verdict off $? rather than inferring it +# from elapsed-time arithmetic. jq (when present) is still the completeness +# backstop: a stall that nevertheless delivered a complete payload is the Win32 +# late-EOF case this function exists for and must succeed, not block. A +# missing/broken jq (exit 127) fails open like absent jq. +# +# `read -N` is Bash 4.1+; macOS ships Bash 3.2 and these hooks document 3.2+ +# support, so the pre-4.1 branch falls back to the delimiter read, which already +# reads to EOF and is fast enough on native POSIX pipes. Same guard and same +# rationale as plugins/context-guard/scripts/statusline-tee.sh. The re-arming +# loop wraps both forms, so 3.2 gets the progress semantics too — just in +# byte-at-a-time-sized steps. # INPUT=$(hook::buffer_stdin) || exit 0 + +# The `read -N` availability guard, split out as its own predicate so the +# pre-4.1 path stays reachable in tests on a modern host: BASH_VERSINFO is +# readonly, so it cannot be shadowed, but a test can override this function +# after sourcing. Not a consumer seam — nothing reads it from the environment. +hook::read_supports_nchars() { + ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))) +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + local -a read_opts=(-r -t "$read_timeout") + if hook::read_supports_nchars; then + read_opts+=(-N 65536) + else + read_opts+=(-d '') + fi while :; do chunk="" read_rc=0 - IFS= read -r -t "$read_timeout" -N 65536 chunk || read_rc=$? + # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array + IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" if ((read_rc == 0)); then - continue # a full chunk — more may still be coming + continue # a full chunk (or a delimiter) — more may still be coming fi if ((read_rc > 128)); then + # Timed out. Bytes in this window mean the producer is alive: keep them + # and re-arm. An empty window is the stall this guard exists to catch. + if [[ -n "$chunk" ]]; then + continue + fi stalled=1 fi - break # EOF (rc 1), a stall (rc >128), or a read error + break # EOF (rc 1), an empty timed-out window, or a read error done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 @@ -1138,7 +1176,7 @@ hook::bash_parse_segments() { # the '(' separator splits it into a segment that gets scanned. skipnext=0 else - while ((i + 1 < n)) && [[ "${chars[i + 1]}" == [\<\>] ]]; do ((i++)); done + while ((i + 1 < n)) && [[ "${chars[i + 1]}" == [\<\>] ]]; do ((i++)); done # portability-ok: bash glob bracket class matching a literal < or > character, not a GNU grep \< \> word boundary if ((i + 1 < n)) && [[ "${chars[i + 1]}" == '&' ]]; then ((i++)) if ((i + 1 < n)) && [[ "${chars[i + 1]}" == [0-9-] ]]; then diff --git a/plugins/eol-normalizer/CHANGELOG.md b/plugins/eol-normalizer/CHANGELOG.md index c2624b094..0839c75c0 100644 --- a/plugins/eol-normalizer/CHANGELOG.md +++ b/plugins/eol-normalizer/CHANGELOG.md @@ -14,9 +14,12 @@ All notable changes to the `eol-normalizer` plugin are documented here. Format f the read returned a truncated payload and rc 1, and this plugin's hooks took their `|| exit 0` branch — the hook did not run at all, with no diagnostic, on exactly the large writes it was most wanted for. The read is now chunked (`read -N`), which bash satisfies with block reads, and - the bound became an idle bound that arms per chunk: a read still making progress is never cut - off, while a pipe that goes silent for `stdin_read_timeout` seconds still stops the read the - same way. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. Synced + the bound became a true idle bound: `read -t` is a deadline for the whole requested read rather + than an inactivity timer, so a timed-out read that nevertheless returned bytes is now treated as + progress — its partial chunk is kept and a fresh window is armed. Only a window that delivers + nothing at all is a stall. `read -N` is Bash 4.1+, and these hooks support Bash 3.2+ (macOS + system bash), so the pre-4.1 path falls back to the delimiter read inside the same re-arming + loop. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. Synced from `lib/hook-utils.sh`; this plugin's own hook behavior is otherwise unchanged. ## [0.5.4] diff --git a/plugins/eol-normalizer/hooks/hook-utils.sh b/plugins/eol-normalizer/hooks/hook-utils.sh index 858e644dc..52398f67e 100644 --- a/plugins/eol-normalizer/hooks/hook-utils.sh +++ b/plugins/eol-normalizer/hooks/hook-utils.sh @@ -235,41 +235,79 @@ hook::repo_root() { # # The bound (stdin_read_timeout userConfig option, in seconds, read via # CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2) is an IDLE bound, not a -# total one: it arms per chunk, so a read that keeps making progress is never -# cut off, while a pipe that goes silent for that long still fails closed. The -# distinction is load-bearing. `read -d ''` consumes a pipe one byte at a time -# (~32 KB/s on Git Bash), so a total bound was really a ~64 KB throughput -# ceiling — every larger payload tripped the timeout branch and every -# fail-closed caller blocked a legitimate write. `read -N` lets bash satisfy -# the read in blocks instead: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from -# ~6800 ms to ~85 ms. Claude Code's own default `command` hook timeout is 600 s -# (https://code.claude.com/docs/en/hooks), so the idle bound is not tracking -# any harness limit. +# total one: only a window in which NOTHING arrives ends the read. A single +# `read -d ''` bounded by -t was a total bound, and because bash consumes +# `read -d ''` on a pipe one byte at a time (~32 KB/s on Git Bash) that made it +# a ~64 KB THROUGHPUT ceiling — every larger payload tripped the timeout branch, +# so fail-closed callers blocked a legitimate write and fail-open callers +# skipped silently. Two things together make the bound mean what it says: +# +# * The read is chunked. `read -N` lets bash satisfy it in blocks instead of +# byte-at-a-time: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms +# to ~85 ms. +# * The timer re-arms on progress. `read -t` is a deadline for the WHOLE +# requested read, not an inactivity timer, so a producer that keeps +# delivering but slower than one chunk per window would still trip it. `read` +# assigns whatever it did receive even when it times out, so a timed-out read +# that returned bytes is progress, not a stall: the loop keeps that partial +# chunk and arms a fresh window. Only a window that delivers nothing at all +# is a stall. +# +# The trade this makes: a producer trickling bytes indefinitely is never cut off +# here. That is deliberate — the harness already caps a `command` hook at 600 s +# by default (https://code.claude.com/docs/en/hooks), and blocking a live +# producer is exactly the failure this function had. # # `read` reports which stop condition it hit — EOF returns 1, an exceeded -t -# returns >128 — and assigns whatever it did read either way, so the loop reads -# the stall verdict off $? instead of inferring it from elapsed-time arithmetic. -# jq (when present) is still the completeness backstop: a stall that neverthe- -# less delivered a complete payload is the Win32 late-EOF case this function -# exists for and must succeed, not block. A missing/broken jq (exit 127) fails -# open like absent jq. Requires Bash 4.1+ for `read -N` (this library already -# requires 4.0+ for the `${var^}` case operators in hook::normalize_path). +# returns >128 — so the loop takes the verdict off $? rather than inferring it +# from elapsed-time arithmetic. jq (when present) is still the completeness +# backstop: a stall that nevertheless delivered a complete payload is the Win32 +# late-EOF case this function exists for and must succeed, not block. A +# missing/broken jq (exit 127) fails open like absent jq. +# +# `read -N` is Bash 4.1+; macOS ships Bash 3.2 and these hooks document 3.2+ +# support, so the pre-4.1 branch falls back to the delimiter read, which already +# reads to EOF and is fast enough on native POSIX pipes. Same guard and same +# rationale as plugins/context-guard/scripts/statusline-tee.sh. The re-arming +# loop wraps both forms, so 3.2 gets the progress semantics too — just in +# byte-at-a-time-sized steps. # INPUT=$(hook::buffer_stdin) || exit 0 + +# The `read -N` availability guard, split out as its own predicate so the +# pre-4.1 path stays reachable in tests on a modern host: BASH_VERSINFO is +# readonly, so it cannot be shadowed, but a test can override this function +# after sourcing. Not a consumer seam — nothing reads it from the environment. +hook::read_supports_nchars() { + ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))) +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + local -a read_opts=(-r -t "$read_timeout") + if hook::read_supports_nchars; then + read_opts+=(-N 65536) + else + read_opts+=(-d '') + fi while :; do chunk="" read_rc=0 - IFS= read -r -t "$read_timeout" -N 65536 chunk || read_rc=$? + # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array + IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" if ((read_rc == 0)); then - continue # a full chunk — more may still be coming + continue # a full chunk (or a delimiter) — more may still be coming fi if ((read_rc > 128)); then + # Timed out. Bytes in this window mean the producer is alive: keep them + # and re-arm. An empty window is the stall this guard exists to catch. + if [[ -n "$chunk" ]]; then + continue + fi stalled=1 fi - break # EOF (rc 1), a stall (rc >128), or a read error + break # EOF (rc 1), an empty timed-out window, or a read error done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 @@ -1138,7 +1176,7 @@ hook::bash_parse_segments() { # the '(' separator splits it into a segment that gets scanned. skipnext=0 else - while ((i + 1 < n)) && [[ "${chars[i + 1]}" == [\<\>] ]]; do ((i++)); done + while ((i + 1 < n)) && [[ "${chars[i + 1]}" == [\<\>] ]]; do ((i++)); done # portability-ok: bash glob bracket class matching a literal < or > character, not a GNU grep \< \> word boundary if ((i + 1 < n)) && [[ "${chars[i + 1]}" == '&' ]]; then ((i++)) if ((i + 1 < n)) && [[ "${chars[i + 1]}" == [0-9-] ]]; then diff --git a/plugins/go-format/CHANGELOG.md b/plugins/go-format/CHANGELOG.md index 6a2973333..bf2dffa6d 100644 --- a/plugins/go-format/CHANGELOG.md +++ b/plugins/go-format/CHANGELOG.md @@ -14,9 +14,12 @@ All notable changes to the `go-format` plugin are documented here. Format follow the read returned a truncated payload and rc 1, and this plugin's hooks took their `|| exit 0` branch — the hook did not run at all, with no diagnostic, on exactly the large writes it was most wanted for. The read is now chunked (`read -N`), which bash satisfies with block reads, and - the bound became an idle bound that arms per chunk: a read still making progress is never cut - off, while a pipe that goes silent for `stdin_read_timeout` seconds still stops the read the - same way. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. Synced + the bound became a true idle bound: `read -t` is a deadline for the whole requested read rather + than an inactivity timer, so a timed-out read that nevertheless returned bytes is now treated as + progress — its partial chunk is kept and a fresh window is armed. Only a window that delivers + nothing at all is a stall. `read -N` is Bash 4.1+, and these hooks support Bash 3.2+ (macOS + system bash), so the pre-4.1 path falls back to the delimiter read inside the same re-arming + loop. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. Synced from `lib/hook-utils.sh`; this plugin's own hook behavior is otherwise unchanged. ## [0.2.4] diff --git a/plugins/go-format/hooks/hook-utils.sh b/plugins/go-format/hooks/hook-utils.sh index 858e644dc..52398f67e 100644 --- a/plugins/go-format/hooks/hook-utils.sh +++ b/plugins/go-format/hooks/hook-utils.sh @@ -235,41 +235,79 @@ hook::repo_root() { # # The bound (stdin_read_timeout userConfig option, in seconds, read via # CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2) is an IDLE bound, not a -# total one: it arms per chunk, so a read that keeps making progress is never -# cut off, while a pipe that goes silent for that long still fails closed. The -# distinction is load-bearing. `read -d ''` consumes a pipe one byte at a time -# (~32 KB/s on Git Bash), so a total bound was really a ~64 KB throughput -# ceiling — every larger payload tripped the timeout branch and every -# fail-closed caller blocked a legitimate write. `read -N` lets bash satisfy -# the read in blocks instead: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from -# ~6800 ms to ~85 ms. Claude Code's own default `command` hook timeout is 600 s -# (https://code.claude.com/docs/en/hooks), so the idle bound is not tracking -# any harness limit. +# total one: only a window in which NOTHING arrives ends the read. A single +# `read -d ''` bounded by -t was a total bound, and because bash consumes +# `read -d ''` on a pipe one byte at a time (~32 KB/s on Git Bash) that made it +# a ~64 KB THROUGHPUT ceiling — every larger payload tripped the timeout branch, +# so fail-closed callers blocked a legitimate write and fail-open callers +# skipped silently. Two things together make the bound mean what it says: +# +# * The read is chunked. `read -N` lets bash satisfy it in blocks instead of +# byte-at-a-time: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms +# to ~85 ms. +# * The timer re-arms on progress. `read -t` is a deadline for the WHOLE +# requested read, not an inactivity timer, so a producer that keeps +# delivering but slower than one chunk per window would still trip it. `read` +# assigns whatever it did receive even when it times out, so a timed-out read +# that returned bytes is progress, not a stall: the loop keeps that partial +# chunk and arms a fresh window. Only a window that delivers nothing at all +# is a stall. +# +# The trade this makes: a producer trickling bytes indefinitely is never cut off +# here. That is deliberate — the harness already caps a `command` hook at 600 s +# by default (https://code.claude.com/docs/en/hooks), and blocking a live +# producer is exactly the failure this function had. # # `read` reports which stop condition it hit — EOF returns 1, an exceeded -t -# returns >128 — and assigns whatever it did read either way, so the loop reads -# the stall verdict off $? instead of inferring it from elapsed-time arithmetic. -# jq (when present) is still the completeness backstop: a stall that neverthe- -# less delivered a complete payload is the Win32 late-EOF case this function -# exists for and must succeed, not block. A missing/broken jq (exit 127) fails -# open like absent jq. Requires Bash 4.1+ for `read -N` (this library already -# requires 4.0+ for the `${var^}` case operators in hook::normalize_path). +# returns >128 — so the loop takes the verdict off $? rather than inferring it +# from elapsed-time arithmetic. jq (when present) is still the completeness +# backstop: a stall that nevertheless delivered a complete payload is the Win32 +# late-EOF case this function exists for and must succeed, not block. A +# missing/broken jq (exit 127) fails open like absent jq. +# +# `read -N` is Bash 4.1+; macOS ships Bash 3.2 and these hooks document 3.2+ +# support, so the pre-4.1 branch falls back to the delimiter read, which already +# reads to EOF and is fast enough on native POSIX pipes. Same guard and same +# rationale as plugins/context-guard/scripts/statusline-tee.sh. The re-arming +# loop wraps both forms, so 3.2 gets the progress semantics too — just in +# byte-at-a-time-sized steps. # INPUT=$(hook::buffer_stdin) || exit 0 + +# The `read -N` availability guard, split out as its own predicate so the +# pre-4.1 path stays reachable in tests on a modern host: BASH_VERSINFO is +# readonly, so it cannot be shadowed, but a test can override this function +# after sourcing. Not a consumer seam — nothing reads it from the environment. +hook::read_supports_nchars() { + ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))) +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + local -a read_opts=(-r -t "$read_timeout") + if hook::read_supports_nchars; then + read_opts+=(-N 65536) + else + read_opts+=(-d '') + fi while :; do chunk="" read_rc=0 - IFS= read -r -t "$read_timeout" -N 65536 chunk || read_rc=$? + # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array + IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" if ((read_rc == 0)); then - continue # a full chunk — more may still be coming + continue # a full chunk (or a delimiter) — more may still be coming fi if ((read_rc > 128)); then + # Timed out. Bytes in this window mean the producer is alive: keep them + # and re-arm. An empty window is the stall this guard exists to catch. + if [[ -n "$chunk" ]]; then + continue + fi stalled=1 fi - break # EOF (rc 1), a stall (rc >128), or a read error + break # EOF (rc 1), an empty timed-out window, or a read error done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 @@ -1138,7 +1176,7 @@ hook::bash_parse_segments() { # the '(' separator splits it into a segment that gets scanned. skipnext=0 else - while ((i + 1 < n)) && [[ "${chars[i + 1]}" == [\<\>] ]]; do ((i++)); done + while ((i + 1 < n)) && [[ "${chars[i + 1]}" == [\<\>] ]]; do ((i++)); done # portability-ok: bash glob bracket class matching a literal < or > character, not a GNU grep \< \> word boundary if ((i + 1 < n)) && [[ "${chars[i + 1]}" == '&' ]]; then ((i++)) if ((i + 1 < n)) && [[ "${chars[i + 1]}" == [0-9-] ]]; then diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index c014ad91e..2ef877c4e 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -17,10 +17,15 @@ All notable changes to the `guardrails` plugin are documented here. Format follo write whose content was never even scanned. Observed in the field as a full-file write of an 844-line document being blocked repeatedly, forcing the author to write it in five chunks; reproduced here end-to-end with a benign 100 KB payload. - The read is now chunked (`read -N`), which bash satisfies with block reads, and the bound became an - **idle** bound that arms per chunk: a read still making progress is never cut off, while a pipe - that goes silent for `stdin_read_timeout` seconds still fails closed with rc 2 exactly as before. - Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. + The read is now chunked (`read -N`), which bash satisfies with block reads, and the bound became a + true **idle** bound: `read -t` is a deadline for the whole requested read rather than an inactivity + timer, so a timed-out read that nevertheless returned bytes is now treated as progress — its + partial chunk is kept and a fresh window is armed. Only a window that delivers nothing at all is a + stall, and that still fails closed with rc 2 exactly as before. Measured: 50 KB drops from + ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. + `read -N` is Bash 4.1+, and these hooks support Bash 3.2+ (macOS system bash), so the pre-4.1 path + falls back to the delimiter read inside the same re-arming loop — same guard and rationale as + `context-guard`'s `statusline-tee.sh`. **The fail-closed posture is unchanged** — a stalled pipe still yields rc 2 (regression test in `lib/hook-utils.test.sh`), a payload containing a violation is still blocked, and a violation sitting at the very end of a 200 KB payload is now *caught* rather than swept up in a diff --git a/plugins/guardrails/README.md b/plugins/guardrails/README.md index 136a9136b..2d196034a 100644 --- a/plugins/guardrails/README.md +++ b/plugins/guardrails/README.md @@ -131,12 +131,12 @@ plugin in that project's `enabledPlugins` instead. One further option tunes the hooks' shared plumbing rather than a single guard: - **`stdin_read_timeout`** (number, default `2`, minimum `1`) — idle bound in - seconds on reading the hook payload from stdin. It arms per chunk, not over - the whole read, so a large payload that keeps arriving is never cut off; it - fires only when the pipe goes silent for that long, at which point a blocking - guard fails **closed** (`exit 2` with a `BLOCKED:` reason) rather than letting - an unscanned tool call through. Raise it only if a genuinely slow producer - trips it. + seconds on reading the hook payload from stdin. It re-arms whenever bytes + arrive, not once over the whole read, so a large or slowly-delivered payload + is never cut off while it is still coming; it fires only when the pipe goes + silent for that long, at which point a blocking guard fails **closed** + (`exit 2` with a `BLOCKED:` reason) rather than letting an unscanned tool call + through. You should not need to change it. ## Consumer seams diff --git a/plugins/guardrails/hooks/hook-utils.sh b/plugins/guardrails/hooks/hook-utils.sh index 858e644dc..52398f67e 100644 --- a/plugins/guardrails/hooks/hook-utils.sh +++ b/plugins/guardrails/hooks/hook-utils.sh @@ -235,41 +235,79 @@ hook::repo_root() { # # The bound (stdin_read_timeout userConfig option, in seconds, read via # CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2) is an IDLE bound, not a -# total one: it arms per chunk, so a read that keeps making progress is never -# cut off, while a pipe that goes silent for that long still fails closed. The -# distinction is load-bearing. `read -d ''` consumes a pipe one byte at a time -# (~32 KB/s on Git Bash), so a total bound was really a ~64 KB throughput -# ceiling — every larger payload tripped the timeout branch and every -# fail-closed caller blocked a legitimate write. `read -N` lets bash satisfy -# the read in blocks instead: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from -# ~6800 ms to ~85 ms. Claude Code's own default `command` hook timeout is 600 s -# (https://code.claude.com/docs/en/hooks), so the idle bound is not tracking -# any harness limit. +# total one: only a window in which NOTHING arrives ends the read. A single +# `read -d ''` bounded by -t was a total bound, and because bash consumes +# `read -d ''` on a pipe one byte at a time (~32 KB/s on Git Bash) that made it +# a ~64 KB THROUGHPUT ceiling — every larger payload tripped the timeout branch, +# so fail-closed callers blocked a legitimate write and fail-open callers +# skipped silently. Two things together make the bound mean what it says: +# +# * The read is chunked. `read -N` lets bash satisfy it in blocks instead of +# byte-at-a-time: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms +# to ~85 ms. +# * The timer re-arms on progress. `read -t` is a deadline for the WHOLE +# requested read, not an inactivity timer, so a producer that keeps +# delivering but slower than one chunk per window would still trip it. `read` +# assigns whatever it did receive even when it times out, so a timed-out read +# that returned bytes is progress, not a stall: the loop keeps that partial +# chunk and arms a fresh window. Only a window that delivers nothing at all +# is a stall. +# +# The trade this makes: a producer trickling bytes indefinitely is never cut off +# here. That is deliberate — the harness already caps a `command` hook at 600 s +# by default (https://code.claude.com/docs/en/hooks), and blocking a live +# producer is exactly the failure this function had. # # `read` reports which stop condition it hit — EOF returns 1, an exceeded -t -# returns >128 — and assigns whatever it did read either way, so the loop reads -# the stall verdict off $? instead of inferring it from elapsed-time arithmetic. -# jq (when present) is still the completeness backstop: a stall that neverthe- -# less delivered a complete payload is the Win32 late-EOF case this function -# exists for and must succeed, not block. A missing/broken jq (exit 127) fails -# open like absent jq. Requires Bash 4.1+ for `read -N` (this library already -# requires 4.0+ for the `${var^}` case operators in hook::normalize_path). +# returns >128 — so the loop takes the verdict off $? rather than inferring it +# from elapsed-time arithmetic. jq (when present) is still the completeness +# backstop: a stall that nevertheless delivered a complete payload is the Win32 +# late-EOF case this function exists for and must succeed, not block. A +# missing/broken jq (exit 127) fails open like absent jq. +# +# `read -N` is Bash 4.1+; macOS ships Bash 3.2 and these hooks document 3.2+ +# support, so the pre-4.1 branch falls back to the delimiter read, which already +# reads to EOF and is fast enough on native POSIX pipes. Same guard and same +# rationale as plugins/context-guard/scripts/statusline-tee.sh. The re-arming +# loop wraps both forms, so 3.2 gets the progress semantics too — just in +# byte-at-a-time-sized steps. # INPUT=$(hook::buffer_stdin) || exit 0 + +# The `read -N` availability guard, split out as its own predicate so the +# pre-4.1 path stays reachable in tests on a modern host: BASH_VERSINFO is +# readonly, so it cannot be shadowed, but a test can override this function +# after sourcing. Not a consumer seam — nothing reads it from the environment. +hook::read_supports_nchars() { + ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))) +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + local -a read_opts=(-r -t "$read_timeout") + if hook::read_supports_nchars; then + read_opts+=(-N 65536) + else + read_opts+=(-d '') + fi while :; do chunk="" read_rc=0 - IFS= read -r -t "$read_timeout" -N 65536 chunk || read_rc=$? + # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array + IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" if ((read_rc == 0)); then - continue # a full chunk — more may still be coming + continue # a full chunk (or a delimiter) — more may still be coming fi if ((read_rc > 128)); then + # Timed out. Bytes in this window mean the producer is alive: keep them + # and re-arm. An empty window is the stall this guard exists to catch. + if [[ -n "$chunk" ]]; then + continue + fi stalled=1 fi - break # EOF (rc 1), a stall (rc >128), or a read error + break # EOF (rc 1), an empty timed-out window, or a read error done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 @@ -1138,7 +1176,7 @@ hook::bash_parse_segments() { # the '(' separator splits it into a segment that gets scanned. skipnext=0 else - while ((i + 1 < n)) && [[ "${chars[i + 1]}" == [\<\>] ]]; do ((i++)); done + while ((i + 1 < n)) && [[ "${chars[i + 1]}" == [\<\>] ]]; do ((i++)); done # portability-ok: bash glob bracket class matching a literal < or > character, not a GNU grep \< \> word boundary if ((i + 1 < n)) && [[ "${chars[i + 1]}" == '&' ]]; then ((i++)) if ((i + 1 < n)) && [[ "${chars[i + 1]}" == [0-9-] ]]; then diff --git a/plugins/markdown-format/CHANGELOG.md b/plugins/markdown-format/CHANGELOG.md index e87416d53..095b1492c 100644 --- a/plugins/markdown-format/CHANGELOG.md +++ b/plugins/markdown-format/CHANGELOG.md @@ -14,9 +14,12 @@ All notable changes to the `markdown-format` plugin are documented here. Format the read returned a truncated payload and rc 1, and this plugin's hooks took their `|| exit 0` branch — the hook did not run at all, with no diagnostic, on exactly the large writes it was most wanted for. The read is now chunked (`read -N`), which bash satisfies with block reads, and - the bound became an idle bound that arms per chunk: a read still making progress is never cut - off, while a pipe that goes silent for `stdin_read_timeout` seconds still stops the read the - same way. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. Synced + the bound became a true idle bound: `read -t` is a deadline for the whole requested read rather + than an inactivity timer, so a timed-out read that nevertheless returned bytes is now treated as + progress — its partial chunk is kept and a fresh window is armed. Only a window that delivers + nothing at all is a stall. `read -N` is Bash 4.1+, and these hooks support Bash 3.2+ (macOS + system bash), so the pre-4.1 path falls back to the delimiter read inside the same re-arming + loop. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. Synced from `lib/hook-utils.sh`; this plugin's own hook behavior is otherwise unchanged. ## [0.7.1] diff --git a/plugins/markdown-format/hooks/hook-utils.sh b/plugins/markdown-format/hooks/hook-utils.sh index 858e644dc..52398f67e 100644 --- a/plugins/markdown-format/hooks/hook-utils.sh +++ b/plugins/markdown-format/hooks/hook-utils.sh @@ -235,41 +235,79 @@ hook::repo_root() { # # The bound (stdin_read_timeout userConfig option, in seconds, read via # CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2) is an IDLE bound, not a -# total one: it arms per chunk, so a read that keeps making progress is never -# cut off, while a pipe that goes silent for that long still fails closed. The -# distinction is load-bearing. `read -d ''` consumes a pipe one byte at a time -# (~32 KB/s on Git Bash), so a total bound was really a ~64 KB throughput -# ceiling — every larger payload tripped the timeout branch and every -# fail-closed caller blocked a legitimate write. `read -N` lets bash satisfy -# the read in blocks instead: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from -# ~6800 ms to ~85 ms. Claude Code's own default `command` hook timeout is 600 s -# (https://code.claude.com/docs/en/hooks), so the idle bound is not tracking -# any harness limit. +# total one: only a window in which NOTHING arrives ends the read. A single +# `read -d ''` bounded by -t was a total bound, and because bash consumes +# `read -d ''` on a pipe one byte at a time (~32 KB/s on Git Bash) that made it +# a ~64 KB THROUGHPUT ceiling — every larger payload tripped the timeout branch, +# so fail-closed callers blocked a legitimate write and fail-open callers +# skipped silently. Two things together make the bound mean what it says: +# +# * The read is chunked. `read -N` lets bash satisfy it in blocks instead of +# byte-at-a-time: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms +# to ~85 ms. +# * The timer re-arms on progress. `read -t` is a deadline for the WHOLE +# requested read, not an inactivity timer, so a producer that keeps +# delivering but slower than one chunk per window would still trip it. `read` +# assigns whatever it did receive even when it times out, so a timed-out read +# that returned bytes is progress, not a stall: the loop keeps that partial +# chunk and arms a fresh window. Only a window that delivers nothing at all +# is a stall. +# +# The trade this makes: a producer trickling bytes indefinitely is never cut off +# here. That is deliberate — the harness already caps a `command` hook at 600 s +# by default (https://code.claude.com/docs/en/hooks), and blocking a live +# producer is exactly the failure this function had. # # `read` reports which stop condition it hit — EOF returns 1, an exceeded -t -# returns >128 — and assigns whatever it did read either way, so the loop reads -# the stall verdict off $? instead of inferring it from elapsed-time arithmetic. -# jq (when present) is still the completeness backstop: a stall that neverthe- -# less delivered a complete payload is the Win32 late-EOF case this function -# exists for and must succeed, not block. A missing/broken jq (exit 127) fails -# open like absent jq. Requires Bash 4.1+ for `read -N` (this library already -# requires 4.0+ for the `${var^}` case operators in hook::normalize_path). +# returns >128 — so the loop takes the verdict off $? rather than inferring it +# from elapsed-time arithmetic. jq (when present) is still the completeness +# backstop: a stall that nevertheless delivered a complete payload is the Win32 +# late-EOF case this function exists for and must succeed, not block. A +# missing/broken jq (exit 127) fails open like absent jq. +# +# `read -N` is Bash 4.1+; macOS ships Bash 3.2 and these hooks document 3.2+ +# support, so the pre-4.1 branch falls back to the delimiter read, which already +# reads to EOF and is fast enough on native POSIX pipes. Same guard and same +# rationale as plugins/context-guard/scripts/statusline-tee.sh. The re-arming +# loop wraps both forms, so 3.2 gets the progress semantics too — just in +# byte-at-a-time-sized steps. # INPUT=$(hook::buffer_stdin) || exit 0 + +# The `read -N` availability guard, split out as its own predicate so the +# pre-4.1 path stays reachable in tests on a modern host: BASH_VERSINFO is +# readonly, so it cannot be shadowed, but a test can override this function +# after sourcing. Not a consumer seam — nothing reads it from the environment. +hook::read_supports_nchars() { + ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))) +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + local -a read_opts=(-r -t "$read_timeout") + if hook::read_supports_nchars; then + read_opts+=(-N 65536) + else + read_opts+=(-d '') + fi while :; do chunk="" read_rc=0 - IFS= read -r -t "$read_timeout" -N 65536 chunk || read_rc=$? + # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array + IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" if ((read_rc == 0)); then - continue # a full chunk — more may still be coming + continue # a full chunk (or a delimiter) — more may still be coming fi if ((read_rc > 128)); then + # Timed out. Bytes in this window mean the producer is alive: keep them + # and re-arm. An empty window is the stall this guard exists to catch. + if [[ -n "$chunk" ]]; then + continue + fi stalled=1 fi - break # EOF (rc 1), a stall (rc >128), or a read error + break # EOF (rc 1), an empty timed-out window, or a read error done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 @@ -1138,7 +1176,7 @@ hook::bash_parse_segments() { # the '(' separator splits it into a segment that gets scanned. skipnext=0 else - while ((i + 1 < n)) && [[ "${chars[i + 1]}" == [\<\>] ]]; do ((i++)); done + while ((i + 1 < n)) && [[ "${chars[i + 1]}" == [\<\>] ]]; do ((i++)); done # portability-ok: bash glob bracket class matching a literal < or > character, not a GNU grep \< \> word boundary if ((i + 1 < n)) && [[ "${chars[i + 1]}" == '&' ]]; then ((i++)) if ((i + 1 < n)) && [[ "${chars[i + 1]}" == [0-9-] ]]; then diff --git a/plugins/powershell-format/CHANGELOG.md b/plugins/powershell-format/CHANGELOG.md index 5776b1c2f..563b7267d 100644 --- a/plugins/powershell-format/CHANGELOG.md +++ b/plugins/powershell-format/CHANGELOG.md @@ -14,9 +14,12 @@ All notable changes to the `powershell-format` plugin are documented here. Forma the read returned a truncated payload and rc 1, and this plugin's hooks took their `|| exit 0` branch — the hook did not run at all, with no diagnostic, on exactly the large writes it was most wanted for. The read is now chunked (`read -N`), which bash satisfies with block reads, and - the bound became an idle bound that arms per chunk: a read still making progress is never cut - off, while a pipe that goes silent for `stdin_read_timeout` seconds still stops the read the - same way. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. Synced + the bound became a true idle bound: `read -t` is a deadline for the whole requested read rather + than an inactivity timer, so a timed-out read that nevertheless returned bytes is now treated as + progress — its partial chunk is kept and a fresh window is armed. Only a window that delivers + nothing at all is a stall. `read -N` is Bash 4.1+, and these hooks support Bash 3.2+ (macOS + system bash), so the pre-4.1 path falls back to the delimiter read inside the same re-arming + loop. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. Synced from `lib/hook-utils.sh`; this plugin's own hook behavior is otherwise unchanged. ## [0.6.1] diff --git a/plugins/powershell-format/hooks/hook-utils.sh b/plugins/powershell-format/hooks/hook-utils.sh index 858e644dc..52398f67e 100644 --- a/plugins/powershell-format/hooks/hook-utils.sh +++ b/plugins/powershell-format/hooks/hook-utils.sh @@ -235,41 +235,79 @@ hook::repo_root() { # # The bound (stdin_read_timeout userConfig option, in seconds, read via # CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2) is an IDLE bound, not a -# total one: it arms per chunk, so a read that keeps making progress is never -# cut off, while a pipe that goes silent for that long still fails closed. The -# distinction is load-bearing. `read -d ''` consumes a pipe one byte at a time -# (~32 KB/s on Git Bash), so a total bound was really a ~64 KB throughput -# ceiling — every larger payload tripped the timeout branch and every -# fail-closed caller blocked a legitimate write. `read -N` lets bash satisfy -# the read in blocks instead: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from -# ~6800 ms to ~85 ms. Claude Code's own default `command` hook timeout is 600 s -# (https://code.claude.com/docs/en/hooks), so the idle bound is not tracking -# any harness limit. +# total one: only a window in which NOTHING arrives ends the read. A single +# `read -d ''` bounded by -t was a total bound, and because bash consumes +# `read -d ''` on a pipe one byte at a time (~32 KB/s on Git Bash) that made it +# a ~64 KB THROUGHPUT ceiling — every larger payload tripped the timeout branch, +# so fail-closed callers blocked a legitimate write and fail-open callers +# skipped silently. Two things together make the bound mean what it says: +# +# * The read is chunked. `read -N` lets bash satisfy it in blocks instead of +# byte-at-a-time: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms +# to ~85 ms. +# * The timer re-arms on progress. `read -t` is a deadline for the WHOLE +# requested read, not an inactivity timer, so a producer that keeps +# delivering but slower than one chunk per window would still trip it. `read` +# assigns whatever it did receive even when it times out, so a timed-out read +# that returned bytes is progress, not a stall: the loop keeps that partial +# chunk and arms a fresh window. Only a window that delivers nothing at all +# is a stall. +# +# The trade this makes: a producer trickling bytes indefinitely is never cut off +# here. That is deliberate — the harness already caps a `command` hook at 600 s +# by default (https://code.claude.com/docs/en/hooks), and blocking a live +# producer is exactly the failure this function had. # # `read` reports which stop condition it hit — EOF returns 1, an exceeded -t -# returns >128 — and assigns whatever it did read either way, so the loop reads -# the stall verdict off $? instead of inferring it from elapsed-time arithmetic. -# jq (when present) is still the completeness backstop: a stall that neverthe- -# less delivered a complete payload is the Win32 late-EOF case this function -# exists for and must succeed, not block. A missing/broken jq (exit 127) fails -# open like absent jq. Requires Bash 4.1+ for `read -N` (this library already -# requires 4.0+ for the `${var^}` case operators in hook::normalize_path). +# returns >128 — so the loop takes the verdict off $? rather than inferring it +# from elapsed-time arithmetic. jq (when present) is still the completeness +# backstop: a stall that nevertheless delivered a complete payload is the Win32 +# late-EOF case this function exists for and must succeed, not block. A +# missing/broken jq (exit 127) fails open like absent jq. +# +# `read -N` is Bash 4.1+; macOS ships Bash 3.2 and these hooks document 3.2+ +# support, so the pre-4.1 branch falls back to the delimiter read, which already +# reads to EOF and is fast enough on native POSIX pipes. Same guard and same +# rationale as plugins/context-guard/scripts/statusline-tee.sh. The re-arming +# loop wraps both forms, so 3.2 gets the progress semantics too — just in +# byte-at-a-time-sized steps. # INPUT=$(hook::buffer_stdin) || exit 0 + +# The `read -N` availability guard, split out as its own predicate so the +# pre-4.1 path stays reachable in tests on a modern host: BASH_VERSINFO is +# readonly, so it cannot be shadowed, but a test can override this function +# after sourcing. Not a consumer seam — nothing reads it from the environment. +hook::read_supports_nchars() { + ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))) +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + local -a read_opts=(-r -t "$read_timeout") + if hook::read_supports_nchars; then + read_opts+=(-N 65536) + else + read_opts+=(-d '') + fi while :; do chunk="" read_rc=0 - IFS= read -r -t "$read_timeout" -N 65536 chunk || read_rc=$? + # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array + IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" if ((read_rc == 0)); then - continue # a full chunk — more may still be coming + continue # a full chunk (or a delimiter) — more may still be coming fi if ((read_rc > 128)); then + # Timed out. Bytes in this window mean the producer is alive: keep them + # and re-arm. An empty window is the stall this guard exists to catch. + if [[ -n "$chunk" ]]; then + continue + fi stalled=1 fi - break # EOF (rc 1), a stall (rc >128), or a read error + break # EOF (rc 1), an empty timed-out window, or a read error done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 @@ -1138,7 +1176,7 @@ hook::bash_parse_segments() { # the '(' separator splits it into a segment that gets scanned. skipnext=0 else - while ((i + 1 < n)) && [[ "${chars[i + 1]}" == [\<\>] ]]; do ((i++)); done + while ((i + 1 < n)) && [[ "${chars[i + 1]}" == [\<\>] ]]; do ((i++)); done # portability-ok: bash glob bracket class matching a literal < or > character, not a GNU grep \< \> word boundary if ((i + 1 < n)) && [[ "${chars[i + 1]}" == '&' ]]; then ((i++)) if ((i + 1 < n)) && [[ "${chars[i + 1]}" == [0-9-] ]]; then diff --git a/plugins/rate-limit-guard/CHANGELOG.md b/plugins/rate-limit-guard/CHANGELOG.md index 257ad42ca..63e2e88c4 100644 --- a/plugins/rate-limit-guard/CHANGELOG.md +++ b/plugins/rate-limit-guard/CHANGELOG.md @@ -14,9 +14,12 @@ All notable changes to the `rate-limit-guard` plugin are documented here. Format the read returned a truncated payload and rc 1, and this plugin's hooks took their `|| exit 0` branch — the hook did not run at all, with no diagnostic, on exactly the large writes it was most wanted for. The read is now chunked (`read -N`), which bash satisfies with block reads, and - the bound became an idle bound that arms per chunk: a read still making progress is never cut - off, while a pipe that goes silent for `stdin_read_timeout` seconds still stops the read the - same way. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. Synced + the bound became a true idle bound: `read -t` is a deadline for the whole requested read rather + than an inactivity timer, so a timed-out read that nevertheless returned bytes is now treated as + progress — its partial chunk is kept and a fresh window is armed. Only a window that delivers + nothing at all is a stall. `read -N` is Bash 4.1+, and these hooks support Bash 3.2+ (macOS + system bash), so the pre-4.1 path falls back to the delimiter read inside the same re-arming + loop. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. Synced from `lib/hook-utils.sh`; this plugin's own hook behavior is otherwise unchanged. ## [0.3.2] diff --git a/plugins/rate-limit-guard/hooks/hook-utils.sh b/plugins/rate-limit-guard/hooks/hook-utils.sh index 858e644dc..52398f67e 100755 --- a/plugins/rate-limit-guard/hooks/hook-utils.sh +++ b/plugins/rate-limit-guard/hooks/hook-utils.sh @@ -235,41 +235,79 @@ hook::repo_root() { # # The bound (stdin_read_timeout userConfig option, in seconds, read via # CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2) is an IDLE bound, not a -# total one: it arms per chunk, so a read that keeps making progress is never -# cut off, while a pipe that goes silent for that long still fails closed. The -# distinction is load-bearing. `read -d ''` consumes a pipe one byte at a time -# (~32 KB/s on Git Bash), so a total bound was really a ~64 KB throughput -# ceiling — every larger payload tripped the timeout branch and every -# fail-closed caller blocked a legitimate write. `read -N` lets bash satisfy -# the read in blocks instead: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from -# ~6800 ms to ~85 ms. Claude Code's own default `command` hook timeout is 600 s -# (https://code.claude.com/docs/en/hooks), so the idle bound is not tracking -# any harness limit. +# total one: only a window in which NOTHING arrives ends the read. A single +# `read -d ''` bounded by -t was a total bound, and because bash consumes +# `read -d ''` on a pipe one byte at a time (~32 KB/s on Git Bash) that made it +# a ~64 KB THROUGHPUT ceiling — every larger payload tripped the timeout branch, +# so fail-closed callers blocked a legitimate write and fail-open callers +# skipped silently. Two things together make the bound mean what it says: +# +# * The read is chunked. `read -N` lets bash satisfy it in blocks instead of +# byte-at-a-time: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms +# to ~85 ms. +# * The timer re-arms on progress. `read -t` is a deadline for the WHOLE +# requested read, not an inactivity timer, so a producer that keeps +# delivering but slower than one chunk per window would still trip it. `read` +# assigns whatever it did receive even when it times out, so a timed-out read +# that returned bytes is progress, not a stall: the loop keeps that partial +# chunk and arms a fresh window. Only a window that delivers nothing at all +# is a stall. +# +# The trade this makes: a producer trickling bytes indefinitely is never cut off +# here. That is deliberate — the harness already caps a `command` hook at 600 s +# by default (https://code.claude.com/docs/en/hooks), and blocking a live +# producer is exactly the failure this function had. # # `read` reports which stop condition it hit — EOF returns 1, an exceeded -t -# returns >128 — and assigns whatever it did read either way, so the loop reads -# the stall verdict off $? instead of inferring it from elapsed-time arithmetic. -# jq (when present) is still the completeness backstop: a stall that neverthe- -# less delivered a complete payload is the Win32 late-EOF case this function -# exists for and must succeed, not block. A missing/broken jq (exit 127) fails -# open like absent jq. Requires Bash 4.1+ for `read -N` (this library already -# requires 4.0+ for the `${var^}` case operators in hook::normalize_path). +# returns >128 — so the loop takes the verdict off $? rather than inferring it +# from elapsed-time arithmetic. jq (when present) is still the completeness +# backstop: a stall that nevertheless delivered a complete payload is the Win32 +# late-EOF case this function exists for and must succeed, not block. A +# missing/broken jq (exit 127) fails open like absent jq. +# +# `read -N` is Bash 4.1+; macOS ships Bash 3.2 and these hooks document 3.2+ +# support, so the pre-4.1 branch falls back to the delimiter read, which already +# reads to EOF and is fast enough on native POSIX pipes. Same guard and same +# rationale as plugins/context-guard/scripts/statusline-tee.sh. The re-arming +# loop wraps both forms, so 3.2 gets the progress semantics too — just in +# byte-at-a-time-sized steps. # INPUT=$(hook::buffer_stdin) || exit 0 + +# The `read -N` availability guard, split out as its own predicate so the +# pre-4.1 path stays reachable in tests on a modern host: BASH_VERSINFO is +# readonly, so it cannot be shadowed, but a test can override this function +# after sourcing. Not a consumer seam — nothing reads it from the environment. +hook::read_supports_nchars() { + ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))) +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + local -a read_opts=(-r -t "$read_timeout") + if hook::read_supports_nchars; then + read_opts+=(-N 65536) + else + read_opts+=(-d '') + fi while :; do chunk="" read_rc=0 - IFS= read -r -t "$read_timeout" -N 65536 chunk || read_rc=$? + # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array + IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" if ((read_rc == 0)); then - continue # a full chunk — more may still be coming + continue # a full chunk (or a delimiter) — more may still be coming fi if ((read_rc > 128)); then + # Timed out. Bytes in this window mean the producer is alive: keep them + # and re-arm. An empty window is the stall this guard exists to catch. + if [[ -n "$chunk" ]]; then + continue + fi stalled=1 fi - break # EOF (rc 1), a stall (rc >128), or a read error + break # EOF (rc 1), an empty timed-out window, or a read error done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 @@ -1138,7 +1176,7 @@ hook::bash_parse_segments() { # the '(' separator splits it into a segment that gets scanned. skipnext=0 else - while ((i + 1 < n)) && [[ "${chars[i + 1]}" == [\<\>] ]]; do ((i++)); done + while ((i + 1 < n)) && [[ "${chars[i + 1]}" == [\<\>] ]]; do ((i++)); done # portability-ok: bash glob bracket class matching a literal < or > character, not a GNU grep \< \> word boundary if ((i + 1 < n)) && [[ "${chars[i + 1]}" == '&' ]]; then ((i++)) if ((i + 1 < n)) && [[ "${chars[i + 1]}" == [0-9-] ]]; then diff --git a/plugins/ruff-format/CHANGELOG.md b/plugins/ruff-format/CHANGELOG.md index 1738b9a2a..436ff8b54 100644 --- a/plugins/ruff-format/CHANGELOG.md +++ b/plugins/ruff-format/CHANGELOG.md @@ -14,9 +14,12 @@ All notable changes to the `ruff-format` plugin are documented here. Format foll the read returned a truncated payload and rc 1, and this plugin's hooks took their `|| exit 0` branch — the hook did not run at all, with no diagnostic, on exactly the large writes it was most wanted for. The read is now chunked (`read -N`), which bash satisfies with block reads, and - the bound became an idle bound that arms per chunk: a read still making progress is never cut - off, while a pipe that goes silent for `stdin_read_timeout` seconds still stops the read the - same way. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. Synced + the bound became a true idle bound: `read -t` is a deadline for the whole requested read rather + than an inactivity timer, so a timed-out read that nevertheless returned bytes is now treated as + progress — its partial chunk is kept and a fresh window is armed. Only a window that delivers + nothing at all is a stall. `read -N` is Bash 4.1+, and these hooks support Bash 3.2+ (macOS + system bash), so the pre-4.1 path falls back to the delimiter read inside the same re-arming + loop. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. Synced from `lib/hook-utils.sh`; this plugin's own hook behavior is otherwise unchanged. ## [0.5.4] diff --git a/plugins/ruff-format/hooks/hook-utils.sh b/plugins/ruff-format/hooks/hook-utils.sh index 858e644dc..52398f67e 100644 --- a/plugins/ruff-format/hooks/hook-utils.sh +++ b/plugins/ruff-format/hooks/hook-utils.sh @@ -235,41 +235,79 @@ hook::repo_root() { # # The bound (stdin_read_timeout userConfig option, in seconds, read via # CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2) is an IDLE bound, not a -# total one: it arms per chunk, so a read that keeps making progress is never -# cut off, while a pipe that goes silent for that long still fails closed. The -# distinction is load-bearing. `read -d ''` consumes a pipe one byte at a time -# (~32 KB/s on Git Bash), so a total bound was really a ~64 KB throughput -# ceiling — every larger payload tripped the timeout branch and every -# fail-closed caller blocked a legitimate write. `read -N` lets bash satisfy -# the read in blocks instead: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from -# ~6800 ms to ~85 ms. Claude Code's own default `command` hook timeout is 600 s -# (https://code.claude.com/docs/en/hooks), so the idle bound is not tracking -# any harness limit. +# total one: only a window in which NOTHING arrives ends the read. A single +# `read -d ''` bounded by -t was a total bound, and because bash consumes +# `read -d ''` on a pipe one byte at a time (~32 KB/s on Git Bash) that made it +# a ~64 KB THROUGHPUT ceiling — every larger payload tripped the timeout branch, +# so fail-closed callers blocked a legitimate write and fail-open callers +# skipped silently. Two things together make the bound mean what it says: +# +# * The read is chunked. `read -N` lets bash satisfy it in blocks instead of +# byte-at-a-time: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms +# to ~85 ms. +# * The timer re-arms on progress. `read -t` is a deadline for the WHOLE +# requested read, not an inactivity timer, so a producer that keeps +# delivering but slower than one chunk per window would still trip it. `read` +# assigns whatever it did receive even when it times out, so a timed-out read +# that returned bytes is progress, not a stall: the loop keeps that partial +# chunk and arms a fresh window. Only a window that delivers nothing at all +# is a stall. +# +# The trade this makes: a producer trickling bytes indefinitely is never cut off +# here. That is deliberate — the harness already caps a `command` hook at 600 s +# by default (https://code.claude.com/docs/en/hooks), and blocking a live +# producer is exactly the failure this function had. # # `read` reports which stop condition it hit — EOF returns 1, an exceeded -t -# returns >128 — and assigns whatever it did read either way, so the loop reads -# the stall verdict off $? instead of inferring it from elapsed-time arithmetic. -# jq (when present) is still the completeness backstop: a stall that neverthe- -# less delivered a complete payload is the Win32 late-EOF case this function -# exists for and must succeed, not block. A missing/broken jq (exit 127) fails -# open like absent jq. Requires Bash 4.1+ for `read -N` (this library already -# requires 4.0+ for the `${var^}` case operators in hook::normalize_path). +# returns >128 — so the loop takes the verdict off $? rather than inferring it +# from elapsed-time arithmetic. jq (when present) is still the completeness +# backstop: a stall that nevertheless delivered a complete payload is the Win32 +# late-EOF case this function exists for and must succeed, not block. A +# missing/broken jq (exit 127) fails open like absent jq. +# +# `read -N` is Bash 4.1+; macOS ships Bash 3.2 and these hooks document 3.2+ +# support, so the pre-4.1 branch falls back to the delimiter read, which already +# reads to EOF and is fast enough on native POSIX pipes. Same guard and same +# rationale as plugins/context-guard/scripts/statusline-tee.sh. The re-arming +# loop wraps both forms, so 3.2 gets the progress semantics too — just in +# byte-at-a-time-sized steps. # INPUT=$(hook::buffer_stdin) || exit 0 + +# The `read -N` availability guard, split out as its own predicate so the +# pre-4.1 path stays reachable in tests on a modern host: BASH_VERSINFO is +# readonly, so it cannot be shadowed, but a test can override this function +# after sourcing. Not a consumer seam — nothing reads it from the environment. +hook::read_supports_nchars() { + ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))) +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + local -a read_opts=(-r -t "$read_timeout") + if hook::read_supports_nchars; then + read_opts+=(-N 65536) + else + read_opts+=(-d '') + fi while :; do chunk="" read_rc=0 - IFS= read -r -t "$read_timeout" -N 65536 chunk || read_rc=$? + # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array + IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" if ((read_rc == 0)); then - continue # a full chunk — more may still be coming + continue # a full chunk (or a delimiter) — more may still be coming fi if ((read_rc > 128)); then + # Timed out. Bytes in this window mean the producer is alive: keep them + # and re-arm. An empty window is the stall this guard exists to catch. + if [[ -n "$chunk" ]]; then + continue + fi stalled=1 fi - break # EOF (rc 1), a stall (rc >128), or a read error + break # EOF (rc 1), an empty timed-out window, or a read error done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 @@ -1138,7 +1176,7 @@ hook::bash_parse_segments() { # the '(' separator splits it into a segment that gets scanned. skipnext=0 else - while ((i + 1 < n)) && [[ "${chars[i + 1]}" == [\<\>] ]]; do ((i++)); done + while ((i + 1 < n)) && [[ "${chars[i + 1]}" == [\<\>] ]]; do ((i++)); done # portability-ok: bash glob bracket class matching a literal < or > character, not a GNU grep \< \> word boundary if ((i + 1 < n)) && [[ "${chars[i + 1]}" == '&' ]]; then ((i++)) if ((i + 1 < n)) && [[ "${chars[i + 1]}" == [0-9-] ]]; then diff --git a/plugins/typos-format/CHANGELOG.md b/plugins/typos-format/CHANGELOG.md index 2361ce53b..cdea24ddc 100644 --- a/plugins/typos-format/CHANGELOG.md +++ b/plugins/typos-format/CHANGELOG.md @@ -14,9 +14,12 @@ All notable changes to the `typos-format` plugin are documented here. Format fol the read returned a truncated payload and rc 1, and this plugin's hooks took their `|| exit 0` branch — the hook did not run at all, with no diagnostic, on exactly the large writes it was most wanted for. The read is now chunked (`read -N`), which bash satisfies with block reads, and - the bound became an idle bound that arms per chunk: a read still making progress is never cut - off, while a pipe that goes silent for `stdin_read_timeout` seconds still stops the read the - same way. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. Synced + the bound became a true idle bound: `read -t` is a deadline for the whole requested read rather + than an inactivity timer, so a timed-out read that nevertheless returned bytes is now treated as + progress — its partial chunk is kept and a fresh window is armed. Only a window that delivers + nothing at all is a stall. `read -N` is Bash 4.1+, and these hooks support Bash 3.2+ (macOS + system bash), so the pre-4.1 path falls back to the delimiter read inside the same re-arming + loop. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. Synced from `lib/hook-utils.sh`; this plugin's own hook behavior is otherwise unchanged. ## [0.3.4] diff --git a/plugins/typos-format/hooks/hook-utils.sh b/plugins/typos-format/hooks/hook-utils.sh index 858e644dc..52398f67e 100644 --- a/plugins/typos-format/hooks/hook-utils.sh +++ b/plugins/typos-format/hooks/hook-utils.sh @@ -235,41 +235,79 @@ hook::repo_root() { # # The bound (stdin_read_timeout userConfig option, in seconds, read via # CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2) is an IDLE bound, not a -# total one: it arms per chunk, so a read that keeps making progress is never -# cut off, while a pipe that goes silent for that long still fails closed. The -# distinction is load-bearing. `read -d ''` consumes a pipe one byte at a time -# (~32 KB/s on Git Bash), so a total bound was really a ~64 KB throughput -# ceiling — every larger payload tripped the timeout branch and every -# fail-closed caller blocked a legitimate write. `read -N` lets bash satisfy -# the read in blocks instead: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from -# ~6800 ms to ~85 ms. Claude Code's own default `command` hook timeout is 600 s -# (https://code.claude.com/docs/en/hooks), so the idle bound is not tracking -# any harness limit. +# total one: only a window in which NOTHING arrives ends the read. A single +# `read -d ''` bounded by -t was a total bound, and because bash consumes +# `read -d ''` on a pipe one byte at a time (~32 KB/s on Git Bash) that made it +# a ~64 KB THROUGHPUT ceiling — every larger payload tripped the timeout branch, +# so fail-closed callers blocked a legitimate write and fail-open callers +# skipped silently. Two things together make the bound mean what it says: +# +# * The read is chunked. `read -N` lets bash satisfy it in blocks instead of +# byte-at-a-time: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms +# to ~85 ms. +# * The timer re-arms on progress. `read -t` is a deadline for the WHOLE +# requested read, not an inactivity timer, so a producer that keeps +# delivering but slower than one chunk per window would still trip it. `read` +# assigns whatever it did receive even when it times out, so a timed-out read +# that returned bytes is progress, not a stall: the loop keeps that partial +# chunk and arms a fresh window. Only a window that delivers nothing at all +# is a stall. +# +# The trade this makes: a producer trickling bytes indefinitely is never cut off +# here. That is deliberate — the harness already caps a `command` hook at 600 s +# by default (https://code.claude.com/docs/en/hooks), and blocking a live +# producer is exactly the failure this function had. # # `read` reports which stop condition it hit — EOF returns 1, an exceeded -t -# returns >128 — and assigns whatever it did read either way, so the loop reads -# the stall verdict off $? instead of inferring it from elapsed-time arithmetic. -# jq (when present) is still the completeness backstop: a stall that neverthe- -# less delivered a complete payload is the Win32 late-EOF case this function -# exists for and must succeed, not block. A missing/broken jq (exit 127) fails -# open like absent jq. Requires Bash 4.1+ for `read -N` (this library already -# requires 4.0+ for the `${var^}` case operators in hook::normalize_path). +# returns >128 — so the loop takes the verdict off $? rather than inferring it +# from elapsed-time arithmetic. jq (when present) is still the completeness +# backstop: a stall that nevertheless delivered a complete payload is the Win32 +# late-EOF case this function exists for and must succeed, not block. A +# missing/broken jq (exit 127) fails open like absent jq. +# +# `read -N` is Bash 4.1+; macOS ships Bash 3.2 and these hooks document 3.2+ +# support, so the pre-4.1 branch falls back to the delimiter read, which already +# reads to EOF and is fast enough on native POSIX pipes. Same guard and same +# rationale as plugins/context-guard/scripts/statusline-tee.sh. The re-arming +# loop wraps both forms, so 3.2 gets the progress semantics too — just in +# byte-at-a-time-sized steps. # INPUT=$(hook::buffer_stdin) || exit 0 + +# The `read -N` availability guard, split out as its own predicate so the +# pre-4.1 path stays reachable in tests on a modern host: BASH_VERSINFO is +# readonly, so it cannot be shadowed, but a test can override this function +# after sourcing. Not a consumer seam — nothing reads it from the environment. +hook::read_supports_nchars() { + ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))) +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + local -a read_opts=(-r -t "$read_timeout") + if hook::read_supports_nchars; then + read_opts+=(-N 65536) + else + read_opts+=(-d '') + fi while :; do chunk="" read_rc=0 - IFS= read -r -t "$read_timeout" -N 65536 chunk || read_rc=$? + # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array + IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" if ((read_rc == 0)); then - continue # a full chunk — more may still be coming + continue # a full chunk (or a delimiter) — more may still be coming fi if ((read_rc > 128)); then + # Timed out. Bytes in this window mean the producer is alive: keep them + # and re-arm. An empty window is the stall this guard exists to catch. + if [[ -n "$chunk" ]]; then + continue + fi stalled=1 fi - break # EOF (rc 1), a stall (rc >128), or a read error + break # EOF (rc 1), an empty timed-out window, or a read error done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 @@ -1138,7 +1176,7 @@ hook::bash_parse_segments() { # the '(' separator splits it into a segment that gets scanned. skipnext=0 else - while ((i + 1 < n)) && [[ "${chars[i + 1]}" == [\<\>] ]]; do ((i++)); done + while ((i + 1 < n)) && [[ "${chars[i + 1]}" == [\<\>] ]]; do ((i++)); done # portability-ok: bash glob bracket class matching a literal < or > character, not a GNU grep \< \> word boundary if ((i + 1 < n)) && [[ "${chars[i + 1]}" == '&' ]]; then ((i++)) if ((i + 1 < n)) && [[ "${chars[i + 1]}" == [0-9-] ]]; then From d0608e9b62a15f664c93d84c97cb3c88dd32d755 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:21:53 -0400 Subject: [PATCH 3/7] fix(hook-utils): stop re-arming once the buffered payload is already complete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third review finding on #1587, and a real one: the re-arm-on-progress fix made the Win32 late-EOF case cost TWO idle windows instead of one. The producer sends a complete payload and holds the pipe open; the first read times out, returns that payload as a partial chunk, and the loop — seeing bytes, therefore progress — armed another full window waiting for an EOF that is never coming. The loop now checks whether what it already holds parses as whole JSON before re-arming, and stops if it does. One window is the floor here: until a window expires, a held-open pipe is indistinguishable from a slow producer. Measured on this host at a 0.4 s bound, three runs each: 694 ms average with the early stop, 1074 ms without. The new case in lib/hook-utils.test.sh asserts this by COMPARISON rather than against a wall-clock constant — it times the real function and a variant with the completeness predicate overridden to always fail (reproducing the pre-fix behavior) back to back on the same host, so runner load cancels out and there is no absolute threshold to tune. It also fails loudly if the timing harness returns no measurement on a host that has EPOCHREALTIME, because the first two attempts at this test were silently vacuous: one bracketed the whole pipeline and measured the producer's sleep, the next lost its measurement to nested-quoting breakage and passed via the not-timed branch. hook::json_complete returns non-zero when jq is absent or broken, so the loop keeps reading rather than guessing, and the caller's existing fail-open handling for absent jq is untouched. Refs #1563 Co-Authored-By: Claude Fable 5 --- lib/hook-utils.sh | 22 ++++++++- lib/hook-utils.test.sh | 47 +++++++++++++++++++ plugins/actionlint/hooks/hook-utils.sh | 22 ++++++++- plugins/autonomy/hooks/hook-utils.sh | 22 ++++++++- plugins/bash-format/hooks/hook-utils.sh | 22 ++++++++- plugins/biome-format/hooks/hook-utils.sh | 22 ++++++++- plugins/claude-ops/hooks/hook-utils.sh | 22 ++++++++- .../desktop-notification/hooks/hook-utils.sh | 22 ++++++++- plugins/eol-normalizer/hooks/hook-utils.sh | 22 ++++++++- plugins/go-format/hooks/hook-utils.sh | 22 ++++++++- plugins/guardrails/CHANGELOG.md | 6 ++- plugins/guardrails/hooks/hook-utils.sh | 22 ++++++++- plugins/markdown-format/hooks/hook-utils.sh | 22 ++++++++- plugins/powershell-format/hooks/hook-utils.sh | 22 ++++++++- plugins/rate-limit-guard/hooks/hook-utils.sh | 22 ++++++++- plugins/ruff-format/hooks/hook-utils.sh | 22 ++++++++- plugins/typos-format/hooks/hook-utils.sh | 22 ++++++++- 17 files changed, 366 insertions(+), 17 deletions(-) diff --git a/lib/hook-utils.sh b/lib/hook-utils.sh index 52398f67e..4a129702f 100644 --- a/lib/hook-utils.sh +++ b/lib/hook-utils.sh @@ -251,7 +251,10 @@ hook::repo_root() { # assigns whatever it did receive even when it times out, so a timed-out read # that returned bytes is progress, not a stall: the loop keeps that partial # chunk and arms a fresh window. Only a window that delivers nothing at all -# is a stall. +# is a stall. Re-arming is skipped once the buffer already parses as whole +# JSON, so the late-EOF case costs ONE window, not two — that is the floor, +# since a producer holding the pipe open cannot be distinguished from a slow +# one until a window expires. # # The trade this makes: a producer trickling bytes indefinitely is never cut off # here. That is deliberate — the harness already caps a `command` hook at 600 s @@ -281,6 +284,17 @@ hook::read_supports_nchars() { ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))) } +# Is the buffered text already a complete JSON document? Lets the read stop the +# moment the payload is whole instead of spending another idle window waiting +# for an EOF a Win32 pipe may never deliver. Returns non-zero when jq is +# unavailable or broken (exit 127) as well as when the text is incomplete — the +# caller must keep reading rather than guess, and the caller's own fail-open +# handling for absent jq is unaffected. +hook::json_complete() { + command -v jq >/dev/null 2>&1 || return 1 + jq -e . >/dev/null 2>&1 <<<"$1" +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" @@ -303,6 +317,12 @@ hook::buffer_stdin() { # Timed out. Bytes in this window mean the producer is alive: keep them # and re-arm. An empty window is the stall this guard exists to catch. if [[ -n "$chunk" ]]; then + # ... but stop immediately if what we already hold is a whole JSON + # document. That is the Win32 late-EOF case — the payload arrived, the + # pipe just never closed — and re-arming there would spend a second full + # window waiting for an EOF that is not coming, doubling the delay this + # function is supposed to bound. + hook::json_complete "${input//$'\r'/}" && break continue fi stalled=1 diff --git a/lib/hook-utils.test.sh b/lib/hook-utils.test.sh index e882cc2bc..6291687d2 100755 --- a/lib/hook-utils.test.sh +++ b/lib/hook-utils.test.sh @@ -854,6 +854,53 @@ if [[ "$bs_rc" == "0" ]] && [[ "$(cat "$bs_out_file")" == '{"complete":true}' ]] else fail "buffer_stdin late-EOF: rc=$bs_rc out=$(cat "$bs_out_file")" fi + +# ...and it must cost ONE window, not two. Re-arming on progress would otherwise +# spend a second full window waiting for an EOF this producer never sends, +# doubling the delay the bound is supposed to cap; the loop therefore stops as +# soon as the buffer already parses as whole JSON. One window is the floor — +# until a window expires, a held-open pipe is indistinguishable from a slow one. +# +# Asserted by COMPARISON, not against a wall-clock constant: both variants run +# back to back on the same host, so runner load cancels out and no absolute +# threshold has to be tuned. The slow variant is produced by overriding the +# completeness predicate in a child shell (the same idiom Test 18e uses), which +# reproduces the pre-fix behavior exactly. Timing is taken INSIDE the consumer — +# the pipeline as a whole does not finish until the producer's sleep ends, so +# bracketing the pipeline would measure the producer, not the read. +bs_time_late_eof() { # $1 = shell prelude; prints elapsed ms (empty if untimed) + local t_file + t_file="$(mktemp)" + { printf '{"complete":true}'; sleep 3; } | { + CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT=0.4 bash -c ' + source "$1" + eval "$2" + printf "%s\n" "${EPOCHREALTIME:-0}" + hook::buffer_stdin >/dev/null 2>&1 + printf "%s\n" "${EPOCHREALTIME:-0}" + ' _ "$HOOK_DIR/hook-utils.sh" "$1" >"$t_file" + } + awk 'NR==1 {s=$0} NR==2 {e=$0} + END { if (s == 0 || e == 0 || NR < 2) print ""; else printf "%.0f", (e - s) * 1000 }' \ + "$t_file" + rm -f "$t_file" +} +bs_fast=$(bs_time_late_eof "") +bs_slow=$(bs_time_late_eof 'hook::json_complete() { return 1; }') +if [[ -z "$bs_fast" || -z "$bs_slow" ]]; then + # Only legitimate below Bash 5.0, where EPOCHREALTIME does not exist. On a + # host that HAS it, an empty measurement means the harness broke — which would + # silently turn this case into a vacuous pass, so it fails instead. + if [[ -n "${EPOCHREALTIME:-}" ]]; then + fail "late-EOF timing harness produced no measurement (fast='$bs_fast' slow='$bs_slow')" + else + ok "buffer_stdin: late-EOF window count not timed (EPOCHREALTIME absent, Bash < 5.0)" + fi +elif ((bs_fast < bs_slow - 200)); then + ok "buffer_stdin: late-EOF costs one window, not two (${bs_fast} ms vs ${bs_slow} ms re-arming)" +else + fail "buffer_stdin late-EOF: ${bs_fast} ms vs ${bs_slow} ms re-arming — expected >200 ms faster" +fi rm -f "$bs_rc_file" "$bs_out_file" # --- Test 18c: hook::buffer_stdin — a large payload is neither blocked nor slow - diff --git a/plugins/actionlint/hooks/hook-utils.sh b/plugins/actionlint/hooks/hook-utils.sh index 52398f67e..4a129702f 100644 --- a/plugins/actionlint/hooks/hook-utils.sh +++ b/plugins/actionlint/hooks/hook-utils.sh @@ -251,7 +251,10 @@ hook::repo_root() { # assigns whatever it did receive even when it times out, so a timed-out read # that returned bytes is progress, not a stall: the loop keeps that partial # chunk and arms a fresh window. Only a window that delivers nothing at all -# is a stall. +# is a stall. Re-arming is skipped once the buffer already parses as whole +# JSON, so the late-EOF case costs ONE window, not two — that is the floor, +# since a producer holding the pipe open cannot be distinguished from a slow +# one until a window expires. # # The trade this makes: a producer trickling bytes indefinitely is never cut off # here. That is deliberate — the harness already caps a `command` hook at 600 s @@ -281,6 +284,17 @@ hook::read_supports_nchars() { ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))) } +# Is the buffered text already a complete JSON document? Lets the read stop the +# moment the payload is whole instead of spending another idle window waiting +# for an EOF a Win32 pipe may never deliver. Returns non-zero when jq is +# unavailable or broken (exit 127) as well as when the text is incomplete — the +# caller must keep reading rather than guess, and the caller's own fail-open +# handling for absent jq is unaffected. +hook::json_complete() { + command -v jq >/dev/null 2>&1 || return 1 + jq -e . >/dev/null 2>&1 <<<"$1" +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" @@ -303,6 +317,12 @@ hook::buffer_stdin() { # Timed out. Bytes in this window mean the producer is alive: keep them # and re-arm. An empty window is the stall this guard exists to catch. if [[ -n "$chunk" ]]; then + # ... but stop immediately if what we already hold is a whole JSON + # document. That is the Win32 late-EOF case — the payload arrived, the + # pipe just never closed — and re-arming there would spend a second full + # window waiting for an EOF that is not coming, doubling the delay this + # function is supposed to bound. + hook::json_complete "${input//$'\r'/}" && break continue fi stalled=1 diff --git a/plugins/autonomy/hooks/hook-utils.sh b/plugins/autonomy/hooks/hook-utils.sh index 52398f67e..4a129702f 100644 --- a/plugins/autonomy/hooks/hook-utils.sh +++ b/plugins/autonomy/hooks/hook-utils.sh @@ -251,7 +251,10 @@ hook::repo_root() { # assigns whatever it did receive even when it times out, so a timed-out read # that returned bytes is progress, not a stall: the loop keeps that partial # chunk and arms a fresh window. Only a window that delivers nothing at all -# is a stall. +# is a stall. Re-arming is skipped once the buffer already parses as whole +# JSON, so the late-EOF case costs ONE window, not two — that is the floor, +# since a producer holding the pipe open cannot be distinguished from a slow +# one until a window expires. # # The trade this makes: a producer trickling bytes indefinitely is never cut off # here. That is deliberate — the harness already caps a `command` hook at 600 s @@ -281,6 +284,17 @@ hook::read_supports_nchars() { ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))) } +# Is the buffered text already a complete JSON document? Lets the read stop the +# moment the payload is whole instead of spending another idle window waiting +# for an EOF a Win32 pipe may never deliver. Returns non-zero when jq is +# unavailable or broken (exit 127) as well as when the text is incomplete — the +# caller must keep reading rather than guess, and the caller's own fail-open +# handling for absent jq is unaffected. +hook::json_complete() { + command -v jq >/dev/null 2>&1 || return 1 + jq -e . >/dev/null 2>&1 <<<"$1" +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" @@ -303,6 +317,12 @@ hook::buffer_stdin() { # Timed out. Bytes in this window mean the producer is alive: keep them # and re-arm. An empty window is the stall this guard exists to catch. if [[ -n "$chunk" ]]; then + # ... but stop immediately if what we already hold is a whole JSON + # document. That is the Win32 late-EOF case — the payload arrived, the + # pipe just never closed — and re-arming there would spend a second full + # window waiting for an EOF that is not coming, doubling the delay this + # function is supposed to bound. + hook::json_complete "${input//$'\r'/}" && break continue fi stalled=1 diff --git a/plugins/bash-format/hooks/hook-utils.sh b/plugins/bash-format/hooks/hook-utils.sh index 52398f67e..4a129702f 100644 --- a/plugins/bash-format/hooks/hook-utils.sh +++ b/plugins/bash-format/hooks/hook-utils.sh @@ -251,7 +251,10 @@ hook::repo_root() { # assigns whatever it did receive even when it times out, so a timed-out read # that returned bytes is progress, not a stall: the loop keeps that partial # chunk and arms a fresh window. Only a window that delivers nothing at all -# is a stall. +# is a stall. Re-arming is skipped once the buffer already parses as whole +# JSON, so the late-EOF case costs ONE window, not two — that is the floor, +# since a producer holding the pipe open cannot be distinguished from a slow +# one until a window expires. # # The trade this makes: a producer trickling bytes indefinitely is never cut off # here. That is deliberate — the harness already caps a `command` hook at 600 s @@ -281,6 +284,17 @@ hook::read_supports_nchars() { ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))) } +# Is the buffered text already a complete JSON document? Lets the read stop the +# moment the payload is whole instead of spending another idle window waiting +# for an EOF a Win32 pipe may never deliver. Returns non-zero when jq is +# unavailable or broken (exit 127) as well as when the text is incomplete — the +# caller must keep reading rather than guess, and the caller's own fail-open +# handling for absent jq is unaffected. +hook::json_complete() { + command -v jq >/dev/null 2>&1 || return 1 + jq -e . >/dev/null 2>&1 <<<"$1" +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" @@ -303,6 +317,12 @@ hook::buffer_stdin() { # Timed out. Bytes in this window mean the producer is alive: keep them # and re-arm. An empty window is the stall this guard exists to catch. if [[ -n "$chunk" ]]; then + # ... but stop immediately if what we already hold is a whole JSON + # document. That is the Win32 late-EOF case — the payload arrived, the + # pipe just never closed — and re-arming there would spend a second full + # window waiting for an EOF that is not coming, doubling the delay this + # function is supposed to bound. + hook::json_complete "${input//$'\r'/}" && break continue fi stalled=1 diff --git a/plugins/biome-format/hooks/hook-utils.sh b/plugins/biome-format/hooks/hook-utils.sh index 52398f67e..4a129702f 100644 --- a/plugins/biome-format/hooks/hook-utils.sh +++ b/plugins/biome-format/hooks/hook-utils.sh @@ -251,7 +251,10 @@ hook::repo_root() { # assigns whatever it did receive even when it times out, so a timed-out read # that returned bytes is progress, not a stall: the loop keeps that partial # chunk and arms a fresh window. Only a window that delivers nothing at all -# is a stall. +# is a stall. Re-arming is skipped once the buffer already parses as whole +# JSON, so the late-EOF case costs ONE window, not two — that is the floor, +# since a producer holding the pipe open cannot be distinguished from a slow +# one until a window expires. # # The trade this makes: a producer trickling bytes indefinitely is never cut off # here. That is deliberate — the harness already caps a `command` hook at 600 s @@ -281,6 +284,17 @@ hook::read_supports_nchars() { ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))) } +# Is the buffered text already a complete JSON document? Lets the read stop the +# moment the payload is whole instead of spending another idle window waiting +# for an EOF a Win32 pipe may never deliver. Returns non-zero when jq is +# unavailable or broken (exit 127) as well as when the text is incomplete — the +# caller must keep reading rather than guess, and the caller's own fail-open +# handling for absent jq is unaffected. +hook::json_complete() { + command -v jq >/dev/null 2>&1 || return 1 + jq -e . >/dev/null 2>&1 <<<"$1" +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" @@ -303,6 +317,12 @@ hook::buffer_stdin() { # Timed out. Bytes in this window mean the producer is alive: keep them # and re-arm. An empty window is the stall this guard exists to catch. if [[ -n "$chunk" ]]; then + # ... but stop immediately if what we already hold is a whole JSON + # document. That is the Win32 late-EOF case — the payload arrived, the + # pipe just never closed — and re-arming there would spend a second full + # window waiting for an EOF that is not coming, doubling the delay this + # function is supposed to bound. + hook::json_complete "${input//$'\r'/}" && break continue fi stalled=1 diff --git a/plugins/claude-ops/hooks/hook-utils.sh b/plugins/claude-ops/hooks/hook-utils.sh index 52398f67e..4a129702f 100644 --- a/plugins/claude-ops/hooks/hook-utils.sh +++ b/plugins/claude-ops/hooks/hook-utils.sh @@ -251,7 +251,10 @@ hook::repo_root() { # assigns whatever it did receive even when it times out, so a timed-out read # that returned bytes is progress, not a stall: the loop keeps that partial # chunk and arms a fresh window. Only a window that delivers nothing at all -# is a stall. +# is a stall. Re-arming is skipped once the buffer already parses as whole +# JSON, so the late-EOF case costs ONE window, not two — that is the floor, +# since a producer holding the pipe open cannot be distinguished from a slow +# one until a window expires. # # The trade this makes: a producer trickling bytes indefinitely is never cut off # here. That is deliberate — the harness already caps a `command` hook at 600 s @@ -281,6 +284,17 @@ hook::read_supports_nchars() { ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))) } +# Is the buffered text already a complete JSON document? Lets the read stop the +# moment the payload is whole instead of spending another idle window waiting +# for an EOF a Win32 pipe may never deliver. Returns non-zero when jq is +# unavailable or broken (exit 127) as well as when the text is incomplete — the +# caller must keep reading rather than guess, and the caller's own fail-open +# handling for absent jq is unaffected. +hook::json_complete() { + command -v jq >/dev/null 2>&1 || return 1 + jq -e . >/dev/null 2>&1 <<<"$1" +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" @@ -303,6 +317,12 @@ hook::buffer_stdin() { # Timed out. Bytes in this window mean the producer is alive: keep them # and re-arm. An empty window is the stall this guard exists to catch. if [[ -n "$chunk" ]]; then + # ... but stop immediately if what we already hold is a whole JSON + # document. That is the Win32 late-EOF case — the payload arrived, the + # pipe just never closed — and re-arming there would spend a second full + # window waiting for an EOF that is not coming, doubling the delay this + # function is supposed to bound. + hook::json_complete "${input//$'\r'/}" && break continue fi stalled=1 diff --git a/plugins/desktop-notification/hooks/hook-utils.sh b/plugins/desktop-notification/hooks/hook-utils.sh index 52398f67e..4a129702f 100644 --- a/plugins/desktop-notification/hooks/hook-utils.sh +++ b/plugins/desktop-notification/hooks/hook-utils.sh @@ -251,7 +251,10 @@ hook::repo_root() { # assigns whatever it did receive even when it times out, so a timed-out read # that returned bytes is progress, not a stall: the loop keeps that partial # chunk and arms a fresh window. Only a window that delivers nothing at all -# is a stall. +# is a stall. Re-arming is skipped once the buffer already parses as whole +# JSON, so the late-EOF case costs ONE window, not two — that is the floor, +# since a producer holding the pipe open cannot be distinguished from a slow +# one until a window expires. # # The trade this makes: a producer trickling bytes indefinitely is never cut off # here. That is deliberate — the harness already caps a `command` hook at 600 s @@ -281,6 +284,17 @@ hook::read_supports_nchars() { ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))) } +# Is the buffered text already a complete JSON document? Lets the read stop the +# moment the payload is whole instead of spending another idle window waiting +# for an EOF a Win32 pipe may never deliver. Returns non-zero when jq is +# unavailable or broken (exit 127) as well as when the text is incomplete — the +# caller must keep reading rather than guess, and the caller's own fail-open +# handling for absent jq is unaffected. +hook::json_complete() { + command -v jq >/dev/null 2>&1 || return 1 + jq -e . >/dev/null 2>&1 <<<"$1" +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" @@ -303,6 +317,12 @@ hook::buffer_stdin() { # Timed out. Bytes in this window mean the producer is alive: keep them # and re-arm. An empty window is the stall this guard exists to catch. if [[ -n "$chunk" ]]; then + # ... but stop immediately if what we already hold is a whole JSON + # document. That is the Win32 late-EOF case — the payload arrived, the + # pipe just never closed — and re-arming there would spend a second full + # window waiting for an EOF that is not coming, doubling the delay this + # function is supposed to bound. + hook::json_complete "${input//$'\r'/}" && break continue fi stalled=1 diff --git a/plugins/eol-normalizer/hooks/hook-utils.sh b/plugins/eol-normalizer/hooks/hook-utils.sh index 52398f67e..4a129702f 100644 --- a/plugins/eol-normalizer/hooks/hook-utils.sh +++ b/plugins/eol-normalizer/hooks/hook-utils.sh @@ -251,7 +251,10 @@ hook::repo_root() { # assigns whatever it did receive even when it times out, so a timed-out read # that returned bytes is progress, not a stall: the loop keeps that partial # chunk and arms a fresh window. Only a window that delivers nothing at all -# is a stall. +# is a stall. Re-arming is skipped once the buffer already parses as whole +# JSON, so the late-EOF case costs ONE window, not two — that is the floor, +# since a producer holding the pipe open cannot be distinguished from a slow +# one until a window expires. # # The trade this makes: a producer trickling bytes indefinitely is never cut off # here. That is deliberate — the harness already caps a `command` hook at 600 s @@ -281,6 +284,17 @@ hook::read_supports_nchars() { ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))) } +# Is the buffered text already a complete JSON document? Lets the read stop the +# moment the payload is whole instead of spending another idle window waiting +# for an EOF a Win32 pipe may never deliver. Returns non-zero when jq is +# unavailable or broken (exit 127) as well as when the text is incomplete — the +# caller must keep reading rather than guess, and the caller's own fail-open +# handling for absent jq is unaffected. +hook::json_complete() { + command -v jq >/dev/null 2>&1 || return 1 + jq -e . >/dev/null 2>&1 <<<"$1" +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" @@ -303,6 +317,12 @@ hook::buffer_stdin() { # Timed out. Bytes in this window mean the producer is alive: keep them # and re-arm. An empty window is the stall this guard exists to catch. if [[ -n "$chunk" ]]; then + # ... but stop immediately if what we already hold is a whole JSON + # document. That is the Win32 late-EOF case — the payload arrived, the + # pipe just never closed — and re-arming there would spend a second full + # window waiting for an EOF that is not coming, doubling the delay this + # function is supposed to bound. + hook::json_complete "${input//$'\r'/}" && break continue fi stalled=1 diff --git a/plugins/go-format/hooks/hook-utils.sh b/plugins/go-format/hooks/hook-utils.sh index 52398f67e..4a129702f 100644 --- a/plugins/go-format/hooks/hook-utils.sh +++ b/plugins/go-format/hooks/hook-utils.sh @@ -251,7 +251,10 @@ hook::repo_root() { # assigns whatever it did receive even when it times out, so a timed-out read # that returned bytes is progress, not a stall: the loop keeps that partial # chunk and arms a fresh window. Only a window that delivers nothing at all -# is a stall. +# is a stall. Re-arming is skipped once the buffer already parses as whole +# JSON, so the late-EOF case costs ONE window, not two — that is the floor, +# since a producer holding the pipe open cannot be distinguished from a slow +# one until a window expires. # # The trade this makes: a producer trickling bytes indefinitely is never cut off # here. That is deliberate — the harness already caps a `command` hook at 600 s @@ -281,6 +284,17 @@ hook::read_supports_nchars() { ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))) } +# Is the buffered text already a complete JSON document? Lets the read stop the +# moment the payload is whole instead of spending another idle window waiting +# for an EOF a Win32 pipe may never deliver. Returns non-zero when jq is +# unavailable or broken (exit 127) as well as when the text is incomplete — the +# caller must keep reading rather than guess, and the caller's own fail-open +# handling for absent jq is unaffected. +hook::json_complete() { + command -v jq >/dev/null 2>&1 || return 1 + jq -e . >/dev/null 2>&1 <<<"$1" +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" @@ -303,6 +317,12 @@ hook::buffer_stdin() { # Timed out. Bytes in this window mean the producer is alive: keep them # and re-arm. An empty window is the stall this guard exists to catch. if [[ -n "$chunk" ]]; then + # ... but stop immediately if what we already hold is a whole JSON + # document. That is the Win32 late-EOF case — the payload arrived, the + # pipe just never closed — and re-arming there would spend a second full + # window waiting for an EOF that is not coming, doubling the delay this + # function is supposed to bound. + hook::json_complete "${input//$'\r'/}" && break continue fi stalled=1 diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index 2ef877c4e..7b4e83ac1 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -21,8 +21,10 @@ All notable changes to the `guardrails` plugin are documented here. Format follo true **idle** bound: `read -t` is a deadline for the whole requested read rather than an inactivity timer, so a timed-out read that nevertheless returned bytes is now treated as progress — its partial chunk is kept and a fresh window is armed. Only a window that delivers nothing at all is a - stall, and that still fails closed with rc 2 exactly as before. Measured: 50 KB drops from - ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. + stall, and that still fails closed with rc 2 exactly as before. Re-arming stops once the buffer + already parses as whole JSON, so the Win32 late-EOF case (payload complete, pipe simply never + closed) settles in one window instead of two. Measured: 50 KB drops from ~2100 ms to ~20 ms, + 200 KB from ~6800 ms to ~85 ms. `read -N` is Bash 4.1+, and these hooks support Bash 3.2+ (macOS system bash), so the pre-4.1 path falls back to the delimiter read inside the same re-arming loop — same guard and rationale as `context-guard`'s `statusline-tee.sh`. diff --git a/plugins/guardrails/hooks/hook-utils.sh b/plugins/guardrails/hooks/hook-utils.sh index 52398f67e..4a129702f 100644 --- a/plugins/guardrails/hooks/hook-utils.sh +++ b/plugins/guardrails/hooks/hook-utils.sh @@ -251,7 +251,10 @@ hook::repo_root() { # assigns whatever it did receive even when it times out, so a timed-out read # that returned bytes is progress, not a stall: the loop keeps that partial # chunk and arms a fresh window. Only a window that delivers nothing at all -# is a stall. +# is a stall. Re-arming is skipped once the buffer already parses as whole +# JSON, so the late-EOF case costs ONE window, not two — that is the floor, +# since a producer holding the pipe open cannot be distinguished from a slow +# one until a window expires. # # The trade this makes: a producer trickling bytes indefinitely is never cut off # here. That is deliberate — the harness already caps a `command` hook at 600 s @@ -281,6 +284,17 @@ hook::read_supports_nchars() { ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))) } +# Is the buffered text already a complete JSON document? Lets the read stop the +# moment the payload is whole instead of spending another idle window waiting +# for an EOF a Win32 pipe may never deliver. Returns non-zero when jq is +# unavailable or broken (exit 127) as well as when the text is incomplete — the +# caller must keep reading rather than guess, and the caller's own fail-open +# handling for absent jq is unaffected. +hook::json_complete() { + command -v jq >/dev/null 2>&1 || return 1 + jq -e . >/dev/null 2>&1 <<<"$1" +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" @@ -303,6 +317,12 @@ hook::buffer_stdin() { # Timed out. Bytes in this window mean the producer is alive: keep them # and re-arm. An empty window is the stall this guard exists to catch. if [[ -n "$chunk" ]]; then + # ... but stop immediately if what we already hold is a whole JSON + # document. That is the Win32 late-EOF case — the payload arrived, the + # pipe just never closed — and re-arming there would spend a second full + # window waiting for an EOF that is not coming, doubling the delay this + # function is supposed to bound. + hook::json_complete "${input//$'\r'/}" && break continue fi stalled=1 diff --git a/plugins/markdown-format/hooks/hook-utils.sh b/plugins/markdown-format/hooks/hook-utils.sh index 52398f67e..4a129702f 100644 --- a/plugins/markdown-format/hooks/hook-utils.sh +++ b/plugins/markdown-format/hooks/hook-utils.sh @@ -251,7 +251,10 @@ hook::repo_root() { # assigns whatever it did receive even when it times out, so a timed-out read # that returned bytes is progress, not a stall: the loop keeps that partial # chunk and arms a fresh window. Only a window that delivers nothing at all -# is a stall. +# is a stall. Re-arming is skipped once the buffer already parses as whole +# JSON, so the late-EOF case costs ONE window, not two — that is the floor, +# since a producer holding the pipe open cannot be distinguished from a slow +# one until a window expires. # # The trade this makes: a producer trickling bytes indefinitely is never cut off # here. That is deliberate — the harness already caps a `command` hook at 600 s @@ -281,6 +284,17 @@ hook::read_supports_nchars() { ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))) } +# Is the buffered text already a complete JSON document? Lets the read stop the +# moment the payload is whole instead of spending another idle window waiting +# for an EOF a Win32 pipe may never deliver. Returns non-zero when jq is +# unavailable or broken (exit 127) as well as when the text is incomplete — the +# caller must keep reading rather than guess, and the caller's own fail-open +# handling for absent jq is unaffected. +hook::json_complete() { + command -v jq >/dev/null 2>&1 || return 1 + jq -e . >/dev/null 2>&1 <<<"$1" +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" @@ -303,6 +317,12 @@ hook::buffer_stdin() { # Timed out. Bytes in this window mean the producer is alive: keep them # and re-arm. An empty window is the stall this guard exists to catch. if [[ -n "$chunk" ]]; then + # ... but stop immediately if what we already hold is a whole JSON + # document. That is the Win32 late-EOF case — the payload arrived, the + # pipe just never closed — and re-arming there would spend a second full + # window waiting for an EOF that is not coming, doubling the delay this + # function is supposed to bound. + hook::json_complete "${input//$'\r'/}" && break continue fi stalled=1 diff --git a/plugins/powershell-format/hooks/hook-utils.sh b/plugins/powershell-format/hooks/hook-utils.sh index 52398f67e..4a129702f 100644 --- a/plugins/powershell-format/hooks/hook-utils.sh +++ b/plugins/powershell-format/hooks/hook-utils.sh @@ -251,7 +251,10 @@ hook::repo_root() { # assigns whatever it did receive even when it times out, so a timed-out read # that returned bytes is progress, not a stall: the loop keeps that partial # chunk and arms a fresh window. Only a window that delivers nothing at all -# is a stall. +# is a stall. Re-arming is skipped once the buffer already parses as whole +# JSON, so the late-EOF case costs ONE window, not two — that is the floor, +# since a producer holding the pipe open cannot be distinguished from a slow +# one until a window expires. # # The trade this makes: a producer trickling bytes indefinitely is never cut off # here. That is deliberate — the harness already caps a `command` hook at 600 s @@ -281,6 +284,17 @@ hook::read_supports_nchars() { ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))) } +# Is the buffered text already a complete JSON document? Lets the read stop the +# moment the payload is whole instead of spending another idle window waiting +# for an EOF a Win32 pipe may never deliver. Returns non-zero when jq is +# unavailable or broken (exit 127) as well as when the text is incomplete — the +# caller must keep reading rather than guess, and the caller's own fail-open +# handling for absent jq is unaffected. +hook::json_complete() { + command -v jq >/dev/null 2>&1 || return 1 + jq -e . >/dev/null 2>&1 <<<"$1" +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" @@ -303,6 +317,12 @@ hook::buffer_stdin() { # Timed out. Bytes in this window mean the producer is alive: keep them # and re-arm. An empty window is the stall this guard exists to catch. if [[ -n "$chunk" ]]; then + # ... but stop immediately if what we already hold is a whole JSON + # document. That is the Win32 late-EOF case — the payload arrived, the + # pipe just never closed — and re-arming there would spend a second full + # window waiting for an EOF that is not coming, doubling the delay this + # function is supposed to bound. + hook::json_complete "${input//$'\r'/}" && break continue fi stalled=1 diff --git a/plugins/rate-limit-guard/hooks/hook-utils.sh b/plugins/rate-limit-guard/hooks/hook-utils.sh index 52398f67e..4a129702f 100755 --- a/plugins/rate-limit-guard/hooks/hook-utils.sh +++ b/plugins/rate-limit-guard/hooks/hook-utils.sh @@ -251,7 +251,10 @@ hook::repo_root() { # assigns whatever it did receive even when it times out, so a timed-out read # that returned bytes is progress, not a stall: the loop keeps that partial # chunk and arms a fresh window. Only a window that delivers nothing at all -# is a stall. +# is a stall. Re-arming is skipped once the buffer already parses as whole +# JSON, so the late-EOF case costs ONE window, not two — that is the floor, +# since a producer holding the pipe open cannot be distinguished from a slow +# one until a window expires. # # The trade this makes: a producer trickling bytes indefinitely is never cut off # here. That is deliberate — the harness already caps a `command` hook at 600 s @@ -281,6 +284,17 @@ hook::read_supports_nchars() { ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))) } +# Is the buffered text already a complete JSON document? Lets the read stop the +# moment the payload is whole instead of spending another idle window waiting +# for an EOF a Win32 pipe may never deliver. Returns non-zero when jq is +# unavailable or broken (exit 127) as well as when the text is incomplete — the +# caller must keep reading rather than guess, and the caller's own fail-open +# handling for absent jq is unaffected. +hook::json_complete() { + command -v jq >/dev/null 2>&1 || return 1 + jq -e . >/dev/null 2>&1 <<<"$1" +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" @@ -303,6 +317,12 @@ hook::buffer_stdin() { # Timed out. Bytes in this window mean the producer is alive: keep them # and re-arm. An empty window is the stall this guard exists to catch. if [[ -n "$chunk" ]]; then + # ... but stop immediately if what we already hold is a whole JSON + # document. That is the Win32 late-EOF case — the payload arrived, the + # pipe just never closed — and re-arming there would spend a second full + # window waiting for an EOF that is not coming, doubling the delay this + # function is supposed to bound. + hook::json_complete "${input//$'\r'/}" && break continue fi stalled=1 diff --git a/plugins/ruff-format/hooks/hook-utils.sh b/plugins/ruff-format/hooks/hook-utils.sh index 52398f67e..4a129702f 100644 --- a/plugins/ruff-format/hooks/hook-utils.sh +++ b/plugins/ruff-format/hooks/hook-utils.sh @@ -251,7 +251,10 @@ hook::repo_root() { # assigns whatever it did receive even when it times out, so a timed-out read # that returned bytes is progress, not a stall: the loop keeps that partial # chunk and arms a fresh window. Only a window that delivers nothing at all -# is a stall. +# is a stall. Re-arming is skipped once the buffer already parses as whole +# JSON, so the late-EOF case costs ONE window, not two — that is the floor, +# since a producer holding the pipe open cannot be distinguished from a slow +# one until a window expires. # # The trade this makes: a producer trickling bytes indefinitely is never cut off # here. That is deliberate — the harness already caps a `command` hook at 600 s @@ -281,6 +284,17 @@ hook::read_supports_nchars() { ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))) } +# Is the buffered text already a complete JSON document? Lets the read stop the +# moment the payload is whole instead of spending another idle window waiting +# for an EOF a Win32 pipe may never deliver. Returns non-zero when jq is +# unavailable or broken (exit 127) as well as when the text is incomplete — the +# caller must keep reading rather than guess, and the caller's own fail-open +# handling for absent jq is unaffected. +hook::json_complete() { + command -v jq >/dev/null 2>&1 || return 1 + jq -e . >/dev/null 2>&1 <<<"$1" +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" @@ -303,6 +317,12 @@ hook::buffer_stdin() { # Timed out. Bytes in this window mean the producer is alive: keep them # and re-arm. An empty window is the stall this guard exists to catch. if [[ -n "$chunk" ]]; then + # ... but stop immediately if what we already hold is a whole JSON + # document. That is the Win32 late-EOF case — the payload arrived, the + # pipe just never closed — and re-arming there would spend a second full + # window waiting for an EOF that is not coming, doubling the delay this + # function is supposed to bound. + hook::json_complete "${input//$'\r'/}" && break continue fi stalled=1 diff --git a/plugins/typos-format/hooks/hook-utils.sh b/plugins/typos-format/hooks/hook-utils.sh index 52398f67e..4a129702f 100644 --- a/plugins/typos-format/hooks/hook-utils.sh +++ b/plugins/typos-format/hooks/hook-utils.sh @@ -251,7 +251,10 @@ hook::repo_root() { # assigns whatever it did receive even when it times out, so a timed-out read # that returned bytes is progress, not a stall: the loop keeps that partial # chunk and arms a fresh window. Only a window that delivers nothing at all -# is a stall. +# is a stall. Re-arming is skipped once the buffer already parses as whole +# JSON, so the late-EOF case costs ONE window, not two — that is the floor, +# since a producer holding the pipe open cannot be distinguished from a slow +# one until a window expires. # # The trade this makes: a producer trickling bytes indefinitely is never cut off # here. That is deliberate — the harness already caps a `command` hook at 600 s @@ -281,6 +284,17 @@ hook::read_supports_nchars() { ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))) } +# Is the buffered text already a complete JSON document? Lets the read stop the +# moment the payload is whole instead of spending another idle window waiting +# for an EOF a Win32 pipe may never deliver. Returns non-zero when jq is +# unavailable or broken (exit 127) as well as when the text is incomplete — the +# caller must keep reading rather than guess, and the caller's own fail-open +# handling for absent jq is unaffected. +hook::json_complete() { + command -v jq >/dev/null 2>&1 || return 1 + jq -e . >/dev/null 2>&1 <<<"$1" +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" @@ -303,6 +317,12 @@ hook::buffer_stdin() { # Timed out. Bytes in this window mean the producer is alive: keep them # and re-arm. An empty window is the stall this guard exists to catch. if [[ -n "$chunk" ]]; then + # ... but stop immediately if what we already hold is a whole JSON + # document. That is the Win32 late-EOF case — the payload arrived, the + # pipe just never closed — and re-arming there would spend a second full + # window waiting for an EOF that is not coming, doubling the delay this + # function is supposed to bound. + hook::json_complete "${input//$'\r'/}" && break continue fi stalled=1 From 316ae52b2ee6069e18caa6645a2844ce9388f5fa Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:43:55 -0400 Subject: [PATCH 4/7] fix(hook-utils): validate stdin_read_timeout instead of passing it straight to read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth review finding on #1587, and reproducing it turned up something worse than reported. Reported: `stdin_read_timeout` is consumer-configurable and reaches `read -t` directly, so a fractional value on a Bash release without fractional timeouts is rejected. Confirmed, and the failure mode is a SILENT DISABLE, not a degraded one: `read` rejects a bad spec with rc 1, the loop reads rc 1 as EOF, the payload comes back empty, every caller takes its skip branch, and a usage error is printed to stderr on every single hook invocation. Measured on this host, `abc` and `-1` both behave exactly that way. Found while reproducing: `stdin_read_timeout=0` made the loop SPIN. `read -t 0` returns success having consumed nothing, so the chunked loop `continue`d forever — the probe script hung outright. The manifest declares `min: 1`, but nothing validates the environment mirror the code actually reads, so that was reachable. Both are now resolved by hook::resolve_read_timeout, which falls back to the documented default of 2. Acceptance is decided by PROBING the running shell (`read -t "$t" --- lib/hook-utils.sh | 42 +++++++++++++++++- lib/hook-utils.test.sh | 44 +++++++++++++++++++ plugins/actionlint/hooks/hook-utils.sh | 42 +++++++++++++++++- plugins/autonomy/hooks/hook-utils.sh | 42 +++++++++++++++++- plugins/bash-format/hooks/hook-utils.sh | 42 +++++++++++++++++- plugins/biome-format/hooks/hook-utils.sh | 42 +++++++++++++++++- plugins/claude-ops/hooks/hook-utils.sh | 42 +++++++++++++++++- .../desktop-notification/hooks/hook-utils.sh | 42 +++++++++++++++++- plugins/eol-normalizer/hooks/hook-utils.sh | 42 +++++++++++++++++- plugins/go-format/hooks/hook-utils.sh | 42 +++++++++++++++++- plugins/guardrails/CHANGELOG.md | 6 ++- plugins/guardrails/README.md | 4 +- plugins/guardrails/hooks/hook-utils.sh | 42 +++++++++++++++++- plugins/markdown-format/hooks/hook-utils.sh | 42 +++++++++++++++++- plugins/powershell-format/hooks/hook-utils.sh | 42 +++++++++++++++++- plugins/rate-limit-guard/hooks/hook-utils.sh | 42 +++++++++++++++++- plugins/ruff-format/hooks/hook-utils.sh | 42 +++++++++++++++++- plugins/typos-format/hooks/hook-utils.sh | 42 +++++++++++++++++- 18 files changed, 652 insertions(+), 32 deletions(-) diff --git a/lib/hook-utils.sh b/lib/hook-utils.sh index 4a129702f..52efd692b 100644 --- a/lib/hook-utils.sh +++ b/lib/hook-utils.sh @@ -295,9 +295,40 @@ hook::json_complete() { jq -e . >/dev/null 2>&1 <<<"$1" } +# Resolve the read timeout to a value THIS shell's `read -t` will actually +# accept, falling back to the documented default of 2 otherwise. +# +# The configured value reaches `read -t` directly, and an unusable one is not a +# tuning mistake — it is a silent disable. `read` rejects a bad spec with rc 1 +# plus a usage error on stderr for EVERY hook invocation; the buffer loop reads +# rc 1 as EOF, produces an empty payload, and every caller skips. `0` is worse +# still: it makes `read` return immediately having consumed nothing, which would +# spin the loop. +# +# Acceptance is settled by PROBING this shell rather than consulting a version +# table: which spellings `read -t` accepts varies across the Bash releases these +# hooks support (fractional values are not universally available, and the +# upstream changelog does not date their introduction), so asking the running +# shell is exact where a version check would be a guess. Reading /dev/null hits +# EOF immediately, so a valid timeout produces no stderr at all. The probe is +# skipped for the default, which is known-good everywhere. +hook::resolve_read_timeout() { + local t="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + if [[ "$t" != "2" ]]; then + local probe + # shellcheck disable=SC2034 # `discard` is the read target; only stderr matters + probe=$(read -r -t "$t" discard &1) + if ! [[ "$t" =~ ^[0-9]+(\.[0-9]+)?$ ]] || [[ "$t" =~ ^0+(\.0+)?$ ]] || [[ -n "$probe" ]]; then + t=2 + fi + fi + printf '%s' "$t" +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 - local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + local read_timeout + read_timeout=$(hook::resolve_read_timeout) local -a read_opts=(-r -t "$read_timeout") if hook::read_supports_nchars; then read_opts+=(-N 65536) @@ -311,7 +342,14 @@ hook::buffer_stdin() { IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" if ((read_rc == 0)); then - continue # a full chunk (or a delimiter) — more may still be coming + # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL + # read that consumed nothing, however, cannot make progress, so continuing + # would spin: break instead. hook::resolve_read_timeout already excludes + # the only known way to reach that (`read -t 0`, which returns success + # without consuming); this keeps loop termination a structural property + # rather than a consequence of validation staying correct. + [[ -n "$chunk" ]] || break + continue fi if ((read_rc > 128)); then # Timed out. Bytes in this window mean the producer is alive: keep them diff --git a/lib/hook-utils.test.sh b/lib/hook-utils.test.sh index 6291687d2..d64a9f059 100755 --- a/lib/hook-utils.test.sh +++ b/lib/hook-utils.test.sh @@ -1042,6 +1042,50 @@ else fi rm -f "$bs_big_file" "$bs_payload_file" "$bs_rc_file" "$bs_out_file" +# --- Test 18f: hook::buffer_stdin — an unusable stdin_read_timeout ------------ +# stdin_read_timeout is consumer-configurable and reaches `read -t` directly, so +# an unusable value is a silent DISABLE, not a tuning mistake: `read` rejects a +# bad spec with rc 1 (which the loop reads as EOF → empty payload → every caller +# skips) and prints a usage error on stderr on every hook invocation. `0` is +# worse — `read -t 0` returns success having consumed nothing, which spins the +# loop until the harness kills the hook. Both must degrade to the default and +# still deliver the payload. The `0` case doubles as the loop-termination test: +# before the guard it hung outright, so a regression here shows up as this suite +# never finishing. +for bs_bad in "abc" "0" "-1" "1e3" ""; do + bs_rc_file="$(mktemp)" + bs_out_file="$(mktemp)" + bs_err_file="$(mktemp)" + printf '{"ok":true}' | { + CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT="$bs_bad" hook::buffer_stdin \ + >"$bs_out_file" 2>"$bs_err_file" + echo "$?" >"$bs_rc_file" + } + bs_rc=$(cat "$bs_rc_file") + if [[ "$bs_rc" == "0" ]] && [[ "$(cat "$bs_out_file")" == '{"ok":true}' ]] && + [[ ! -s "$bs_err_file" ]]; then + ok "buffer_stdin: unusable stdin_read_timeout '$bs_bad' → default, payload intact, no stderr" + else + fail "buffer_stdin bad timeout '$bs_bad': rc=$bs_rc out=$(cat "$bs_out_file") err=$(cat "$bs_err_file")" + fi + rm -f "$bs_rc_file" "$bs_out_file" "$bs_err_file" +done + +# A VALID non-default value must still be honored — the guard must not collapse +# every setting to the default. 0.5 s against a producer that stalls for 3 s. +bs_rc_file="$(mktemp)" +{ printf '{"incomplete":'; sleep 3; } | { + CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT=0.5 hook::buffer_stdin >/dev/null 2>&1 + echo "$?" >"$bs_rc_file" +} +bs_rc=$(cat "$bs_rc_file") +if [[ "$bs_rc" == "2" ]]; then + ok "buffer_stdin: a valid non-default stdin_read_timeout is honored, not overridden" +else + fail "buffer_stdin valid non-default timeout: rc=$bs_rc (expected 2)" +fi +rm -f "$bs_rc_file" + # --- Test 19: hook::emit_telemetry — EPOCHREALTIME-absent (Bash < 5.0) skip --- # On Bash < 5.0 EPOCHREALTIME is unset, so the caller's `start=${EPOCHREALTIME:-}` # snapshot is empty. emit_telemetry must then skip fail-open (return 0, emit no diff --git a/plugins/actionlint/hooks/hook-utils.sh b/plugins/actionlint/hooks/hook-utils.sh index 4a129702f..52efd692b 100644 --- a/plugins/actionlint/hooks/hook-utils.sh +++ b/plugins/actionlint/hooks/hook-utils.sh @@ -295,9 +295,40 @@ hook::json_complete() { jq -e . >/dev/null 2>&1 <<<"$1" } +# Resolve the read timeout to a value THIS shell's `read -t` will actually +# accept, falling back to the documented default of 2 otherwise. +# +# The configured value reaches `read -t` directly, and an unusable one is not a +# tuning mistake — it is a silent disable. `read` rejects a bad spec with rc 1 +# plus a usage error on stderr for EVERY hook invocation; the buffer loop reads +# rc 1 as EOF, produces an empty payload, and every caller skips. `0` is worse +# still: it makes `read` return immediately having consumed nothing, which would +# spin the loop. +# +# Acceptance is settled by PROBING this shell rather than consulting a version +# table: which spellings `read -t` accepts varies across the Bash releases these +# hooks support (fractional values are not universally available, and the +# upstream changelog does not date their introduction), so asking the running +# shell is exact where a version check would be a guess. Reading /dev/null hits +# EOF immediately, so a valid timeout produces no stderr at all. The probe is +# skipped for the default, which is known-good everywhere. +hook::resolve_read_timeout() { + local t="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + if [[ "$t" != "2" ]]; then + local probe + # shellcheck disable=SC2034 # `discard` is the read target; only stderr matters + probe=$(read -r -t "$t" discard &1) + if ! [[ "$t" =~ ^[0-9]+(\.[0-9]+)?$ ]] || [[ "$t" =~ ^0+(\.0+)?$ ]] || [[ -n "$probe" ]]; then + t=2 + fi + fi + printf '%s' "$t" +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 - local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + local read_timeout + read_timeout=$(hook::resolve_read_timeout) local -a read_opts=(-r -t "$read_timeout") if hook::read_supports_nchars; then read_opts+=(-N 65536) @@ -311,7 +342,14 @@ hook::buffer_stdin() { IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" if ((read_rc == 0)); then - continue # a full chunk (or a delimiter) — more may still be coming + # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL + # read that consumed nothing, however, cannot make progress, so continuing + # would spin: break instead. hook::resolve_read_timeout already excludes + # the only known way to reach that (`read -t 0`, which returns success + # without consuming); this keeps loop termination a structural property + # rather than a consequence of validation staying correct. + [[ -n "$chunk" ]] || break + continue fi if ((read_rc > 128)); then # Timed out. Bytes in this window mean the producer is alive: keep them diff --git a/plugins/autonomy/hooks/hook-utils.sh b/plugins/autonomy/hooks/hook-utils.sh index 4a129702f..52efd692b 100644 --- a/plugins/autonomy/hooks/hook-utils.sh +++ b/plugins/autonomy/hooks/hook-utils.sh @@ -295,9 +295,40 @@ hook::json_complete() { jq -e . >/dev/null 2>&1 <<<"$1" } +# Resolve the read timeout to a value THIS shell's `read -t` will actually +# accept, falling back to the documented default of 2 otherwise. +# +# The configured value reaches `read -t` directly, and an unusable one is not a +# tuning mistake — it is a silent disable. `read` rejects a bad spec with rc 1 +# plus a usage error on stderr for EVERY hook invocation; the buffer loop reads +# rc 1 as EOF, produces an empty payload, and every caller skips. `0` is worse +# still: it makes `read` return immediately having consumed nothing, which would +# spin the loop. +# +# Acceptance is settled by PROBING this shell rather than consulting a version +# table: which spellings `read -t` accepts varies across the Bash releases these +# hooks support (fractional values are not universally available, and the +# upstream changelog does not date their introduction), so asking the running +# shell is exact where a version check would be a guess. Reading /dev/null hits +# EOF immediately, so a valid timeout produces no stderr at all. The probe is +# skipped for the default, which is known-good everywhere. +hook::resolve_read_timeout() { + local t="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + if [[ "$t" != "2" ]]; then + local probe + # shellcheck disable=SC2034 # `discard` is the read target; only stderr matters + probe=$(read -r -t "$t" discard &1) + if ! [[ "$t" =~ ^[0-9]+(\.[0-9]+)?$ ]] || [[ "$t" =~ ^0+(\.0+)?$ ]] || [[ -n "$probe" ]]; then + t=2 + fi + fi + printf '%s' "$t" +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 - local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + local read_timeout + read_timeout=$(hook::resolve_read_timeout) local -a read_opts=(-r -t "$read_timeout") if hook::read_supports_nchars; then read_opts+=(-N 65536) @@ -311,7 +342,14 @@ hook::buffer_stdin() { IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" if ((read_rc == 0)); then - continue # a full chunk (or a delimiter) — more may still be coming + # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL + # read that consumed nothing, however, cannot make progress, so continuing + # would spin: break instead. hook::resolve_read_timeout already excludes + # the only known way to reach that (`read -t 0`, which returns success + # without consuming); this keeps loop termination a structural property + # rather than a consequence of validation staying correct. + [[ -n "$chunk" ]] || break + continue fi if ((read_rc > 128)); then # Timed out. Bytes in this window mean the producer is alive: keep them diff --git a/plugins/bash-format/hooks/hook-utils.sh b/plugins/bash-format/hooks/hook-utils.sh index 4a129702f..52efd692b 100644 --- a/plugins/bash-format/hooks/hook-utils.sh +++ b/plugins/bash-format/hooks/hook-utils.sh @@ -295,9 +295,40 @@ hook::json_complete() { jq -e . >/dev/null 2>&1 <<<"$1" } +# Resolve the read timeout to a value THIS shell's `read -t` will actually +# accept, falling back to the documented default of 2 otherwise. +# +# The configured value reaches `read -t` directly, and an unusable one is not a +# tuning mistake — it is a silent disable. `read` rejects a bad spec with rc 1 +# plus a usage error on stderr for EVERY hook invocation; the buffer loop reads +# rc 1 as EOF, produces an empty payload, and every caller skips. `0` is worse +# still: it makes `read` return immediately having consumed nothing, which would +# spin the loop. +# +# Acceptance is settled by PROBING this shell rather than consulting a version +# table: which spellings `read -t` accepts varies across the Bash releases these +# hooks support (fractional values are not universally available, and the +# upstream changelog does not date their introduction), so asking the running +# shell is exact where a version check would be a guess. Reading /dev/null hits +# EOF immediately, so a valid timeout produces no stderr at all. The probe is +# skipped for the default, which is known-good everywhere. +hook::resolve_read_timeout() { + local t="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + if [[ "$t" != "2" ]]; then + local probe + # shellcheck disable=SC2034 # `discard` is the read target; only stderr matters + probe=$(read -r -t "$t" discard &1) + if ! [[ "$t" =~ ^[0-9]+(\.[0-9]+)?$ ]] || [[ "$t" =~ ^0+(\.0+)?$ ]] || [[ -n "$probe" ]]; then + t=2 + fi + fi + printf '%s' "$t" +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 - local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + local read_timeout + read_timeout=$(hook::resolve_read_timeout) local -a read_opts=(-r -t "$read_timeout") if hook::read_supports_nchars; then read_opts+=(-N 65536) @@ -311,7 +342,14 @@ hook::buffer_stdin() { IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" if ((read_rc == 0)); then - continue # a full chunk (or a delimiter) — more may still be coming + # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL + # read that consumed nothing, however, cannot make progress, so continuing + # would spin: break instead. hook::resolve_read_timeout already excludes + # the only known way to reach that (`read -t 0`, which returns success + # without consuming); this keeps loop termination a structural property + # rather than a consequence of validation staying correct. + [[ -n "$chunk" ]] || break + continue fi if ((read_rc > 128)); then # Timed out. Bytes in this window mean the producer is alive: keep them diff --git a/plugins/biome-format/hooks/hook-utils.sh b/plugins/biome-format/hooks/hook-utils.sh index 4a129702f..52efd692b 100644 --- a/plugins/biome-format/hooks/hook-utils.sh +++ b/plugins/biome-format/hooks/hook-utils.sh @@ -295,9 +295,40 @@ hook::json_complete() { jq -e . >/dev/null 2>&1 <<<"$1" } +# Resolve the read timeout to a value THIS shell's `read -t` will actually +# accept, falling back to the documented default of 2 otherwise. +# +# The configured value reaches `read -t` directly, and an unusable one is not a +# tuning mistake — it is a silent disable. `read` rejects a bad spec with rc 1 +# plus a usage error on stderr for EVERY hook invocation; the buffer loop reads +# rc 1 as EOF, produces an empty payload, and every caller skips. `0` is worse +# still: it makes `read` return immediately having consumed nothing, which would +# spin the loop. +# +# Acceptance is settled by PROBING this shell rather than consulting a version +# table: which spellings `read -t` accepts varies across the Bash releases these +# hooks support (fractional values are not universally available, and the +# upstream changelog does not date their introduction), so asking the running +# shell is exact where a version check would be a guess. Reading /dev/null hits +# EOF immediately, so a valid timeout produces no stderr at all. The probe is +# skipped for the default, which is known-good everywhere. +hook::resolve_read_timeout() { + local t="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + if [[ "$t" != "2" ]]; then + local probe + # shellcheck disable=SC2034 # `discard` is the read target; only stderr matters + probe=$(read -r -t "$t" discard &1) + if ! [[ "$t" =~ ^[0-9]+(\.[0-9]+)?$ ]] || [[ "$t" =~ ^0+(\.0+)?$ ]] || [[ -n "$probe" ]]; then + t=2 + fi + fi + printf '%s' "$t" +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 - local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + local read_timeout + read_timeout=$(hook::resolve_read_timeout) local -a read_opts=(-r -t "$read_timeout") if hook::read_supports_nchars; then read_opts+=(-N 65536) @@ -311,7 +342,14 @@ hook::buffer_stdin() { IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" if ((read_rc == 0)); then - continue # a full chunk (or a delimiter) — more may still be coming + # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL + # read that consumed nothing, however, cannot make progress, so continuing + # would spin: break instead. hook::resolve_read_timeout already excludes + # the only known way to reach that (`read -t 0`, which returns success + # without consuming); this keeps loop termination a structural property + # rather than a consequence of validation staying correct. + [[ -n "$chunk" ]] || break + continue fi if ((read_rc > 128)); then # Timed out. Bytes in this window mean the producer is alive: keep them diff --git a/plugins/claude-ops/hooks/hook-utils.sh b/plugins/claude-ops/hooks/hook-utils.sh index 4a129702f..52efd692b 100644 --- a/plugins/claude-ops/hooks/hook-utils.sh +++ b/plugins/claude-ops/hooks/hook-utils.sh @@ -295,9 +295,40 @@ hook::json_complete() { jq -e . >/dev/null 2>&1 <<<"$1" } +# Resolve the read timeout to a value THIS shell's `read -t` will actually +# accept, falling back to the documented default of 2 otherwise. +# +# The configured value reaches `read -t` directly, and an unusable one is not a +# tuning mistake — it is a silent disable. `read` rejects a bad spec with rc 1 +# plus a usage error on stderr for EVERY hook invocation; the buffer loop reads +# rc 1 as EOF, produces an empty payload, and every caller skips. `0` is worse +# still: it makes `read` return immediately having consumed nothing, which would +# spin the loop. +# +# Acceptance is settled by PROBING this shell rather than consulting a version +# table: which spellings `read -t` accepts varies across the Bash releases these +# hooks support (fractional values are not universally available, and the +# upstream changelog does not date their introduction), so asking the running +# shell is exact where a version check would be a guess. Reading /dev/null hits +# EOF immediately, so a valid timeout produces no stderr at all. The probe is +# skipped for the default, which is known-good everywhere. +hook::resolve_read_timeout() { + local t="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + if [[ "$t" != "2" ]]; then + local probe + # shellcheck disable=SC2034 # `discard` is the read target; only stderr matters + probe=$(read -r -t "$t" discard &1) + if ! [[ "$t" =~ ^[0-9]+(\.[0-9]+)?$ ]] || [[ "$t" =~ ^0+(\.0+)?$ ]] || [[ -n "$probe" ]]; then + t=2 + fi + fi + printf '%s' "$t" +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 - local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + local read_timeout + read_timeout=$(hook::resolve_read_timeout) local -a read_opts=(-r -t "$read_timeout") if hook::read_supports_nchars; then read_opts+=(-N 65536) @@ -311,7 +342,14 @@ hook::buffer_stdin() { IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" if ((read_rc == 0)); then - continue # a full chunk (or a delimiter) — more may still be coming + # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL + # read that consumed nothing, however, cannot make progress, so continuing + # would spin: break instead. hook::resolve_read_timeout already excludes + # the only known way to reach that (`read -t 0`, which returns success + # without consuming); this keeps loop termination a structural property + # rather than a consequence of validation staying correct. + [[ -n "$chunk" ]] || break + continue fi if ((read_rc > 128)); then # Timed out. Bytes in this window mean the producer is alive: keep them diff --git a/plugins/desktop-notification/hooks/hook-utils.sh b/plugins/desktop-notification/hooks/hook-utils.sh index 4a129702f..52efd692b 100644 --- a/plugins/desktop-notification/hooks/hook-utils.sh +++ b/plugins/desktop-notification/hooks/hook-utils.sh @@ -295,9 +295,40 @@ hook::json_complete() { jq -e . >/dev/null 2>&1 <<<"$1" } +# Resolve the read timeout to a value THIS shell's `read -t` will actually +# accept, falling back to the documented default of 2 otherwise. +# +# The configured value reaches `read -t` directly, and an unusable one is not a +# tuning mistake — it is a silent disable. `read` rejects a bad spec with rc 1 +# plus a usage error on stderr for EVERY hook invocation; the buffer loop reads +# rc 1 as EOF, produces an empty payload, and every caller skips. `0` is worse +# still: it makes `read` return immediately having consumed nothing, which would +# spin the loop. +# +# Acceptance is settled by PROBING this shell rather than consulting a version +# table: which spellings `read -t` accepts varies across the Bash releases these +# hooks support (fractional values are not universally available, and the +# upstream changelog does not date their introduction), so asking the running +# shell is exact where a version check would be a guess. Reading /dev/null hits +# EOF immediately, so a valid timeout produces no stderr at all. The probe is +# skipped for the default, which is known-good everywhere. +hook::resolve_read_timeout() { + local t="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + if [[ "$t" != "2" ]]; then + local probe + # shellcheck disable=SC2034 # `discard` is the read target; only stderr matters + probe=$(read -r -t "$t" discard &1) + if ! [[ "$t" =~ ^[0-9]+(\.[0-9]+)?$ ]] || [[ "$t" =~ ^0+(\.0+)?$ ]] || [[ -n "$probe" ]]; then + t=2 + fi + fi + printf '%s' "$t" +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 - local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + local read_timeout + read_timeout=$(hook::resolve_read_timeout) local -a read_opts=(-r -t "$read_timeout") if hook::read_supports_nchars; then read_opts+=(-N 65536) @@ -311,7 +342,14 @@ hook::buffer_stdin() { IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" if ((read_rc == 0)); then - continue # a full chunk (or a delimiter) — more may still be coming + # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL + # read that consumed nothing, however, cannot make progress, so continuing + # would spin: break instead. hook::resolve_read_timeout already excludes + # the only known way to reach that (`read -t 0`, which returns success + # without consuming); this keeps loop termination a structural property + # rather than a consequence of validation staying correct. + [[ -n "$chunk" ]] || break + continue fi if ((read_rc > 128)); then # Timed out. Bytes in this window mean the producer is alive: keep them diff --git a/plugins/eol-normalizer/hooks/hook-utils.sh b/plugins/eol-normalizer/hooks/hook-utils.sh index 4a129702f..52efd692b 100644 --- a/plugins/eol-normalizer/hooks/hook-utils.sh +++ b/plugins/eol-normalizer/hooks/hook-utils.sh @@ -295,9 +295,40 @@ hook::json_complete() { jq -e . >/dev/null 2>&1 <<<"$1" } +# Resolve the read timeout to a value THIS shell's `read -t` will actually +# accept, falling back to the documented default of 2 otherwise. +# +# The configured value reaches `read -t` directly, and an unusable one is not a +# tuning mistake — it is a silent disable. `read` rejects a bad spec with rc 1 +# plus a usage error on stderr for EVERY hook invocation; the buffer loop reads +# rc 1 as EOF, produces an empty payload, and every caller skips. `0` is worse +# still: it makes `read` return immediately having consumed nothing, which would +# spin the loop. +# +# Acceptance is settled by PROBING this shell rather than consulting a version +# table: which spellings `read -t` accepts varies across the Bash releases these +# hooks support (fractional values are not universally available, and the +# upstream changelog does not date their introduction), so asking the running +# shell is exact where a version check would be a guess. Reading /dev/null hits +# EOF immediately, so a valid timeout produces no stderr at all. The probe is +# skipped for the default, which is known-good everywhere. +hook::resolve_read_timeout() { + local t="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + if [[ "$t" != "2" ]]; then + local probe + # shellcheck disable=SC2034 # `discard` is the read target; only stderr matters + probe=$(read -r -t "$t" discard &1) + if ! [[ "$t" =~ ^[0-9]+(\.[0-9]+)?$ ]] || [[ "$t" =~ ^0+(\.0+)?$ ]] || [[ -n "$probe" ]]; then + t=2 + fi + fi + printf '%s' "$t" +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 - local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + local read_timeout + read_timeout=$(hook::resolve_read_timeout) local -a read_opts=(-r -t "$read_timeout") if hook::read_supports_nchars; then read_opts+=(-N 65536) @@ -311,7 +342,14 @@ hook::buffer_stdin() { IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" if ((read_rc == 0)); then - continue # a full chunk (or a delimiter) — more may still be coming + # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL + # read that consumed nothing, however, cannot make progress, so continuing + # would spin: break instead. hook::resolve_read_timeout already excludes + # the only known way to reach that (`read -t 0`, which returns success + # without consuming); this keeps loop termination a structural property + # rather than a consequence of validation staying correct. + [[ -n "$chunk" ]] || break + continue fi if ((read_rc > 128)); then # Timed out. Bytes in this window mean the producer is alive: keep them diff --git a/plugins/go-format/hooks/hook-utils.sh b/plugins/go-format/hooks/hook-utils.sh index 4a129702f..52efd692b 100644 --- a/plugins/go-format/hooks/hook-utils.sh +++ b/plugins/go-format/hooks/hook-utils.sh @@ -295,9 +295,40 @@ hook::json_complete() { jq -e . >/dev/null 2>&1 <<<"$1" } +# Resolve the read timeout to a value THIS shell's `read -t` will actually +# accept, falling back to the documented default of 2 otherwise. +# +# The configured value reaches `read -t` directly, and an unusable one is not a +# tuning mistake — it is a silent disable. `read` rejects a bad spec with rc 1 +# plus a usage error on stderr for EVERY hook invocation; the buffer loop reads +# rc 1 as EOF, produces an empty payload, and every caller skips. `0` is worse +# still: it makes `read` return immediately having consumed nothing, which would +# spin the loop. +# +# Acceptance is settled by PROBING this shell rather than consulting a version +# table: which spellings `read -t` accepts varies across the Bash releases these +# hooks support (fractional values are not universally available, and the +# upstream changelog does not date their introduction), so asking the running +# shell is exact where a version check would be a guess. Reading /dev/null hits +# EOF immediately, so a valid timeout produces no stderr at all. The probe is +# skipped for the default, which is known-good everywhere. +hook::resolve_read_timeout() { + local t="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + if [[ "$t" != "2" ]]; then + local probe + # shellcheck disable=SC2034 # `discard` is the read target; only stderr matters + probe=$(read -r -t "$t" discard &1) + if ! [[ "$t" =~ ^[0-9]+(\.[0-9]+)?$ ]] || [[ "$t" =~ ^0+(\.0+)?$ ]] || [[ -n "$probe" ]]; then + t=2 + fi + fi + printf '%s' "$t" +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 - local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + local read_timeout + read_timeout=$(hook::resolve_read_timeout) local -a read_opts=(-r -t "$read_timeout") if hook::read_supports_nchars; then read_opts+=(-N 65536) @@ -311,7 +342,14 @@ hook::buffer_stdin() { IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" if ((read_rc == 0)); then - continue # a full chunk (or a delimiter) — more may still be coming + # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL + # read that consumed nothing, however, cannot make progress, so continuing + # would spin: break instead. hook::resolve_read_timeout already excludes + # the only known way to reach that (`read -t 0`, which returns success + # without consuming); this keeps loop termination a structural property + # rather than a consequence of validation staying correct. + [[ -n "$chunk" ]] || break + continue fi if ((read_rc > 128)); then # Timed out. Bytes in this window mean the producer is alive: keep them diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index 7b4e83ac1..4536d2f19 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -39,7 +39,11 @@ All notable changes to the `guardrails` plugin are documented here. Format follo `CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT` through the shared library but never declared the option, so consumers had no supported way to set it — `actionlint` and `claude-ops` both declare it. Declaring it exposes the same knob here. The effective default when a consumer sets nothing remains - the shell-level `:-2` fallback inside `hook-utils.sh`. + the shell-level `:-2` fallback inside `hook-utils.sh`. A configured value the running shell's + `read -t` will not accept — including a fractional value on a Bash release that has no fractional + timeouts — falls back to that default instead of failing every read, and `0` is rejected outright + because it would make `read` return without consuming anything. Acceptance is settled by probing + the running shell rather than a Bash version table. ## [0.17.3] diff --git a/plugins/guardrails/README.md b/plugins/guardrails/README.md index 2d196034a..117aa701e 100644 --- a/plugins/guardrails/README.md +++ b/plugins/guardrails/README.md @@ -136,7 +136,9 @@ One further option tunes the hooks' shared plumbing rather than a single guard: is never cut off while it is still coming; it fires only when the pipe goes silent for that long, at which point a blocking guard fails **closed** (`exit 2` with a `BLOCKED:` reason) rather than letting an unscanned tool call - through. You should not need to change it. + through. A value this shell's `read -t` will not accept — or `0`, which would + make the read consume nothing — falls back to the default rather than + disabling the guards. You should not need to change it. ## Consumer seams diff --git a/plugins/guardrails/hooks/hook-utils.sh b/plugins/guardrails/hooks/hook-utils.sh index 4a129702f..52efd692b 100644 --- a/plugins/guardrails/hooks/hook-utils.sh +++ b/plugins/guardrails/hooks/hook-utils.sh @@ -295,9 +295,40 @@ hook::json_complete() { jq -e . >/dev/null 2>&1 <<<"$1" } +# Resolve the read timeout to a value THIS shell's `read -t` will actually +# accept, falling back to the documented default of 2 otherwise. +# +# The configured value reaches `read -t` directly, and an unusable one is not a +# tuning mistake — it is a silent disable. `read` rejects a bad spec with rc 1 +# plus a usage error on stderr for EVERY hook invocation; the buffer loop reads +# rc 1 as EOF, produces an empty payload, and every caller skips. `0` is worse +# still: it makes `read` return immediately having consumed nothing, which would +# spin the loop. +# +# Acceptance is settled by PROBING this shell rather than consulting a version +# table: which spellings `read -t` accepts varies across the Bash releases these +# hooks support (fractional values are not universally available, and the +# upstream changelog does not date their introduction), so asking the running +# shell is exact where a version check would be a guess. Reading /dev/null hits +# EOF immediately, so a valid timeout produces no stderr at all. The probe is +# skipped for the default, which is known-good everywhere. +hook::resolve_read_timeout() { + local t="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + if [[ "$t" != "2" ]]; then + local probe + # shellcheck disable=SC2034 # `discard` is the read target; only stderr matters + probe=$(read -r -t "$t" discard &1) + if ! [[ "$t" =~ ^[0-9]+(\.[0-9]+)?$ ]] || [[ "$t" =~ ^0+(\.0+)?$ ]] || [[ -n "$probe" ]]; then + t=2 + fi + fi + printf '%s' "$t" +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 - local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + local read_timeout + read_timeout=$(hook::resolve_read_timeout) local -a read_opts=(-r -t "$read_timeout") if hook::read_supports_nchars; then read_opts+=(-N 65536) @@ -311,7 +342,14 @@ hook::buffer_stdin() { IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" if ((read_rc == 0)); then - continue # a full chunk (or a delimiter) — more may still be coming + # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL + # read that consumed nothing, however, cannot make progress, so continuing + # would spin: break instead. hook::resolve_read_timeout already excludes + # the only known way to reach that (`read -t 0`, which returns success + # without consuming); this keeps loop termination a structural property + # rather than a consequence of validation staying correct. + [[ -n "$chunk" ]] || break + continue fi if ((read_rc > 128)); then # Timed out. Bytes in this window mean the producer is alive: keep them diff --git a/plugins/markdown-format/hooks/hook-utils.sh b/plugins/markdown-format/hooks/hook-utils.sh index 4a129702f..52efd692b 100644 --- a/plugins/markdown-format/hooks/hook-utils.sh +++ b/plugins/markdown-format/hooks/hook-utils.sh @@ -295,9 +295,40 @@ hook::json_complete() { jq -e . >/dev/null 2>&1 <<<"$1" } +# Resolve the read timeout to a value THIS shell's `read -t` will actually +# accept, falling back to the documented default of 2 otherwise. +# +# The configured value reaches `read -t` directly, and an unusable one is not a +# tuning mistake — it is a silent disable. `read` rejects a bad spec with rc 1 +# plus a usage error on stderr for EVERY hook invocation; the buffer loop reads +# rc 1 as EOF, produces an empty payload, and every caller skips. `0` is worse +# still: it makes `read` return immediately having consumed nothing, which would +# spin the loop. +# +# Acceptance is settled by PROBING this shell rather than consulting a version +# table: which spellings `read -t` accepts varies across the Bash releases these +# hooks support (fractional values are not universally available, and the +# upstream changelog does not date their introduction), so asking the running +# shell is exact where a version check would be a guess. Reading /dev/null hits +# EOF immediately, so a valid timeout produces no stderr at all. The probe is +# skipped for the default, which is known-good everywhere. +hook::resolve_read_timeout() { + local t="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + if [[ "$t" != "2" ]]; then + local probe + # shellcheck disable=SC2034 # `discard` is the read target; only stderr matters + probe=$(read -r -t "$t" discard &1) + if ! [[ "$t" =~ ^[0-9]+(\.[0-9]+)?$ ]] || [[ "$t" =~ ^0+(\.0+)?$ ]] || [[ -n "$probe" ]]; then + t=2 + fi + fi + printf '%s' "$t" +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 - local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + local read_timeout + read_timeout=$(hook::resolve_read_timeout) local -a read_opts=(-r -t "$read_timeout") if hook::read_supports_nchars; then read_opts+=(-N 65536) @@ -311,7 +342,14 @@ hook::buffer_stdin() { IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" if ((read_rc == 0)); then - continue # a full chunk (or a delimiter) — more may still be coming + # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL + # read that consumed nothing, however, cannot make progress, so continuing + # would spin: break instead. hook::resolve_read_timeout already excludes + # the only known way to reach that (`read -t 0`, which returns success + # without consuming); this keeps loop termination a structural property + # rather than a consequence of validation staying correct. + [[ -n "$chunk" ]] || break + continue fi if ((read_rc > 128)); then # Timed out. Bytes in this window mean the producer is alive: keep them diff --git a/plugins/powershell-format/hooks/hook-utils.sh b/plugins/powershell-format/hooks/hook-utils.sh index 4a129702f..52efd692b 100644 --- a/plugins/powershell-format/hooks/hook-utils.sh +++ b/plugins/powershell-format/hooks/hook-utils.sh @@ -295,9 +295,40 @@ hook::json_complete() { jq -e . >/dev/null 2>&1 <<<"$1" } +# Resolve the read timeout to a value THIS shell's `read -t` will actually +# accept, falling back to the documented default of 2 otherwise. +# +# The configured value reaches `read -t` directly, and an unusable one is not a +# tuning mistake — it is a silent disable. `read` rejects a bad spec with rc 1 +# plus a usage error on stderr for EVERY hook invocation; the buffer loop reads +# rc 1 as EOF, produces an empty payload, and every caller skips. `0` is worse +# still: it makes `read` return immediately having consumed nothing, which would +# spin the loop. +# +# Acceptance is settled by PROBING this shell rather than consulting a version +# table: which spellings `read -t` accepts varies across the Bash releases these +# hooks support (fractional values are not universally available, and the +# upstream changelog does not date their introduction), so asking the running +# shell is exact where a version check would be a guess. Reading /dev/null hits +# EOF immediately, so a valid timeout produces no stderr at all. The probe is +# skipped for the default, which is known-good everywhere. +hook::resolve_read_timeout() { + local t="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + if [[ "$t" != "2" ]]; then + local probe + # shellcheck disable=SC2034 # `discard` is the read target; only stderr matters + probe=$(read -r -t "$t" discard &1) + if ! [[ "$t" =~ ^[0-9]+(\.[0-9]+)?$ ]] || [[ "$t" =~ ^0+(\.0+)?$ ]] || [[ -n "$probe" ]]; then + t=2 + fi + fi + printf '%s' "$t" +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 - local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + local read_timeout + read_timeout=$(hook::resolve_read_timeout) local -a read_opts=(-r -t "$read_timeout") if hook::read_supports_nchars; then read_opts+=(-N 65536) @@ -311,7 +342,14 @@ hook::buffer_stdin() { IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" if ((read_rc == 0)); then - continue # a full chunk (or a delimiter) — more may still be coming + # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL + # read that consumed nothing, however, cannot make progress, so continuing + # would spin: break instead. hook::resolve_read_timeout already excludes + # the only known way to reach that (`read -t 0`, which returns success + # without consuming); this keeps loop termination a structural property + # rather than a consequence of validation staying correct. + [[ -n "$chunk" ]] || break + continue fi if ((read_rc > 128)); then # Timed out. Bytes in this window mean the producer is alive: keep them diff --git a/plugins/rate-limit-guard/hooks/hook-utils.sh b/plugins/rate-limit-guard/hooks/hook-utils.sh index 4a129702f..52efd692b 100755 --- a/plugins/rate-limit-guard/hooks/hook-utils.sh +++ b/plugins/rate-limit-guard/hooks/hook-utils.sh @@ -295,9 +295,40 @@ hook::json_complete() { jq -e . >/dev/null 2>&1 <<<"$1" } +# Resolve the read timeout to a value THIS shell's `read -t` will actually +# accept, falling back to the documented default of 2 otherwise. +# +# The configured value reaches `read -t` directly, and an unusable one is not a +# tuning mistake — it is a silent disable. `read` rejects a bad spec with rc 1 +# plus a usage error on stderr for EVERY hook invocation; the buffer loop reads +# rc 1 as EOF, produces an empty payload, and every caller skips. `0` is worse +# still: it makes `read` return immediately having consumed nothing, which would +# spin the loop. +# +# Acceptance is settled by PROBING this shell rather than consulting a version +# table: which spellings `read -t` accepts varies across the Bash releases these +# hooks support (fractional values are not universally available, and the +# upstream changelog does not date their introduction), so asking the running +# shell is exact where a version check would be a guess. Reading /dev/null hits +# EOF immediately, so a valid timeout produces no stderr at all. The probe is +# skipped for the default, which is known-good everywhere. +hook::resolve_read_timeout() { + local t="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + if [[ "$t" != "2" ]]; then + local probe + # shellcheck disable=SC2034 # `discard` is the read target; only stderr matters + probe=$(read -r -t "$t" discard &1) + if ! [[ "$t" =~ ^[0-9]+(\.[0-9]+)?$ ]] || [[ "$t" =~ ^0+(\.0+)?$ ]] || [[ -n "$probe" ]]; then + t=2 + fi + fi + printf '%s' "$t" +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 - local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + local read_timeout + read_timeout=$(hook::resolve_read_timeout) local -a read_opts=(-r -t "$read_timeout") if hook::read_supports_nchars; then read_opts+=(-N 65536) @@ -311,7 +342,14 @@ hook::buffer_stdin() { IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" if ((read_rc == 0)); then - continue # a full chunk (or a delimiter) — more may still be coming + # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL + # read that consumed nothing, however, cannot make progress, so continuing + # would spin: break instead. hook::resolve_read_timeout already excludes + # the only known way to reach that (`read -t 0`, which returns success + # without consuming); this keeps loop termination a structural property + # rather than a consequence of validation staying correct. + [[ -n "$chunk" ]] || break + continue fi if ((read_rc > 128)); then # Timed out. Bytes in this window mean the producer is alive: keep them diff --git a/plugins/ruff-format/hooks/hook-utils.sh b/plugins/ruff-format/hooks/hook-utils.sh index 4a129702f..52efd692b 100644 --- a/plugins/ruff-format/hooks/hook-utils.sh +++ b/plugins/ruff-format/hooks/hook-utils.sh @@ -295,9 +295,40 @@ hook::json_complete() { jq -e . >/dev/null 2>&1 <<<"$1" } +# Resolve the read timeout to a value THIS shell's `read -t` will actually +# accept, falling back to the documented default of 2 otherwise. +# +# The configured value reaches `read -t` directly, and an unusable one is not a +# tuning mistake — it is a silent disable. `read` rejects a bad spec with rc 1 +# plus a usage error on stderr for EVERY hook invocation; the buffer loop reads +# rc 1 as EOF, produces an empty payload, and every caller skips. `0` is worse +# still: it makes `read` return immediately having consumed nothing, which would +# spin the loop. +# +# Acceptance is settled by PROBING this shell rather than consulting a version +# table: which spellings `read -t` accepts varies across the Bash releases these +# hooks support (fractional values are not universally available, and the +# upstream changelog does not date their introduction), so asking the running +# shell is exact where a version check would be a guess. Reading /dev/null hits +# EOF immediately, so a valid timeout produces no stderr at all. The probe is +# skipped for the default, which is known-good everywhere. +hook::resolve_read_timeout() { + local t="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + if [[ "$t" != "2" ]]; then + local probe + # shellcheck disable=SC2034 # `discard` is the read target; only stderr matters + probe=$(read -r -t "$t" discard &1) + if ! [[ "$t" =~ ^[0-9]+(\.[0-9]+)?$ ]] || [[ "$t" =~ ^0+(\.0+)?$ ]] || [[ -n "$probe" ]]; then + t=2 + fi + fi + printf '%s' "$t" +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 - local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + local read_timeout + read_timeout=$(hook::resolve_read_timeout) local -a read_opts=(-r -t "$read_timeout") if hook::read_supports_nchars; then read_opts+=(-N 65536) @@ -311,7 +342,14 @@ hook::buffer_stdin() { IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" if ((read_rc == 0)); then - continue # a full chunk (or a delimiter) — more may still be coming + # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL + # read that consumed nothing, however, cannot make progress, so continuing + # would spin: break instead. hook::resolve_read_timeout already excludes + # the only known way to reach that (`read -t 0`, which returns success + # without consuming); this keeps loop termination a structural property + # rather than a consequence of validation staying correct. + [[ -n "$chunk" ]] || break + continue fi if ((read_rc > 128)); then # Timed out. Bytes in this window mean the producer is alive: keep them diff --git a/plugins/typos-format/hooks/hook-utils.sh b/plugins/typos-format/hooks/hook-utils.sh index 4a129702f..52efd692b 100644 --- a/plugins/typos-format/hooks/hook-utils.sh +++ b/plugins/typos-format/hooks/hook-utils.sh @@ -295,9 +295,40 @@ hook::json_complete() { jq -e . >/dev/null 2>&1 <<<"$1" } +# Resolve the read timeout to a value THIS shell's `read -t` will actually +# accept, falling back to the documented default of 2 otherwise. +# +# The configured value reaches `read -t` directly, and an unusable one is not a +# tuning mistake — it is a silent disable. `read` rejects a bad spec with rc 1 +# plus a usage error on stderr for EVERY hook invocation; the buffer loop reads +# rc 1 as EOF, produces an empty payload, and every caller skips. `0` is worse +# still: it makes `read` return immediately having consumed nothing, which would +# spin the loop. +# +# Acceptance is settled by PROBING this shell rather than consulting a version +# table: which spellings `read -t` accepts varies across the Bash releases these +# hooks support (fractional values are not universally available, and the +# upstream changelog does not date their introduction), so asking the running +# shell is exact where a version check would be a guess. Reading /dev/null hits +# EOF immediately, so a valid timeout produces no stderr at all. The probe is +# skipped for the default, which is known-good everywhere. +hook::resolve_read_timeout() { + local t="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + if [[ "$t" != "2" ]]; then + local probe + # shellcheck disable=SC2034 # `discard` is the read target; only stderr matters + probe=$(read -r -t "$t" discard &1) + if ! [[ "$t" =~ ^[0-9]+(\.[0-9]+)?$ ]] || [[ "$t" =~ ^0+(\.0+)?$ ]] || [[ -n "$probe" ]]; then + t=2 + fi + fi + printf '%s' "$t" +} + hook::buffer_stdin() { local input="" chunk="" read_rc=0 stalled=0 - local read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + local read_timeout + read_timeout=$(hook::resolve_read_timeout) local -a read_opts=(-r -t "$read_timeout") if hook::read_supports_nchars; then read_opts+=(-N 65536) @@ -311,7 +342,14 @@ hook::buffer_stdin() { IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" if ((read_rc == 0)); then - continue # a full chunk (or a delimiter) — more may still be coming + # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL + # read that consumed nothing, however, cannot make progress, so continuing + # would spin: break instead. hook::resolve_read_timeout already excludes + # the only known way to reach that (`read -t 0`, which returns success + # without consuming); this keeps loop termination a structural property + # rather than a consequence of validation staying correct. + [[ -n "$chunk" ]] || break + continue fi if ((read_rc > 128)); then # Timed out. Bytes in this window mean the producer is alive: keep them From 9cb84e15a9ba500ae1945f81a3756d908c3a4eec Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 26 Jul 2026 16:32:19 -0400 Subject: [PATCH 5/7] fix(hook-utils): read the idle bound in slices so a stall lands near one bound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fifth review finding on #1587. `read -t` reports only that its window expired, never WHEN inside it the last byte arrived. Armed as a single window, a producer that emits bytes early and then goes quiet is not declared stalled until the SECOND window expires — so a 2 s idle bound could take nearly 4 s to fail closed, and calling it an idle bound overstated what the code did. Rather than re-word the claim a third time, make it true: the bound is now read in HOOK_STDIN_READ_SLICES (4) slices, and a stall requires that many CONSECUTIVE slices with no bytes. Any byte at all resets the count — that reset, not the read's exit status, is what makes this an inactivity timer. Worst-case overshoot drops from 100% of the bound to 25%, and the residual quarter is now stated plainly in the function comment, the guardrails README, and the guardrails changelog rather than glossed as "idle". Slice acceptance is probed exactly like the timeout itself, so a shell whose `read -t` rejects the fractional slice degrades to a count of 1 — precisely today's unsliced behavior — instead of failing. Same reasoning as the previous commit: the upstream changelog does not date fractional-timeout support, so asking the running shell beats a version table. Measured back to back on this host at a 1.2 s bound: a partial-then-silent producer is declared stalled at 2012 ms sliced vs 2728 ms unsliced; the late-EOF case settles at 719 ms vs 1909 ms. Both new assertions are comparisons against a forced-unsliced variant rather than wall-clock constants, so runner load cancels out, and both are preceded by a precondition check that the override actually engages. That check earned its keep immediately: it caught a missing `;` in the override string that would otherwise have made the new cases pass while testing the unmodified function — the third vacuous-test near-miss on this branch. Existing behavior is unchanged everywhere else: 112/112 in lib/hook-utils.test.sh (52 s), 72/72 hardcoded-path-check, 203/203 block-hook-bypass, 75/75 typos-format, and the end-to-end fail-closed triple (violation blocked, violation at the end of a 200 KB payload blocked, stalled pipe blocked) all still hold. Refs #1563 Co-Authored-By: Claude Fable 5 --- lib/hook-utils.sh | 86 +++++++++++++++---- lib/hook-utils.test.sh | 60 ++++++++++++- plugins/actionlint/hooks/hook-utils.sh | 86 +++++++++++++++---- plugins/autonomy/hooks/hook-utils.sh | 86 +++++++++++++++---- plugins/bash-format/hooks/hook-utils.sh | 86 +++++++++++++++---- plugins/biome-format/hooks/hook-utils.sh | 86 +++++++++++++++---- plugins/claude-ops/hooks/hook-utils.sh | 86 +++++++++++++++---- .../desktop-notification/hooks/hook-utils.sh | 86 +++++++++++++++---- plugins/eol-normalizer/hooks/hook-utils.sh | 86 +++++++++++++++---- plugins/go-format/hooks/hook-utils.sh | 86 +++++++++++++++---- plugins/guardrails/CHANGELOG.md | 14 +-- plugins/guardrails/README.md | 19 ++-- plugins/guardrails/hooks/hook-utils.sh | 86 +++++++++++++++---- plugins/markdown-format/hooks/hook-utils.sh | 86 +++++++++++++++---- plugins/powershell-format/hooks/hook-utils.sh | 86 +++++++++++++++---- plugins/rate-limit-guard/hooks/hook-utils.sh | 86 +++++++++++++++---- plugins/ruff-format/hooks/hook-utils.sh | 86 +++++++++++++++---- plugins/typos-format/hooks/hook-utils.sh | 86 +++++++++++++++---- 18 files changed, 1081 insertions(+), 302 deletions(-) diff --git a/lib/hook-utils.sh b/lib/hook-utils.sh index 52efd692b..14d9ae252 100644 --- a/lib/hook-utils.sh +++ b/lib/hook-utils.sh @@ -245,16 +245,26 @@ hook::repo_root() { # * The read is chunked. `read -N` lets bash satisfy it in blocks instead of # byte-at-a-time: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms # to ~85 ms. -# * The timer re-arms on progress. `read -t` is a deadline for the WHOLE -# requested read, not an inactivity timer, so a producer that keeps -# delivering but slower than one chunk per window would still trip it. `read` -# assigns whatever it did receive even when it times out, so a timed-out read -# that returned bytes is progress, not a stall: the loop keeps that partial -# chunk and arms a fresh window. Only a window that delivers nothing at all -# is a stall. Re-arming is skipped once the buffer already parses as whole -# JSON, so the late-EOF case costs ONE window, not two — that is the floor, -# since a producer holding the pipe open cannot be distinguished from a slow -# one until a window expires. +# * The timer measures inactivity, not the read. `read -t` is a deadline for +# the WHOLE requested read, so a producer that keeps delivering but slower +# than one chunk per window would still trip it. `read` assigns whatever it +# did receive even when it times out, so any byte counts as progress: the +# loop keeps that partial chunk and reads on. Only the absence of bytes for a +# whole stdin_read_timeout is a stall. +# * The bound is read in HOOK_STDIN_READ_SLICES slices. `read -t` reports only +# that its window expired, never WHEN inside it the last byte arrived, so a +# bound armed as one window would declare a stall anywhere between one and +# TWO bounds after the pipe actually went quiet. Slicing bounds that +# overshoot: with four slices a stall lands within a quarter-bound of the +# configured interval. That residual quarter is the honest limit of the +# approximation, and it errs toward waiting — never toward declaring a live +# producer dead. On a shell whose `read -t` rejects the fractional slice the +# count degrades to 1, i.e. the unsliced one-to-two-bound behavior. +# +# Reading on is skipped once the buffer already parses as whole JSON, so the +# late-EOF case costs ONE slice past the payload rather than the rest of the +# bound — a producer holding the pipe open cannot be distinguished from a slow +# one until a window expires, so some wait there is the floor. # # The trade this makes: a producer trickling bytes indefinitely is never cut off # here. That is deliberate — the harness already caps a `command` hook at 600 s @@ -325,11 +335,43 @@ hook::resolve_read_timeout() { printf '%s' "$t" } +# How many slices the idle bound is divided into. `read -t` reports only that a +# window expired, never WHEN inside it the last byte arrived, so a bound armed as +# one window declares a stall anywhere between one and two bounds after the pipe +# actually went quiet. Asking more often shrinks that: with N slices, a stall is +# declared within one slice of the configured interval. Four is the compromise — +# it cuts worst-case overshoot from 100% of the bound to 25% while keeping the +# idle path to four cheap builtin reads. +HOOK_STDIN_READ_SLICES=4 + +# Resolve the per-read slice for an already-resolved timeout, printing +# " ". Falls back to " 1" — exactly the unsliced +# behavior — when this shell's `read -t` will not accept the fractional slice, +# which is the pre-4.1/no-fractional-timeout case the delimiter-read branch +# already covers. Probed, not version-tested, for the same reason as +# hook::resolve_read_timeout. +hook::resolve_read_slice() { + local t="$1" slice + slice=$(awk -v t="$t" -v n="$HOOK_STDIN_READ_SLICES" \ + 'BEGIN { printf "%.3f", t / n }' 2>/dev/null) || slice="" + if [[ -n "$slice" && "$slice" =~ ^[0-9]+\.[0-9]+$ ]] && ! [[ "$slice" =~ ^0+\.0+$ ]]; then + local probe + # shellcheck disable=SC2034 # `discard` is the read target; only stderr matters + probe=$(read -r -t "$slice" discard &1) + if [[ -z "$probe" ]]; then + printf '%s %s' "$slice" "$HOOK_STDIN_READ_SLICES" + return 0 + fi + fi + printf '%s 1' "$t" +} + hook::buffer_stdin() { - local input="" chunk="" read_rc=0 stalled=0 - local read_timeout + local input="" chunk="" read_rc=0 stalled=0 idle_slices=0 + local read_timeout read_slice slice_count read_timeout=$(hook::resolve_read_timeout) - local -a read_opts=(-r -t "$read_timeout") + read -r read_slice slice_count < <(hook::resolve_read_slice "$read_timeout") + local -a read_opts=(-r -t "$read_slice") if hook::read_supports_nchars; then read_opts+=(-N 65536) else @@ -341,6 +383,9 @@ hook::buffer_stdin() { # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" + # Any byte at all resets the idle count — that, not the read's exit status, + # is what makes this an idle timer rather than a per-read deadline. + [[ -n "$chunk" ]] && idle_slices=0 if ((read_rc == 0)); then # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL # read that consumed nothing, however, cannot make progress, so continuing @@ -352,20 +397,23 @@ hook::buffer_stdin() { continue fi if ((read_rc > 128)); then - # Timed out. Bytes in this window mean the producer is alive: keep them - # and re-arm. An empty window is the stall this guard exists to catch. + # A slice expired. Bytes in it mean the producer is alive: keep them and + # read on. Only slice_count CONSECUTIVE empty slices — one whole + # stdin_read_timeout with nothing arriving — is the stall this guard + # exists to catch, which is why the count is not reset here. if [[ -n "$chunk" ]]; then # ... but stop immediately if what we already hold is a whole JSON # document. That is the Win32 late-EOF case — the payload arrived, the - # pipe just never closed — and re-arming there would spend a second full - # window waiting for an EOF that is not coming, doubling the delay this - # function is supposed to bound. + # pipe just never closed — and reading on there would spend the rest of + # the bound waiting for an EOF that is not coming. hook::json_complete "${input//$'\r'/}" && break continue fi + ((idle_slices++)) + ((idle_slices >= slice_count)) || continue stalled=1 fi - break # EOF (rc 1), an empty timed-out window, or a read error + break # EOF (rc 1), a full idle bound with no bytes, or a read error done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 diff --git a/lib/hook-utils.test.sh b/lib/hook-utils.test.sh index d64a9f059..c70532f1d 100755 --- a/lib/hook-utils.test.sh +++ b/lib/hook-utils.test.sh @@ -872,7 +872,7 @@ bs_time_late_eof() { # $1 = shell prelude; prints elapsed ms (empty if untimed) local t_file t_file="$(mktemp)" { printf '{"complete":true}'; sleep 3; } | { - CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT=0.4 bash -c ' + CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT=1.2 bash -c ' source "$1" eval "$2" printf "%s\n" "${EPOCHREALTIME:-0}" @@ -896,10 +896,10 @@ if [[ -z "$bs_fast" || -z "$bs_slow" ]]; then else ok "buffer_stdin: late-EOF window count not timed (EPOCHREALTIME absent, Bash < 5.0)" fi -elif ((bs_fast < bs_slow - 200)); then - ok "buffer_stdin: late-EOF costs one window, not two (${bs_fast} ms vs ${bs_slow} ms re-arming)" +elif ((bs_fast < bs_slow - 400)); then + ok "buffer_stdin: late-EOF stops at the payload, not the bound (${bs_fast} ms vs ${bs_slow} ms)" else - fail "buffer_stdin late-EOF: ${bs_fast} ms vs ${bs_slow} ms re-arming — expected >200 ms faster" + fail "buffer_stdin late-EOF: ${bs_fast} ms vs ${bs_slow} ms reading on — expected >400 ms faster" fi rm -f "$bs_rc_file" "$bs_out_file" @@ -1086,6 +1086,58 @@ else fi rm -f "$bs_rc_file" +# --- Test 18g: hook::buffer_stdin — a stall lands near ONE bound, not two ----- +# `read -t` reports only that its window expired, never WHEN inside it the last +# byte arrived. Armed as a single window, a producer that emits bytes early and +# then goes quiet is not declared stalled until the SECOND window expires — +# almost twice the configured bound. Reading the bound in slices caps that +# overshoot at one slice. Asserted by comparison against a variant with the slice +# count forced to 1 (the unsliced behavior), both timed back to back so runner +# load cancels out. The override is asserted to actually engage first — a +# silently ineffective override would make this a vacuous pass, which has already +# happened twice on this branch. +bs_time_stall() { # $1 = shell prelude; prints elapsed ms (empty if untimed) + local t_file + t_file="$(mktemp)" + # Bytes land immediately, then the pipe goes quiet well past two bounds. + { printf '{"partial":'; sleep 4; } | { + CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT=1.2 bash -c ' + source "$1" + eval "$2" + printf "%s\n" "${EPOCHREALTIME:-0}" + hook::buffer_stdin >/dev/null 2>&1 + printf "%s\n" "${EPOCHREALTIME:-0}" + ' _ "$HOOK_DIR/hook-utils.sh" "$1" >"$t_file" + } + awk 'NR==1 {s=$0} NR==2 {e=$0} + END { if (s == 0 || e == 0 || NR < 2) print ""; else printf "%.0f", (e - s) * 1000 }' \ + "$t_file" + rm -f "$t_file" +} +# shellcheck disable=SC2016 # $1 is the overriding function's own positional, not this shell's +bs_unsliced='hook::resolve_read_slice() { printf "%s 1" "$1"; }' +bs_slices=$(bash -c 'source "$1"; hook::resolve_read_slice 1.2' _ "$HOOK_DIR/hook-utils.sh") +bs_slices_forced=$(bash -c 'source "$1"; '"$bs_unsliced"'; hook::resolve_read_slice 1.2' \ + _ "$HOOK_DIR/hook-utils.sh") +if [[ "$bs_slices" == "0.300 4" ]] && [[ "$bs_slices_forced" == "1.2 1" ]]; then + ok "buffer_stdin: slice resolution splits the bound, and the unsliced override engages" +else + fail "slice resolution: got '$bs_slices' / forced '$bs_slices_forced'; cases below are vacuous" +fi +bs_sliced_ms=$(bs_time_stall "") +bs_unsliced_ms=$(bs_time_stall "$bs_unsliced") +if [[ -z "$bs_sliced_ms" || -z "$bs_unsliced_ms" ]]; then + if [[ -n "${EPOCHREALTIME:-}" ]]; then + fail "stall timing harness produced no measurement (sliced='$bs_sliced_ms' unsliced='$bs_unsliced_ms')" + else + ok "buffer_stdin: stall overshoot not timed (EPOCHREALTIME absent, Bash < 5.0)" + fi +elif ((bs_sliced_ms < bs_unsliced_ms - 400)); then + ok "buffer_stdin: stall declared near one bound, not two (${bs_sliced_ms} ms vs ${bs_unsliced_ms} ms unsliced)" +else + fail "buffer_stdin stall overshoot: ${bs_sliced_ms} ms vs ${bs_unsliced_ms} ms unsliced — expected >400 ms faster" +fi + # --- Test 19: hook::emit_telemetry — EPOCHREALTIME-absent (Bash < 5.0) skip --- # On Bash < 5.0 EPOCHREALTIME is unset, so the caller's `start=${EPOCHREALTIME:-}` # snapshot is empty. emit_telemetry must then skip fail-open (return 0, emit no diff --git a/plugins/actionlint/hooks/hook-utils.sh b/plugins/actionlint/hooks/hook-utils.sh index 52efd692b..14d9ae252 100644 --- a/plugins/actionlint/hooks/hook-utils.sh +++ b/plugins/actionlint/hooks/hook-utils.sh @@ -245,16 +245,26 @@ hook::repo_root() { # * The read is chunked. `read -N` lets bash satisfy it in blocks instead of # byte-at-a-time: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms # to ~85 ms. -# * The timer re-arms on progress. `read -t` is a deadline for the WHOLE -# requested read, not an inactivity timer, so a producer that keeps -# delivering but slower than one chunk per window would still trip it. `read` -# assigns whatever it did receive even when it times out, so a timed-out read -# that returned bytes is progress, not a stall: the loop keeps that partial -# chunk and arms a fresh window. Only a window that delivers nothing at all -# is a stall. Re-arming is skipped once the buffer already parses as whole -# JSON, so the late-EOF case costs ONE window, not two — that is the floor, -# since a producer holding the pipe open cannot be distinguished from a slow -# one until a window expires. +# * The timer measures inactivity, not the read. `read -t` is a deadline for +# the WHOLE requested read, so a producer that keeps delivering but slower +# than one chunk per window would still trip it. `read` assigns whatever it +# did receive even when it times out, so any byte counts as progress: the +# loop keeps that partial chunk and reads on. Only the absence of bytes for a +# whole stdin_read_timeout is a stall. +# * The bound is read in HOOK_STDIN_READ_SLICES slices. `read -t` reports only +# that its window expired, never WHEN inside it the last byte arrived, so a +# bound armed as one window would declare a stall anywhere between one and +# TWO bounds after the pipe actually went quiet. Slicing bounds that +# overshoot: with four slices a stall lands within a quarter-bound of the +# configured interval. That residual quarter is the honest limit of the +# approximation, and it errs toward waiting — never toward declaring a live +# producer dead. On a shell whose `read -t` rejects the fractional slice the +# count degrades to 1, i.e. the unsliced one-to-two-bound behavior. +# +# Reading on is skipped once the buffer already parses as whole JSON, so the +# late-EOF case costs ONE slice past the payload rather than the rest of the +# bound — a producer holding the pipe open cannot be distinguished from a slow +# one until a window expires, so some wait there is the floor. # # The trade this makes: a producer trickling bytes indefinitely is never cut off # here. That is deliberate — the harness already caps a `command` hook at 600 s @@ -325,11 +335,43 @@ hook::resolve_read_timeout() { printf '%s' "$t" } +# How many slices the idle bound is divided into. `read -t` reports only that a +# window expired, never WHEN inside it the last byte arrived, so a bound armed as +# one window declares a stall anywhere between one and two bounds after the pipe +# actually went quiet. Asking more often shrinks that: with N slices, a stall is +# declared within one slice of the configured interval. Four is the compromise — +# it cuts worst-case overshoot from 100% of the bound to 25% while keeping the +# idle path to four cheap builtin reads. +HOOK_STDIN_READ_SLICES=4 + +# Resolve the per-read slice for an already-resolved timeout, printing +# " ". Falls back to " 1" — exactly the unsliced +# behavior — when this shell's `read -t` will not accept the fractional slice, +# which is the pre-4.1/no-fractional-timeout case the delimiter-read branch +# already covers. Probed, not version-tested, for the same reason as +# hook::resolve_read_timeout. +hook::resolve_read_slice() { + local t="$1" slice + slice=$(awk -v t="$t" -v n="$HOOK_STDIN_READ_SLICES" \ + 'BEGIN { printf "%.3f", t / n }' 2>/dev/null) || slice="" + if [[ -n "$slice" && "$slice" =~ ^[0-9]+\.[0-9]+$ ]] && ! [[ "$slice" =~ ^0+\.0+$ ]]; then + local probe + # shellcheck disable=SC2034 # `discard` is the read target; only stderr matters + probe=$(read -r -t "$slice" discard &1) + if [[ -z "$probe" ]]; then + printf '%s %s' "$slice" "$HOOK_STDIN_READ_SLICES" + return 0 + fi + fi + printf '%s 1' "$t" +} + hook::buffer_stdin() { - local input="" chunk="" read_rc=0 stalled=0 - local read_timeout + local input="" chunk="" read_rc=0 stalled=0 idle_slices=0 + local read_timeout read_slice slice_count read_timeout=$(hook::resolve_read_timeout) - local -a read_opts=(-r -t "$read_timeout") + read -r read_slice slice_count < <(hook::resolve_read_slice "$read_timeout") + local -a read_opts=(-r -t "$read_slice") if hook::read_supports_nchars; then read_opts+=(-N 65536) else @@ -341,6 +383,9 @@ hook::buffer_stdin() { # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" + # Any byte at all resets the idle count — that, not the read's exit status, + # is what makes this an idle timer rather than a per-read deadline. + [[ -n "$chunk" ]] && idle_slices=0 if ((read_rc == 0)); then # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL # read that consumed nothing, however, cannot make progress, so continuing @@ -352,20 +397,23 @@ hook::buffer_stdin() { continue fi if ((read_rc > 128)); then - # Timed out. Bytes in this window mean the producer is alive: keep them - # and re-arm. An empty window is the stall this guard exists to catch. + # A slice expired. Bytes in it mean the producer is alive: keep them and + # read on. Only slice_count CONSECUTIVE empty slices — one whole + # stdin_read_timeout with nothing arriving — is the stall this guard + # exists to catch, which is why the count is not reset here. if [[ -n "$chunk" ]]; then # ... but stop immediately if what we already hold is a whole JSON # document. That is the Win32 late-EOF case — the payload arrived, the - # pipe just never closed — and re-arming there would spend a second full - # window waiting for an EOF that is not coming, doubling the delay this - # function is supposed to bound. + # pipe just never closed — and reading on there would spend the rest of + # the bound waiting for an EOF that is not coming. hook::json_complete "${input//$'\r'/}" && break continue fi + ((idle_slices++)) + ((idle_slices >= slice_count)) || continue stalled=1 fi - break # EOF (rc 1), an empty timed-out window, or a read error + break # EOF (rc 1), a full idle bound with no bytes, or a read error done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 diff --git a/plugins/autonomy/hooks/hook-utils.sh b/plugins/autonomy/hooks/hook-utils.sh index 52efd692b..14d9ae252 100644 --- a/plugins/autonomy/hooks/hook-utils.sh +++ b/plugins/autonomy/hooks/hook-utils.sh @@ -245,16 +245,26 @@ hook::repo_root() { # * The read is chunked. `read -N` lets bash satisfy it in blocks instead of # byte-at-a-time: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms # to ~85 ms. -# * The timer re-arms on progress. `read -t` is a deadline for the WHOLE -# requested read, not an inactivity timer, so a producer that keeps -# delivering but slower than one chunk per window would still trip it. `read` -# assigns whatever it did receive even when it times out, so a timed-out read -# that returned bytes is progress, not a stall: the loop keeps that partial -# chunk and arms a fresh window. Only a window that delivers nothing at all -# is a stall. Re-arming is skipped once the buffer already parses as whole -# JSON, so the late-EOF case costs ONE window, not two — that is the floor, -# since a producer holding the pipe open cannot be distinguished from a slow -# one until a window expires. +# * The timer measures inactivity, not the read. `read -t` is a deadline for +# the WHOLE requested read, so a producer that keeps delivering but slower +# than one chunk per window would still trip it. `read` assigns whatever it +# did receive even when it times out, so any byte counts as progress: the +# loop keeps that partial chunk and reads on. Only the absence of bytes for a +# whole stdin_read_timeout is a stall. +# * The bound is read in HOOK_STDIN_READ_SLICES slices. `read -t` reports only +# that its window expired, never WHEN inside it the last byte arrived, so a +# bound armed as one window would declare a stall anywhere between one and +# TWO bounds after the pipe actually went quiet. Slicing bounds that +# overshoot: with four slices a stall lands within a quarter-bound of the +# configured interval. That residual quarter is the honest limit of the +# approximation, and it errs toward waiting — never toward declaring a live +# producer dead. On a shell whose `read -t` rejects the fractional slice the +# count degrades to 1, i.e. the unsliced one-to-two-bound behavior. +# +# Reading on is skipped once the buffer already parses as whole JSON, so the +# late-EOF case costs ONE slice past the payload rather than the rest of the +# bound — a producer holding the pipe open cannot be distinguished from a slow +# one until a window expires, so some wait there is the floor. # # The trade this makes: a producer trickling bytes indefinitely is never cut off # here. That is deliberate — the harness already caps a `command` hook at 600 s @@ -325,11 +335,43 @@ hook::resolve_read_timeout() { printf '%s' "$t" } +# How many slices the idle bound is divided into. `read -t` reports only that a +# window expired, never WHEN inside it the last byte arrived, so a bound armed as +# one window declares a stall anywhere between one and two bounds after the pipe +# actually went quiet. Asking more often shrinks that: with N slices, a stall is +# declared within one slice of the configured interval. Four is the compromise — +# it cuts worst-case overshoot from 100% of the bound to 25% while keeping the +# idle path to four cheap builtin reads. +HOOK_STDIN_READ_SLICES=4 + +# Resolve the per-read slice for an already-resolved timeout, printing +# " ". Falls back to " 1" — exactly the unsliced +# behavior — when this shell's `read -t` will not accept the fractional slice, +# which is the pre-4.1/no-fractional-timeout case the delimiter-read branch +# already covers. Probed, not version-tested, for the same reason as +# hook::resolve_read_timeout. +hook::resolve_read_slice() { + local t="$1" slice + slice=$(awk -v t="$t" -v n="$HOOK_STDIN_READ_SLICES" \ + 'BEGIN { printf "%.3f", t / n }' 2>/dev/null) || slice="" + if [[ -n "$slice" && "$slice" =~ ^[0-9]+\.[0-9]+$ ]] && ! [[ "$slice" =~ ^0+\.0+$ ]]; then + local probe + # shellcheck disable=SC2034 # `discard` is the read target; only stderr matters + probe=$(read -r -t "$slice" discard &1) + if [[ -z "$probe" ]]; then + printf '%s %s' "$slice" "$HOOK_STDIN_READ_SLICES" + return 0 + fi + fi + printf '%s 1' "$t" +} + hook::buffer_stdin() { - local input="" chunk="" read_rc=0 stalled=0 - local read_timeout + local input="" chunk="" read_rc=0 stalled=0 idle_slices=0 + local read_timeout read_slice slice_count read_timeout=$(hook::resolve_read_timeout) - local -a read_opts=(-r -t "$read_timeout") + read -r read_slice slice_count < <(hook::resolve_read_slice "$read_timeout") + local -a read_opts=(-r -t "$read_slice") if hook::read_supports_nchars; then read_opts+=(-N 65536) else @@ -341,6 +383,9 @@ hook::buffer_stdin() { # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" + # Any byte at all resets the idle count — that, not the read's exit status, + # is what makes this an idle timer rather than a per-read deadline. + [[ -n "$chunk" ]] && idle_slices=0 if ((read_rc == 0)); then # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL # read that consumed nothing, however, cannot make progress, so continuing @@ -352,20 +397,23 @@ hook::buffer_stdin() { continue fi if ((read_rc > 128)); then - # Timed out. Bytes in this window mean the producer is alive: keep them - # and re-arm. An empty window is the stall this guard exists to catch. + # A slice expired. Bytes in it mean the producer is alive: keep them and + # read on. Only slice_count CONSECUTIVE empty slices — one whole + # stdin_read_timeout with nothing arriving — is the stall this guard + # exists to catch, which is why the count is not reset here. if [[ -n "$chunk" ]]; then # ... but stop immediately if what we already hold is a whole JSON # document. That is the Win32 late-EOF case — the payload arrived, the - # pipe just never closed — and re-arming there would spend a second full - # window waiting for an EOF that is not coming, doubling the delay this - # function is supposed to bound. + # pipe just never closed — and reading on there would spend the rest of + # the bound waiting for an EOF that is not coming. hook::json_complete "${input//$'\r'/}" && break continue fi + ((idle_slices++)) + ((idle_slices >= slice_count)) || continue stalled=1 fi - break # EOF (rc 1), an empty timed-out window, or a read error + break # EOF (rc 1), a full idle bound with no bytes, or a read error done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 diff --git a/plugins/bash-format/hooks/hook-utils.sh b/plugins/bash-format/hooks/hook-utils.sh index 52efd692b..14d9ae252 100644 --- a/plugins/bash-format/hooks/hook-utils.sh +++ b/plugins/bash-format/hooks/hook-utils.sh @@ -245,16 +245,26 @@ hook::repo_root() { # * The read is chunked. `read -N` lets bash satisfy it in blocks instead of # byte-at-a-time: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms # to ~85 ms. -# * The timer re-arms on progress. `read -t` is a deadline for the WHOLE -# requested read, not an inactivity timer, so a producer that keeps -# delivering but slower than one chunk per window would still trip it. `read` -# assigns whatever it did receive even when it times out, so a timed-out read -# that returned bytes is progress, not a stall: the loop keeps that partial -# chunk and arms a fresh window. Only a window that delivers nothing at all -# is a stall. Re-arming is skipped once the buffer already parses as whole -# JSON, so the late-EOF case costs ONE window, not two — that is the floor, -# since a producer holding the pipe open cannot be distinguished from a slow -# one until a window expires. +# * The timer measures inactivity, not the read. `read -t` is a deadline for +# the WHOLE requested read, so a producer that keeps delivering but slower +# than one chunk per window would still trip it. `read` assigns whatever it +# did receive even when it times out, so any byte counts as progress: the +# loop keeps that partial chunk and reads on. Only the absence of bytes for a +# whole stdin_read_timeout is a stall. +# * The bound is read in HOOK_STDIN_READ_SLICES slices. `read -t` reports only +# that its window expired, never WHEN inside it the last byte arrived, so a +# bound armed as one window would declare a stall anywhere between one and +# TWO bounds after the pipe actually went quiet. Slicing bounds that +# overshoot: with four slices a stall lands within a quarter-bound of the +# configured interval. That residual quarter is the honest limit of the +# approximation, and it errs toward waiting — never toward declaring a live +# producer dead. On a shell whose `read -t` rejects the fractional slice the +# count degrades to 1, i.e. the unsliced one-to-two-bound behavior. +# +# Reading on is skipped once the buffer already parses as whole JSON, so the +# late-EOF case costs ONE slice past the payload rather than the rest of the +# bound — a producer holding the pipe open cannot be distinguished from a slow +# one until a window expires, so some wait there is the floor. # # The trade this makes: a producer trickling bytes indefinitely is never cut off # here. That is deliberate — the harness already caps a `command` hook at 600 s @@ -325,11 +335,43 @@ hook::resolve_read_timeout() { printf '%s' "$t" } +# How many slices the idle bound is divided into. `read -t` reports only that a +# window expired, never WHEN inside it the last byte arrived, so a bound armed as +# one window declares a stall anywhere between one and two bounds after the pipe +# actually went quiet. Asking more often shrinks that: with N slices, a stall is +# declared within one slice of the configured interval. Four is the compromise — +# it cuts worst-case overshoot from 100% of the bound to 25% while keeping the +# idle path to four cheap builtin reads. +HOOK_STDIN_READ_SLICES=4 + +# Resolve the per-read slice for an already-resolved timeout, printing +# " ". Falls back to " 1" — exactly the unsliced +# behavior — when this shell's `read -t` will not accept the fractional slice, +# which is the pre-4.1/no-fractional-timeout case the delimiter-read branch +# already covers. Probed, not version-tested, for the same reason as +# hook::resolve_read_timeout. +hook::resolve_read_slice() { + local t="$1" slice + slice=$(awk -v t="$t" -v n="$HOOK_STDIN_READ_SLICES" \ + 'BEGIN { printf "%.3f", t / n }' 2>/dev/null) || slice="" + if [[ -n "$slice" && "$slice" =~ ^[0-9]+\.[0-9]+$ ]] && ! [[ "$slice" =~ ^0+\.0+$ ]]; then + local probe + # shellcheck disable=SC2034 # `discard` is the read target; only stderr matters + probe=$(read -r -t "$slice" discard &1) + if [[ -z "$probe" ]]; then + printf '%s %s' "$slice" "$HOOK_STDIN_READ_SLICES" + return 0 + fi + fi + printf '%s 1' "$t" +} + hook::buffer_stdin() { - local input="" chunk="" read_rc=0 stalled=0 - local read_timeout + local input="" chunk="" read_rc=0 stalled=0 idle_slices=0 + local read_timeout read_slice slice_count read_timeout=$(hook::resolve_read_timeout) - local -a read_opts=(-r -t "$read_timeout") + read -r read_slice slice_count < <(hook::resolve_read_slice "$read_timeout") + local -a read_opts=(-r -t "$read_slice") if hook::read_supports_nchars; then read_opts+=(-N 65536) else @@ -341,6 +383,9 @@ hook::buffer_stdin() { # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" + # Any byte at all resets the idle count — that, not the read's exit status, + # is what makes this an idle timer rather than a per-read deadline. + [[ -n "$chunk" ]] && idle_slices=0 if ((read_rc == 0)); then # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL # read that consumed nothing, however, cannot make progress, so continuing @@ -352,20 +397,23 @@ hook::buffer_stdin() { continue fi if ((read_rc > 128)); then - # Timed out. Bytes in this window mean the producer is alive: keep them - # and re-arm. An empty window is the stall this guard exists to catch. + # A slice expired. Bytes in it mean the producer is alive: keep them and + # read on. Only slice_count CONSECUTIVE empty slices — one whole + # stdin_read_timeout with nothing arriving — is the stall this guard + # exists to catch, which is why the count is not reset here. if [[ -n "$chunk" ]]; then # ... but stop immediately if what we already hold is a whole JSON # document. That is the Win32 late-EOF case — the payload arrived, the - # pipe just never closed — and re-arming there would spend a second full - # window waiting for an EOF that is not coming, doubling the delay this - # function is supposed to bound. + # pipe just never closed — and reading on there would spend the rest of + # the bound waiting for an EOF that is not coming. hook::json_complete "${input//$'\r'/}" && break continue fi + ((idle_slices++)) + ((idle_slices >= slice_count)) || continue stalled=1 fi - break # EOF (rc 1), an empty timed-out window, or a read error + break # EOF (rc 1), a full idle bound with no bytes, or a read error done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 diff --git a/plugins/biome-format/hooks/hook-utils.sh b/plugins/biome-format/hooks/hook-utils.sh index 52efd692b..14d9ae252 100644 --- a/plugins/biome-format/hooks/hook-utils.sh +++ b/plugins/biome-format/hooks/hook-utils.sh @@ -245,16 +245,26 @@ hook::repo_root() { # * The read is chunked. `read -N` lets bash satisfy it in blocks instead of # byte-at-a-time: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms # to ~85 ms. -# * The timer re-arms on progress. `read -t` is a deadline for the WHOLE -# requested read, not an inactivity timer, so a producer that keeps -# delivering but slower than one chunk per window would still trip it. `read` -# assigns whatever it did receive even when it times out, so a timed-out read -# that returned bytes is progress, not a stall: the loop keeps that partial -# chunk and arms a fresh window. Only a window that delivers nothing at all -# is a stall. Re-arming is skipped once the buffer already parses as whole -# JSON, so the late-EOF case costs ONE window, not two — that is the floor, -# since a producer holding the pipe open cannot be distinguished from a slow -# one until a window expires. +# * The timer measures inactivity, not the read. `read -t` is a deadline for +# the WHOLE requested read, so a producer that keeps delivering but slower +# than one chunk per window would still trip it. `read` assigns whatever it +# did receive even when it times out, so any byte counts as progress: the +# loop keeps that partial chunk and reads on. Only the absence of bytes for a +# whole stdin_read_timeout is a stall. +# * The bound is read in HOOK_STDIN_READ_SLICES slices. `read -t` reports only +# that its window expired, never WHEN inside it the last byte arrived, so a +# bound armed as one window would declare a stall anywhere between one and +# TWO bounds after the pipe actually went quiet. Slicing bounds that +# overshoot: with four slices a stall lands within a quarter-bound of the +# configured interval. That residual quarter is the honest limit of the +# approximation, and it errs toward waiting — never toward declaring a live +# producer dead. On a shell whose `read -t` rejects the fractional slice the +# count degrades to 1, i.e. the unsliced one-to-two-bound behavior. +# +# Reading on is skipped once the buffer already parses as whole JSON, so the +# late-EOF case costs ONE slice past the payload rather than the rest of the +# bound — a producer holding the pipe open cannot be distinguished from a slow +# one until a window expires, so some wait there is the floor. # # The trade this makes: a producer trickling bytes indefinitely is never cut off # here. That is deliberate — the harness already caps a `command` hook at 600 s @@ -325,11 +335,43 @@ hook::resolve_read_timeout() { printf '%s' "$t" } +# How many slices the idle bound is divided into. `read -t` reports only that a +# window expired, never WHEN inside it the last byte arrived, so a bound armed as +# one window declares a stall anywhere between one and two bounds after the pipe +# actually went quiet. Asking more often shrinks that: with N slices, a stall is +# declared within one slice of the configured interval. Four is the compromise — +# it cuts worst-case overshoot from 100% of the bound to 25% while keeping the +# idle path to four cheap builtin reads. +HOOK_STDIN_READ_SLICES=4 + +# Resolve the per-read slice for an already-resolved timeout, printing +# " ". Falls back to " 1" — exactly the unsliced +# behavior — when this shell's `read -t` will not accept the fractional slice, +# which is the pre-4.1/no-fractional-timeout case the delimiter-read branch +# already covers. Probed, not version-tested, for the same reason as +# hook::resolve_read_timeout. +hook::resolve_read_slice() { + local t="$1" slice + slice=$(awk -v t="$t" -v n="$HOOK_STDIN_READ_SLICES" \ + 'BEGIN { printf "%.3f", t / n }' 2>/dev/null) || slice="" + if [[ -n "$slice" && "$slice" =~ ^[0-9]+\.[0-9]+$ ]] && ! [[ "$slice" =~ ^0+\.0+$ ]]; then + local probe + # shellcheck disable=SC2034 # `discard` is the read target; only stderr matters + probe=$(read -r -t "$slice" discard &1) + if [[ -z "$probe" ]]; then + printf '%s %s' "$slice" "$HOOK_STDIN_READ_SLICES" + return 0 + fi + fi + printf '%s 1' "$t" +} + hook::buffer_stdin() { - local input="" chunk="" read_rc=0 stalled=0 - local read_timeout + local input="" chunk="" read_rc=0 stalled=0 idle_slices=0 + local read_timeout read_slice slice_count read_timeout=$(hook::resolve_read_timeout) - local -a read_opts=(-r -t "$read_timeout") + read -r read_slice slice_count < <(hook::resolve_read_slice "$read_timeout") + local -a read_opts=(-r -t "$read_slice") if hook::read_supports_nchars; then read_opts+=(-N 65536) else @@ -341,6 +383,9 @@ hook::buffer_stdin() { # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" + # Any byte at all resets the idle count — that, not the read's exit status, + # is what makes this an idle timer rather than a per-read deadline. + [[ -n "$chunk" ]] && idle_slices=0 if ((read_rc == 0)); then # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL # read that consumed nothing, however, cannot make progress, so continuing @@ -352,20 +397,23 @@ hook::buffer_stdin() { continue fi if ((read_rc > 128)); then - # Timed out. Bytes in this window mean the producer is alive: keep them - # and re-arm. An empty window is the stall this guard exists to catch. + # A slice expired. Bytes in it mean the producer is alive: keep them and + # read on. Only slice_count CONSECUTIVE empty slices — one whole + # stdin_read_timeout with nothing arriving — is the stall this guard + # exists to catch, which is why the count is not reset here. if [[ -n "$chunk" ]]; then # ... but stop immediately if what we already hold is a whole JSON # document. That is the Win32 late-EOF case — the payload arrived, the - # pipe just never closed — and re-arming there would spend a second full - # window waiting for an EOF that is not coming, doubling the delay this - # function is supposed to bound. + # pipe just never closed — and reading on there would spend the rest of + # the bound waiting for an EOF that is not coming. hook::json_complete "${input//$'\r'/}" && break continue fi + ((idle_slices++)) + ((idle_slices >= slice_count)) || continue stalled=1 fi - break # EOF (rc 1), an empty timed-out window, or a read error + break # EOF (rc 1), a full idle bound with no bytes, or a read error done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 diff --git a/plugins/claude-ops/hooks/hook-utils.sh b/plugins/claude-ops/hooks/hook-utils.sh index 52efd692b..14d9ae252 100644 --- a/plugins/claude-ops/hooks/hook-utils.sh +++ b/plugins/claude-ops/hooks/hook-utils.sh @@ -245,16 +245,26 @@ hook::repo_root() { # * The read is chunked. `read -N` lets bash satisfy it in blocks instead of # byte-at-a-time: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms # to ~85 ms. -# * The timer re-arms on progress. `read -t` is a deadline for the WHOLE -# requested read, not an inactivity timer, so a producer that keeps -# delivering but slower than one chunk per window would still trip it. `read` -# assigns whatever it did receive even when it times out, so a timed-out read -# that returned bytes is progress, not a stall: the loop keeps that partial -# chunk and arms a fresh window. Only a window that delivers nothing at all -# is a stall. Re-arming is skipped once the buffer already parses as whole -# JSON, so the late-EOF case costs ONE window, not two — that is the floor, -# since a producer holding the pipe open cannot be distinguished from a slow -# one until a window expires. +# * The timer measures inactivity, not the read. `read -t` is a deadline for +# the WHOLE requested read, so a producer that keeps delivering but slower +# than one chunk per window would still trip it. `read` assigns whatever it +# did receive even when it times out, so any byte counts as progress: the +# loop keeps that partial chunk and reads on. Only the absence of bytes for a +# whole stdin_read_timeout is a stall. +# * The bound is read in HOOK_STDIN_READ_SLICES slices. `read -t` reports only +# that its window expired, never WHEN inside it the last byte arrived, so a +# bound armed as one window would declare a stall anywhere between one and +# TWO bounds after the pipe actually went quiet. Slicing bounds that +# overshoot: with four slices a stall lands within a quarter-bound of the +# configured interval. That residual quarter is the honest limit of the +# approximation, and it errs toward waiting — never toward declaring a live +# producer dead. On a shell whose `read -t` rejects the fractional slice the +# count degrades to 1, i.e. the unsliced one-to-two-bound behavior. +# +# Reading on is skipped once the buffer already parses as whole JSON, so the +# late-EOF case costs ONE slice past the payload rather than the rest of the +# bound — a producer holding the pipe open cannot be distinguished from a slow +# one until a window expires, so some wait there is the floor. # # The trade this makes: a producer trickling bytes indefinitely is never cut off # here. That is deliberate — the harness already caps a `command` hook at 600 s @@ -325,11 +335,43 @@ hook::resolve_read_timeout() { printf '%s' "$t" } +# How many slices the idle bound is divided into. `read -t` reports only that a +# window expired, never WHEN inside it the last byte arrived, so a bound armed as +# one window declares a stall anywhere between one and two bounds after the pipe +# actually went quiet. Asking more often shrinks that: with N slices, a stall is +# declared within one slice of the configured interval. Four is the compromise — +# it cuts worst-case overshoot from 100% of the bound to 25% while keeping the +# idle path to four cheap builtin reads. +HOOK_STDIN_READ_SLICES=4 + +# Resolve the per-read slice for an already-resolved timeout, printing +# " ". Falls back to " 1" — exactly the unsliced +# behavior — when this shell's `read -t` will not accept the fractional slice, +# which is the pre-4.1/no-fractional-timeout case the delimiter-read branch +# already covers. Probed, not version-tested, for the same reason as +# hook::resolve_read_timeout. +hook::resolve_read_slice() { + local t="$1" slice + slice=$(awk -v t="$t" -v n="$HOOK_STDIN_READ_SLICES" \ + 'BEGIN { printf "%.3f", t / n }' 2>/dev/null) || slice="" + if [[ -n "$slice" && "$slice" =~ ^[0-9]+\.[0-9]+$ ]] && ! [[ "$slice" =~ ^0+\.0+$ ]]; then + local probe + # shellcheck disable=SC2034 # `discard` is the read target; only stderr matters + probe=$(read -r -t "$slice" discard &1) + if [[ -z "$probe" ]]; then + printf '%s %s' "$slice" "$HOOK_STDIN_READ_SLICES" + return 0 + fi + fi + printf '%s 1' "$t" +} + hook::buffer_stdin() { - local input="" chunk="" read_rc=0 stalled=0 - local read_timeout + local input="" chunk="" read_rc=0 stalled=0 idle_slices=0 + local read_timeout read_slice slice_count read_timeout=$(hook::resolve_read_timeout) - local -a read_opts=(-r -t "$read_timeout") + read -r read_slice slice_count < <(hook::resolve_read_slice "$read_timeout") + local -a read_opts=(-r -t "$read_slice") if hook::read_supports_nchars; then read_opts+=(-N 65536) else @@ -341,6 +383,9 @@ hook::buffer_stdin() { # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" + # Any byte at all resets the idle count — that, not the read's exit status, + # is what makes this an idle timer rather than a per-read deadline. + [[ -n "$chunk" ]] && idle_slices=0 if ((read_rc == 0)); then # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL # read that consumed nothing, however, cannot make progress, so continuing @@ -352,20 +397,23 @@ hook::buffer_stdin() { continue fi if ((read_rc > 128)); then - # Timed out. Bytes in this window mean the producer is alive: keep them - # and re-arm. An empty window is the stall this guard exists to catch. + # A slice expired. Bytes in it mean the producer is alive: keep them and + # read on. Only slice_count CONSECUTIVE empty slices — one whole + # stdin_read_timeout with nothing arriving — is the stall this guard + # exists to catch, which is why the count is not reset here. if [[ -n "$chunk" ]]; then # ... but stop immediately if what we already hold is a whole JSON # document. That is the Win32 late-EOF case — the payload arrived, the - # pipe just never closed — and re-arming there would spend a second full - # window waiting for an EOF that is not coming, doubling the delay this - # function is supposed to bound. + # pipe just never closed — and reading on there would spend the rest of + # the bound waiting for an EOF that is not coming. hook::json_complete "${input//$'\r'/}" && break continue fi + ((idle_slices++)) + ((idle_slices >= slice_count)) || continue stalled=1 fi - break # EOF (rc 1), an empty timed-out window, or a read error + break # EOF (rc 1), a full idle bound with no bytes, or a read error done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 diff --git a/plugins/desktop-notification/hooks/hook-utils.sh b/plugins/desktop-notification/hooks/hook-utils.sh index 52efd692b..14d9ae252 100644 --- a/plugins/desktop-notification/hooks/hook-utils.sh +++ b/plugins/desktop-notification/hooks/hook-utils.sh @@ -245,16 +245,26 @@ hook::repo_root() { # * The read is chunked. `read -N` lets bash satisfy it in blocks instead of # byte-at-a-time: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms # to ~85 ms. -# * The timer re-arms on progress. `read -t` is a deadline for the WHOLE -# requested read, not an inactivity timer, so a producer that keeps -# delivering but slower than one chunk per window would still trip it. `read` -# assigns whatever it did receive even when it times out, so a timed-out read -# that returned bytes is progress, not a stall: the loop keeps that partial -# chunk and arms a fresh window. Only a window that delivers nothing at all -# is a stall. Re-arming is skipped once the buffer already parses as whole -# JSON, so the late-EOF case costs ONE window, not two — that is the floor, -# since a producer holding the pipe open cannot be distinguished from a slow -# one until a window expires. +# * The timer measures inactivity, not the read. `read -t` is a deadline for +# the WHOLE requested read, so a producer that keeps delivering but slower +# than one chunk per window would still trip it. `read` assigns whatever it +# did receive even when it times out, so any byte counts as progress: the +# loop keeps that partial chunk and reads on. Only the absence of bytes for a +# whole stdin_read_timeout is a stall. +# * The bound is read in HOOK_STDIN_READ_SLICES slices. `read -t` reports only +# that its window expired, never WHEN inside it the last byte arrived, so a +# bound armed as one window would declare a stall anywhere between one and +# TWO bounds after the pipe actually went quiet. Slicing bounds that +# overshoot: with four slices a stall lands within a quarter-bound of the +# configured interval. That residual quarter is the honest limit of the +# approximation, and it errs toward waiting — never toward declaring a live +# producer dead. On a shell whose `read -t` rejects the fractional slice the +# count degrades to 1, i.e. the unsliced one-to-two-bound behavior. +# +# Reading on is skipped once the buffer already parses as whole JSON, so the +# late-EOF case costs ONE slice past the payload rather than the rest of the +# bound — a producer holding the pipe open cannot be distinguished from a slow +# one until a window expires, so some wait there is the floor. # # The trade this makes: a producer trickling bytes indefinitely is never cut off # here. That is deliberate — the harness already caps a `command` hook at 600 s @@ -325,11 +335,43 @@ hook::resolve_read_timeout() { printf '%s' "$t" } +# How many slices the idle bound is divided into. `read -t` reports only that a +# window expired, never WHEN inside it the last byte arrived, so a bound armed as +# one window declares a stall anywhere between one and two bounds after the pipe +# actually went quiet. Asking more often shrinks that: with N slices, a stall is +# declared within one slice of the configured interval. Four is the compromise — +# it cuts worst-case overshoot from 100% of the bound to 25% while keeping the +# idle path to four cheap builtin reads. +HOOK_STDIN_READ_SLICES=4 + +# Resolve the per-read slice for an already-resolved timeout, printing +# " ". Falls back to " 1" — exactly the unsliced +# behavior — when this shell's `read -t` will not accept the fractional slice, +# which is the pre-4.1/no-fractional-timeout case the delimiter-read branch +# already covers. Probed, not version-tested, for the same reason as +# hook::resolve_read_timeout. +hook::resolve_read_slice() { + local t="$1" slice + slice=$(awk -v t="$t" -v n="$HOOK_STDIN_READ_SLICES" \ + 'BEGIN { printf "%.3f", t / n }' 2>/dev/null) || slice="" + if [[ -n "$slice" && "$slice" =~ ^[0-9]+\.[0-9]+$ ]] && ! [[ "$slice" =~ ^0+\.0+$ ]]; then + local probe + # shellcheck disable=SC2034 # `discard` is the read target; only stderr matters + probe=$(read -r -t "$slice" discard &1) + if [[ -z "$probe" ]]; then + printf '%s %s' "$slice" "$HOOK_STDIN_READ_SLICES" + return 0 + fi + fi + printf '%s 1' "$t" +} + hook::buffer_stdin() { - local input="" chunk="" read_rc=0 stalled=0 - local read_timeout + local input="" chunk="" read_rc=0 stalled=0 idle_slices=0 + local read_timeout read_slice slice_count read_timeout=$(hook::resolve_read_timeout) - local -a read_opts=(-r -t "$read_timeout") + read -r read_slice slice_count < <(hook::resolve_read_slice "$read_timeout") + local -a read_opts=(-r -t "$read_slice") if hook::read_supports_nchars; then read_opts+=(-N 65536) else @@ -341,6 +383,9 @@ hook::buffer_stdin() { # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" + # Any byte at all resets the idle count — that, not the read's exit status, + # is what makes this an idle timer rather than a per-read deadline. + [[ -n "$chunk" ]] && idle_slices=0 if ((read_rc == 0)); then # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL # read that consumed nothing, however, cannot make progress, so continuing @@ -352,20 +397,23 @@ hook::buffer_stdin() { continue fi if ((read_rc > 128)); then - # Timed out. Bytes in this window mean the producer is alive: keep them - # and re-arm. An empty window is the stall this guard exists to catch. + # A slice expired. Bytes in it mean the producer is alive: keep them and + # read on. Only slice_count CONSECUTIVE empty slices — one whole + # stdin_read_timeout with nothing arriving — is the stall this guard + # exists to catch, which is why the count is not reset here. if [[ -n "$chunk" ]]; then # ... but stop immediately if what we already hold is a whole JSON # document. That is the Win32 late-EOF case — the payload arrived, the - # pipe just never closed — and re-arming there would spend a second full - # window waiting for an EOF that is not coming, doubling the delay this - # function is supposed to bound. + # pipe just never closed — and reading on there would spend the rest of + # the bound waiting for an EOF that is not coming. hook::json_complete "${input//$'\r'/}" && break continue fi + ((idle_slices++)) + ((idle_slices >= slice_count)) || continue stalled=1 fi - break # EOF (rc 1), an empty timed-out window, or a read error + break # EOF (rc 1), a full idle bound with no bytes, or a read error done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 diff --git a/plugins/eol-normalizer/hooks/hook-utils.sh b/plugins/eol-normalizer/hooks/hook-utils.sh index 52efd692b..14d9ae252 100644 --- a/plugins/eol-normalizer/hooks/hook-utils.sh +++ b/plugins/eol-normalizer/hooks/hook-utils.sh @@ -245,16 +245,26 @@ hook::repo_root() { # * The read is chunked. `read -N` lets bash satisfy it in blocks instead of # byte-at-a-time: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms # to ~85 ms. -# * The timer re-arms on progress. `read -t` is a deadline for the WHOLE -# requested read, not an inactivity timer, so a producer that keeps -# delivering but slower than one chunk per window would still trip it. `read` -# assigns whatever it did receive even when it times out, so a timed-out read -# that returned bytes is progress, not a stall: the loop keeps that partial -# chunk and arms a fresh window. Only a window that delivers nothing at all -# is a stall. Re-arming is skipped once the buffer already parses as whole -# JSON, so the late-EOF case costs ONE window, not two — that is the floor, -# since a producer holding the pipe open cannot be distinguished from a slow -# one until a window expires. +# * The timer measures inactivity, not the read. `read -t` is a deadline for +# the WHOLE requested read, so a producer that keeps delivering but slower +# than one chunk per window would still trip it. `read` assigns whatever it +# did receive even when it times out, so any byte counts as progress: the +# loop keeps that partial chunk and reads on. Only the absence of bytes for a +# whole stdin_read_timeout is a stall. +# * The bound is read in HOOK_STDIN_READ_SLICES slices. `read -t` reports only +# that its window expired, never WHEN inside it the last byte arrived, so a +# bound armed as one window would declare a stall anywhere between one and +# TWO bounds after the pipe actually went quiet. Slicing bounds that +# overshoot: with four slices a stall lands within a quarter-bound of the +# configured interval. That residual quarter is the honest limit of the +# approximation, and it errs toward waiting — never toward declaring a live +# producer dead. On a shell whose `read -t` rejects the fractional slice the +# count degrades to 1, i.e. the unsliced one-to-two-bound behavior. +# +# Reading on is skipped once the buffer already parses as whole JSON, so the +# late-EOF case costs ONE slice past the payload rather than the rest of the +# bound — a producer holding the pipe open cannot be distinguished from a slow +# one until a window expires, so some wait there is the floor. # # The trade this makes: a producer trickling bytes indefinitely is never cut off # here. That is deliberate — the harness already caps a `command` hook at 600 s @@ -325,11 +335,43 @@ hook::resolve_read_timeout() { printf '%s' "$t" } +# How many slices the idle bound is divided into. `read -t` reports only that a +# window expired, never WHEN inside it the last byte arrived, so a bound armed as +# one window declares a stall anywhere between one and two bounds after the pipe +# actually went quiet. Asking more often shrinks that: with N slices, a stall is +# declared within one slice of the configured interval. Four is the compromise — +# it cuts worst-case overshoot from 100% of the bound to 25% while keeping the +# idle path to four cheap builtin reads. +HOOK_STDIN_READ_SLICES=4 + +# Resolve the per-read slice for an already-resolved timeout, printing +# " ". Falls back to " 1" — exactly the unsliced +# behavior — when this shell's `read -t` will not accept the fractional slice, +# which is the pre-4.1/no-fractional-timeout case the delimiter-read branch +# already covers. Probed, not version-tested, for the same reason as +# hook::resolve_read_timeout. +hook::resolve_read_slice() { + local t="$1" slice + slice=$(awk -v t="$t" -v n="$HOOK_STDIN_READ_SLICES" \ + 'BEGIN { printf "%.3f", t / n }' 2>/dev/null) || slice="" + if [[ -n "$slice" && "$slice" =~ ^[0-9]+\.[0-9]+$ ]] && ! [[ "$slice" =~ ^0+\.0+$ ]]; then + local probe + # shellcheck disable=SC2034 # `discard` is the read target; only stderr matters + probe=$(read -r -t "$slice" discard &1) + if [[ -z "$probe" ]]; then + printf '%s %s' "$slice" "$HOOK_STDIN_READ_SLICES" + return 0 + fi + fi + printf '%s 1' "$t" +} + hook::buffer_stdin() { - local input="" chunk="" read_rc=0 stalled=0 - local read_timeout + local input="" chunk="" read_rc=0 stalled=0 idle_slices=0 + local read_timeout read_slice slice_count read_timeout=$(hook::resolve_read_timeout) - local -a read_opts=(-r -t "$read_timeout") + read -r read_slice slice_count < <(hook::resolve_read_slice "$read_timeout") + local -a read_opts=(-r -t "$read_slice") if hook::read_supports_nchars; then read_opts+=(-N 65536) else @@ -341,6 +383,9 @@ hook::buffer_stdin() { # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" + # Any byte at all resets the idle count — that, not the read's exit status, + # is what makes this an idle timer rather than a per-read deadline. + [[ -n "$chunk" ]] && idle_slices=0 if ((read_rc == 0)); then # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL # read that consumed nothing, however, cannot make progress, so continuing @@ -352,20 +397,23 @@ hook::buffer_stdin() { continue fi if ((read_rc > 128)); then - # Timed out. Bytes in this window mean the producer is alive: keep them - # and re-arm. An empty window is the stall this guard exists to catch. + # A slice expired. Bytes in it mean the producer is alive: keep them and + # read on. Only slice_count CONSECUTIVE empty slices — one whole + # stdin_read_timeout with nothing arriving — is the stall this guard + # exists to catch, which is why the count is not reset here. if [[ -n "$chunk" ]]; then # ... but stop immediately if what we already hold is a whole JSON # document. That is the Win32 late-EOF case — the payload arrived, the - # pipe just never closed — and re-arming there would spend a second full - # window waiting for an EOF that is not coming, doubling the delay this - # function is supposed to bound. + # pipe just never closed — and reading on there would spend the rest of + # the bound waiting for an EOF that is not coming. hook::json_complete "${input//$'\r'/}" && break continue fi + ((idle_slices++)) + ((idle_slices >= slice_count)) || continue stalled=1 fi - break # EOF (rc 1), an empty timed-out window, or a read error + break # EOF (rc 1), a full idle bound with no bytes, or a read error done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 diff --git a/plugins/go-format/hooks/hook-utils.sh b/plugins/go-format/hooks/hook-utils.sh index 52efd692b..14d9ae252 100644 --- a/plugins/go-format/hooks/hook-utils.sh +++ b/plugins/go-format/hooks/hook-utils.sh @@ -245,16 +245,26 @@ hook::repo_root() { # * The read is chunked. `read -N` lets bash satisfy it in blocks instead of # byte-at-a-time: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms # to ~85 ms. -# * The timer re-arms on progress. `read -t` is a deadline for the WHOLE -# requested read, not an inactivity timer, so a producer that keeps -# delivering but slower than one chunk per window would still trip it. `read` -# assigns whatever it did receive even when it times out, so a timed-out read -# that returned bytes is progress, not a stall: the loop keeps that partial -# chunk and arms a fresh window. Only a window that delivers nothing at all -# is a stall. Re-arming is skipped once the buffer already parses as whole -# JSON, so the late-EOF case costs ONE window, not two — that is the floor, -# since a producer holding the pipe open cannot be distinguished from a slow -# one until a window expires. +# * The timer measures inactivity, not the read. `read -t` is a deadline for +# the WHOLE requested read, so a producer that keeps delivering but slower +# than one chunk per window would still trip it. `read` assigns whatever it +# did receive even when it times out, so any byte counts as progress: the +# loop keeps that partial chunk and reads on. Only the absence of bytes for a +# whole stdin_read_timeout is a stall. +# * The bound is read in HOOK_STDIN_READ_SLICES slices. `read -t` reports only +# that its window expired, never WHEN inside it the last byte arrived, so a +# bound armed as one window would declare a stall anywhere between one and +# TWO bounds after the pipe actually went quiet. Slicing bounds that +# overshoot: with four slices a stall lands within a quarter-bound of the +# configured interval. That residual quarter is the honest limit of the +# approximation, and it errs toward waiting — never toward declaring a live +# producer dead. On a shell whose `read -t` rejects the fractional slice the +# count degrades to 1, i.e. the unsliced one-to-two-bound behavior. +# +# Reading on is skipped once the buffer already parses as whole JSON, so the +# late-EOF case costs ONE slice past the payload rather than the rest of the +# bound — a producer holding the pipe open cannot be distinguished from a slow +# one until a window expires, so some wait there is the floor. # # The trade this makes: a producer trickling bytes indefinitely is never cut off # here. That is deliberate — the harness already caps a `command` hook at 600 s @@ -325,11 +335,43 @@ hook::resolve_read_timeout() { printf '%s' "$t" } +# How many slices the idle bound is divided into. `read -t` reports only that a +# window expired, never WHEN inside it the last byte arrived, so a bound armed as +# one window declares a stall anywhere between one and two bounds after the pipe +# actually went quiet. Asking more often shrinks that: with N slices, a stall is +# declared within one slice of the configured interval. Four is the compromise — +# it cuts worst-case overshoot from 100% of the bound to 25% while keeping the +# idle path to four cheap builtin reads. +HOOK_STDIN_READ_SLICES=4 + +# Resolve the per-read slice for an already-resolved timeout, printing +# " ". Falls back to " 1" — exactly the unsliced +# behavior — when this shell's `read -t` will not accept the fractional slice, +# which is the pre-4.1/no-fractional-timeout case the delimiter-read branch +# already covers. Probed, not version-tested, for the same reason as +# hook::resolve_read_timeout. +hook::resolve_read_slice() { + local t="$1" slice + slice=$(awk -v t="$t" -v n="$HOOK_STDIN_READ_SLICES" \ + 'BEGIN { printf "%.3f", t / n }' 2>/dev/null) || slice="" + if [[ -n "$slice" && "$slice" =~ ^[0-9]+\.[0-9]+$ ]] && ! [[ "$slice" =~ ^0+\.0+$ ]]; then + local probe + # shellcheck disable=SC2034 # `discard` is the read target; only stderr matters + probe=$(read -r -t "$slice" discard &1) + if [[ -z "$probe" ]]; then + printf '%s %s' "$slice" "$HOOK_STDIN_READ_SLICES" + return 0 + fi + fi + printf '%s 1' "$t" +} + hook::buffer_stdin() { - local input="" chunk="" read_rc=0 stalled=0 - local read_timeout + local input="" chunk="" read_rc=0 stalled=0 idle_slices=0 + local read_timeout read_slice slice_count read_timeout=$(hook::resolve_read_timeout) - local -a read_opts=(-r -t "$read_timeout") + read -r read_slice slice_count < <(hook::resolve_read_slice "$read_timeout") + local -a read_opts=(-r -t "$read_slice") if hook::read_supports_nchars; then read_opts+=(-N 65536) else @@ -341,6 +383,9 @@ hook::buffer_stdin() { # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" + # Any byte at all resets the idle count — that, not the read's exit status, + # is what makes this an idle timer rather than a per-read deadline. + [[ -n "$chunk" ]] && idle_slices=0 if ((read_rc == 0)); then # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL # read that consumed nothing, however, cannot make progress, so continuing @@ -352,20 +397,23 @@ hook::buffer_stdin() { continue fi if ((read_rc > 128)); then - # Timed out. Bytes in this window mean the producer is alive: keep them - # and re-arm. An empty window is the stall this guard exists to catch. + # A slice expired. Bytes in it mean the producer is alive: keep them and + # read on. Only slice_count CONSECUTIVE empty slices — one whole + # stdin_read_timeout with nothing arriving — is the stall this guard + # exists to catch, which is why the count is not reset here. if [[ -n "$chunk" ]]; then # ... but stop immediately if what we already hold is a whole JSON # document. That is the Win32 late-EOF case — the payload arrived, the - # pipe just never closed — and re-arming there would spend a second full - # window waiting for an EOF that is not coming, doubling the delay this - # function is supposed to bound. + # pipe just never closed — and reading on there would spend the rest of + # the bound waiting for an EOF that is not coming. hook::json_complete "${input//$'\r'/}" && break continue fi + ((idle_slices++)) + ((idle_slices >= slice_count)) || continue stalled=1 fi - break # EOF (rc 1), an empty timed-out window, or a read error + break # EOF (rc 1), a full idle bound with no bytes, or a read error done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index 4536d2f19..b3f492e1b 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -20,11 +20,15 @@ All notable changes to the `guardrails` plugin are documented here. Format follo The read is now chunked (`read -N`), which bash satisfies with block reads, and the bound became a true **idle** bound: `read -t` is a deadline for the whole requested read rather than an inactivity timer, so a timed-out read that nevertheless returned bytes is now treated as progress — its - partial chunk is kept and a fresh window is armed. Only a window that delivers nothing at all is a - stall, and that still fails closed with rc 2 exactly as before. Re-arming stops once the buffer - already parses as whole JSON, so the Win32 late-EOF case (payload complete, pipe simply never - closed) settles in one window instead of two. Measured: 50 KB drops from ~2100 ms to ~20 ms, - 200 KB from ~6800 ms to ~85 ms. + partial chunk is kept and the read continues. Only the absence of bytes for a whole + `stdin_read_timeout` is a stall, and that still fails closed with rc 2 exactly as before. The bound + is read in four slices, because `read -t` reports only that its window expired and never when + inside it the last byte arrived — armed as one window, a stall would be declared anywhere between + one and *two* bounds after the pipe went quiet. Slicing caps that overshoot at a quarter-bound; + that residual quarter is the limit of the approximation and always errs toward waiting. Reading on + stops once the buffer already parses as whole JSON, so the Win32 late-EOF case (payload complete, + pipe simply never closed) settles at the payload rather than at the bound. Measured: 50 KB drops + from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. `read -N` is Bash 4.1+, and these hooks support Bash 3.2+ (macOS system bash), so the pre-4.1 path falls back to the delimiter read inside the same re-arming loop — same guard and rationale as `context-guard`'s `statusline-tee.sh`. diff --git a/plugins/guardrails/README.md b/plugins/guardrails/README.md index 117aa701e..1c96e8a1a 100644 --- a/plugins/guardrails/README.md +++ b/plugins/guardrails/README.md @@ -131,14 +131,17 @@ plugin in that project's `enabledPlugins` instead. One further option tunes the hooks' shared plumbing rather than a single guard: - **`stdin_read_timeout`** (number, default `2`, minimum `1`) — idle bound in - seconds on reading the hook payload from stdin. It re-arms whenever bytes - arrive, not once over the whole read, so a large or slowly-delivered payload - is never cut off while it is still coming; it fires only when the pipe goes - silent for that long, at which point a blocking guard fails **closed** - (`exit 2` with a `BLOCKED:` reason) rather than letting an unscanned tool call - through. A value this shell's `read -t` will not accept — or `0`, which would - make the read consume nothing — falls back to the default rather than - disabling the guards. You should not need to change it. + seconds on reading the hook payload from stdin. Any byte arriving resets it, + so a large or slowly-delivered payload is never cut off while it is still + coming; it fires only once the pipe has gone silent for that long, at which + point a blocking guard fails **closed** (`exit 2` with a `BLOCKED:` reason) + rather than letting an unscanned tool call through. The bound is read in four + slices, so a stall is declared within a quarter of the configured interval of + it — that quarter is the limit of the approximation, and it errs toward + waiting rather than toward calling a live producer dead. A value this shell's + `read -t` will not accept — or `0`, which would make the read consume nothing + — falls back to the default rather than disabling the guards. You should not + need to change it. ## Consumer seams diff --git a/plugins/guardrails/hooks/hook-utils.sh b/plugins/guardrails/hooks/hook-utils.sh index 52efd692b..14d9ae252 100644 --- a/plugins/guardrails/hooks/hook-utils.sh +++ b/plugins/guardrails/hooks/hook-utils.sh @@ -245,16 +245,26 @@ hook::repo_root() { # * The read is chunked. `read -N` lets bash satisfy it in blocks instead of # byte-at-a-time: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms # to ~85 ms. -# * The timer re-arms on progress. `read -t` is a deadline for the WHOLE -# requested read, not an inactivity timer, so a producer that keeps -# delivering but slower than one chunk per window would still trip it. `read` -# assigns whatever it did receive even when it times out, so a timed-out read -# that returned bytes is progress, not a stall: the loop keeps that partial -# chunk and arms a fresh window. Only a window that delivers nothing at all -# is a stall. Re-arming is skipped once the buffer already parses as whole -# JSON, so the late-EOF case costs ONE window, not two — that is the floor, -# since a producer holding the pipe open cannot be distinguished from a slow -# one until a window expires. +# * The timer measures inactivity, not the read. `read -t` is a deadline for +# the WHOLE requested read, so a producer that keeps delivering but slower +# than one chunk per window would still trip it. `read` assigns whatever it +# did receive even when it times out, so any byte counts as progress: the +# loop keeps that partial chunk and reads on. Only the absence of bytes for a +# whole stdin_read_timeout is a stall. +# * The bound is read in HOOK_STDIN_READ_SLICES slices. `read -t` reports only +# that its window expired, never WHEN inside it the last byte arrived, so a +# bound armed as one window would declare a stall anywhere between one and +# TWO bounds after the pipe actually went quiet. Slicing bounds that +# overshoot: with four slices a stall lands within a quarter-bound of the +# configured interval. That residual quarter is the honest limit of the +# approximation, and it errs toward waiting — never toward declaring a live +# producer dead. On a shell whose `read -t` rejects the fractional slice the +# count degrades to 1, i.e. the unsliced one-to-two-bound behavior. +# +# Reading on is skipped once the buffer already parses as whole JSON, so the +# late-EOF case costs ONE slice past the payload rather than the rest of the +# bound — a producer holding the pipe open cannot be distinguished from a slow +# one until a window expires, so some wait there is the floor. # # The trade this makes: a producer trickling bytes indefinitely is never cut off # here. That is deliberate — the harness already caps a `command` hook at 600 s @@ -325,11 +335,43 @@ hook::resolve_read_timeout() { printf '%s' "$t" } +# How many slices the idle bound is divided into. `read -t` reports only that a +# window expired, never WHEN inside it the last byte arrived, so a bound armed as +# one window declares a stall anywhere between one and two bounds after the pipe +# actually went quiet. Asking more often shrinks that: with N slices, a stall is +# declared within one slice of the configured interval. Four is the compromise — +# it cuts worst-case overshoot from 100% of the bound to 25% while keeping the +# idle path to four cheap builtin reads. +HOOK_STDIN_READ_SLICES=4 + +# Resolve the per-read slice for an already-resolved timeout, printing +# " ". Falls back to " 1" — exactly the unsliced +# behavior — when this shell's `read -t` will not accept the fractional slice, +# which is the pre-4.1/no-fractional-timeout case the delimiter-read branch +# already covers. Probed, not version-tested, for the same reason as +# hook::resolve_read_timeout. +hook::resolve_read_slice() { + local t="$1" slice + slice=$(awk -v t="$t" -v n="$HOOK_STDIN_READ_SLICES" \ + 'BEGIN { printf "%.3f", t / n }' 2>/dev/null) || slice="" + if [[ -n "$slice" && "$slice" =~ ^[0-9]+\.[0-9]+$ ]] && ! [[ "$slice" =~ ^0+\.0+$ ]]; then + local probe + # shellcheck disable=SC2034 # `discard` is the read target; only stderr matters + probe=$(read -r -t "$slice" discard &1) + if [[ -z "$probe" ]]; then + printf '%s %s' "$slice" "$HOOK_STDIN_READ_SLICES" + return 0 + fi + fi + printf '%s 1' "$t" +} + hook::buffer_stdin() { - local input="" chunk="" read_rc=0 stalled=0 - local read_timeout + local input="" chunk="" read_rc=0 stalled=0 idle_slices=0 + local read_timeout read_slice slice_count read_timeout=$(hook::resolve_read_timeout) - local -a read_opts=(-r -t "$read_timeout") + read -r read_slice slice_count < <(hook::resolve_read_slice "$read_timeout") + local -a read_opts=(-r -t "$read_slice") if hook::read_supports_nchars; then read_opts+=(-N 65536) else @@ -341,6 +383,9 @@ hook::buffer_stdin() { # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" + # Any byte at all resets the idle count — that, not the read's exit status, + # is what makes this an idle timer rather than a per-read deadline. + [[ -n "$chunk" ]] && idle_slices=0 if ((read_rc == 0)); then # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL # read that consumed nothing, however, cannot make progress, so continuing @@ -352,20 +397,23 @@ hook::buffer_stdin() { continue fi if ((read_rc > 128)); then - # Timed out. Bytes in this window mean the producer is alive: keep them - # and re-arm. An empty window is the stall this guard exists to catch. + # A slice expired. Bytes in it mean the producer is alive: keep them and + # read on. Only slice_count CONSECUTIVE empty slices — one whole + # stdin_read_timeout with nothing arriving — is the stall this guard + # exists to catch, which is why the count is not reset here. if [[ -n "$chunk" ]]; then # ... but stop immediately if what we already hold is a whole JSON # document. That is the Win32 late-EOF case — the payload arrived, the - # pipe just never closed — and re-arming there would spend a second full - # window waiting for an EOF that is not coming, doubling the delay this - # function is supposed to bound. + # pipe just never closed — and reading on there would spend the rest of + # the bound waiting for an EOF that is not coming. hook::json_complete "${input//$'\r'/}" && break continue fi + ((idle_slices++)) + ((idle_slices >= slice_count)) || continue stalled=1 fi - break # EOF (rc 1), an empty timed-out window, or a read error + break # EOF (rc 1), a full idle bound with no bytes, or a read error done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 diff --git a/plugins/markdown-format/hooks/hook-utils.sh b/plugins/markdown-format/hooks/hook-utils.sh index 52efd692b..14d9ae252 100644 --- a/plugins/markdown-format/hooks/hook-utils.sh +++ b/plugins/markdown-format/hooks/hook-utils.sh @@ -245,16 +245,26 @@ hook::repo_root() { # * The read is chunked. `read -N` lets bash satisfy it in blocks instead of # byte-at-a-time: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms # to ~85 ms. -# * The timer re-arms on progress. `read -t` is a deadline for the WHOLE -# requested read, not an inactivity timer, so a producer that keeps -# delivering but slower than one chunk per window would still trip it. `read` -# assigns whatever it did receive even when it times out, so a timed-out read -# that returned bytes is progress, not a stall: the loop keeps that partial -# chunk and arms a fresh window. Only a window that delivers nothing at all -# is a stall. Re-arming is skipped once the buffer already parses as whole -# JSON, so the late-EOF case costs ONE window, not two — that is the floor, -# since a producer holding the pipe open cannot be distinguished from a slow -# one until a window expires. +# * The timer measures inactivity, not the read. `read -t` is a deadline for +# the WHOLE requested read, so a producer that keeps delivering but slower +# than one chunk per window would still trip it. `read` assigns whatever it +# did receive even when it times out, so any byte counts as progress: the +# loop keeps that partial chunk and reads on. Only the absence of bytes for a +# whole stdin_read_timeout is a stall. +# * The bound is read in HOOK_STDIN_READ_SLICES slices. `read -t` reports only +# that its window expired, never WHEN inside it the last byte arrived, so a +# bound armed as one window would declare a stall anywhere between one and +# TWO bounds after the pipe actually went quiet. Slicing bounds that +# overshoot: with four slices a stall lands within a quarter-bound of the +# configured interval. That residual quarter is the honest limit of the +# approximation, and it errs toward waiting — never toward declaring a live +# producer dead. On a shell whose `read -t` rejects the fractional slice the +# count degrades to 1, i.e. the unsliced one-to-two-bound behavior. +# +# Reading on is skipped once the buffer already parses as whole JSON, so the +# late-EOF case costs ONE slice past the payload rather than the rest of the +# bound — a producer holding the pipe open cannot be distinguished from a slow +# one until a window expires, so some wait there is the floor. # # The trade this makes: a producer trickling bytes indefinitely is never cut off # here. That is deliberate — the harness already caps a `command` hook at 600 s @@ -325,11 +335,43 @@ hook::resolve_read_timeout() { printf '%s' "$t" } +# How many slices the idle bound is divided into. `read -t` reports only that a +# window expired, never WHEN inside it the last byte arrived, so a bound armed as +# one window declares a stall anywhere between one and two bounds after the pipe +# actually went quiet. Asking more often shrinks that: with N slices, a stall is +# declared within one slice of the configured interval. Four is the compromise — +# it cuts worst-case overshoot from 100% of the bound to 25% while keeping the +# idle path to four cheap builtin reads. +HOOK_STDIN_READ_SLICES=4 + +# Resolve the per-read slice for an already-resolved timeout, printing +# " ". Falls back to " 1" — exactly the unsliced +# behavior — when this shell's `read -t` will not accept the fractional slice, +# which is the pre-4.1/no-fractional-timeout case the delimiter-read branch +# already covers. Probed, not version-tested, for the same reason as +# hook::resolve_read_timeout. +hook::resolve_read_slice() { + local t="$1" slice + slice=$(awk -v t="$t" -v n="$HOOK_STDIN_READ_SLICES" \ + 'BEGIN { printf "%.3f", t / n }' 2>/dev/null) || slice="" + if [[ -n "$slice" && "$slice" =~ ^[0-9]+\.[0-9]+$ ]] && ! [[ "$slice" =~ ^0+\.0+$ ]]; then + local probe + # shellcheck disable=SC2034 # `discard` is the read target; only stderr matters + probe=$(read -r -t "$slice" discard &1) + if [[ -z "$probe" ]]; then + printf '%s %s' "$slice" "$HOOK_STDIN_READ_SLICES" + return 0 + fi + fi + printf '%s 1' "$t" +} + hook::buffer_stdin() { - local input="" chunk="" read_rc=0 stalled=0 - local read_timeout + local input="" chunk="" read_rc=0 stalled=0 idle_slices=0 + local read_timeout read_slice slice_count read_timeout=$(hook::resolve_read_timeout) - local -a read_opts=(-r -t "$read_timeout") + read -r read_slice slice_count < <(hook::resolve_read_slice "$read_timeout") + local -a read_opts=(-r -t "$read_slice") if hook::read_supports_nchars; then read_opts+=(-N 65536) else @@ -341,6 +383,9 @@ hook::buffer_stdin() { # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" + # Any byte at all resets the idle count — that, not the read's exit status, + # is what makes this an idle timer rather than a per-read deadline. + [[ -n "$chunk" ]] && idle_slices=0 if ((read_rc == 0)); then # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL # read that consumed nothing, however, cannot make progress, so continuing @@ -352,20 +397,23 @@ hook::buffer_stdin() { continue fi if ((read_rc > 128)); then - # Timed out. Bytes in this window mean the producer is alive: keep them - # and re-arm. An empty window is the stall this guard exists to catch. + # A slice expired. Bytes in it mean the producer is alive: keep them and + # read on. Only slice_count CONSECUTIVE empty slices — one whole + # stdin_read_timeout with nothing arriving — is the stall this guard + # exists to catch, which is why the count is not reset here. if [[ -n "$chunk" ]]; then # ... but stop immediately if what we already hold is a whole JSON # document. That is the Win32 late-EOF case — the payload arrived, the - # pipe just never closed — and re-arming there would spend a second full - # window waiting for an EOF that is not coming, doubling the delay this - # function is supposed to bound. + # pipe just never closed — and reading on there would spend the rest of + # the bound waiting for an EOF that is not coming. hook::json_complete "${input//$'\r'/}" && break continue fi + ((idle_slices++)) + ((idle_slices >= slice_count)) || continue stalled=1 fi - break # EOF (rc 1), an empty timed-out window, or a read error + break # EOF (rc 1), a full idle bound with no bytes, or a read error done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 diff --git a/plugins/powershell-format/hooks/hook-utils.sh b/plugins/powershell-format/hooks/hook-utils.sh index 52efd692b..14d9ae252 100644 --- a/plugins/powershell-format/hooks/hook-utils.sh +++ b/plugins/powershell-format/hooks/hook-utils.sh @@ -245,16 +245,26 @@ hook::repo_root() { # * The read is chunked. `read -N` lets bash satisfy it in blocks instead of # byte-at-a-time: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms # to ~85 ms. -# * The timer re-arms on progress. `read -t` is a deadline for the WHOLE -# requested read, not an inactivity timer, so a producer that keeps -# delivering but slower than one chunk per window would still trip it. `read` -# assigns whatever it did receive even when it times out, so a timed-out read -# that returned bytes is progress, not a stall: the loop keeps that partial -# chunk and arms a fresh window. Only a window that delivers nothing at all -# is a stall. Re-arming is skipped once the buffer already parses as whole -# JSON, so the late-EOF case costs ONE window, not two — that is the floor, -# since a producer holding the pipe open cannot be distinguished from a slow -# one until a window expires. +# * The timer measures inactivity, not the read. `read -t` is a deadline for +# the WHOLE requested read, so a producer that keeps delivering but slower +# than one chunk per window would still trip it. `read` assigns whatever it +# did receive even when it times out, so any byte counts as progress: the +# loop keeps that partial chunk and reads on. Only the absence of bytes for a +# whole stdin_read_timeout is a stall. +# * The bound is read in HOOK_STDIN_READ_SLICES slices. `read -t` reports only +# that its window expired, never WHEN inside it the last byte arrived, so a +# bound armed as one window would declare a stall anywhere between one and +# TWO bounds after the pipe actually went quiet. Slicing bounds that +# overshoot: with four slices a stall lands within a quarter-bound of the +# configured interval. That residual quarter is the honest limit of the +# approximation, and it errs toward waiting — never toward declaring a live +# producer dead. On a shell whose `read -t` rejects the fractional slice the +# count degrades to 1, i.e. the unsliced one-to-two-bound behavior. +# +# Reading on is skipped once the buffer already parses as whole JSON, so the +# late-EOF case costs ONE slice past the payload rather than the rest of the +# bound — a producer holding the pipe open cannot be distinguished from a slow +# one until a window expires, so some wait there is the floor. # # The trade this makes: a producer trickling bytes indefinitely is never cut off # here. That is deliberate — the harness already caps a `command` hook at 600 s @@ -325,11 +335,43 @@ hook::resolve_read_timeout() { printf '%s' "$t" } +# How many slices the idle bound is divided into. `read -t` reports only that a +# window expired, never WHEN inside it the last byte arrived, so a bound armed as +# one window declares a stall anywhere between one and two bounds after the pipe +# actually went quiet. Asking more often shrinks that: with N slices, a stall is +# declared within one slice of the configured interval. Four is the compromise — +# it cuts worst-case overshoot from 100% of the bound to 25% while keeping the +# idle path to four cheap builtin reads. +HOOK_STDIN_READ_SLICES=4 + +# Resolve the per-read slice for an already-resolved timeout, printing +# " ". Falls back to " 1" — exactly the unsliced +# behavior — when this shell's `read -t` will not accept the fractional slice, +# which is the pre-4.1/no-fractional-timeout case the delimiter-read branch +# already covers. Probed, not version-tested, for the same reason as +# hook::resolve_read_timeout. +hook::resolve_read_slice() { + local t="$1" slice + slice=$(awk -v t="$t" -v n="$HOOK_STDIN_READ_SLICES" \ + 'BEGIN { printf "%.3f", t / n }' 2>/dev/null) || slice="" + if [[ -n "$slice" && "$slice" =~ ^[0-9]+\.[0-9]+$ ]] && ! [[ "$slice" =~ ^0+\.0+$ ]]; then + local probe + # shellcheck disable=SC2034 # `discard` is the read target; only stderr matters + probe=$(read -r -t "$slice" discard &1) + if [[ -z "$probe" ]]; then + printf '%s %s' "$slice" "$HOOK_STDIN_READ_SLICES" + return 0 + fi + fi + printf '%s 1' "$t" +} + hook::buffer_stdin() { - local input="" chunk="" read_rc=0 stalled=0 - local read_timeout + local input="" chunk="" read_rc=0 stalled=0 idle_slices=0 + local read_timeout read_slice slice_count read_timeout=$(hook::resolve_read_timeout) - local -a read_opts=(-r -t "$read_timeout") + read -r read_slice slice_count < <(hook::resolve_read_slice "$read_timeout") + local -a read_opts=(-r -t "$read_slice") if hook::read_supports_nchars; then read_opts+=(-N 65536) else @@ -341,6 +383,9 @@ hook::buffer_stdin() { # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" + # Any byte at all resets the idle count — that, not the read's exit status, + # is what makes this an idle timer rather than a per-read deadline. + [[ -n "$chunk" ]] && idle_slices=0 if ((read_rc == 0)); then # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL # read that consumed nothing, however, cannot make progress, so continuing @@ -352,20 +397,23 @@ hook::buffer_stdin() { continue fi if ((read_rc > 128)); then - # Timed out. Bytes in this window mean the producer is alive: keep them - # and re-arm. An empty window is the stall this guard exists to catch. + # A slice expired. Bytes in it mean the producer is alive: keep them and + # read on. Only slice_count CONSECUTIVE empty slices — one whole + # stdin_read_timeout with nothing arriving — is the stall this guard + # exists to catch, which is why the count is not reset here. if [[ -n "$chunk" ]]; then # ... but stop immediately if what we already hold is a whole JSON # document. That is the Win32 late-EOF case — the payload arrived, the - # pipe just never closed — and re-arming there would spend a second full - # window waiting for an EOF that is not coming, doubling the delay this - # function is supposed to bound. + # pipe just never closed — and reading on there would spend the rest of + # the bound waiting for an EOF that is not coming. hook::json_complete "${input//$'\r'/}" && break continue fi + ((idle_slices++)) + ((idle_slices >= slice_count)) || continue stalled=1 fi - break # EOF (rc 1), an empty timed-out window, or a read error + break # EOF (rc 1), a full idle bound with no bytes, or a read error done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 diff --git a/plugins/rate-limit-guard/hooks/hook-utils.sh b/plugins/rate-limit-guard/hooks/hook-utils.sh index 52efd692b..14d9ae252 100755 --- a/plugins/rate-limit-guard/hooks/hook-utils.sh +++ b/plugins/rate-limit-guard/hooks/hook-utils.sh @@ -245,16 +245,26 @@ hook::repo_root() { # * The read is chunked. `read -N` lets bash satisfy it in blocks instead of # byte-at-a-time: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms # to ~85 ms. -# * The timer re-arms on progress. `read -t` is a deadline for the WHOLE -# requested read, not an inactivity timer, so a producer that keeps -# delivering but slower than one chunk per window would still trip it. `read` -# assigns whatever it did receive even when it times out, so a timed-out read -# that returned bytes is progress, not a stall: the loop keeps that partial -# chunk and arms a fresh window. Only a window that delivers nothing at all -# is a stall. Re-arming is skipped once the buffer already parses as whole -# JSON, so the late-EOF case costs ONE window, not two — that is the floor, -# since a producer holding the pipe open cannot be distinguished from a slow -# one until a window expires. +# * The timer measures inactivity, not the read. `read -t` is a deadline for +# the WHOLE requested read, so a producer that keeps delivering but slower +# than one chunk per window would still trip it. `read` assigns whatever it +# did receive even when it times out, so any byte counts as progress: the +# loop keeps that partial chunk and reads on. Only the absence of bytes for a +# whole stdin_read_timeout is a stall. +# * The bound is read in HOOK_STDIN_READ_SLICES slices. `read -t` reports only +# that its window expired, never WHEN inside it the last byte arrived, so a +# bound armed as one window would declare a stall anywhere between one and +# TWO bounds after the pipe actually went quiet. Slicing bounds that +# overshoot: with four slices a stall lands within a quarter-bound of the +# configured interval. That residual quarter is the honest limit of the +# approximation, and it errs toward waiting — never toward declaring a live +# producer dead. On a shell whose `read -t` rejects the fractional slice the +# count degrades to 1, i.e. the unsliced one-to-two-bound behavior. +# +# Reading on is skipped once the buffer already parses as whole JSON, so the +# late-EOF case costs ONE slice past the payload rather than the rest of the +# bound — a producer holding the pipe open cannot be distinguished from a slow +# one until a window expires, so some wait there is the floor. # # The trade this makes: a producer trickling bytes indefinitely is never cut off # here. That is deliberate — the harness already caps a `command` hook at 600 s @@ -325,11 +335,43 @@ hook::resolve_read_timeout() { printf '%s' "$t" } +# How many slices the idle bound is divided into. `read -t` reports only that a +# window expired, never WHEN inside it the last byte arrived, so a bound armed as +# one window declares a stall anywhere between one and two bounds after the pipe +# actually went quiet. Asking more often shrinks that: with N slices, a stall is +# declared within one slice of the configured interval. Four is the compromise — +# it cuts worst-case overshoot from 100% of the bound to 25% while keeping the +# idle path to four cheap builtin reads. +HOOK_STDIN_READ_SLICES=4 + +# Resolve the per-read slice for an already-resolved timeout, printing +# " ". Falls back to " 1" — exactly the unsliced +# behavior — when this shell's `read -t` will not accept the fractional slice, +# which is the pre-4.1/no-fractional-timeout case the delimiter-read branch +# already covers. Probed, not version-tested, for the same reason as +# hook::resolve_read_timeout. +hook::resolve_read_slice() { + local t="$1" slice + slice=$(awk -v t="$t" -v n="$HOOK_STDIN_READ_SLICES" \ + 'BEGIN { printf "%.3f", t / n }' 2>/dev/null) || slice="" + if [[ -n "$slice" && "$slice" =~ ^[0-9]+\.[0-9]+$ ]] && ! [[ "$slice" =~ ^0+\.0+$ ]]; then + local probe + # shellcheck disable=SC2034 # `discard` is the read target; only stderr matters + probe=$(read -r -t "$slice" discard &1) + if [[ -z "$probe" ]]; then + printf '%s %s' "$slice" "$HOOK_STDIN_READ_SLICES" + return 0 + fi + fi + printf '%s 1' "$t" +} + hook::buffer_stdin() { - local input="" chunk="" read_rc=0 stalled=0 - local read_timeout + local input="" chunk="" read_rc=0 stalled=0 idle_slices=0 + local read_timeout read_slice slice_count read_timeout=$(hook::resolve_read_timeout) - local -a read_opts=(-r -t "$read_timeout") + read -r read_slice slice_count < <(hook::resolve_read_slice "$read_timeout") + local -a read_opts=(-r -t "$read_slice") if hook::read_supports_nchars; then read_opts+=(-N 65536) else @@ -341,6 +383,9 @@ hook::buffer_stdin() { # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" + # Any byte at all resets the idle count — that, not the read's exit status, + # is what makes this an idle timer rather than a per-read deadline. + [[ -n "$chunk" ]] && idle_slices=0 if ((read_rc == 0)); then # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL # read that consumed nothing, however, cannot make progress, so continuing @@ -352,20 +397,23 @@ hook::buffer_stdin() { continue fi if ((read_rc > 128)); then - # Timed out. Bytes in this window mean the producer is alive: keep them - # and re-arm. An empty window is the stall this guard exists to catch. + # A slice expired. Bytes in it mean the producer is alive: keep them and + # read on. Only slice_count CONSECUTIVE empty slices — one whole + # stdin_read_timeout with nothing arriving — is the stall this guard + # exists to catch, which is why the count is not reset here. if [[ -n "$chunk" ]]; then # ... but stop immediately if what we already hold is a whole JSON # document. That is the Win32 late-EOF case — the payload arrived, the - # pipe just never closed — and re-arming there would spend a second full - # window waiting for an EOF that is not coming, doubling the delay this - # function is supposed to bound. + # pipe just never closed — and reading on there would spend the rest of + # the bound waiting for an EOF that is not coming. hook::json_complete "${input//$'\r'/}" && break continue fi + ((idle_slices++)) + ((idle_slices >= slice_count)) || continue stalled=1 fi - break # EOF (rc 1), an empty timed-out window, or a read error + break # EOF (rc 1), a full idle bound with no bytes, or a read error done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 diff --git a/plugins/ruff-format/hooks/hook-utils.sh b/plugins/ruff-format/hooks/hook-utils.sh index 52efd692b..14d9ae252 100644 --- a/plugins/ruff-format/hooks/hook-utils.sh +++ b/plugins/ruff-format/hooks/hook-utils.sh @@ -245,16 +245,26 @@ hook::repo_root() { # * The read is chunked. `read -N` lets bash satisfy it in blocks instead of # byte-at-a-time: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms # to ~85 ms. -# * The timer re-arms on progress. `read -t` is a deadline for the WHOLE -# requested read, not an inactivity timer, so a producer that keeps -# delivering but slower than one chunk per window would still trip it. `read` -# assigns whatever it did receive even when it times out, so a timed-out read -# that returned bytes is progress, not a stall: the loop keeps that partial -# chunk and arms a fresh window. Only a window that delivers nothing at all -# is a stall. Re-arming is skipped once the buffer already parses as whole -# JSON, so the late-EOF case costs ONE window, not two — that is the floor, -# since a producer holding the pipe open cannot be distinguished from a slow -# one until a window expires. +# * The timer measures inactivity, not the read. `read -t` is a deadline for +# the WHOLE requested read, so a producer that keeps delivering but slower +# than one chunk per window would still trip it. `read` assigns whatever it +# did receive even when it times out, so any byte counts as progress: the +# loop keeps that partial chunk and reads on. Only the absence of bytes for a +# whole stdin_read_timeout is a stall. +# * The bound is read in HOOK_STDIN_READ_SLICES slices. `read -t` reports only +# that its window expired, never WHEN inside it the last byte arrived, so a +# bound armed as one window would declare a stall anywhere between one and +# TWO bounds after the pipe actually went quiet. Slicing bounds that +# overshoot: with four slices a stall lands within a quarter-bound of the +# configured interval. That residual quarter is the honest limit of the +# approximation, and it errs toward waiting — never toward declaring a live +# producer dead. On a shell whose `read -t` rejects the fractional slice the +# count degrades to 1, i.e. the unsliced one-to-two-bound behavior. +# +# Reading on is skipped once the buffer already parses as whole JSON, so the +# late-EOF case costs ONE slice past the payload rather than the rest of the +# bound — a producer holding the pipe open cannot be distinguished from a slow +# one until a window expires, so some wait there is the floor. # # The trade this makes: a producer trickling bytes indefinitely is never cut off # here. That is deliberate — the harness already caps a `command` hook at 600 s @@ -325,11 +335,43 @@ hook::resolve_read_timeout() { printf '%s' "$t" } +# How many slices the idle bound is divided into. `read -t` reports only that a +# window expired, never WHEN inside it the last byte arrived, so a bound armed as +# one window declares a stall anywhere between one and two bounds after the pipe +# actually went quiet. Asking more often shrinks that: with N slices, a stall is +# declared within one slice of the configured interval. Four is the compromise — +# it cuts worst-case overshoot from 100% of the bound to 25% while keeping the +# idle path to four cheap builtin reads. +HOOK_STDIN_READ_SLICES=4 + +# Resolve the per-read slice for an already-resolved timeout, printing +# " ". Falls back to " 1" — exactly the unsliced +# behavior — when this shell's `read -t` will not accept the fractional slice, +# which is the pre-4.1/no-fractional-timeout case the delimiter-read branch +# already covers. Probed, not version-tested, for the same reason as +# hook::resolve_read_timeout. +hook::resolve_read_slice() { + local t="$1" slice + slice=$(awk -v t="$t" -v n="$HOOK_STDIN_READ_SLICES" \ + 'BEGIN { printf "%.3f", t / n }' 2>/dev/null) || slice="" + if [[ -n "$slice" && "$slice" =~ ^[0-9]+\.[0-9]+$ ]] && ! [[ "$slice" =~ ^0+\.0+$ ]]; then + local probe + # shellcheck disable=SC2034 # `discard` is the read target; only stderr matters + probe=$(read -r -t "$slice" discard &1) + if [[ -z "$probe" ]]; then + printf '%s %s' "$slice" "$HOOK_STDIN_READ_SLICES" + return 0 + fi + fi + printf '%s 1' "$t" +} + hook::buffer_stdin() { - local input="" chunk="" read_rc=0 stalled=0 - local read_timeout + local input="" chunk="" read_rc=0 stalled=0 idle_slices=0 + local read_timeout read_slice slice_count read_timeout=$(hook::resolve_read_timeout) - local -a read_opts=(-r -t "$read_timeout") + read -r read_slice slice_count < <(hook::resolve_read_slice "$read_timeout") + local -a read_opts=(-r -t "$read_slice") if hook::read_supports_nchars; then read_opts+=(-N 65536) else @@ -341,6 +383,9 @@ hook::buffer_stdin() { # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" + # Any byte at all resets the idle count — that, not the read's exit status, + # is what makes this an idle timer rather than a per-read deadline. + [[ -n "$chunk" ]] && idle_slices=0 if ((read_rc == 0)); then # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL # read that consumed nothing, however, cannot make progress, so continuing @@ -352,20 +397,23 @@ hook::buffer_stdin() { continue fi if ((read_rc > 128)); then - # Timed out. Bytes in this window mean the producer is alive: keep them - # and re-arm. An empty window is the stall this guard exists to catch. + # A slice expired. Bytes in it mean the producer is alive: keep them and + # read on. Only slice_count CONSECUTIVE empty slices — one whole + # stdin_read_timeout with nothing arriving — is the stall this guard + # exists to catch, which is why the count is not reset here. if [[ -n "$chunk" ]]; then # ... but stop immediately if what we already hold is a whole JSON # document. That is the Win32 late-EOF case — the payload arrived, the - # pipe just never closed — and re-arming there would spend a second full - # window waiting for an EOF that is not coming, doubling the delay this - # function is supposed to bound. + # pipe just never closed — and reading on there would spend the rest of + # the bound waiting for an EOF that is not coming. hook::json_complete "${input//$'\r'/}" && break continue fi + ((idle_slices++)) + ((idle_slices >= slice_count)) || continue stalled=1 fi - break # EOF (rc 1), an empty timed-out window, or a read error + break # EOF (rc 1), a full idle bound with no bytes, or a read error done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 diff --git a/plugins/typos-format/hooks/hook-utils.sh b/plugins/typos-format/hooks/hook-utils.sh index 52efd692b..14d9ae252 100644 --- a/plugins/typos-format/hooks/hook-utils.sh +++ b/plugins/typos-format/hooks/hook-utils.sh @@ -245,16 +245,26 @@ hook::repo_root() { # * The read is chunked. `read -N` lets bash satisfy it in blocks instead of # byte-at-a-time: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms # to ~85 ms. -# * The timer re-arms on progress. `read -t` is a deadline for the WHOLE -# requested read, not an inactivity timer, so a producer that keeps -# delivering but slower than one chunk per window would still trip it. `read` -# assigns whatever it did receive even when it times out, so a timed-out read -# that returned bytes is progress, not a stall: the loop keeps that partial -# chunk and arms a fresh window. Only a window that delivers nothing at all -# is a stall. Re-arming is skipped once the buffer already parses as whole -# JSON, so the late-EOF case costs ONE window, not two — that is the floor, -# since a producer holding the pipe open cannot be distinguished from a slow -# one until a window expires. +# * The timer measures inactivity, not the read. `read -t` is a deadline for +# the WHOLE requested read, so a producer that keeps delivering but slower +# than one chunk per window would still trip it. `read` assigns whatever it +# did receive even when it times out, so any byte counts as progress: the +# loop keeps that partial chunk and reads on. Only the absence of bytes for a +# whole stdin_read_timeout is a stall. +# * The bound is read in HOOK_STDIN_READ_SLICES slices. `read -t` reports only +# that its window expired, never WHEN inside it the last byte arrived, so a +# bound armed as one window would declare a stall anywhere between one and +# TWO bounds after the pipe actually went quiet. Slicing bounds that +# overshoot: with four slices a stall lands within a quarter-bound of the +# configured interval. That residual quarter is the honest limit of the +# approximation, and it errs toward waiting — never toward declaring a live +# producer dead. On a shell whose `read -t` rejects the fractional slice the +# count degrades to 1, i.e. the unsliced one-to-two-bound behavior. +# +# Reading on is skipped once the buffer already parses as whole JSON, so the +# late-EOF case costs ONE slice past the payload rather than the rest of the +# bound — a producer holding the pipe open cannot be distinguished from a slow +# one until a window expires, so some wait there is the floor. # # The trade this makes: a producer trickling bytes indefinitely is never cut off # here. That is deliberate — the harness already caps a `command` hook at 600 s @@ -325,11 +335,43 @@ hook::resolve_read_timeout() { printf '%s' "$t" } +# How many slices the idle bound is divided into. `read -t` reports only that a +# window expired, never WHEN inside it the last byte arrived, so a bound armed as +# one window declares a stall anywhere between one and two bounds after the pipe +# actually went quiet. Asking more often shrinks that: with N slices, a stall is +# declared within one slice of the configured interval. Four is the compromise — +# it cuts worst-case overshoot from 100% of the bound to 25% while keeping the +# idle path to four cheap builtin reads. +HOOK_STDIN_READ_SLICES=4 + +# Resolve the per-read slice for an already-resolved timeout, printing +# " ". Falls back to " 1" — exactly the unsliced +# behavior — when this shell's `read -t` will not accept the fractional slice, +# which is the pre-4.1/no-fractional-timeout case the delimiter-read branch +# already covers. Probed, not version-tested, for the same reason as +# hook::resolve_read_timeout. +hook::resolve_read_slice() { + local t="$1" slice + slice=$(awk -v t="$t" -v n="$HOOK_STDIN_READ_SLICES" \ + 'BEGIN { printf "%.3f", t / n }' 2>/dev/null) || slice="" + if [[ -n "$slice" && "$slice" =~ ^[0-9]+\.[0-9]+$ ]] && ! [[ "$slice" =~ ^0+\.0+$ ]]; then + local probe + # shellcheck disable=SC2034 # `discard` is the read target; only stderr matters + probe=$(read -r -t "$slice" discard &1) + if [[ -z "$probe" ]]; then + printf '%s %s' "$slice" "$HOOK_STDIN_READ_SLICES" + return 0 + fi + fi + printf '%s 1' "$t" +} + hook::buffer_stdin() { - local input="" chunk="" read_rc=0 stalled=0 - local read_timeout + local input="" chunk="" read_rc=0 stalled=0 idle_slices=0 + local read_timeout read_slice slice_count read_timeout=$(hook::resolve_read_timeout) - local -a read_opts=(-r -t "$read_timeout") + read -r read_slice slice_count < <(hook::resolve_read_slice "$read_timeout") + local -a read_opts=(-r -t "$read_slice") if hook::read_supports_nchars; then read_opts+=(-N 65536) else @@ -341,6 +383,9 @@ hook::buffer_stdin() { # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array IFS= read "${read_opts[@]}" chunk || read_rc=$? input+="$chunk" + # Any byte at all resets the idle count — that, not the read's exit status, + # is what makes this an idle timer rather than a per-read deadline. + [[ -n "$chunk" ]] && idle_slices=0 if ((read_rc == 0)); then # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL # read that consumed nothing, however, cannot make progress, so continuing @@ -352,20 +397,23 @@ hook::buffer_stdin() { continue fi if ((read_rc > 128)); then - # Timed out. Bytes in this window mean the producer is alive: keep them - # and re-arm. An empty window is the stall this guard exists to catch. + # A slice expired. Bytes in it mean the producer is alive: keep them and + # read on. Only slice_count CONSECUTIVE empty slices — one whole + # stdin_read_timeout with nothing arriving — is the stall this guard + # exists to catch, which is why the count is not reset here. if [[ -n "$chunk" ]]; then # ... but stop immediately if what we already hold is a whole JSON # document. That is the Win32 late-EOF case — the payload arrived, the - # pipe just never closed — and re-arming there would spend a second full - # window waiting for an EOF that is not coming, doubling the delay this - # function is supposed to bound. + # pipe just never closed — and reading on there would spend the rest of + # the bound waiting for an EOF that is not coming. hook::json_complete "${input//$'\r'/}" && break continue fi + ((idle_slices++)) + ((idle_slices >= slice_count)) || continue stalled=1 fi - break # EOF (rc 1), an empty timed-out window, or a read error + break # EOF (rc 1), a full idle bound with no bytes, or a read error done input=$(printf '%s' "$input" | tr -d '\r') [[ -n "$input" ]] || return 1 From 9e112a513fa37f4bdf300760f19784f39388288e Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 26 Jul 2026 17:25:04 -0400 Subject: [PATCH 6/7] fix(hook-utils): never feed a hook payload to jq through a here-string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more review findings on #1587, and chasing the first one uncovered a hang that this PR would otherwise have shipped. REPORTED: a late-EOF payload whose length is an exact multiple of 65536 completes on a read that returns rc 0, so it never reached the with-bytes completeness check and waited out the whole idle bound instead of a slice. The check now also runs on the FIRST empty slice of a quiet period — the buffer cannot grow while nothing is arriving, so once per quiet period is both sufficient and the most this can cost. Checking there rather than on the rc-0 path keeps jq off the hot path: a large payload costs one check when the producer first pauses, not one per 64 KB chunk. A first attempt that checked on EVERY empty slice made the sliced stall SLOWER than the unsliced one (3182 ms vs 2834 ms) — four jq spawns cost more than slicing saved — which the existing comparison test caught. FOUND WHILE REPRODUCING IT: hook::json_complete hung outright on a 65536-byte buffer. `jq -e . <<< "$buf"` delivers the here-string through a pipe that bash fills ITSELF before exec'ing jq, so a payload at or above the pipe capacity — 65536 bytes here, exactly one read chunk — blocks the shell forever. Traced it to the exact call; 65536 hung indefinitely, 65000 returned immediately. Every such call now goes through `printf | jq`, where a separate writer process makes the deadlock impossible: hook::json_complete, buffer_stdin's final check, hook::jq_field, and claude-ops' skill-usage-expansion-audit. That last one is load-bearing for this PR specifically. hook::jq_field takes the WHOLE buffered payload and is called by most hooks in the fleet; a bounded stdin read used to reject anything at that size before it could reach the here-string. Removing the throughput ceiling is exactly what makes those payloads reachable, so this PR had to fix the deadlock it exposed. hook::json_complete also gained an O(1) structural pre-filter (a complete payload ends in `}`) so the jq spawn is skipped for every mid-payload buffer. A false negative there costs only the early break, never correctness. ALSO REPORTED: actionlint and claude-ops already exposed stdin_read_timeout, and their READMEs, manifest descriptions, and setup skill still described it as a total read deadline "before failing open". It is an inactivity deadline now, and a producer that keeps emitting is bounded by Claude Code's hook timeout rather than by this value — materially misleading for anyone reading that contract. All four surfaces updated, with changelog entries under the versions this PR already bumps. Tests: the chunk-boundary case asserts a 65536-byte held-open payload costs no more than a non-boundary one, preceded by an assertion that the fixtures are EXACTLY 65536 and 65000 bytes. That precondition earned its keep — the first generator produced 59578 bytes because it stripped newlines after truncating, so the case was comparing two non-boundary payloads. 114/114 in lib/hook-utils.test.sh (66 s); 72/72, 42/42, 112/112, 28/28, 41/41, 10/10 across the affected hook suites; end-to-end fail-closed triple still holds. Refs #1563 Co-Authored-By: Claude Fable 5 --- lib/hook-utils.sh | 43 ++++++++++++- lib/hook-utils.test.sh | 62 +++++++++++++++++++ plugins/actionlint/.claude-plugin/plugin.json | 2 +- plugins/actionlint/CHANGELOG.md | 10 +++ plugins/actionlint/README.md | 11 +++- plugins/actionlint/hooks/hook-utils.sh | 43 ++++++++++++- plugins/actionlint/skills/setup/SKILL.md | 4 +- plugins/autonomy/hooks/hook-utils.sh | 43 ++++++++++++- plugins/bash-format/hooks/hook-utils.sh | 43 ++++++++++++- plugins/biome-format/hooks/hook-utils.sh | 43 ++++++++++++- plugins/claude-ops/.claude-plugin/plugin.json | 2 +- plugins/claude-ops/CHANGELOG.md | 18 +++++- plugins/claude-ops/README.md | 11 +++- plugins/claude-ops/hooks/hook-utils.sh | 43 ++++++++++++- .../hooks/skill-usage-expansion-audit.sh | 4 +- .../desktop-notification/hooks/hook-utils.sh | 43 ++++++++++++- plugins/eol-normalizer/hooks/hook-utils.sh | 43 ++++++++++++- plugins/go-format/hooks/hook-utils.sh | 43 ++++++++++++- plugins/guardrails/hooks/hook-utils.sh | 43 ++++++++++++- plugins/markdown-format/hooks/hook-utils.sh | 43 ++++++++++++- plugins/powershell-format/hooks/hook-utils.sh | 43 ++++++++++++- plugins/rate-limit-guard/hooks/hook-utils.sh | 43 ++++++++++++- plugins/ruff-format/hooks/hook-utils.sh | 43 ++++++++++++- plugins/typos-format/hooks/hook-utils.sh | 43 ++++++++++++- 24 files changed, 715 insertions(+), 54 deletions(-) diff --git a/lib/hook-utils.sh b/lib/hook-utils.sh index 14d9ae252..5e9eddf15 100644 --- a/lib/hook-utils.sh +++ b/lib/hook-utils.sh @@ -301,8 +301,22 @@ hook::read_supports_nchars() { # caller must keep reading rather than guess, and the caller's own fail-open # handling for absent jq is unaffected. hook::json_complete() { + # Structural pre-filter before paying for a jq process: a hook payload is a + # JSON object, so a complete one ends in `}` (possibly with trailing newline + # or CR). Testing the last few characters is O(1) and skips the spawn for + # every mid-payload buffer, which is what keeps this off the hot path of a + # large or slow read. A false negative here costs only the early break — the + # read continues and the caller's final completeness check still decides — so + # the pre-filter can never turn a whole payload into a wrong verdict. + [[ "${1: -4}" == *"}"* ]] || return 1 command -v jq >/dev/null 2>&1 || return 1 - jq -e . >/dev/null 2>&1 <<<"$1" + # `printf | jq`, never `jq <<< "$1"`. A here-string is delivered through a pipe + # that bash fills itself, so a payload at or above the pipe capacity (65536 + # bytes on this platform — exactly one read chunk) blocks the shell forever + # before jq is ever exec'd. Reproduced: a 65536-byte buffer hung here + # indefinitely while 65000 returned immediately. A separate writer process + # cannot deadlock that way. + printf '%s' "$1" | jq -e . >/dev/null 2>&1 } # Resolve the read timeout to a value THIS shell's `read -t` will actually @@ -409,6 +423,20 @@ hook::buffer_stdin() { hook::json_complete "${input//$'\r'/}" && break continue fi + # An EMPTY slice can also be the late-EOF case: the payload may have been + # completed by the PREVIOUS read, which returned rc 0 and so never reached + # the completeness check above. That happens whenever the payload ends on a + # 65536-character boundary, and without this the helper would wait out the + # whole bound instead of a single slice. Checking here rather than on the + # rc-0 path keeps jq off the hot path — a large payload costs one check + # when the producer first pauses, not one per 64 KB chunk. + # + # Only on the FIRST empty slice of a quiet period: the buffer cannot grow + # while nothing is arriving, so re-checking an unchanged buffer would spend + # a jq process per slice to re-derive the same answer — enough overhead on + # a slow-spawning host to cost more than slicing saves. idle_slices resets + # the moment a byte lands, so the next quiet period checks again. + ((idle_slices == 0)) && hook::json_complete "${input//$'\r'/}" && break ((idle_slices++)) ((idle_slices >= slice_count)) || continue stalled=1 @@ -419,7 +447,10 @@ hook::buffer_stdin() { [[ -n "$input" ]] || return 1 local jq_rc=0 if command -v jq >/dev/null 2>&1; then - jq -e . >/dev/null 2>&1 <<<"$input" || jq_rc=$? + # `printf | jq`, not a here-string — see hook::json_complete: a here-string + # at or above the pipe capacity deadlocks the shell before jq is exec'd, and + # a hook payload routinely exceeds it. + printf '%s' "$input" | jq -e . >/dev/null 2>&1 || jq_rc=$? fi if ((jq_rc != 0 && jq_rc != 127)); then if ((stalled)); then @@ -433,10 +464,16 @@ hook::buffer_stdin() { # Extract a single jq field from a buffered input string. CR-stripped. Returns 1 # when the field is empty or jq fails, so the caller can skip. +# +# Fed through `printf | jq`, never a here-string: bash fills a here-string's pipe +# itself, so a payload at or above the pipe capacity (65536 bytes here) blocks +# before jq is exec'd. Callers pass the WHOLE buffered hook payload, which now +# routinely exceeds that — a bounded stdin read used to reject anything that +# large before it reached this helper. # FIELD=$(hook::jq_field "$INPUT" '.tool_input.file_path') || exit 0 hook::jq_field() { local field - field=$(jq -r "(${2} // empty)"' | gsub("\r";"")' <<<"$1" 2>/dev/null) + field=$(printf '%s' "$1" | jq -r "(${2} // empty)"' | gsub("\r";"")' 2>/dev/null) [[ -n "$field" ]] || return 1 printf '%s' "$field" } diff --git a/lib/hook-utils.test.sh b/lib/hook-utils.test.sh index c70532f1d..c87c1cb9f 100755 --- a/lib/hook-utils.test.sh +++ b/lib/hook-utils.test.sh @@ -1124,6 +1124,68 @@ if [[ "$bs_slices" == "0.300 4" ]] && [[ "$bs_slices_forced" == "1.2 1" ]]; then else fail "slice resolution: got '$bs_slices' / forced '$bs_slices_forced'; cases below are vacuous" fi +# A late-EOF payload whose length lands exactly on a 65536-character read +# boundary completes on a read that returns rc 0, so it never reaches the +# with-bytes completeness check — only the empty-slice one catches it. Without +# that, this case waits out the whole bound instead of a slice. Asserted against +# a deliberately NON-boundary length of the same shape, so the comparison isolates +# the boundary rather than measuring absolute latency. +bs_make_payload() { # $1 = exact payload length in bytes, $2 = destination file + # `{"p":"` + pad + `"}` — 8 bytes of envelope. Built with pure parameter + # expansion rather than a `yes | … | head -c` pipeline: the exact length is the + # entire point of this case, and a generator whose length depends on where the + # newline stripping happens (or on a pipeline terminating cleanly under MSYS) + # is one that can silently produce the wrong fixture. + local pad + printf -v pad '%*s' "$(($1 - 8))" '' + printf '{"p":"%s"}' "${pad// /a}" >"$2" +} +bs_time_held_open() { # $1 = exact payload length in bytes; prints elapsed ms + local payload_file t_file + payload_file="$(mktemp)" + t_file="$(mktemp)" + bs_make_payload "$1" "$payload_file" + { cat "$payload_file"; sleep 3; } | { + CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT=1.2 bash -c ' + source "$1" + printf "%s\n" "${EPOCHREALTIME:-0}" + hook::buffer_stdin >/dev/null 2>&1 + printf "%s\n" "${EPOCHREALTIME:-0}" + ' _ "$HOOK_DIR/hook-utils.sh" >"$t_file" + } + awk 'NR==1 {s=$0} NR==2 {e=$0} + END { if (s == 0 || e == 0 || NR < 2) print ""; else printf "%.0f", (e - s) * 1000 }' \ + "$t_file" + rm -f "$payload_file" "$t_file" +} +# The whole case turns on the payload being EXACTLY chunk-sized, and an off-by-a- +# few generator would quietly compare two non-boundary payloads. Assert the +# lengths first. +bs_len_file="$(mktemp)" +bs_make_payload 65536 "$bs_len_file" +bs_len_a=$(wc -c <"$bs_len_file") +bs_make_payload 65000 "$bs_len_file" +bs_len_b=$(wc -c <"$bs_len_file") +rm -f "$bs_len_file" +if ((bs_len_a == 65536)) && ((bs_len_b == 65000)); then + ok "buffer_stdin: chunk-boundary fixtures are exactly sized ($bs_len_a / $bs_len_b)" +else + fail "chunk-boundary fixtures wrong size ($bs_len_a / $bs_len_b); the case below is vacuous" +fi +bs_boundary_ms=$(bs_time_held_open 65536) +bs_offset_ms=$(bs_time_held_open 65000) +if [[ -z "$bs_boundary_ms" || -z "$bs_offset_ms" ]]; then + if [[ -n "${EPOCHREALTIME:-}" ]]; then + fail "boundary timing harness produced no measurement ('$bs_boundary_ms' / '$bs_offset_ms')" + else + ok "buffer_stdin: chunk-boundary latency not timed (EPOCHREALTIME absent, Bash < 5.0)" + fi +elif ((bs_boundary_ms < bs_offset_ms + 400)); then + ok "buffer_stdin: a 65536-char payload costs no more than a non-boundary one (${bs_boundary_ms} ms vs ${bs_offset_ms} ms)" +else + fail "buffer_stdin chunk boundary: ${bs_boundary_ms} ms vs ${bs_offset_ms} ms non-boundary — boundary payload waits out the bound" +fi + bs_sliced_ms=$(bs_time_stall "") bs_unsliced_ms=$(bs_time_stall "$bs_unsliced") if [[ -z "$bs_sliced_ms" || -z "$bs_unsliced_ms" ]]; then diff --git a/plugins/actionlint/.claude-plugin/plugin.json b/plugins/actionlint/.claude-plugin/plugin.json index 64b478bb9..b68d57b29 100644 --- a/plugins/actionlint/.claude-plugin/plugin.json +++ b/plugins/actionlint/.claude-plugin/plugin.json @@ -26,7 +26,7 @@ "stdin_read_timeout": { "type": "number", "title": "Hook stdin read timeout (seconds)", - "description": "Bound on reading the hook payload from stdin before failing open", + "description": "Idle bound on reading the hook payload from stdin — how long the pipe may go silent before the hook gives up and fails open", "default": 2, "min": 1 } diff --git a/plugins/actionlint/CHANGELOG.md b/plugins/actionlint/CHANGELOG.md index ff2f0dee7..4845cabcd 100644 --- a/plugins/actionlint/CHANGELOG.md +++ b/plugins/actionlint/CHANGELOG.md @@ -22,6 +22,16 @@ All notable changes to the `actionlint` plugin are documented here. Format follo loop. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. Synced from `lib/hook-utils.sh`; this plugin's own hook behavior is otherwise unchanged. +### Changed + +- **`stdin_read_timeout` is documented as the idle bound it now is.** This plugin already exposed + the option, and its README, manifest description, and setup skill all described it as bounding + "reading the hook payload from stdin before failing open" — a total read deadline. It is now an + inactivity deadline: any byte resets it, so a producer that keeps emitting is bounded by Claude + Code's own hook timeout rather than by this value, and the bound is read in four slices so a stall + is detected within a quarter of the configured interval. Documentation only — the configuration + contract users read was materially misleading after the shared-library change above. + ## [0.7.3] ### Changed diff --git a/plugins/actionlint/README.md b/plugins/actionlint/README.md index bf89dbc62..8f1f11111 100644 --- a/plugins/actionlint/README.md +++ b/plugins/actionlint/README.md @@ -54,8 +54,15 @@ repository when present. Two `userConfig` options tune the hook itself: - **`actionlint_enabled`** (boolean, default `true`) — kill switch for the actionlint-check hook. -- **`stdin_read_timeout`** (number, default `2`, minimum `1`) — bound in - seconds on reading the hook payload from stdin before failing open. +- **`stdin_read_timeout`** (number, default `2`, minimum `1`) — **idle** bound in + seconds on reading the hook payload from stdin. Any byte arriving resets it, so + a large or slowly-delivered payload is never cut off while it is still coming; + it fires only once the pipe has gone silent for that long, and this hook then + fails open (skips). The bound is read in four slices, so the stall is detected + within a quarter of the configured interval of it. A producer that keeps + emitting is bounded by Claude Code's own hook timeout, not by this value. A + setting this shell's `read -t` will not accept — or `0` — falls back to the + default. Configure interactively with `/plugin configure actionlint` or headless at install time: diff --git a/plugins/actionlint/hooks/hook-utils.sh b/plugins/actionlint/hooks/hook-utils.sh index 14d9ae252..5e9eddf15 100644 --- a/plugins/actionlint/hooks/hook-utils.sh +++ b/plugins/actionlint/hooks/hook-utils.sh @@ -301,8 +301,22 @@ hook::read_supports_nchars() { # caller must keep reading rather than guess, and the caller's own fail-open # handling for absent jq is unaffected. hook::json_complete() { + # Structural pre-filter before paying for a jq process: a hook payload is a + # JSON object, so a complete one ends in `}` (possibly with trailing newline + # or CR). Testing the last few characters is O(1) and skips the spawn for + # every mid-payload buffer, which is what keeps this off the hot path of a + # large or slow read. A false negative here costs only the early break — the + # read continues and the caller's final completeness check still decides — so + # the pre-filter can never turn a whole payload into a wrong verdict. + [[ "${1: -4}" == *"}"* ]] || return 1 command -v jq >/dev/null 2>&1 || return 1 - jq -e . >/dev/null 2>&1 <<<"$1" + # `printf | jq`, never `jq <<< "$1"`. A here-string is delivered through a pipe + # that bash fills itself, so a payload at or above the pipe capacity (65536 + # bytes on this platform — exactly one read chunk) blocks the shell forever + # before jq is ever exec'd. Reproduced: a 65536-byte buffer hung here + # indefinitely while 65000 returned immediately. A separate writer process + # cannot deadlock that way. + printf '%s' "$1" | jq -e . >/dev/null 2>&1 } # Resolve the read timeout to a value THIS shell's `read -t` will actually @@ -409,6 +423,20 @@ hook::buffer_stdin() { hook::json_complete "${input//$'\r'/}" && break continue fi + # An EMPTY slice can also be the late-EOF case: the payload may have been + # completed by the PREVIOUS read, which returned rc 0 and so never reached + # the completeness check above. That happens whenever the payload ends on a + # 65536-character boundary, and without this the helper would wait out the + # whole bound instead of a single slice. Checking here rather than on the + # rc-0 path keeps jq off the hot path — a large payload costs one check + # when the producer first pauses, not one per 64 KB chunk. + # + # Only on the FIRST empty slice of a quiet period: the buffer cannot grow + # while nothing is arriving, so re-checking an unchanged buffer would spend + # a jq process per slice to re-derive the same answer — enough overhead on + # a slow-spawning host to cost more than slicing saves. idle_slices resets + # the moment a byte lands, so the next quiet period checks again. + ((idle_slices == 0)) && hook::json_complete "${input//$'\r'/}" && break ((idle_slices++)) ((idle_slices >= slice_count)) || continue stalled=1 @@ -419,7 +447,10 @@ hook::buffer_stdin() { [[ -n "$input" ]] || return 1 local jq_rc=0 if command -v jq >/dev/null 2>&1; then - jq -e . >/dev/null 2>&1 <<<"$input" || jq_rc=$? + # `printf | jq`, not a here-string — see hook::json_complete: a here-string + # at or above the pipe capacity deadlocks the shell before jq is exec'd, and + # a hook payload routinely exceeds it. + printf '%s' "$input" | jq -e . >/dev/null 2>&1 || jq_rc=$? fi if ((jq_rc != 0 && jq_rc != 127)); then if ((stalled)); then @@ -433,10 +464,16 @@ hook::buffer_stdin() { # Extract a single jq field from a buffered input string. CR-stripped. Returns 1 # when the field is empty or jq fails, so the caller can skip. +# +# Fed through `printf | jq`, never a here-string: bash fills a here-string's pipe +# itself, so a payload at or above the pipe capacity (65536 bytes here) blocks +# before jq is exec'd. Callers pass the WHOLE buffered hook payload, which now +# routinely exceeds that — a bounded stdin read used to reject anything that +# large before it reached this helper. # FIELD=$(hook::jq_field "$INPUT" '.tool_input.file_path') || exit 0 hook::jq_field() { local field - field=$(jq -r "(${2} // empty)"' | gsub("\r";"")' <<<"$1" 2>/dev/null) + field=$(printf '%s' "$1" | jq -r "(${2} // empty)"' | gsub("\r";"")' 2>/dev/null) [[ -n "$field" ]] || return 1 printf '%s' "$field" } diff --git a/plugins/actionlint/skills/setup/SKILL.md b/plugins/actionlint/skills/setup/SKILL.md index 762d974e0..d06852a62 100644 --- a/plugins/actionlint/skills/setup/SKILL.md +++ b/plugins/actionlint/skills/setup/SKILL.md @@ -47,7 +47,9 @@ restores the FAIL semantics. other than `true` disables the hook). 5b. **Stdin read timeout** — INFO: report the effective `stdin_read_timeout` value: `${user_config.stdin_read_timeout}` (unexpanded or empty means default `2` seconds, - minimum `1`; bounds reading the hook payload before failing open). + minimum `1`). It is an IDLE bound — any byte arriving resets it, so it fires only once + the pipe has gone silent for that long, at which point this hook fails open. A value + `read -t` will not accept, or `0`, falls back to the default. 6. **Hook registration** — INFO: confirm the plugin is enabled for this project (`/plugin` → Installed) rather than parsing settings files. diff --git a/plugins/autonomy/hooks/hook-utils.sh b/plugins/autonomy/hooks/hook-utils.sh index 14d9ae252..5e9eddf15 100644 --- a/plugins/autonomy/hooks/hook-utils.sh +++ b/plugins/autonomy/hooks/hook-utils.sh @@ -301,8 +301,22 @@ hook::read_supports_nchars() { # caller must keep reading rather than guess, and the caller's own fail-open # handling for absent jq is unaffected. hook::json_complete() { + # Structural pre-filter before paying for a jq process: a hook payload is a + # JSON object, so a complete one ends in `}` (possibly with trailing newline + # or CR). Testing the last few characters is O(1) and skips the spawn for + # every mid-payload buffer, which is what keeps this off the hot path of a + # large or slow read. A false negative here costs only the early break — the + # read continues and the caller's final completeness check still decides — so + # the pre-filter can never turn a whole payload into a wrong verdict. + [[ "${1: -4}" == *"}"* ]] || return 1 command -v jq >/dev/null 2>&1 || return 1 - jq -e . >/dev/null 2>&1 <<<"$1" + # `printf | jq`, never `jq <<< "$1"`. A here-string is delivered through a pipe + # that bash fills itself, so a payload at or above the pipe capacity (65536 + # bytes on this platform — exactly one read chunk) blocks the shell forever + # before jq is ever exec'd. Reproduced: a 65536-byte buffer hung here + # indefinitely while 65000 returned immediately. A separate writer process + # cannot deadlock that way. + printf '%s' "$1" | jq -e . >/dev/null 2>&1 } # Resolve the read timeout to a value THIS shell's `read -t` will actually @@ -409,6 +423,20 @@ hook::buffer_stdin() { hook::json_complete "${input//$'\r'/}" && break continue fi + # An EMPTY slice can also be the late-EOF case: the payload may have been + # completed by the PREVIOUS read, which returned rc 0 and so never reached + # the completeness check above. That happens whenever the payload ends on a + # 65536-character boundary, and without this the helper would wait out the + # whole bound instead of a single slice. Checking here rather than on the + # rc-0 path keeps jq off the hot path — a large payload costs one check + # when the producer first pauses, not one per 64 KB chunk. + # + # Only on the FIRST empty slice of a quiet period: the buffer cannot grow + # while nothing is arriving, so re-checking an unchanged buffer would spend + # a jq process per slice to re-derive the same answer — enough overhead on + # a slow-spawning host to cost more than slicing saves. idle_slices resets + # the moment a byte lands, so the next quiet period checks again. + ((idle_slices == 0)) && hook::json_complete "${input//$'\r'/}" && break ((idle_slices++)) ((idle_slices >= slice_count)) || continue stalled=1 @@ -419,7 +447,10 @@ hook::buffer_stdin() { [[ -n "$input" ]] || return 1 local jq_rc=0 if command -v jq >/dev/null 2>&1; then - jq -e . >/dev/null 2>&1 <<<"$input" || jq_rc=$? + # `printf | jq`, not a here-string — see hook::json_complete: a here-string + # at or above the pipe capacity deadlocks the shell before jq is exec'd, and + # a hook payload routinely exceeds it. + printf '%s' "$input" | jq -e . >/dev/null 2>&1 || jq_rc=$? fi if ((jq_rc != 0 && jq_rc != 127)); then if ((stalled)); then @@ -433,10 +464,16 @@ hook::buffer_stdin() { # Extract a single jq field from a buffered input string. CR-stripped. Returns 1 # when the field is empty or jq fails, so the caller can skip. +# +# Fed through `printf | jq`, never a here-string: bash fills a here-string's pipe +# itself, so a payload at or above the pipe capacity (65536 bytes here) blocks +# before jq is exec'd. Callers pass the WHOLE buffered hook payload, which now +# routinely exceeds that — a bounded stdin read used to reject anything that +# large before it reached this helper. # FIELD=$(hook::jq_field "$INPUT" '.tool_input.file_path') || exit 0 hook::jq_field() { local field - field=$(jq -r "(${2} // empty)"' | gsub("\r";"")' <<<"$1" 2>/dev/null) + field=$(printf '%s' "$1" | jq -r "(${2} // empty)"' | gsub("\r";"")' 2>/dev/null) [[ -n "$field" ]] || return 1 printf '%s' "$field" } diff --git a/plugins/bash-format/hooks/hook-utils.sh b/plugins/bash-format/hooks/hook-utils.sh index 14d9ae252..5e9eddf15 100644 --- a/plugins/bash-format/hooks/hook-utils.sh +++ b/plugins/bash-format/hooks/hook-utils.sh @@ -301,8 +301,22 @@ hook::read_supports_nchars() { # caller must keep reading rather than guess, and the caller's own fail-open # handling for absent jq is unaffected. hook::json_complete() { + # Structural pre-filter before paying for a jq process: a hook payload is a + # JSON object, so a complete one ends in `}` (possibly with trailing newline + # or CR). Testing the last few characters is O(1) and skips the spawn for + # every mid-payload buffer, which is what keeps this off the hot path of a + # large or slow read. A false negative here costs only the early break — the + # read continues and the caller's final completeness check still decides — so + # the pre-filter can never turn a whole payload into a wrong verdict. + [[ "${1: -4}" == *"}"* ]] || return 1 command -v jq >/dev/null 2>&1 || return 1 - jq -e . >/dev/null 2>&1 <<<"$1" + # `printf | jq`, never `jq <<< "$1"`. A here-string is delivered through a pipe + # that bash fills itself, so a payload at or above the pipe capacity (65536 + # bytes on this platform — exactly one read chunk) blocks the shell forever + # before jq is ever exec'd. Reproduced: a 65536-byte buffer hung here + # indefinitely while 65000 returned immediately. A separate writer process + # cannot deadlock that way. + printf '%s' "$1" | jq -e . >/dev/null 2>&1 } # Resolve the read timeout to a value THIS shell's `read -t` will actually @@ -409,6 +423,20 @@ hook::buffer_stdin() { hook::json_complete "${input//$'\r'/}" && break continue fi + # An EMPTY slice can also be the late-EOF case: the payload may have been + # completed by the PREVIOUS read, which returned rc 0 and so never reached + # the completeness check above. That happens whenever the payload ends on a + # 65536-character boundary, and without this the helper would wait out the + # whole bound instead of a single slice. Checking here rather than on the + # rc-0 path keeps jq off the hot path — a large payload costs one check + # when the producer first pauses, not one per 64 KB chunk. + # + # Only on the FIRST empty slice of a quiet period: the buffer cannot grow + # while nothing is arriving, so re-checking an unchanged buffer would spend + # a jq process per slice to re-derive the same answer — enough overhead on + # a slow-spawning host to cost more than slicing saves. idle_slices resets + # the moment a byte lands, so the next quiet period checks again. + ((idle_slices == 0)) && hook::json_complete "${input//$'\r'/}" && break ((idle_slices++)) ((idle_slices >= slice_count)) || continue stalled=1 @@ -419,7 +447,10 @@ hook::buffer_stdin() { [[ -n "$input" ]] || return 1 local jq_rc=0 if command -v jq >/dev/null 2>&1; then - jq -e . >/dev/null 2>&1 <<<"$input" || jq_rc=$? + # `printf | jq`, not a here-string — see hook::json_complete: a here-string + # at or above the pipe capacity deadlocks the shell before jq is exec'd, and + # a hook payload routinely exceeds it. + printf '%s' "$input" | jq -e . >/dev/null 2>&1 || jq_rc=$? fi if ((jq_rc != 0 && jq_rc != 127)); then if ((stalled)); then @@ -433,10 +464,16 @@ hook::buffer_stdin() { # Extract a single jq field from a buffered input string. CR-stripped. Returns 1 # when the field is empty or jq fails, so the caller can skip. +# +# Fed through `printf | jq`, never a here-string: bash fills a here-string's pipe +# itself, so a payload at or above the pipe capacity (65536 bytes here) blocks +# before jq is exec'd. Callers pass the WHOLE buffered hook payload, which now +# routinely exceeds that — a bounded stdin read used to reject anything that +# large before it reached this helper. # FIELD=$(hook::jq_field "$INPUT" '.tool_input.file_path') || exit 0 hook::jq_field() { local field - field=$(jq -r "(${2} // empty)"' | gsub("\r";"")' <<<"$1" 2>/dev/null) + field=$(printf '%s' "$1" | jq -r "(${2} // empty)"' | gsub("\r";"")' 2>/dev/null) [[ -n "$field" ]] || return 1 printf '%s' "$field" } diff --git a/plugins/biome-format/hooks/hook-utils.sh b/plugins/biome-format/hooks/hook-utils.sh index 14d9ae252..5e9eddf15 100644 --- a/plugins/biome-format/hooks/hook-utils.sh +++ b/plugins/biome-format/hooks/hook-utils.sh @@ -301,8 +301,22 @@ hook::read_supports_nchars() { # caller must keep reading rather than guess, and the caller's own fail-open # handling for absent jq is unaffected. hook::json_complete() { + # Structural pre-filter before paying for a jq process: a hook payload is a + # JSON object, so a complete one ends in `}` (possibly with trailing newline + # or CR). Testing the last few characters is O(1) and skips the spawn for + # every mid-payload buffer, which is what keeps this off the hot path of a + # large or slow read. A false negative here costs only the early break — the + # read continues and the caller's final completeness check still decides — so + # the pre-filter can never turn a whole payload into a wrong verdict. + [[ "${1: -4}" == *"}"* ]] || return 1 command -v jq >/dev/null 2>&1 || return 1 - jq -e . >/dev/null 2>&1 <<<"$1" + # `printf | jq`, never `jq <<< "$1"`. A here-string is delivered through a pipe + # that bash fills itself, so a payload at or above the pipe capacity (65536 + # bytes on this platform — exactly one read chunk) blocks the shell forever + # before jq is ever exec'd. Reproduced: a 65536-byte buffer hung here + # indefinitely while 65000 returned immediately. A separate writer process + # cannot deadlock that way. + printf '%s' "$1" | jq -e . >/dev/null 2>&1 } # Resolve the read timeout to a value THIS shell's `read -t` will actually @@ -409,6 +423,20 @@ hook::buffer_stdin() { hook::json_complete "${input//$'\r'/}" && break continue fi + # An EMPTY slice can also be the late-EOF case: the payload may have been + # completed by the PREVIOUS read, which returned rc 0 and so never reached + # the completeness check above. That happens whenever the payload ends on a + # 65536-character boundary, and without this the helper would wait out the + # whole bound instead of a single slice. Checking here rather than on the + # rc-0 path keeps jq off the hot path — a large payload costs one check + # when the producer first pauses, not one per 64 KB chunk. + # + # Only on the FIRST empty slice of a quiet period: the buffer cannot grow + # while nothing is arriving, so re-checking an unchanged buffer would spend + # a jq process per slice to re-derive the same answer — enough overhead on + # a slow-spawning host to cost more than slicing saves. idle_slices resets + # the moment a byte lands, so the next quiet period checks again. + ((idle_slices == 0)) && hook::json_complete "${input//$'\r'/}" && break ((idle_slices++)) ((idle_slices >= slice_count)) || continue stalled=1 @@ -419,7 +447,10 @@ hook::buffer_stdin() { [[ -n "$input" ]] || return 1 local jq_rc=0 if command -v jq >/dev/null 2>&1; then - jq -e . >/dev/null 2>&1 <<<"$input" || jq_rc=$? + # `printf | jq`, not a here-string — see hook::json_complete: a here-string + # at or above the pipe capacity deadlocks the shell before jq is exec'd, and + # a hook payload routinely exceeds it. + printf '%s' "$input" | jq -e . >/dev/null 2>&1 || jq_rc=$? fi if ((jq_rc != 0 && jq_rc != 127)); then if ((stalled)); then @@ -433,10 +464,16 @@ hook::buffer_stdin() { # Extract a single jq field from a buffered input string. CR-stripped. Returns 1 # when the field is empty or jq fails, so the caller can skip. +# +# Fed through `printf | jq`, never a here-string: bash fills a here-string's pipe +# itself, so a payload at or above the pipe capacity (65536 bytes here) blocks +# before jq is exec'd. Callers pass the WHOLE buffered hook payload, which now +# routinely exceeds that — a bounded stdin read used to reject anything that +# large before it reached this helper. # FIELD=$(hook::jq_field "$INPUT" '.tool_input.file_path') || exit 0 hook::jq_field() { local field - field=$(jq -r "(${2} // empty)"' | gsub("\r";"")' <<<"$1" 2>/dev/null) + field=$(printf '%s' "$1" | jq -r "(${2} // empty)"' | gsub("\r";"")' 2>/dev/null) [[ -n "$field" ]] || return 1 printf '%s' "$field" } diff --git a/plugins/claude-ops/.claude-plugin/plugin.json b/plugins/claude-ops/.claude-plugin/plugin.json index 040bc1448..81324e3ed 100644 --- a/plugins/claude-ops/.claude-plugin/plugin.json +++ b/plugins/claude-ops/.claude-plugin/plugin.json @@ -103,7 +103,7 @@ "stdin_read_timeout": { "type": "number", "title": "Hook stdin read timeout (seconds)", - "description": "Bound on reading the hook payload from stdin before failing open", + "description": "Idle bound on reading the hook payload from stdin — how long the pipe may go silent before the hook gives up and fails open", "default": 2, "min": 1 } diff --git a/plugins/claude-ops/CHANGELOG.md b/plugins/claude-ops/CHANGELOG.md index 102298619..b70608a14 100644 --- a/plugins/claude-ops/CHANGELOG.md +++ b/plugins/claude-ops/CHANGELOG.md @@ -20,7 +20,23 @@ All notable changes to the `claude-ops` plugin are documented here. Format follo nothing at all is a stall. `read -N` is Bash 4.1+, and these hooks support Bash 3.2+ (macOS system bash), so the pre-4.1 path falls back to the delimiter read inside the same re-arming loop. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. Synced - from `lib/hook-utils.sh`; this plugin's own hook behavior is otherwise unchanged. + from `lib/hook-utils.sh`. +- **`skill-usage-expansion-audit` could hang on a large payload.** It read `expansion_type` with a + jq here-string. Bash fills a here-string's pipe itself, so a payload at or above the pipe capacity + (65536 bytes) blocks the hook forever before jq is exec'd. The bounded stdin read used to reject + anything that large before it got here; now that it does not, the call goes through + `printf | jq` instead. Reproduced on the shared library's own equivalent call: a 65536-byte buffer + hung indefinitely while 65000 returned immediately. + +### Changed + +- **`stdin_read_timeout` is documented as the idle bound it now is.** This plugin already exposed + the option, and its README and manifest description both described it as bounding "how long each + hook waits for its payload before failing open" — a total read deadline. It is now an inactivity + deadline: any byte resets it, so a producer that keeps emitting is bounded by Claude Code's own + hook timeout rather than by this value, and the bound is read in four slices so a stall is detected + within a quarter of the configured interval. Documentation only — the configuration contract users + read was materially misleading after the shared-library change above. ## [0.21.5] diff --git a/plugins/claude-ops/README.md b/plugins/claude-ops/README.md index 8e6040b84..0566ef3fe 100644 --- a/plugins/claude-ops/README.md +++ b/plugins/claude-ops/README.md @@ -79,8 +79,15 @@ mirror. `instructions-loaded-audit` drops deterministic, high-volume `session_start` loads by default; set `instructions_loaded_audit_log_session_start=true` to opt -back into logging them. A `stdin_read_timeout` option (seconds, default `2`) -bounds how long each hook waits for its payload before failing open. +back into logging them. A `stdin_read_timeout` option (seconds, default `2`) is +an **idle** bound on reading each hook's payload: any byte arriving resets it, so +a large or slowly-delivered payload is never cut off while it is still coming, +and it fires only once the pipe has gone silent for that long — at which point +these audit hooks fail open (skip). The bound is read in four slices, so the +stall is detected within a quarter of the configured interval of it. A producer +that keeps emitting is bounded by Claude Code's own hook timeout, not by this +value. A setting this shell's `read -t` will not accept — or `0` — falls back to +the default. Set them interactively with `/plugin configure claude-ops`, or headless on the install command: diff --git a/plugins/claude-ops/hooks/hook-utils.sh b/plugins/claude-ops/hooks/hook-utils.sh index 14d9ae252..5e9eddf15 100644 --- a/plugins/claude-ops/hooks/hook-utils.sh +++ b/plugins/claude-ops/hooks/hook-utils.sh @@ -301,8 +301,22 @@ hook::read_supports_nchars() { # caller must keep reading rather than guess, and the caller's own fail-open # handling for absent jq is unaffected. hook::json_complete() { + # Structural pre-filter before paying for a jq process: a hook payload is a + # JSON object, so a complete one ends in `}` (possibly with trailing newline + # or CR). Testing the last few characters is O(1) and skips the spawn for + # every mid-payload buffer, which is what keeps this off the hot path of a + # large or slow read. A false negative here costs only the early break — the + # read continues and the caller's final completeness check still decides — so + # the pre-filter can never turn a whole payload into a wrong verdict. + [[ "${1: -4}" == *"}"* ]] || return 1 command -v jq >/dev/null 2>&1 || return 1 - jq -e . >/dev/null 2>&1 <<<"$1" + # `printf | jq`, never `jq <<< "$1"`. A here-string is delivered through a pipe + # that bash fills itself, so a payload at or above the pipe capacity (65536 + # bytes on this platform — exactly one read chunk) blocks the shell forever + # before jq is ever exec'd. Reproduced: a 65536-byte buffer hung here + # indefinitely while 65000 returned immediately. A separate writer process + # cannot deadlock that way. + printf '%s' "$1" | jq -e . >/dev/null 2>&1 } # Resolve the read timeout to a value THIS shell's `read -t` will actually @@ -409,6 +423,20 @@ hook::buffer_stdin() { hook::json_complete "${input//$'\r'/}" && break continue fi + # An EMPTY slice can also be the late-EOF case: the payload may have been + # completed by the PREVIOUS read, which returned rc 0 and so never reached + # the completeness check above. That happens whenever the payload ends on a + # 65536-character boundary, and without this the helper would wait out the + # whole bound instead of a single slice. Checking here rather than on the + # rc-0 path keeps jq off the hot path — a large payload costs one check + # when the producer first pauses, not one per 64 KB chunk. + # + # Only on the FIRST empty slice of a quiet period: the buffer cannot grow + # while nothing is arriving, so re-checking an unchanged buffer would spend + # a jq process per slice to re-derive the same answer — enough overhead on + # a slow-spawning host to cost more than slicing saves. idle_slices resets + # the moment a byte lands, so the next quiet period checks again. + ((idle_slices == 0)) && hook::json_complete "${input//$'\r'/}" && break ((idle_slices++)) ((idle_slices >= slice_count)) || continue stalled=1 @@ -419,7 +447,10 @@ hook::buffer_stdin() { [[ -n "$input" ]] || return 1 local jq_rc=0 if command -v jq >/dev/null 2>&1; then - jq -e . >/dev/null 2>&1 <<<"$input" || jq_rc=$? + # `printf | jq`, not a here-string — see hook::json_complete: a here-string + # at or above the pipe capacity deadlocks the shell before jq is exec'd, and + # a hook payload routinely exceeds it. + printf '%s' "$input" | jq -e . >/dev/null 2>&1 || jq_rc=$? fi if ((jq_rc != 0 && jq_rc != 127)); then if ((stalled)); then @@ -433,10 +464,16 @@ hook::buffer_stdin() { # Extract a single jq field from a buffered input string. CR-stripped. Returns 1 # when the field is empty or jq fails, so the caller can skip. +# +# Fed through `printf | jq`, never a here-string: bash fills a here-string's pipe +# itself, so a payload at or above the pipe capacity (65536 bytes here) blocks +# before jq is exec'd. Callers pass the WHOLE buffered hook payload, which now +# routinely exceeds that — a bounded stdin read used to reject anything that +# large before it reached this helper. # FIELD=$(hook::jq_field "$INPUT" '.tool_input.file_path') || exit 0 hook::jq_field() { local field - field=$(jq -r "(${2} // empty)"' | gsub("\r";"")' <<<"$1" 2>/dev/null) + field=$(printf '%s' "$1" | jq -r "(${2} // empty)"' | gsub("\r";"")' 2>/dev/null) [[ -n "$field" ]] || return 1 printf '%s' "$field" } diff --git a/plugins/claude-ops/hooks/skill-usage-expansion-audit.sh b/plugins/claude-ops/hooks/skill-usage-expansion-audit.sh index eb7215e73..a758feef3 100755 --- a/plugins/claude-ops/hooks/skill-usage-expansion-audit.sh +++ b/plugins/claude-ops/hooks/skill-usage-expansion-audit.sh @@ -44,7 +44,9 @@ SKILL=$(hook::jq_field "$INPUT" '.command_name') || exit 0 SKILL="${SKILL#/}" # Optional: slash_command | mcp_prompt. Recorded when present, never gated on. -EXP_TYPE=$(jq -r '(.expansion_type // empty) | gsub("\r";"")' <<<"$INPUT" 2>/dev/null) +# Fed through `printf | jq` rather than a here-string: bash fills a here-string's +# pipe itself, so a payload at or above the pipe capacity blocks before jq runs. +EXP_TYPE=$(printf '%s' "$INPUT" | jq -r '(.expansion_type // empty) | gsub("\r";"")' 2>/dev/null) # --- Second store: skill-usage.jsonl (unconditional) ------------------------ project_dir=$(hook::repo_root "${CLAUDE_PROJECT_DIR:-.}") diff --git a/plugins/desktop-notification/hooks/hook-utils.sh b/plugins/desktop-notification/hooks/hook-utils.sh index 14d9ae252..5e9eddf15 100644 --- a/plugins/desktop-notification/hooks/hook-utils.sh +++ b/plugins/desktop-notification/hooks/hook-utils.sh @@ -301,8 +301,22 @@ hook::read_supports_nchars() { # caller must keep reading rather than guess, and the caller's own fail-open # handling for absent jq is unaffected. hook::json_complete() { + # Structural pre-filter before paying for a jq process: a hook payload is a + # JSON object, so a complete one ends in `}` (possibly with trailing newline + # or CR). Testing the last few characters is O(1) and skips the spawn for + # every mid-payload buffer, which is what keeps this off the hot path of a + # large or slow read. A false negative here costs only the early break — the + # read continues and the caller's final completeness check still decides — so + # the pre-filter can never turn a whole payload into a wrong verdict. + [[ "${1: -4}" == *"}"* ]] || return 1 command -v jq >/dev/null 2>&1 || return 1 - jq -e . >/dev/null 2>&1 <<<"$1" + # `printf | jq`, never `jq <<< "$1"`. A here-string is delivered through a pipe + # that bash fills itself, so a payload at or above the pipe capacity (65536 + # bytes on this platform — exactly one read chunk) blocks the shell forever + # before jq is ever exec'd. Reproduced: a 65536-byte buffer hung here + # indefinitely while 65000 returned immediately. A separate writer process + # cannot deadlock that way. + printf '%s' "$1" | jq -e . >/dev/null 2>&1 } # Resolve the read timeout to a value THIS shell's `read -t` will actually @@ -409,6 +423,20 @@ hook::buffer_stdin() { hook::json_complete "${input//$'\r'/}" && break continue fi + # An EMPTY slice can also be the late-EOF case: the payload may have been + # completed by the PREVIOUS read, which returned rc 0 and so never reached + # the completeness check above. That happens whenever the payload ends on a + # 65536-character boundary, and without this the helper would wait out the + # whole bound instead of a single slice. Checking here rather than on the + # rc-0 path keeps jq off the hot path — a large payload costs one check + # when the producer first pauses, not one per 64 KB chunk. + # + # Only on the FIRST empty slice of a quiet period: the buffer cannot grow + # while nothing is arriving, so re-checking an unchanged buffer would spend + # a jq process per slice to re-derive the same answer — enough overhead on + # a slow-spawning host to cost more than slicing saves. idle_slices resets + # the moment a byte lands, so the next quiet period checks again. + ((idle_slices == 0)) && hook::json_complete "${input//$'\r'/}" && break ((idle_slices++)) ((idle_slices >= slice_count)) || continue stalled=1 @@ -419,7 +447,10 @@ hook::buffer_stdin() { [[ -n "$input" ]] || return 1 local jq_rc=0 if command -v jq >/dev/null 2>&1; then - jq -e . >/dev/null 2>&1 <<<"$input" || jq_rc=$? + # `printf | jq`, not a here-string — see hook::json_complete: a here-string + # at or above the pipe capacity deadlocks the shell before jq is exec'd, and + # a hook payload routinely exceeds it. + printf '%s' "$input" | jq -e . >/dev/null 2>&1 || jq_rc=$? fi if ((jq_rc != 0 && jq_rc != 127)); then if ((stalled)); then @@ -433,10 +464,16 @@ hook::buffer_stdin() { # Extract a single jq field from a buffered input string. CR-stripped. Returns 1 # when the field is empty or jq fails, so the caller can skip. +# +# Fed through `printf | jq`, never a here-string: bash fills a here-string's pipe +# itself, so a payload at or above the pipe capacity (65536 bytes here) blocks +# before jq is exec'd. Callers pass the WHOLE buffered hook payload, which now +# routinely exceeds that — a bounded stdin read used to reject anything that +# large before it reached this helper. # FIELD=$(hook::jq_field "$INPUT" '.tool_input.file_path') || exit 0 hook::jq_field() { local field - field=$(jq -r "(${2} // empty)"' | gsub("\r";"")' <<<"$1" 2>/dev/null) + field=$(printf '%s' "$1" | jq -r "(${2} // empty)"' | gsub("\r";"")' 2>/dev/null) [[ -n "$field" ]] || return 1 printf '%s' "$field" } diff --git a/plugins/eol-normalizer/hooks/hook-utils.sh b/plugins/eol-normalizer/hooks/hook-utils.sh index 14d9ae252..5e9eddf15 100644 --- a/plugins/eol-normalizer/hooks/hook-utils.sh +++ b/plugins/eol-normalizer/hooks/hook-utils.sh @@ -301,8 +301,22 @@ hook::read_supports_nchars() { # caller must keep reading rather than guess, and the caller's own fail-open # handling for absent jq is unaffected. hook::json_complete() { + # Structural pre-filter before paying for a jq process: a hook payload is a + # JSON object, so a complete one ends in `}` (possibly with trailing newline + # or CR). Testing the last few characters is O(1) and skips the spawn for + # every mid-payload buffer, which is what keeps this off the hot path of a + # large or slow read. A false negative here costs only the early break — the + # read continues and the caller's final completeness check still decides — so + # the pre-filter can never turn a whole payload into a wrong verdict. + [[ "${1: -4}" == *"}"* ]] || return 1 command -v jq >/dev/null 2>&1 || return 1 - jq -e . >/dev/null 2>&1 <<<"$1" + # `printf | jq`, never `jq <<< "$1"`. A here-string is delivered through a pipe + # that bash fills itself, so a payload at or above the pipe capacity (65536 + # bytes on this platform — exactly one read chunk) blocks the shell forever + # before jq is ever exec'd. Reproduced: a 65536-byte buffer hung here + # indefinitely while 65000 returned immediately. A separate writer process + # cannot deadlock that way. + printf '%s' "$1" | jq -e . >/dev/null 2>&1 } # Resolve the read timeout to a value THIS shell's `read -t` will actually @@ -409,6 +423,20 @@ hook::buffer_stdin() { hook::json_complete "${input//$'\r'/}" && break continue fi + # An EMPTY slice can also be the late-EOF case: the payload may have been + # completed by the PREVIOUS read, which returned rc 0 and so never reached + # the completeness check above. That happens whenever the payload ends on a + # 65536-character boundary, and without this the helper would wait out the + # whole bound instead of a single slice. Checking here rather than on the + # rc-0 path keeps jq off the hot path — a large payload costs one check + # when the producer first pauses, not one per 64 KB chunk. + # + # Only on the FIRST empty slice of a quiet period: the buffer cannot grow + # while nothing is arriving, so re-checking an unchanged buffer would spend + # a jq process per slice to re-derive the same answer — enough overhead on + # a slow-spawning host to cost more than slicing saves. idle_slices resets + # the moment a byte lands, so the next quiet period checks again. + ((idle_slices == 0)) && hook::json_complete "${input//$'\r'/}" && break ((idle_slices++)) ((idle_slices >= slice_count)) || continue stalled=1 @@ -419,7 +447,10 @@ hook::buffer_stdin() { [[ -n "$input" ]] || return 1 local jq_rc=0 if command -v jq >/dev/null 2>&1; then - jq -e . >/dev/null 2>&1 <<<"$input" || jq_rc=$? + # `printf | jq`, not a here-string — see hook::json_complete: a here-string + # at or above the pipe capacity deadlocks the shell before jq is exec'd, and + # a hook payload routinely exceeds it. + printf '%s' "$input" | jq -e . >/dev/null 2>&1 || jq_rc=$? fi if ((jq_rc != 0 && jq_rc != 127)); then if ((stalled)); then @@ -433,10 +464,16 @@ hook::buffer_stdin() { # Extract a single jq field from a buffered input string. CR-stripped. Returns 1 # when the field is empty or jq fails, so the caller can skip. +# +# Fed through `printf | jq`, never a here-string: bash fills a here-string's pipe +# itself, so a payload at or above the pipe capacity (65536 bytes here) blocks +# before jq is exec'd. Callers pass the WHOLE buffered hook payload, which now +# routinely exceeds that — a bounded stdin read used to reject anything that +# large before it reached this helper. # FIELD=$(hook::jq_field "$INPUT" '.tool_input.file_path') || exit 0 hook::jq_field() { local field - field=$(jq -r "(${2} // empty)"' | gsub("\r";"")' <<<"$1" 2>/dev/null) + field=$(printf '%s' "$1" | jq -r "(${2} // empty)"' | gsub("\r";"")' 2>/dev/null) [[ -n "$field" ]] || return 1 printf '%s' "$field" } diff --git a/plugins/go-format/hooks/hook-utils.sh b/plugins/go-format/hooks/hook-utils.sh index 14d9ae252..5e9eddf15 100644 --- a/plugins/go-format/hooks/hook-utils.sh +++ b/plugins/go-format/hooks/hook-utils.sh @@ -301,8 +301,22 @@ hook::read_supports_nchars() { # caller must keep reading rather than guess, and the caller's own fail-open # handling for absent jq is unaffected. hook::json_complete() { + # Structural pre-filter before paying for a jq process: a hook payload is a + # JSON object, so a complete one ends in `}` (possibly with trailing newline + # or CR). Testing the last few characters is O(1) and skips the spawn for + # every mid-payload buffer, which is what keeps this off the hot path of a + # large or slow read. A false negative here costs only the early break — the + # read continues and the caller's final completeness check still decides — so + # the pre-filter can never turn a whole payload into a wrong verdict. + [[ "${1: -4}" == *"}"* ]] || return 1 command -v jq >/dev/null 2>&1 || return 1 - jq -e . >/dev/null 2>&1 <<<"$1" + # `printf | jq`, never `jq <<< "$1"`. A here-string is delivered through a pipe + # that bash fills itself, so a payload at or above the pipe capacity (65536 + # bytes on this platform — exactly one read chunk) blocks the shell forever + # before jq is ever exec'd. Reproduced: a 65536-byte buffer hung here + # indefinitely while 65000 returned immediately. A separate writer process + # cannot deadlock that way. + printf '%s' "$1" | jq -e . >/dev/null 2>&1 } # Resolve the read timeout to a value THIS shell's `read -t` will actually @@ -409,6 +423,20 @@ hook::buffer_stdin() { hook::json_complete "${input//$'\r'/}" && break continue fi + # An EMPTY slice can also be the late-EOF case: the payload may have been + # completed by the PREVIOUS read, which returned rc 0 and so never reached + # the completeness check above. That happens whenever the payload ends on a + # 65536-character boundary, and without this the helper would wait out the + # whole bound instead of a single slice. Checking here rather than on the + # rc-0 path keeps jq off the hot path — a large payload costs one check + # when the producer first pauses, not one per 64 KB chunk. + # + # Only on the FIRST empty slice of a quiet period: the buffer cannot grow + # while nothing is arriving, so re-checking an unchanged buffer would spend + # a jq process per slice to re-derive the same answer — enough overhead on + # a slow-spawning host to cost more than slicing saves. idle_slices resets + # the moment a byte lands, so the next quiet period checks again. + ((idle_slices == 0)) && hook::json_complete "${input//$'\r'/}" && break ((idle_slices++)) ((idle_slices >= slice_count)) || continue stalled=1 @@ -419,7 +447,10 @@ hook::buffer_stdin() { [[ -n "$input" ]] || return 1 local jq_rc=0 if command -v jq >/dev/null 2>&1; then - jq -e . >/dev/null 2>&1 <<<"$input" || jq_rc=$? + # `printf | jq`, not a here-string — see hook::json_complete: a here-string + # at or above the pipe capacity deadlocks the shell before jq is exec'd, and + # a hook payload routinely exceeds it. + printf '%s' "$input" | jq -e . >/dev/null 2>&1 || jq_rc=$? fi if ((jq_rc != 0 && jq_rc != 127)); then if ((stalled)); then @@ -433,10 +464,16 @@ hook::buffer_stdin() { # Extract a single jq field from a buffered input string. CR-stripped. Returns 1 # when the field is empty or jq fails, so the caller can skip. +# +# Fed through `printf | jq`, never a here-string: bash fills a here-string's pipe +# itself, so a payload at or above the pipe capacity (65536 bytes here) blocks +# before jq is exec'd. Callers pass the WHOLE buffered hook payload, which now +# routinely exceeds that — a bounded stdin read used to reject anything that +# large before it reached this helper. # FIELD=$(hook::jq_field "$INPUT" '.tool_input.file_path') || exit 0 hook::jq_field() { local field - field=$(jq -r "(${2} // empty)"' | gsub("\r";"")' <<<"$1" 2>/dev/null) + field=$(printf '%s' "$1" | jq -r "(${2} // empty)"' | gsub("\r";"")' 2>/dev/null) [[ -n "$field" ]] || return 1 printf '%s' "$field" } diff --git a/plugins/guardrails/hooks/hook-utils.sh b/plugins/guardrails/hooks/hook-utils.sh index 14d9ae252..5e9eddf15 100644 --- a/plugins/guardrails/hooks/hook-utils.sh +++ b/plugins/guardrails/hooks/hook-utils.sh @@ -301,8 +301,22 @@ hook::read_supports_nchars() { # caller must keep reading rather than guess, and the caller's own fail-open # handling for absent jq is unaffected. hook::json_complete() { + # Structural pre-filter before paying for a jq process: a hook payload is a + # JSON object, so a complete one ends in `}` (possibly with trailing newline + # or CR). Testing the last few characters is O(1) and skips the spawn for + # every mid-payload buffer, which is what keeps this off the hot path of a + # large or slow read. A false negative here costs only the early break — the + # read continues and the caller's final completeness check still decides — so + # the pre-filter can never turn a whole payload into a wrong verdict. + [[ "${1: -4}" == *"}"* ]] || return 1 command -v jq >/dev/null 2>&1 || return 1 - jq -e . >/dev/null 2>&1 <<<"$1" + # `printf | jq`, never `jq <<< "$1"`. A here-string is delivered through a pipe + # that bash fills itself, so a payload at or above the pipe capacity (65536 + # bytes on this platform — exactly one read chunk) blocks the shell forever + # before jq is ever exec'd. Reproduced: a 65536-byte buffer hung here + # indefinitely while 65000 returned immediately. A separate writer process + # cannot deadlock that way. + printf '%s' "$1" | jq -e . >/dev/null 2>&1 } # Resolve the read timeout to a value THIS shell's `read -t` will actually @@ -409,6 +423,20 @@ hook::buffer_stdin() { hook::json_complete "${input//$'\r'/}" && break continue fi + # An EMPTY slice can also be the late-EOF case: the payload may have been + # completed by the PREVIOUS read, which returned rc 0 and so never reached + # the completeness check above. That happens whenever the payload ends on a + # 65536-character boundary, and without this the helper would wait out the + # whole bound instead of a single slice. Checking here rather than on the + # rc-0 path keeps jq off the hot path — a large payload costs one check + # when the producer first pauses, not one per 64 KB chunk. + # + # Only on the FIRST empty slice of a quiet period: the buffer cannot grow + # while nothing is arriving, so re-checking an unchanged buffer would spend + # a jq process per slice to re-derive the same answer — enough overhead on + # a slow-spawning host to cost more than slicing saves. idle_slices resets + # the moment a byte lands, so the next quiet period checks again. + ((idle_slices == 0)) && hook::json_complete "${input//$'\r'/}" && break ((idle_slices++)) ((idle_slices >= slice_count)) || continue stalled=1 @@ -419,7 +447,10 @@ hook::buffer_stdin() { [[ -n "$input" ]] || return 1 local jq_rc=0 if command -v jq >/dev/null 2>&1; then - jq -e . >/dev/null 2>&1 <<<"$input" || jq_rc=$? + # `printf | jq`, not a here-string — see hook::json_complete: a here-string + # at or above the pipe capacity deadlocks the shell before jq is exec'd, and + # a hook payload routinely exceeds it. + printf '%s' "$input" | jq -e . >/dev/null 2>&1 || jq_rc=$? fi if ((jq_rc != 0 && jq_rc != 127)); then if ((stalled)); then @@ -433,10 +464,16 @@ hook::buffer_stdin() { # Extract a single jq field from a buffered input string. CR-stripped. Returns 1 # when the field is empty or jq fails, so the caller can skip. +# +# Fed through `printf | jq`, never a here-string: bash fills a here-string's pipe +# itself, so a payload at or above the pipe capacity (65536 bytes here) blocks +# before jq is exec'd. Callers pass the WHOLE buffered hook payload, which now +# routinely exceeds that — a bounded stdin read used to reject anything that +# large before it reached this helper. # FIELD=$(hook::jq_field "$INPUT" '.tool_input.file_path') || exit 0 hook::jq_field() { local field - field=$(jq -r "(${2} // empty)"' | gsub("\r";"")' <<<"$1" 2>/dev/null) + field=$(printf '%s' "$1" | jq -r "(${2} // empty)"' | gsub("\r";"")' 2>/dev/null) [[ -n "$field" ]] || return 1 printf '%s' "$field" } diff --git a/plugins/markdown-format/hooks/hook-utils.sh b/plugins/markdown-format/hooks/hook-utils.sh index 14d9ae252..5e9eddf15 100644 --- a/plugins/markdown-format/hooks/hook-utils.sh +++ b/plugins/markdown-format/hooks/hook-utils.sh @@ -301,8 +301,22 @@ hook::read_supports_nchars() { # caller must keep reading rather than guess, and the caller's own fail-open # handling for absent jq is unaffected. hook::json_complete() { + # Structural pre-filter before paying for a jq process: a hook payload is a + # JSON object, so a complete one ends in `}` (possibly with trailing newline + # or CR). Testing the last few characters is O(1) and skips the spawn for + # every mid-payload buffer, which is what keeps this off the hot path of a + # large or slow read. A false negative here costs only the early break — the + # read continues and the caller's final completeness check still decides — so + # the pre-filter can never turn a whole payload into a wrong verdict. + [[ "${1: -4}" == *"}"* ]] || return 1 command -v jq >/dev/null 2>&1 || return 1 - jq -e . >/dev/null 2>&1 <<<"$1" + # `printf | jq`, never `jq <<< "$1"`. A here-string is delivered through a pipe + # that bash fills itself, so a payload at or above the pipe capacity (65536 + # bytes on this platform — exactly one read chunk) blocks the shell forever + # before jq is ever exec'd. Reproduced: a 65536-byte buffer hung here + # indefinitely while 65000 returned immediately. A separate writer process + # cannot deadlock that way. + printf '%s' "$1" | jq -e . >/dev/null 2>&1 } # Resolve the read timeout to a value THIS shell's `read -t` will actually @@ -409,6 +423,20 @@ hook::buffer_stdin() { hook::json_complete "${input//$'\r'/}" && break continue fi + # An EMPTY slice can also be the late-EOF case: the payload may have been + # completed by the PREVIOUS read, which returned rc 0 and so never reached + # the completeness check above. That happens whenever the payload ends on a + # 65536-character boundary, and without this the helper would wait out the + # whole bound instead of a single slice. Checking here rather than on the + # rc-0 path keeps jq off the hot path — a large payload costs one check + # when the producer first pauses, not one per 64 KB chunk. + # + # Only on the FIRST empty slice of a quiet period: the buffer cannot grow + # while nothing is arriving, so re-checking an unchanged buffer would spend + # a jq process per slice to re-derive the same answer — enough overhead on + # a slow-spawning host to cost more than slicing saves. idle_slices resets + # the moment a byte lands, so the next quiet period checks again. + ((idle_slices == 0)) && hook::json_complete "${input//$'\r'/}" && break ((idle_slices++)) ((idle_slices >= slice_count)) || continue stalled=1 @@ -419,7 +447,10 @@ hook::buffer_stdin() { [[ -n "$input" ]] || return 1 local jq_rc=0 if command -v jq >/dev/null 2>&1; then - jq -e . >/dev/null 2>&1 <<<"$input" || jq_rc=$? + # `printf | jq`, not a here-string — see hook::json_complete: a here-string + # at or above the pipe capacity deadlocks the shell before jq is exec'd, and + # a hook payload routinely exceeds it. + printf '%s' "$input" | jq -e . >/dev/null 2>&1 || jq_rc=$? fi if ((jq_rc != 0 && jq_rc != 127)); then if ((stalled)); then @@ -433,10 +464,16 @@ hook::buffer_stdin() { # Extract a single jq field from a buffered input string. CR-stripped. Returns 1 # when the field is empty or jq fails, so the caller can skip. +# +# Fed through `printf | jq`, never a here-string: bash fills a here-string's pipe +# itself, so a payload at or above the pipe capacity (65536 bytes here) blocks +# before jq is exec'd. Callers pass the WHOLE buffered hook payload, which now +# routinely exceeds that — a bounded stdin read used to reject anything that +# large before it reached this helper. # FIELD=$(hook::jq_field "$INPUT" '.tool_input.file_path') || exit 0 hook::jq_field() { local field - field=$(jq -r "(${2} // empty)"' | gsub("\r";"")' <<<"$1" 2>/dev/null) + field=$(printf '%s' "$1" | jq -r "(${2} // empty)"' | gsub("\r";"")' 2>/dev/null) [[ -n "$field" ]] || return 1 printf '%s' "$field" } diff --git a/plugins/powershell-format/hooks/hook-utils.sh b/plugins/powershell-format/hooks/hook-utils.sh index 14d9ae252..5e9eddf15 100644 --- a/plugins/powershell-format/hooks/hook-utils.sh +++ b/plugins/powershell-format/hooks/hook-utils.sh @@ -301,8 +301,22 @@ hook::read_supports_nchars() { # caller must keep reading rather than guess, and the caller's own fail-open # handling for absent jq is unaffected. hook::json_complete() { + # Structural pre-filter before paying for a jq process: a hook payload is a + # JSON object, so a complete one ends in `}` (possibly with trailing newline + # or CR). Testing the last few characters is O(1) and skips the spawn for + # every mid-payload buffer, which is what keeps this off the hot path of a + # large or slow read. A false negative here costs only the early break — the + # read continues and the caller's final completeness check still decides — so + # the pre-filter can never turn a whole payload into a wrong verdict. + [[ "${1: -4}" == *"}"* ]] || return 1 command -v jq >/dev/null 2>&1 || return 1 - jq -e . >/dev/null 2>&1 <<<"$1" + # `printf | jq`, never `jq <<< "$1"`. A here-string is delivered through a pipe + # that bash fills itself, so a payload at or above the pipe capacity (65536 + # bytes on this platform — exactly one read chunk) blocks the shell forever + # before jq is ever exec'd. Reproduced: a 65536-byte buffer hung here + # indefinitely while 65000 returned immediately. A separate writer process + # cannot deadlock that way. + printf '%s' "$1" | jq -e . >/dev/null 2>&1 } # Resolve the read timeout to a value THIS shell's `read -t` will actually @@ -409,6 +423,20 @@ hook::buffer_stdin() { hook::json_complete "${input//$'\r'/}" && break continue fi + # An EMPTY slice can also be the late-EOF case: the payload may have been + # completed by the PREVIOUS read, which returned rc 0 and so never reached + # the completeness check above. That happens whenever the payload ends on a + # 65536-character boundary, and without this the helper would wait out the + # whole bound instead of a single slice. Checking here rather than on the + # rc-0 path keeps jq off the hot path — a large payload costs one check + # when the producer first pauses, not one per 64 KB chunk. + # + # Only on the FIRST empty slice of a quiet period: the buffer cannot grow + # while nothing is arriving, so re-checking an unchanged buffer would spend + # a jq process per slice to re-derive the same answer — enough overhead on + # a slow-spawning host to cost more than slicing saves. idle_slices resets + # the moment a byte lands, so the next quiet period checks again. + ((idle_slices == 0)) && hook::json_complete "${input//$'\r'/}" && break ((idle_slices++)) ((idle_slices >= slice_count)) || continue stalled=1 @@ -419,7 +447,10 @@ hook::buffer_stdin() { [[ -n "$input" ]] || return 1 local jq_rc=0 if command -v jq >/dev/null 2>&1; then - jq -e . >/dev/null 2>&1 <<<"$input" || jq_rc=$? + # `printf | jq`, not a here-string — see hook::json_complete: a here-string + # at or above the pipe capacity deadlocks the shell before jq is exec'd, and + # a hook payload routinely exceeds it. + printf '%s' "$input" | jq -e . >/dev/null 2>&1 || jq_rc=$? fi if ((jq_rc != 0 && jq_rc != 127)); then if ((stalled)); then @@ -433,10 +464,16 @@ hook::buffer_stdin() { # Extract a single jq field from a buffered input string. CR-stripped. Returns 1 # when the field is empty or jq fails, so the caller can skip. +# +# Fed through `printf | jq`, never a here-string: bash fills a here-string's pipe +# itself, so a payload at or above the pipe capacity (65536 bytes here) blocks +# before jq is exec'd. Callers pass the WHOLE buffered hook payload, which now +# routinely exceeds that — a bounded stdin read used to reject anything that +# large before it reached this helper. # FIELD=$(hook::jq_field "$INPUT" '.tool_input.file_path') || exit 0 hook::jq_field() { local field - field=$(jq -r "(${2} // empty)"' | gsub("\r";"")' <<<"$1" 2>/dev/null) + field=$(printf '%s' "$1" | jq -r "(${2} // empty)"' | gsub("\r";"")' 2>/dev/null) [[ -n "$field" ]] || return 1 printf '%s' "$field" } diff --git a/plugins/rate-limit-guard/hooks/hook-utils.sh b/plugins/rate-limit-guard/hooks/hook-utils.sh index 14d9ae252..5e9eddf15 100755 --- a/plugins/rate-limit-guard/hooks/hook-utils.sh +++ b/plugins/rate-limit-guard/hooks/hook-utils.sh @@ -301,8 +301,22 @@ hook::read_supports_nchars() { # caller must keep reading rather than guess, and the caller's own fail-open # handling for absent jq is unaffected. hook::json_complete() { + # Structural pre-filter before paying for a jq process: a hook payload is a + # JSON object, so a complete one ends in `}` (possibly with trailing newline + # or CR). Testing the last few characters is O(1) and skips the spawn for + # every mid-payload buffer, which is what keeps this off the hot path of a + # large or slow read. A false negative here costs only the early break — the + # read continues and the caller's final completeness check still decides — so + # the pre-filter can never turn a whole payload into a wrong verdict. + [[ "${1: -4}" == *"}"* ]] || return 1 command -v jq >/dev/null 2>&1 || return 1 - jq -e . >/dev/null 2>&1 <<<"$1" + # `printf | jq`, never `jq <<< "$1"`. A here-string is delivered through a pipe + # that bash fills itself, so a payload at or above the pipe capacity (65536 + # bytes on this platform — exactly one read chunk) blocks the shell forever + # before jq is ever exec'd. Reproduced: a 65536-byte buffer hung here + # indefinitely while 65000 returned immediately. A separate writer process + # cannot deadlock that way. + printf '%s' "$1" | jq -e . >/dev/null 2>&1 } # Resolve the read timeout to a value THIS shell's `read -t` will actually @@ -409,6 +423,20 @@ hook::buffer_stdin() { hook::json_complete "${input//$'\r'/}" && break continue fi + # An EMPTY slice can also be the late-EOF case: the payload may have been + # completed by the PREVIOUS read, which returned rc 0 and so never reached + # the completeness check above. That happens whenever the payload ends on a + # 65536-character boundary, and without this the helper would wait out the + # whole bound instead of a single slice. Checking here rather than on the + # rc-0 path keeps jq off the hot path — a large payload costs one check + # when the producer first pauses, not one per 64 KB chunk. + # + # Only on the FIRST empty slice of a quiet period: the buffer cannot grow + # while nothing is arriving, so re-checking an unchanged buffer would spend + # a jq process per slice to re-derive the same answer — enough overhead on + # a slow-spawning host to cost more than slicing saves. idle_slices resets + # the moment a byte lands, so the next quiet period checks again. + ((idle_slices == 0)) && hook::json_complete "${input//$'\r'/}" && break ((idle_slices++)) ((idle_slices >= slice_count)) || continue stalled=1 @@ -419,7 +447,10 @@ hook::buffer_stdin() { [[ -n "$input" ]] || return 1 local jq_rc=0 if command -v jq >/dev/null 2>&1; then - jq -e . >/dev/null 2>&1 <<<"$input" || jq_rc=$? + # `printf | jq`, not a here-string — see hook::json_complete: a here-string + # at or above the pipe capacity deadlocks the shell before jq is exec'd, and + # a hook payload routinely exceeds it. + printf '%s' "$input" | jq -e . >/dev/null 2>&1 || jq_rc=$? fi if ((jq_rc != 0 && jq_rc != 127)); then if ((stalled)); then @@ -433,10 +464,16 @@ hook::buffer_stdin() { # Extract a single jq field from a buffered input string. CR-stripped. Returns 1 # when the field is empty or jq fails, so the caller can skip. +# +# Fed through `printf | jq`, never a here-string: bash fills a here-string's pipe +# itself, so a payload at or above the pipe capacity (65536 bytes here) blocks +# before jq is exec'd. Callers pass the WHOLE buffered hook payload, which now +# routinely exceeds that — a bounded stdin read used to reject anything that +# large before it reached this helper. # FIELD=$(hook::jq_field "$INPUT" '.tool_input.file_path') || exit 0 hook::jq_field() { local field - field=$(jq -r "(${2} // empty)"' | gsub("\r";"")' <<<"$1" 2>/dev/null) + field=$(printf '%s' "$1" | jq -r "(${2} // empty)"' | gsub("\r";"")' 2>/dev/null) [[ -n "$field" ]] || return 1 printf '%s' "$field" } diff --git a/plugins/ruff-format/hooks/hook-utils.sh b/plugins/ruff-format/hooks/hook-utils.sh index 14d9ae252..5e9eddf15 100644 --- a/plugins/ruff-format/hooks/hook-utils.sh +++ b/plugins/ruff-format/hooks/hook-utils.sh @@ -301,8 +301,22 @@ hook::read_supports_nchars() { # caller must keep reading rather than guess, and the caller's own fail-open # handling for absent jq is unaffected. hook::json_complete() { + # Structural pre-filter before paying for a jq process: a hook payload is a + # JSON object, so a complete one ends in `}` (possibly with trailing newline + # or CR). Testing the last few characters is O(1) and skips the spawn for + # every mid-payload buffer, which is what keeps this off the hot path of a + # large or slow read. A false negative here costs only the early break — the + # read continues and the caller's final completeness check still decides — so + # the pre-filter can never turn a whole payload into a wrong verdict. + [[ "${1: -4}" == *"}"* ]] || return 1 command -v jq >/dev/null 2>&1 || return 1 - jq -e . >/dev/null 2>&1 <<<"$1" + # `printf | jq`, never `jq <<< "$1"`. A here-string is delivered through a pipe + # that bash fills itself, so a payload at or above the pipe capacity (65536 + # bytes on this platform — exactly one read chunk) blocks the shell forever + # before jq is ever exec'd. Reproduced: a 65536-byte buffer hung here + # indefinitely while 65000 returned immediately. A separate writer process + # cannot deadlock that way. + printf '%s' "$1" | jq -e . >/dev/null 2>&1 } # Resolve the read timeout to a value THIS shell's `read -t` will actually @@ -409,6 +423,20 @@ hook::buffer_stdin() { hook::json_complete "${input//$'\r'/}" && break continue fi + # An EMPTY slice can also be the late-EOF case: the payload may have been + # completed by the PREVIOUS read, which returned rc 0 and so never reached + # the completeness check above. That happens whenever the payload ends on a + # 65536-character boundary, and without this the helper would wait out the + # whole bound instead of a single slice. Checking here rather than on the + # rc-0 path keeps jq off the hot path — a large payload costs one check + # when the producer first pauses, not one per 64 KB chunk. + # + # Only on the FIRST empty slice of a quiet period: the buffer cannot grow + # while nothing is arriving, so re-checking an unchanged buffer would spend + # a jq process per slice to re-derive the same answer — enough overhead on + # a slow-spawning host to cost more than slicing saves. idle_slices resets + # the moment a byte lands, so the next quiet period checks again. + ((idle_slices == 0)) && hook::json_complete "${input//$'\r'/}" && break ((idle_slices++)) ((idle_slices >= slice_count)) || continue stalled=1 @@ -419,7 +447,10 @@ hook::buffer_stdin() { [[ -n "$input" ]] || return 1 local jq_rc=0 if command -v jq >/dev/null 2>&1; then - jq -e . >/dev/null 2>&1 <<<"$input" || jq_rc=$? + # `printf | jq`, not a here-string — see hook::json_complete: a here-string + # at or above the pipe capacity deadlocks the shell before jq is exec'd, and + # a hook payload routinely exceeds it. + printf '%s' "$input" | jq -e . >/dev/null 2>&1 || jq_rc=$? fi if ((jq_rc != 0 && jq_rc != 127)); then if ((stalled)); then @@ -433,10 +464,16 @@ hook::buffer_stdin() { # Extract a single jq field from a buffered input string. CR-stripped. Returns 1 # when the field is empty or jq fails, so the caller can skip. +# +# Fed through `printf | jq`, never a here-string: bash fills a here-string's pipe +# itself, so a payload at or above the pipe capacity (65536 bytes here) blocks +# before jq is exec'd. Callers pass the WHOLE buffered hook payload, which now +# routinely exceeds that — a bounded stdin read used to reject anything that +# large before it reached this helper. # FIELD=$(hook::jq_field "$INPUT" '.tool_input.file_path') || exit 0 hook::jq_field() { local field - field=$(jq -r "(${2} // empty)"' | gsub("\r";"")' <<<"$1" 2>/dev/null) + field=$(printf '%s' "$1" | jq -r "(${2} // empty)"' | gsub("\r";"")' 2>/dev/null) [[ -n "$field" ]] || return 1 printf '%s' "$field" } diff --git a/plugins/typos-format/hooks/hook-utils.sh b/plugins/typos-format/hooks/hook-utils.sh index 14d9ae252..5e9eddf15 100644 --- a/plugins/typos-format/hooks/hook-utils.sh +++ b/plugins/typos-format/hooks/hook-utils.sh @@ -301,8 +301,22 @@ hook::read_supports_nchars() { # caller must keep reading rather than guess, and the caller's own fail-open # handling for absent jq is unaffected. hook::json_complete() { + # Structural pre-filter before paying for a jq process: a hook payload is a + # JSON object, so a complete one ends in `}` (possibly with trailing newline + # or CR). Testing the last few characters is O(1) and skips the spawn for + # every mid-payload buffer, which is what keeps this off the hot path of a + # large or slow read. A false negative here costs only the early break — the + # read continues and the caller's final completeness check still decides — so + # the pre-filter can never turn a whole payload into a wrong verdict. + [[ "${1: -4}" == *"}"* ]] || return 1 command -v jq >/dev/null 2>&1 || return 1 - jq -e . >/dev/null 2>&1 <<<"$1" + # `printf | jq`, never `jq <<< "$1"`. A here-string is delivered through a pipe + # that bash fills itself, so a payload at or above the pipe capacity (65536 + # bytes on this platform — exactly one read chunk) blocks the shell forever + # before jq is ever exec'd. Reproduced: a 65536-byte buffer hung here + # indefinitely while 65000 returned immediately. A separate writer process + # cannot deadlock that way. + printf '%s' "$1" | jq -e . >/dev/null 2>&1 } # Resolve the read timeout to a value THIS shell's `read -t` will actually @@ -409,6 +423,20 @@ hook::buffer_stdin() { hook::json_complete "${input//$'\r'/}" && break continue fi + # An EMPTY slice can also be the late-EOF case: the payload may have been + # completed by the PREVIOUS read, which returned rc 0 and so never reached + # the completeness check above. That happens whenever the payload ends on a + # 65536-character boundary, and without this the helper would wait out the + # whole bound instead of a single slice. Checking here rather than on the + # rc-0 path keeps jq off the hot path — a large payload costs one check + # when the producer first pauses, not one per 64 KB chunk. + # + # Only on the FIRST empty slice of a quiet period: the buffer cannot grow + # while nothing is arriving, so re-checking an unchanged buffer would spend + # a jq process per slice to re-derive the same answer — enough overhead on + # a slow-spawning host to cost more than slicing saves. idle_slices resets + # the moment a byte lands, so the next quiet period checks again. + ((idle_slices == 0)) && hook::json_complete "${input//$'\r'/}" && break ((idle_slices++)) ((idle_slices >= slice_count)) || continue stalled=1 @@ -419,7 +447,10 @@ hook::buffer_stdin() { [[ -n "$input" ]] || return 1 local jq_rc=0 if command -v jq >/dev/null 2>&1; then - jq -e . >/dev/null 2>&1 <<<"$input" || jq_rc=$? + # `printf | jq`, not a here-string — see hook::json_complete: a here-string + # at or above the pipe capacity deadlocks the shell before jq is exec'd, and + # a hook payload routinely exceeds it. + printf '%s' "$input" | jq -e . >/dev/null 2>&1 || jq_rc=$? fi if ((jq_rc != 0 && jq_rc != 127)); then if ((stalled)); then @@ -433,10 +464,16 @@ hook::buffer_stdin() { # Extract a single jq field from a buffered input string. CR-stripped. Returns 1 # when the field is empty or jq fails, so the caller can skip. +# +# Fed through `printf | jq`, never a here-string: bash fills a here-string's pipe +# itself, so a payload at or above the pipe capacity (65536 bytes here) blocks +# before jq is exec'd. Callers pass the WHOLE buffered hook payload, which now +# routinely exceeds that — a bounded stdin read used to reject anything that +# large before it reached this helper. # FIELD=$(hook::jq_field "$INPUT" '.tool_input.file_path') || exit 0 hook::jq_field() { local field - field=$(jq -r "(${2} // empty)"' | gsub("\r";"")' <<<"$1" 2>/dev/null) + field=$(printf '%s' "$1" | jq -r "(${2} // empty)"' | gsub("\r";"")' 2>/dev/null) [[ -n "$field" ]] || return 1 printf '%s' "$field" } From 395708b5f5798c203911b2cc66159df3f40f1438 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 26 Jul 2026 17:44:52 -0400 Subject: [PATCH 7/7] docs: qualify the quarter-bound claim for shells without fractional read -t MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seventh review finding on #1587. Slicing the idle bound into quarters needs a fractional `read -t`, and hook::resolve_read_slice already falls back to a single window where the running shell rejects one — Bash 3.2, the macOS system shell, which these hooks explicitly support. The function comment said so; the three plugin READMEs and the two changelog entries that document the option did not, and stated the quarter-bound as unconditional. There, a producer that emits bytes and then goes silent can still take up to two intervals to be declared stalled, so a 2 s bound can take nearly 4 s. Every user-facing surface now names that condition explicitly rather than claiming the tighter figure everywhere: guardrails, actionlint, and claude-ops READMEs, and the actionlint and claude-ops changelog entries. Documentation only — no behavior change. Preserving four-slice behavior on shells without fractional timeouts is not possible with `read -t` alone, which is why this is qualified rather than fixed. Refs #1563 Co-Authored-By: Claude Fable 5 --- plugins/actionlint/CHANGELOG.md | 4 +++- plugins/actionlint/README.md | 7 +++++-- plugins/claude-ops/CHANGELOG.md | 4 +++- plugins/claude-ops/README.md | 7 +++++-- plugins/guardrails/CHANGELOG.md | 5 ++++- plugins/guardrails/README.md | 12 ++++++++---- 6 files changed, 28 insertions(+), 11 deletions(-) diff --git a/plugins/actionlint/CHANGELOG.md b/plugins/actionlint/CHANGELOG.md index 4845cabcd..07c5e3432 100644 --- a/plugins/actionlint/CHANGELOG.md +++ b/plugins/actionlint/CHANGELOG.md @@ -29,7 +29,9 @@ All notable changes to the `actionlint` plugin are documented here. Format follo "reading the hook payload from stdin before failing open" — a total read deadline. It is now an inactivity deadline: any byte resets it, so a producer that keeps emitting is bounded by Claude Code's own hook timeout rather than by this value, and the bound is read in four slices so a stall - is detected within a quarter of the configured interval. Documentation only — the configuration + is detected within a quarter of the configured interval — except on a shell without fractional + `read -t` (Bash 3.2, the macOS system shell), where the bound is read as one window and the + detection can take up to two intervals. Documentation only — the configuration contract users read was materially misleading after the shared-library change above. ## [0.7.3] diff --git a/plugins/actionlint/README.md b/plugins/actionlint/README.md index 8f1f11111..8af4b5d63 100644 --- a/plugins/actionlint/README.md +++ b/plugins/actionlint/README.md @@ -58,8 +58,11 @@ repository when present. Two `userConfig` options tune the hook itself: seconds on reading the hook payload from stdin. Any byte arriving resets it, so a large or slowly-delivered payload is never cut off while it is still coming; it fires only once the pipe has gone silent for that long, and this hook then - fails open (skips). The bound is read in four slices, so the stall is detected - within a quarter of the configured interval of it. A producer that keeps + fails open (skips). On a shell whose `read -t` accepts fractional values the + bound is read in four slices, so the stall is detected within a quarter of the + configured interval of it; where fractional timeouts are unavailable (Bash 3.2, + the macOS system shell) it is read as one window and a producer that sends + bytes then goes silent can take up to two intervals. A producer that keeps emitting is bounded by Claude Code's own hook timeout, not by this value. A setting this shell's `read -t` will not accept — or `0` — falls back to the default. diff --git a/plugins/claude-ops/CHANGELOG.md b/plugins/claude-ops/CHANGELOG.md index b70608a14..20f18feae 100644 --- a/plugins/claude-ops/CHANGELOG.md +++ b/plugins/claude-ops/CHANGELOG.md @@ -35,7 +35,9 @@ All notable changes to the `claude-ops` plugin are documented here. Format follo hook waits for its payload before failing open" — a total read deadline. It is now an inactivity deadline: any byte resets it, so a producer that keeps emitting is bounded by Claude Code's own hook timeout rather than by this value, and the bound is read in four slices so a stall is detected - within a quarter of the configured interval. Documentation only — the configuration contract users + within a quarter of the configured interval — except on a shell without fractional `read -t` + (Bash 3.2, the macOS system shell), where the bound is read as one window and the detection can + take up to two intervals. Documentation only — the configuration contract users read was materially misleading after the shared-library change above. ## [0.21.5] diff --git a/plugins/claude-ops/README.md b/plugins/claude-ops/README.md index 0566ef3fe..c7ecdf6fc 100644 --- a/plugins/claude-ops/README.md +++ b/plugins/claude-ops/README.md @@ -83,8 +83,11 @@ back into logging them. A `stdin_read_timeout` option (seconds, default `2`) is an **idle** bound on reading each hook's payload: any byte arriving resets it, so a large or slowly-delivered payload is never cut off while it is still coming, and it fires only once the pipe has gone silent for that long — at which point -these audit hooks fail open (skip). The bound is read in four slices, so the -stall is detected within a quarter of the configured interval of it. A producer +these audit hooks fail open (skip). On a shell whose `read -t` accepts fractional +values the bound is read in four slices, so the stall is detected within a +quarter of the configured interval of it; where fractional timeouts are +unavailable (Bash 3.2, the macOS system shell) it is read as one window and a +producer that sends bytes then goes silent can take up to two intervals. A producer that keeps emitting is bounded by Claude Code's own hook timeout, not by this value. A setting this shell's `read -t` will not accept — or `0` — falls back to the default. diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index b3f492e1b..0eb755130 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -25,7 +25,10 @@ All notable changes to the `guardrails` plugin are documented here. Format follo is read in four slices, because `read -t` reports only that its window expired and never when inside it the last byte arrived — armed as one window, a stall would be declared anywhere between one and *two* bounds after the pipe went quiet. Slicing caps that overshoot at a quarter-bound; - that residual quarter is the limit of the approximation and always errs toward waiting. Reading on + that residual quarter is the limit of the approximation and always errs toward waiting. Slicing + needs fractional `read -t`, so on a shell without it (Bash 3.2, the macOS system shell) the bound + is read as one window and the one-to-two-bound overshoot remains — documented as such rather than + claimed away. Reading on stops once the buffer already parses as whole JSON, so the Win32 late-EOF case (payload complete, pipe simply never closed) settles at the payload rather than at the bound. Measured: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms to ~85 ms. diff --git a/plugins/guardrails/README.md b/plugins/guardrails/README.md index 1c96e8a1a..e0ab6beb4 100644 --- a/plugins/guardrails/README.md +++ b/plugins/guardrails/README.md @@ -135,10 +135,14 @@ One further option tunes the hooks' shared plumbing rather than a single guard: so a large or slowly-delivered payload is never cut off while it is still coming; it fires only once the pipe has gone silent for that long, at which point a blocking guard fails **closed** (`exit 2` with a `BLOCKED:` reason) - rather than letting an unscanned tool call through. The bound is read in four - slices, so a stall is declared within a quarter of the configured interval of - it — that quarter is the limit of the approximation, and it errs toward - waiting rather than toward calling a live producer dead. A value this shell's + rather than letting an unscanned tool call through. On a shell whose `read -t` + accepts fractional values the bound is read in four slices, so a stall is + declared within a quarter of the configured interval of it — that quarter is + the limit of the approximation, and it errs toward waiting rather than toward + calling a live producer dead. Where fractional timeouts are unavailable (Bash + 3.2, the macOS system shell) the bound is read as one window instead, and a + producer that sends bytes and then goes silent can take up to **two** intervals + to be declared stalled. A value this shell's `read -t` will not accept — or `0`, which would make the read consume nothing — falls back to the default rather than disabling the guards. You should not need to change it.