Skip to content

v2.1.30 — Stop-Hook stdin-Block Hardening (#139)

Choose a tag to compare

@agent-kay-it agent-kay-it released this 14 Jul 05:07
· 68 commits to main since this release
1423f9d

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.jsreadStdinSync() 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.jsreadStdinBounded() (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.jslock() 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.js suite compared against a clean main worktree: 0 new failures (the fix even repaired a pre-existing version-consistency gap in bkit-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