Description
The SecurityValidator.hook.ts PreToolUse hook can hang indefinitely when Claude Code doesn't properly close stdin before invoking the hook.
Root Cause
The hook uses Promise.race to implement a timeout (around line 586 in main()):
const text = await Promise.race([
Bun.stdin.text(),
new Promise<string>((_, reject) =>
setTimeout(() => reject(new Error('timeout')), 100)
)
]);
The problem: Promise.race timeout rejection does NOT cancel the underlying Bun.stdin.text() read. When stdin never receives an EOF (because Claude Code occasionally fails to close stdin properly), the process hangs forever - the timeout promise rejects but Bun.stdin.text() never resolves, keeping the process alive.
This is a fundamental limitation of JavaScript Promises - rejecting a racing promise doesn't abort the other promise's underlying I/O operation.
Symptoms
- Claude Code shows:
running pre-tool use hooks 0 out of 2 done
- Hook never completes
- User must kill the process manually (Ctrl+C or kill)
- Occurs randomly on various Bash/Edit/Write/Read commands
Reproduction
This is intermittent and depends on Claude Code's internal stdin handling. More likely to occur under load or with rapid tool invocations.
To simulate the blocking condition:
mkfifo /tmp/test-fifo
# Open write end without writing (simulates stdin that never closes)
(sleep 30) > /tmp/test-fifo &
# This will hang forever (the bug)
bun run hooks/SecurityValidator.hook.ts < /tmp/test-fifo
Workaround
Wrap the hook with a bash script that uses GNU timeout --signal=KILL to hard-kill processes that block on stdin. SIGTERM doesn't interrupt blocked I/O on macOS FIFOs, but SIGKILL does.
SecurityValidator-wrapper.sh:
#!/bin/bash
TIMEOUT=2
HOOK_SCRIPT="${PAI_DIR:-$HOME/.claude}/hooks/SecurityValidator.hook.ts"
# Find GNU timeout
TIMEOUT_CMD=$(command -v gtimeout || echo timeout)
# Capture stdin with hard kill timeout
STDIN_CONTENT=$("$TIMEOUT_CMD" --signal=KILL "$TIMEOUT" cat /dev/stdin 2>/dev/null) || {
echo '{"continue": true}'
exit 0
}
[[ -z "$STDIN_CONTENT" ]] && { echo '{"continue": true}'; exit 0; }
# Run hook with captured content
"$TIMEOUT_CMD" --signal=KILL "$TIMEOUT" bun run "$HOOK_SCRIPT" <<< "$STDIN_CONTENT" || {
echo '{"continue": true}'
exit 0
}
Then in settings.json, change:
"command": "bun run ${PAI_DIR}/hooks/SecurityValidator.hook.ts"
to:
"command": "bash ${PAI_DIR}/hooks/SecurityValidator-wrapper.sh"
Proposed Fix
Option A (minimal): Document the workaround and add the wrapper script.
Option B (recommended): Add a helper function that spawns stdin read in a killable subprocess:
async function readStdinWithTimeout(timeoutMs: number): Promise<string | null> {
const proc = Bun.spawn(['cat'], {
stdin: 'inherit',
stdout: 'pipe',
});
let killed = false;
const timer = setTimeout(() => {
killed = true;
proc.kill();
}, timeoutMs);
try {
const text = await new Response(proc.stdout).text();
clearTimeout(timer);
return killed ? null : text;
} catch {
clearTimeout(timer);
return null;
}
}
Environment
- macOS (Darwin)
- Bun 1.x
- Claude Code CLI
Impact
This blocks tool execution requiring manual intervention, severely impacting usability.
Description
The
SecurityValidator.hook.tsPreToolUse hook can hang indefinitely when Claude Code doesn't properly close stdin before invoking the hook.Root Cause
The hook uses
Promise.raceto implement a timeout (around line 586 in main()):The problem:
Promise.racetimeout rejection does NOT cancel the underlyingBun.stdin.text()read. When stdin never receives an EOF (because Claude Code occasionally fails to close stdin properly), the process hangs forever - the timeout promise rejects butBun.stdin.text()never resolves, keeping the process alive.This is a fundamental limitation of JavaScript Promises - rejecting a racing promise doesn't abort the other promise's underlying I/O operation.
Symptoms
running pre-tool use hooks 0 out of 2 doneReproduction
This is intermittent and depends on Claude Code's internal stdin handling. More likely to occur under load or with rapid tool invocations.
To simulate the blocking condition:
Workaround
Wrap the hook with a bash script that uses GNU
timeout --signal=KILLto hard-kill processes that block on stdin. SIGTERM doesn't interrupt blocked I/O on macOS FIFOs, but SIGKILL does.SecurityValidator-wrapper.sh:
Then in
settings.json, change:to:
Proposed Fix
Option A (minimal): Document the workaround and add the wrapper script.
Option B (recommended): Add a helper function that spawns stdin read in a killable subprocess:
Environment
Impact
This blocks tool execution requiring manual intervention, severely impacting usability.