v2.1.30 — Stop-Hook stdin-Block Hardening (#139)
bkit v2.1.30 — Stop-Hook stdin-Block Hardening
Resolves #139 (@thenopen, surfaced via Claude Code's /doctor health-check). A real reliability fix: the Stop event hook occasionally stalled up to ~15.5 minutes — far past its own 10 s timeout — blocking the end of a turn.
🎯 Highlights
- The Stop hook can no longer stall on stdin. Worst case drops from ~15.5 min → a bounded ~2 s (normal case ~1 ms).
- Central fix protects all 36 bkit hook scripts, not just Stop — every hook that reads stdin is now resilient to a slow / held-open stdin close.
- Zero new regressions vs the previous release; the change is internal to existing modules (architecture counts unchanged: 44 Skills · 34 Agents · 22 Hook Events / 25 blocks · 195 Lib Modules).
🔬 Root cause (reproduced, not inferred)
Every bkit hook reads its payload through lib/core/io.js readStdinSync(), which used fs.readFileSync(0, 'utf8') — a blocking read on stdin with no timeout that returns only when stdin reaches EOF, i.e. when Claude Code closes the hook's stdin write-end. If CC keeps that write-end open, the hook blocks for exactly that long.
Reproduced 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 — the process is blocked on I/O, not burning CPU, matching the reporter's aggregate profile (healthy ~0.8 s average, extreme tail, 14 timeout-cancellations across ~50 sessions / 5 days).
A/B against the pre-fix code on a 6 s held-open pipe: old fs.readFileSync(0) blocked 5,893 ms vs new 8 ms.
🛠️ What changed
| Area | Change |
|---|---|
lib/core/io.js — readStdinSync() |
Reads fd 0 incrementally with fs.readSync and returns the instant the buffer holds a complete JSON value — never waits for EOF. Raw fd → the process still exits promptly. Return contract unchanged (empty / malformed → {} unless BKIT_STRICT_STDIN=1). |
lib/core/io.js — readStdinBounded() (new) |
Async parse-early reader with a hard timeout that destroy()s stdin on resolve, so the turn-gating Stop hook is fully bounded even for no-data / truncated payloads on a held-open pipe. |
scripts/unified-stop.js |
Reads via readStdinBounded inside an async IIFE. |
lib/core/state-store.js — lock() |
CPU-burning busy-wait spin → Atomics.wait sleepSync() (no CPU burn) — addresses the issue's lock-wait note. |
lib/core/constants.js |
New STDIN_READ_TIMEOUT_MS (default 2000 ms; env override BKIT_STDIN_TIMEOUT_MS). |
✨ User experience changes
- Turns end when they should. No more multi-minute hangs at the end of a turn caused by a stalled Stop hook — the single most visible symptom the reporter hit via
/doctor. - Resilience is now global. Because the fix is in the shared stdin reader, every bkit hook event (PreToolUse, PostToolUse, Stop, SessionEnd, …) is protected — not only the one that was reported.
- No action required. Behavior is unchanged for normal payloads and there is nothing to configure. If you ever need to tune the bound, set
BKIT_STDIN_TIMEOUT_MS. - Lower background CPU under lock contention. The lock backoff no longer pins a CPU core while waiting.
✅ Verification
- New 16-TC regression test
test/regression/issue-139-stdin-bounded.test.js(stable across 5 runs). - End-to-end payload-consumption proof: the fixed hook, run against a held-open pipe, recorded the exact token values to the ledger (
parseStatus: ok). - Full
run-all.jssuite compared against a cleanmainworktree: 0 new failures (the fix even repaired a pre-existing version-consistency gap inbkit-system/docs). - All CI gates green (contract L1/L4, L5 Invocation Inventory).
- Live
claude -p --plugin-dir .on Claude Code v2.1.208.
🙏 Credits
Thank you @thenopen for the precise, /doctor-sourced report with aggregate timing evidence that scoped the root cause exactly.
🤖 This release was investigated, implemented, QA'd, and shipped with Claude Code — session: https://claude.ai/code/session_01WCr8qz6Acx4uFLXmbcikRJ