You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
EAGAIN crash in hook scripts: readFileSync(0) fails when stdin is non-blocking #120
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-23functionreadHookInput(){constraw=fs.readFileSync(0,"utf8").trim();// throws EAGAINif(!raw){return{};}returnJSON.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.
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:
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.
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.
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:
importos, subprocess, timer, w=os.pipe()
os.set_blocking(r, False) # the condition Claude Code createsp=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 windowos.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:
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.
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.
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.
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).
Activity
tmchow commentedon Apr 3, 2026
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.
fix: handle EAGAIN when reading hook stdin in non-blocking mode
fix: handle EAGAIN in readHookInput()
fix: handle EAGAIN in readHookInput()
D2758695161 commentedon Apr 5, 2026
👋 Implemented fix in PR #150 — wrapping
readHookInput()in try/catch to handle EAGAIN errors from non-blocking stdin.— 一筒 🦀
tmchow commentedon Apr 5, 2026
Why did you submit a PR after I already submitted #123 2 days ago? Is this completely AI generated?
fix: handle EAGAIN in hook scripts when stdin is non-blocking
fix(hooks): drain stdin async with a 5s fallback so non-blocking pipe…
sjarmak commentedon Jul 26, 2026
Still present on HEAD:
plugins/codex/scripts/stop-review-gate-hook.mjs:22andsession-lifecycle-hook.mjs:24both 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:
Fails every run on Node v22.22.2 / Linux with the stack in the report. Drop the
os.set_blockingline and it passes every run.Two things I hit that are not in the report yet.
main()callsreadHookInput()at line 143, before it readsconfig.stopReviewGateat 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.inputfeeds both thecwdfallback andfilterJobsForCurrentSession, which falls back toCODEX_COMPANION_SESSION_IDand, when that is unset, stops filtering by session at all. So an empty object means the hook resolves the workspace fromprocess.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:
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.medlars commentedon Aug 8, 2026
Still present at
origin/maintoday, and there is a third call site the issue body doesn't list:Deterministic reproduction
The intermittency is just a race — the condition is simply an
O_NONBLOCKstdin pipe with no data ready yet. This reproduces it 100% of the time: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: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
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.
fix(hooks): bounded stdin read that never drops a complete payload; r…
CBEPX commentedon Sep 28, 2026
Fixed in CBEPX/codex-plugin-cc v1.4.0 (https://github.com/CBEPX/codex-plugin-cc/releases/tag/v1.4.0): the
Stop,SessionStartandSessionEndhooks 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 blockingreadFileSync(0), so a non-blocking stdin file descriptor can no longer crash a hook withEAGAIN; upstream PR #150 used as reference, thanks @D2758695161.This is a fork release, not an upstream fix, so I'm leaving this issue open for the maintainers here.
shuangwangnyc commentedon Oct 3, 2026
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 — withstopReviewGatedisabled, so it is the read at the top ofmain()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:
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.nicoache1 commentedon Oct 9, 2026
Still present in v1.0.6 and on
main(stop-review-gate-hook.mjs:22andsession-lifecycle-hook.mjs:24). We hit it once in a long Claude Code session on macOS (Apple silicon) with six Stop hooks per turn:stopReviewGateis 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; readingprocess.stdinwithfor awaitsucceeds. Empty input still gives{}, malformed JSON still exits 1 (withtry { 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:39has the same pattern (#247).