Skip to content

check-inbox.sh hangs forever when the hook runtime hands it a non-TTY stdin that never reaches EOF (Windows; hung cat + frozen agent pane) #381

Description

@togaiaoi

Summary

check-inbox.sh reads its hook payload with an unbounded cat. When the host hands it a stdin that is not a TTY but whose write end is never closed, that cat waits for an EOF that never comes and the hook never exits.

Stop/turn hooks are synchronous — the agent cannot finish its turn until the hook process exits. So a hook that never returns does not just miss a message: it freezes the agent's pane, spinner stuck, keyboard dead, until the user kills it.

The existing [ ! -t 0 ] guard (from #64, for Gemini's PostToolUse inheriting a terminal stdin) only covers the TTY case. A non-TTY pipe that stays open slips straight past it.

https://github.com/fujibee/agmsg/blob/main/scripts/check-inbox.sh#L31-L37

INPUT=""
if [ ! -t 0 ]; then
  INPUT=$(cat 2>/dev/null || true)   # <-- waits for EOF forever
fi

Evidence (from a real session)

On Windows I found 11 cat.exe and 40 bash.exe processes alive for over 6 hours, all of them check-inbox.sh invocations. Each cat's start time matched its check-inbox.sh parent's start time exactly — they had been blocked on stdin since the moment they were spawned.

The agent panes those hooks belonged to were frozen: TUI stopped repainting, keystrokes ignored, ~0% CPU.

This also compounds a known upstream failure mode. In openai/codex#20862 a hook exited cleanly but a surviving descendant kept the inherited stdout handle open, so Codex waited for an EOF that never arrived. Hung cat/bash descendants of a hook are exactly that shape.

Reproduction

A sleep N | check-inbox.sh pipeline does not work as a repro — bash waits for every pipeline member, so it looks like a hang even when the hook exits immediately. Use a FIFO so only the hook is timed:

fifo=$(mktemp -u); mkfifo "$fifo"
# write the hook JSON, then hold the write end open without closing it
{ printf '%s' '{"stop_hook_active":false,"session_id":"repro"}'; sleep 300; } > "$fifo" &

time timeout 60 bash scripts/check-inbox.sh cursor /path/to/project < "$fifo"
# -> never returns; killed at the 60s cap. `cat` is left running.

Observed: hangs indefinitely.
Expected: reads the payload, delivers (or skips), exits.

Suggested fix

Two layers — bound the read, then bound the whole hook as a backstop. Both fail open: skipping one delivery is recoverable, freezing the user's pane is not.

1. Bound the stdin read

INPUT=""
if [ ! -t 0 ]; then
  if command -v timeout >/dev/null 2>&1; then
    INPUT=$(timeout "${AGMSG_STDIN_TIMEOUT:-2}" cat 2>/dev/null || true)
  else
    INPUT=$(cat 2>/dev/null || true)
  fi
fi

The payload is already in the command substitution by the time timeout fires, so a runtime that writes the JSON and then simply forgets to close the pipe still delivers correctly — it just costs 2s instead of hanging forever.

2. Hard deadline on the hook itself, so any other unbounded wait (a wedged sqlite3, a lock held by a dead writer) can't wedge a turn either. Re-exec under timeout near the top of the script:

if [ -z "${AGMSG_HOOK_GUARDED:-}" ] && command -v timeout >/dev/null 2>&1; then
  export AGMSG_HOOK_GUARDED=1
  rc=0
  timeout --kill-after=5 "${AGMSG_HOOK_DEADLINE:-30}" "$0" "$@" || rc=$?
  if [ "$rc" -eq 124 ] || [ "$rc" -eq 137 ]; then
    echo "check-inbox: exceeded ${AGMSG_HOOK_DEADLINE:-30}s deadline; skipping delivery this turn" >&2
    exit 0
  fi
  exit "$rc"
fi

With both applied, the FIFO repro above exits in ~9s instead of hanging, and no cat is left behind.

The same unbounded-stdin shape exists in any custom Node/Python Stop-hook driver that does for await (const chunk of process.stdin) / sys.stdin.read(), so it may be worth calling out in the hook-authoring docs as well.

Which runtimes hit this

I hit it with check-inbox.sh invoked from Cursor (via the .cursor/rules/*.mdc "run check-inbox after tool calls" integration, so it inherits the agent's shell command runner's stdin rather than a hooks-contract pipe). Any runtime that hands the hook an open, non-TTY stdin will do it. I could not find an existing issue for the non-TTY case — #64 covers only the TTY one.

Environment

  • Windows 11, Git Bash (C:\Program Files\Git), bash 5.x
  • codex-cli 0.144.1, Cursor, Claude Code
  • WezTerm 20240203-110809-5046fc22 (ruled out — it faithfully retained the frozen pane's contents; the agent process had stopped writing)

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