Skip to content

SecurityValidator.hook.ts hangs when stdin doesn't close properly #452

@cleong14

Description

@cleong14

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions