Skip to content

EAGAIN crash in hook scripts: readFileSync(0) fails when stdin is non-blocking #120

Open

Description

@Anfed-2030

Description

Both stop-review-gate-hook.mjs and session-lifecycle-hook.mjs crash intermittently with:

Error: EAGAIN: resource temporarily unavailable, read
    at Object.readFileSync (node:fs:449:20)
    at readHookInput (file:///.claude/plugins/marketplaces/openai-codex/plugins/codex/scripts/stop-review-gate-hook.mjs:22:18)

Root Cause

readHookInput() uses fs.readFileSync(0, "utf8") to read stdin. When Claude Code invokes hooks with stdin as a pipe in non-blocking mode, the kernel returns EAGAIN instead of blocking. Node.js synchronous APIs don't retry — they throw.

// Both scripts, line 22-23
function readHookInput() {
  const raw = fs.readFileSync(0, "utf8").trim();  // throws EAGAIN
  if (!raw) { return {}; }
  return JSON.parse(raw);
}

Reproduction

Happens intermittently across Claude Code sessions on macOS (Darwin 25.3.0, Node.js v20.19.6). More frequent when multiple hooks fire in quick succession.

Suggested Fix

Catch EAGAIN and treat as empty input:

function readHookInput() {
  try {
    const raw = fs.readFileSync(0, "utf8").trim();
    if (!raw) return {};
    return JSON.parse(raw);
  } catch (e) {
    if (e.code === 'EAGAIN') return {};
    throw e;
  }
}

Both stop-review-gate-hook.mjs and session-lifecycle-hook.mjs need the same fix.

Environment

  • macOS Darwin 25.3.0
  • Node.js v20.19.6
  • Plugin version: 1.0.2 (commit 8e403f9)
  • Claude Code v2.1.90

Activity

  1. tmchow commented on Apr 3, 2026

    @tmchow
    Contributor

    Implemented in #123 - adds a bounded retry loop around readFileSync(0) with Atomics.wait for the delay, falls back to empty object after 50 attempts.

  2. added a commit that references this issue on Apr 3, 2026
    e22ed20
  3. added 2 commits that reference this issue on Apr 5, 2026
    f407760
    2d542f8
  4. D2758695161 commented on Apr 5, 2026

    @D2758695161

    👋 Implemented fix in PR #150 — wrapping readHookInput() in try/catch to handle EAGAIN errors from non-blocking stdin.

    — 一筒 🦀

  5. tmchow commented on Apr 5, 2026

    @tmchow
    Contributor

    👋 Implemented fix in PR #150 — wrapping readHookInput() in try/catch to handle EAGAIN errors from non-blocking stdin.

    — 一筒 🦀

    Why did you submit a PR after I already submitted #123 2 days ago? Is this completely AI generated?

  6. added a commit that references this issue on Apr 7, 2026
    7927327
  7. sjarmak commented on Jul 26, 2026

    @sjarmak

    Still present on HEAD: plugins/codex/scripts/stop-review-gate-hook.mjs:22 and session-lifecycle-hook.mjs:24 both carry the unguarded read, so v1.0.6 does not fix this.

    Since the report describes it as intermittent, here is a deterministic reproduction. Set the read end of the child's stdin pipe non-blocking and write the payload after the hook has started:

    import os, subprocess, time
    
    CDX = "<...>/plugins/cache/openai-codex/codex/1.0.2"
    r, w = os.pipe()
    os.set_blocking(r, False)
    p = subprocess.Popen(["node", f"{CDX}/scripts/stop-review-gate-hook.mjs"],
                         stdin=r, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                         env={**os.environ, "CLAUDE_PLUGIN_ROOT": CDX})
    os.close(r)
    time.sleep(0.4)
    os.write(w, b'{"session_id":"x","hook_event_name":"Stop"}')
    os.close(w)
    out, err = p.communicate(timeout=60)
    print(p.returncode, err.decode())

    Fails every run on Node v22.22.2 / Linux with the stack in the report. Drop the os.set_blocking line and it passes every run.

    Two things I hit that are not in the report yet.

    main() calls readHookInput() at line 143, before it reads config.stopReviewGate at line 154. Anyone who has turned the gate off still takes the crash, from the hook they disabled.

    Returning {} on EAGAIN trades the crash for a silent behavior change. input feeds both the cwd fallback and filterJobsForCurrentSession, which falls back to CODEX_COMPANION_SESSION_ID and, when that is unset, stops filtering by session at all. So an empty object means the hook resolves the workspace from process.cwd() and considers every tracked job to belong to this session. EAGAIN means the pipe has no data ready yet, not that stdin is empty, and the payload usually arrives microseconds later.

    A bounded retry keeps the payload:

    function readHookInput() {
      const READ_DEADLINE_MS = 2000;
      const chunks = [];
      const buffer = Buffer.alloc(65536);
      const idle = new Int32Array(new SharedArrayBuffer(4));
      const deadline = Date.now() + READ_DEADLINE_MS;
      for (;;) {
        let bytesRead;
        try {
          bytesRead = fs.readSync(0, buffer, 0, buffer.length, null);
        } catch (err) {
          if (err.code === "EAGAIN" && Date.now() < deadline) {
            Atomics.wait(idle, 0, 0, 10);
            continue;
          }
          if (err.code === "EAGAIN" || err.code === "EOF") break;
          throw err;
        }
        if (bytesRead === 0) break;
        chunks.push(Buffer.from(buffer.subarray(0, bytesRead)));
      }
      const raw = Buffer.concat(chunks).toString("utf8").trim();
      if (!raw) return {};
      return JSON.parse(raw);
    }

    Running that on both scripts locally: the repro exits 0, a malformed body still raises (so the payload is read rather than dropped to {}), and the ordinary blocking-stdin path is unchanged. A stdin that never delivers exits at the 2s deadline instead of blocking, which may be relevant to #191 and #530, though I have not tested on Windows.

  8. medlars commented on Aug 8, 2026

    @medlars

    Still present at origin/main today, and there is a third call site the issue body doesn't list:

    plugins/codex/scripts/lib/fs.mjs:39          readStdinIfPiped()  -> used by codex-companion.mjs
    plugins/codex/scripts/session-lifecycle-hook.mjs:24
    plugins/codex/scripts/stop-review-gate-hook.mjs:22
    

    Deterministic reproduction

    The intermittency is just a race — the condition is simply an O_NONBLOCK stdin pipe with no data ready yet. This reproduces it 100% of the time:

    import os, subprocess, time
    r, w = os.pipe()
    os.set_blocking(r, False)          # the condition Claude Code creates
    p = subprocess.Popen(["node", "stop-review-gate-hook.mjs"], stdin=r,
                         stderr=subprocess.PIPE, text=True)
    os.close(r)
    time.sleep(0.3)                    # writer is late -> EAGAIN window
    os.write(w, b'{"session_id":"x","hook_event_name":"Stop"}'); os.close(w)
    print(p.communicate()[1])          # -> EAGAIN: resource temporarily unavailable

    The suggested fix trades a loud failure for a silent one

    Returning {} on EAGAIN stops the crash, but EAGAIN here means "data isn't ready yet", not "there is no data" — so the hook discards the payload that was about to arrive and proceeds as if invoked with empty input. Measured against the snippet in the issue body, with a 3-key payload written 300ms late:

    suggested-fix exit: 0
    stdout: PARSED_KEYS=[]        <- 3 keys sent, 0 keys seen
    

    Exit 0, no error, hook silently blind to session_id / hook_event_name. For the stop-review gate that means session filtering runs against {}, which is harder to debug than the crash it replaced.

    Retrying is what EAGAIN actually asks for

    export function readFdToEnd(fd = 0, { timeoutMs = 5000 } = {}) {
      const chunks = [];
      const buf = Buffer.alloc(65536);
      const deadline = Date.now() + timeoutMs;
      for (;;) {
        let bytes;
        try {
          bytes = fs.readSync(fd, buf, 0, buf.length, null);
        } catch (err) {
          if (err.code === "EAGAIN" || err.code === "EWOULDBLOCK") {
            if (Date.now() >= deadline) break;
            Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
            continue;
          }
          if (err.code === "EPIPE" || err.code === "EBADF") break;
          throw err;
        }
        if (bytes === 0) break;
        chunks.push(Buffer.from(buf.subarray(0, bytes)));
      }
      return Buffer.concat(chunks).toString("utf8");
    }

    The deadline bounds a stuck writer so the hook can't hang (relevant to #191 / #530), and all three call sites can share it via lib/fs.mjs.

    Verified locally against the harness above — control (unpatched) exits 1 with EAGAIN at node:fs:539; patched exits 0 with the full payload intact at writer delays of 0 / 50 / 300 ms.

    Environment: macOS Darwin 25.6.0, Node v26.6.0, plugin 1.0.1.

  9. CBEPX commented on Sep 28, 2026

    @CBEPX

    Fixed in CBEPX/codex-plugin-cc v1.4.0 (https://github.com/CBEPX/codex-plugin-cc/releases/tag/v1.4.0): the Stop, SessionStart and SessionEnd hooks now read stdin through a new bounded reader with a per-hook deadline (2 s / 5 s / 1 s respectively) and a 1 MiB cap, instead of a blocking readFileSync(0), so a non-blocking stdin file descriptor can no longer crash a hook with EAGAIN; upstream PR #150 used as reference, thanks @D2758695161.

    claude plugin marketplace add CBEPX/codex-plugin-cc && claude plugin install codex@cbepx
    

    This is a fork release, not an upstream fix, so I'm leaving this issue open for the maintainers here.

  10. shuangwangnyc commented on Oct 3, 2026

    @shuangwangnyc

    Also seeing this on macOS with plugin 1.0.6 (installed via Claude Code): the Stop hook intermittently fails with EAGAIN: resource temporarily unavailable, read, exit 1, about 200 ms in — with stopReviewGate disabled, so it is the read at the top of main() failing before the gate is even checked (as noted above). An empty non-blocking pipe with the writer still open reproduces it every time; EOF or complete JSON on stdin runs cleanly.

    Two things we learned while looking for a local workaround, in case they help the fix in #123 / #150:

    • A short bounded retry is not enough on its own. A guard that retried for ~150 ms lost delayed partial input: four 1,000-byte chunks written 120 ms apart arrived as a 2-byte read plus an "exhausted" result. Whatever the fix, it should keep collecting chunks until EOF (or a deadline), not stop at the first successful partial read.
    • A global NODE_OPTIONS=--require=… preload is not a safe user-side workaround: if the preload file is ever missing, every Node process that inherits it exits 1 with MODULE_NOT_FOUND.

    Reading stdin asynchronously until EOF with an explicit deadline, and making "no input available" an explicit outcome (rather than silently {}), would cover both.

  11. nicoache1 commented on Oct 9, 2026

    @nicoache1

    Still present in v1.0.6 and on main (stop-review-gate-hook.mjs:22 and session-lifecycle-hook.mjs:24). We hit it once in a long Claude Code session on macOS (Apple silicon) with six Stop hooks per turn:

    Stop hook error: Failed with non-blocking status code: EAGAIN: resource temporarily unavailable, read
    

    stopReviewGate is false in every project, but the hook reads stdin before it checks the setting, so every user of the plugin runs this read on every turn.

    Reproduced in isolation: a non-blocking stdin pipe whose writer sends the JSON after a short delay makes fs.readFileSync(0) throw EAGAIN; reading process.stdin with for await succeeds. Empty input still gives {}, malformed JSON still exits 1 (with try { await main(); } at the top level of the .mjs). Both readers depend on EOF; the hook's 900 s timeout bounds that.

    We run that patch locally on both hooks. Any of the open PRs that read stdin asynchronously (#123, #132, #150, #165, #189) would fix it; a maintainer merging one of them would close this and the duplicates. lib/fs.mjs:39 has the same pattern (#247).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions