Skip to content

fix(hooks): bound stdin read so Stop hook can't stall (#139, v2.1.30)#140

Merged
agent-kay-it merged 2 commits into
mainfrom
feat/v2.1.30-issue-139
Jul 14, 2026
Merged

fix(hooks): bound stdin read so Stop hook can't stall (#139, v2.1.30)#140
agent-kay-it merged 2 commits into
mainfrom
feat/v2.1.30-issue-139

Conversation

@agent-kay-it

Copy link
Copy Markdown
Contributor

Summary

Resolves #139 (@thenopen, surfaced via Claude Code /doctor). The Stop event hook
(scripts/unified-stop.js, timeout: 10000) occasionally stalled up to ~15.5 min
far past its own 10 s timeout — blocking turn completion (14 timeout-cancellations across
~50 sessions / 5 days; ~0.8 s average but a 928,551 ms max).

Root cause (reproduced, not inferred)

Every bkit hook reads its payload through lib/core/io.js readStdinSync(), which used
fs.readFileSync(0) — a blocking stdin read with no timeout that returns only at EOF.
When Claude Code keeps the hook's stdin write-end open, the hook blocks for exactly that long.

Reproduction against the real hook: stdin closed immediately → 0.19 s; writer holding the
stdin pipe open 4 s → 4.07 s, with user CPU flat at 0.19 s (I/O-blocked, not CPU-bound —
matching the reporter's low-CPU tail).

readStdinSync() is shared by 36 hook scripts, so the fix is central and protects every
hook event, not just Stop.

Changes

File Change
lib/core/io.js readStdinSync() → incremental fs.readSync + parse-early (returns before EOF); new async readStdinBounded() (parse-early + hard timeout + stdin.destroy())
scripts/unified-stop.js reads via readStdinBounded inside an async IIFE (fully bounds the turn-gating Stop hook)
lib/core/state-store.js lock() busy-wait spin → Atomics.wait sleepSync() (no CPU burn)
lib/core/constants.js new STDIN_READ_TIMEOUT_MS (2000 ms, env BKIT_STDIN_TIMEOUT_MS)
lib/core/index.js re-export readStdinBounded
test/regression/issue-139-stdin-bounded.test.js new 16-TC regression guard

Reproduced case: 15.5 min → ~1 ms (parse) / ~374 ms (full Stop hook). No-data/truncated
held-open pipe now hard-capped at ~2 s.

Verification

  • New regression test 16/16 (stable across 5 runs).
  • All CI gates green: contract L1/L4 222+243, l2-smoke 105, l2-hook-attribution 13, l3-mcp
    92+48, integration-runtime 23, invocation-inventory 213, hooks-22 25, bkit-full-system 36,
    deadcode 0-new, validate-plugin 0-err.
  • 0 new regressions vs main (13 qa-aggregate failing files identical to baseline; the 3
    state-store-dependent unit failures reproduce identically on clean main).
  • Live claude -p --plugin-dir . on CC v2.1.208PONG, Stop hook fires without stalling.
  • Architecture counts invariant (44 Skills · 34 Agents · 22 Hook Events / 25 blocks · 195 Lib).

Out of scope (justified)

scripts/lint-skill-md.js's fs.readFileSync(0) reads a markdown file via stdin redirect (a
CI/dev tool, not a runtime hook, not a held-open pipe) — not vulnerable, left unchanged.

Version 2.1.29 → 2.1.30. Docs = Code synced (bilingual PDCA docs + CHANGELOG).

🤖 Generated with Claude Code

The Stop hook (scripts/unified-stop.js) occasionally stalled up to ~15.5 min —
far past its own 10s timeout — blocking turn completion. Root cause, reproduced
end-to-end: every bkit hook reads its payload via lib/core/io.js readStdinSync(),
which used fs.readFileSync(0) — a blocking stdin read with NO timeout that returns
only at EOF. When Claude Code keeps the hook's stdin write-end open, the hook
blocks for exactly that long (payload sent + pipe held open 4s → 4.07s block,
flat CPU = I/O-blocked, matching the reporter's low-CPU tail).

The defect lives in the shared readStdinSync() used by 36 hook scripts, so the
fix is central and protects every hook event, not just Stop.

- io.js readStdinSync(): incremental fs.readSync + parse-early — returns the
  instant the buffer holds a complete JSON value, never waiting for EOF. Raw fd,
  so the process still exits promptly. Return contract preserved (empty/malformed
  → {} unless BKIT_STRICT_STDIN). Reproduced case: 15.5 min → ~1 ms.
- io.js readStdinBounded(): new async parse-early + hard-timeout reader that
  destroys process.stdin on resolve (without that, the open stdin handle keeps the
  event loop alive until EOF and the process lingers — the stall returns via exit).
  unified-stop.js awaits it in an async IIFE, so the turn-gating Stop hook is fully
  bounded even for no-data/truncated held-open pipes.
- state-store.js lock(): CPU-burning busy-wait spin → Atomics.wait sleepSync()
  (no CPU burn), addressing the issue's lock-wait note.
- constants.js: new STDIN_READ_TIMEOUT_MS (2000ms, env BKIT_STDIN_TIMEOUT_MS).

New regression test test/regression/issue-139-stdin-bounded.test.js (16 TC).
All CI gates green; 0 new regressions vs main; live claude -p --plugin-dir . on
CC v2.1.208 OK. Architecture counts invariant. Version 2.1.29 → 2.1.30.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCr8qz6Acx4uFLXmbcikRJ
@agent-kay-it
agent-kay-it force-pushed the feat/v2.1.30-issue-139 branch from 43b9bd8 to 8136e6d Compare July 14, 2026 04:59
Bundles pre-existing local workspace artifacts into the v2.1.30 release at the
maintainer's request:
- docs/04-report/features/bkit-v200-test.report.md
- docs/04-report/features/cc-v2196-v2198-impact-analysis.report.md
- docs/04-report/features/cc-v2199-v2204-impact-analysis.report.md
- docs/04-report/features/cc-v2205-v2207-impact-analysis.report.md
  (outputs of the /bkit:cc-version-analysis workflow — single-file KO reports
  per that workflow's convention; not part of the #139 fix)
- docs/github-stats-bkit-claude-code.md (github-stats skill ledger refresh)

Docs-only; no code, hook, or version-manifest change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCr8qz6Acx4uFLXmbcikRJ
@agent-kay-it
agent-kay-it merged commit 1423f9d into main Jul 14, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Stop hook (unified-stop.js) occasionally stalls up to ~15 minutes, exceeding its own 10s timeout

1 participant