From 7927327b87dcf1807ffc3cb0922c70bc0ae4f0ed Mon Sep 17 00:00:00 2001 From: Leo Date: Tue, 7 Apr 2026 11:14:36 +0800 Subject: [PATCH 1/3] fix: handle EAGAIN in hook scripts when stdin is non-blocking Claude Code invokes hooks with stdin as a non-blocking pipe on some platforms (macOS, Linux). This causes fs.readFileSync(0) to throw EAGAIN: resource temporarily unavailable, making the hook exit with code 1 and showing a SessionStart:startup hook error banner on every session start. Fix by retrying readFileSync up to 20 times (200ms total) when EAGAIN is received, using Atomics.wait for precise synchronous sleeping. If all retries are exhausted, returns {} so the hook succeeds gracefully rather than surfacing a startup error. Fixes #120 --- .../codex/scripts/session-lifecycle-hook.mjs | 27 ++++++++++++++++--- .../codex/scripts/stop-review-gate-hook.mjs | 25 ++++++++++++++--- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/plugins/codex/scripts/session-lifecycle-hook.mjs b/plugins/codex/scripts/session-lifecycle-hook.mjs index 9655eae..7714d66 100644 --- a/plugins/codex/scripts/session-lifecycle-hook.mjs +++ b/plugins/codex/scripts/session-lifecycle-hook.mjs @@ -20,11 +20,30 @@ export const SESSION_ID_ENV = "CODEX_COMPANION_SESSION_ID"; const PLUGIN_DATA_ENV = "CLAUDE_PLUGIN_DATA"; function readHookInput() { - const raw = fs.readFileSync(0, "utf8").trim(); - if (!raw) { - return {}; + // Claude Code may invoke hooks with stdin as a non-blocking pipe, which + // causes fs.readFileSync(0) to throw EAGAIN. Retry with a small delay to + // allow the pipe to become readable. See: https://github.com/openai/codex-plugin-cc/issues/120 + const MAX_RETRIES = 20; + const RETRY_DELAY_MS = 10; + const sleepBuf = new Int32Array(new SharedArrayBuffer(4)); + + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + try { + const raw = fs.readFileSync(0, "utf8").trim(); + return raw ? JSON.parse(raw) : {}; + } catch (err) { + if (err.code !== "EAGAIN") { + throw err; + } + if (attempt === MAX_RETRIES) { + // Gracefully degrade: session_id won't be set in CLAUDE_ENV_FILE, + // but the hook succeeds instead of surfacing a startup error. + return {}; + } + Atomics.wait(sleepBuf, 0, 0, RETRY_DELAY_MS); + } } - return JSON.parse(raw); + return {}; } function shellEscape(value) { diff --git a/plugins/codex/scripts/stop-review-gate-hook.mjs b/plugins/codex/scripts/stop-review-gate-hook.mjs index c22edbd..10b31e9 100644 --- a/plugins/codex/scripts/stop-review-gate-hook.mjs +++ b/plugins/codex/scripts/stop-review-gate-hook.mjs @@ -19,11 +19,28 @@ const ROOT_DIR = path.resolve(SCRIPT_DIR, ".."); const STOP_REVIEW_TASK_MARKER = "Run a stop-gate review of the previous Claude turn."; function readHookInput() { - const raw = fs.readFileSync(0, "utf8").trim(); - if (!raw) { - return {}; + // Claude Code may invoke hooks with stdin as a non-blocking pipe, which + // causes fs.readFileSync(0) to throw EAGAIN. Retry with a small delay to + // allow the pipe to become readable. See: https://github.com/openai/codex-plugin-cc/issues/120 + const MAX_RETRIES = 20; + const RETRY_DELAY_MS = 10; + const sleepBuf = new Int32Array(new SharedArrayBuffer(4)); + + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + try { + const raw = fs.readFileSync(0, "utf8").trim(); + return raw ? JSON.parse(raw) : {}; + } catch (err) { + if (err.code !== "EAGAIN") { + throw err; + } + if (attempt === MAX_RETRIES) { + return {}; + } + Atomics.wait(sleepBuf, 0, 0, RETRY_DELAY_MS); + } } - return JSON.parse(raw); + return {}; } function emitDecision(payload) { From c2d931d06d51ef6650267d3ff38fcbbf7f9237a0 Mon Sep 17 00:00:00 2001 From: Leo Date: Tue, 7 Apr 2026 17:09:06 +0800 Subject: [PATCH 2/3] fix: buffer stdin chunks across EAGAIN retries to avoid losing partial reads When stdin arrives in multiple writes on a non-blocking pipe, readFileSync can consume a partial chunk internally before throwing EAGAIN, causing the already-read bytes to be silently discarded. On the next retry, readFileSync starts from an empty buffer, so the accumulated bytes are gone and JSON.parse fails on the tail chunk alone. Switch to readSync in a manual accumulation loop: each successful read appends to a chunks array, EAGAIN causes a wait-and-retry without touching the accumulated data, and EOF triggers a single Buffer.concat + JSON.parse. Fixes the review comment on #165. --- .../codex/scripts/session-lifecycle-hook.mjs | 28 +++++++++++++------ .../codex/scripts/stop-review-gate-hook.mjs | 28 +++++++++++++------ 2 files changed, 40 insertions(+), 16 deletions(-) diff --git a/plugins/codex/scripts/session-lifecycle-hook.mjs b/plugins/codex/scripts/session-lifecycle-hook.mjs index 7714d66..13cc66a 100644 --- a/plugins/codex/scripts/session-lifecycle-hook.mjs +++ b/plugins/codex/scripts/session-lifecycle-hook.mjs @@ -21,29 +21,41 @@ const PLUGIN_DATA_ENV = "CLAUDE_PLUGIN_DATA"; function readHookInput() { // Claude Code may invoke hooks with stdin as a non-blocking pipe, which - // causes fs.readFileSync(0) to throw EAGAIN. Retry with a small delay to - // allow the pipe to become readable. See: https://github.com/openai/codex-plugin-cc/issues/120 + // causes read() to throw EAGAIN before EOF. We read stdin in chunks, + // accumulating across EAGAIN retries so partial reads are not lost. + // See: https://github.com/openai/codex-plugin-cc/issues/120 const MAX_RETRIES = 20; const RETRY_DELAY_MS = 10; const sleepBuf = new Int32Array(new SharedArrayBuffer(4)); + const chunks = []; + const buf = Buffer.alloc(65536); + let eagainCount = 0; - for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + for (;;) { + let n; try { - const raw = fs.readFileSync(0, "utf8").trim(); - return raw ? JSON.parse(raw) : {}; + n = fs.readSync(0, buf, 0, buf.length, null); } catch (err) { if (err.code !== "EAGAIN") { throw err; } - if (attempt === MAX_RETRIES) { + if (eagainCount >= MAX_RETRIES) { // Gracefully degrade: session_id won't be set in CLAUDE_ENV_FILE, // but the hook succeeds instead of surfacing a startup error. - return {}; + break; } + eagainCount++; Atomics.wait(sleepBuf, 0, 0, RETRY_DELAY_MS); + continue; + } + if (n === 0) { + break; // EOF } + chunks.push(Buffer.from(buf.subarray(0, n))); } - return {}; + + const raw = Buffer.concat(chunks).toString("utf8").trim(); + return raw ? JSON.parse(raw) : {}; } function shellEscape(value) { diff --git a/plugins/codex/scripts/stop-review-gate-hook.mjs b/plugins/codex/scripts/stop-review-gate-hook.mjs index 10b31e9..c18d048 100644 --- a/plugins/codex/scripts/stop-review-gate-hook.mjs +++ b/plugins/codex/scripts/stop-review-gate-hook.mjs @@ -20,27 +20,39 @@ const STOP_REVIEW_TASK_MARKER = "Run a stop-gate review of the previous Claude t function readHookInput() { // Claude Code may invoke hooks with stdin as a non-blocking pipe, which - // causes fs.readFileSync(0) to throw EAGAIN. Retry with a small delay to - // allow the pipe to become readable. See: https://github.com/openai/codex-plugin-cc/issues/120 + // causes read() to throw EAGAIN before EOF. We read stdin in chunks, + // accumulating across EAGAIN retries so partial reads are not lost. + // See: https://github.com/openai/codex-plugin-cc/issues/120 const MAX_RETRIES = 20; const RETRY_DELAY_MS = 10; const sleepBuf = new Int32Array(new SharedArrayBuffer(4)); + const chunks = []; + const buf = Buffer.alloc(65536); + let eagainCount = 0; - for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + for (;;) { + let n; try { - const raw = fs.readFileSync(0, "utf8").trim(); - return raw ? JSON.parse(raw) : {}; + n = fs.readSync(0, buf, 0, buf.length, null); } catch (err) { if (err.code !== "EAGAIN") { throw err; } - if (attempt === MAX_RETRIES) { - return {}; + if (eagainCount >= MAX_RETRIES) { + break; } + eagainCount++; Atomics.wait(sleepBuf, 0, 0, RETRY_DELAY_MS); + continue; } + if (n === 0) { + break; // EOF + } + chunks.push(Buffer.from(buf.subarray(0, n))); } - return {}; + + const raw = Buffer.concat(chunks).toString("utf8").trim(); + return raw ? JSON.parse(raw) : {}; } function emitDecision(payload) { From b51bc284080baaee70bbccd930737d328bcfa2df Mon Sep 17 00:00:00 2001 From: ikbear Date: Tue, 7 Apr 2026 17:12:42 +0800 Subject: [PATCH 3/3] fix: return empty input immediately when EAGAIN retries are exhausted When the retry budget is exhausted after partial bytes have already been read, breaking out of the loop and falling through to JSON.parse risks throwing SyntaxError on truncated JSON, which fails the hook instead of degrading gracefully. Return {} directly on max retries to guarantee clean hook exit regardless of how much data was buffered. --- plugins/codex/scripts/session-lifecycle-hook.mjs | 2 +- plugins/codex/scripts/stop-review-gate-hook.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/codex/scripts/session-lifecycle-hook.mjs b/plugins/codex/scripts/session-lifecycle-hook.mjs index 13cc66a..7942c36 100644 --- a/plugins/codex/scripts/session-lifecycle-hook.mjs +++ b/plugins/codex/scripts/session-lifecycle-hook.mjs @@ -42,7 +42,7 @@ function readHookInput() { if (eagainCount >= MAX_RETRIES) { // Gracefully degrade: session_id won't be set in CLAUDE_ENV_FILE, // but the hook succeeds instead of surfacing a startup error. - break; + return {}; } eagainCount++; Atomics.wait(sleepBuf, 0, 0, RETRY_DELAY_MS); diff --git a/plugins/codex/scripts/stop-review-gate-hook.mjs b/plugins/codex/scripts/stop-review-gate-hook.mjs index c18d048..4196d6e 100644 --- a/plugins/codex/scripts/stop-review-gate-hook.mjs +++ b/plugins/codex/scripts/stop-review-gate-hook.mjs @@ -39,7 +39,7 @@ function readHookInput() { throw err; } if (eagainCount >= MAX_RETRIES) { - break; + return {}; } eagainCount++; Atomics.wait(sleepBuf, 0, 0, RETRY_DELAY_MS);