From 5bb448044d87cea6853413d6e18a3e9d58f44fff Mon Sep 17 00:00:00 2001 From: stantheman0128 Date: Thu, 23 Jul 2026 18:27:36 +0800 Subject: [PATCH] fix: bound Stop hook stdin read so disabled gate cannot hang on Windows fs.readFileSync(0) waits for EOF. On Windows Claude Code may leave the pipe open, so the Stop hook hit the 900s timeout even when stopReviewGate was false. Check config from cwd/env first and use a timed stdin reader (#530). Co-authored-by: Cursor --- plugins/codex/scripts/lib/hook-stdin.mjs | 62 +++++++++++++++++++ .../codex/scripts/stop-review-gate-hook.mjs | 50 +++++++++------ tests/runtime.test.mjs | 56 +++++++++++++++++ 3 files changed, 149 insertions(+), 19 deletions(-) create mode 100644 plugins/codex/scripts/lib/hook-stdin.mjs diff --git a/plugins/codex/scripts/lib/hook-stdin.mjs b/plugins/codex/scripts/lib/hook-stdin.mjs new file mode 100644 index 000000000..8a3d1041a --- /dev/null +++ b/plugins/codex/scripts/lib/hook-stdin.mjs @@ -0,0 +1,62 @@ +import process from "node:process"; + +/** + * Read Claude Code hook stdin JSON with a hard deadline. + * + * `fs.readFileSync(0)` blocks until EOF. On Windows, Claude Code sometimes + * leaves the write end open after sending the payload, so Stop hooks hang + * until the external hook timeout (see #530). Prefer this timed reader. + * + * @param {number} timeoutMs + * @returns {Promise} + */ +export function readHookInput(timeoutMs = 5000) { + if (process.stdin.isTTY) { + return Promise.resolve({}); + } + + return new Promise((resolve) => { + let raw = ""; + let settled = false; + + const finish = (value) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + process.stdin.removeListener("data", onData); + process.stdin.removeListener("end", onEnd); + process.stdin.removeListener("error", onError); + try { + process.stdin.pause(); + } catch { + // ignore + } + + const text = String(value ?? "").trim(); + if (!text) { + resolve({}); + return; + } + try { + resolve(JSON.parse(text)); + } catch { + resolve({}); + } + }; + + const timer = setTimeout(() => finish(raw), timeoutMs); + const onData = (chunk) => { + raw += chunk; + }; + const onEnd = () => finish(raw); + const onError = () => finish(raw); + + process.stdin.setEncoding("utf8"); + process.stdin.on("data", onData); + process.stdin.on("end", onEnd); + process.stdin.on("error", onError); + process.stdin.resume(); + }); +} diff --git a/plugins/codex/scripts/stop-review-gate-hook.mjs b/plugins/codex/scripts/stop-review-gate-hook.mjs index 2346bdcf4..9349bc16c 100644 --- a/plugins/codex/scripts/stop-review-gate-hook.mjs +++ b/plugins/codex/scripts/stop-review-gate-hook.mjs @@ -1,6 +1,5 @@ #!/usr/bin/env node -import fs from "node:fs"; import process from "node:process"; import path from "node:path"; import { spawnSync } from "node:child_process"; @@ -12,19 +11,16 @@ import { getConfig, listJobs } from "./lib/state.mjs"; import { sortJobsNewestFirst } from "./lib/job-control.mjs"; import { SESSION_ID_ENV } from "./lib/tracked-jobs.mjs"; import { resolveWorkspaceRoot } from "./lib/workspace.mjs"; +import { readHookInput } from "./lib/hook-stdin.mjs"; const STOP_REVIEW_TIMEOUT_MS = 15 * 60 * 1000; const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); 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 {}; - } - return JSON.parse(raw); -} +// Gate-off path must not wait on Windows stdin EOF (#530). Keep this short. +const DISABLED_GATE_STDIN_TIMEOUT_MS = 500; +// Gate-on still needs the payload; bound the wait so a stuck pipe cannot eat the full hook budget. +const ENABLED_GATE_STDIN_TIMEOUT_MS = 30_000; function emitDecision(payload) { process.stdout.write(`${JSON.stringify(payload)}\n`); @@ -139,17 +135,34 @@ function runStopReview(cwd, input = {}) { } } -function main() { - const input = readHookInput(); - const cwd = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd(); - const workspaceRoot = resolveWorkspaceRoot(cwd); - const config = getConfig(workspaceRoot); - +function logRunningTaskNote(workspaceRoot, input = {}) { const jobs = sortJobsNewestFirst(filterJobsForCurrentSession(listJobs(workspaceRoot), input)); const runningJob = jobs.find((job) => job.status === "queued" || job.status === "running"); const runningTaskNote = runningJob ? `Codex task ${runningJob.id} is still running. Check /codex:status and use /codex:cancel ${runningJob.id} if you want to stop it before ending the session.` : null; + return runningTaskNote; +} + +async function main() { + // Resolve config from env/cwd first so a disabled gate never waits on stdin EOF (#530). + const cwdGuess = process.env.CLAUDE_PROJECT_DIR || process.cwd(); + const preliminaryRoot = resolveWorkspaceRoot(cwdGuess); + const preliminaryConfig = getConfig(preliminaryRoot); + + if (!preliminaryConfig.stopReviewGate) { + const input = await readHookInput(DISABLED_GATE_STDIN_TIMEOUT_MS); + const cwd = input.cwd || cwdGuess; + const workspaceRoot = resolveWorkspaceRoot(cwd); + logNote(logRunningTaskNote(workspaceRoot, input)); + return; + } + + const input = await readHookInput(ENABLED_GATE_STDIN_TIMEOUT_MS); + const cwd = input.cwd || cwdGuess; + const workspaceRoot = resolveWorkspaceRoot(cwd); + const config = getConfig(workspaceRoot); + const runningTaskNote = logRunningTaskNote(workspaceRoot, input); if (!config.stopReviewGate) { logNote(runningTaskNote); @@ -176,9 +189,8 @@ function main() { } try { - main(); + await main(); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`${message}\n`); - process.exitCode = 1; + process.stderr.write(`${error?.stack || error}\n`); + process.exit(1); } diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 8f276835b..4152e2108 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -2036,6 +2036,62 @@ test("stop hook logs running tasks to stderr without blocking when the review ga assert.match(blocked.stderr, /\/codex:cancel task-live/i); }); +test("stop hook with disabled gate exits quickly when stdin never receives EOF", async () => { + const repo = makeTempDir(); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const stateDir = resolveStateDir(repo); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + path.join(stateDir, "state.json"), + `${JSON.stringify({ version: 1, config: { stopReviewGate: false }, jobs: [] }, null, 2)}\n`, + "utf8" + ); + + const child = spawn(process.execPath, [STOP_HOOK], { + cwd: repo, + env: { + ...process.env, + CLAUDE_PROJECT_DIR: repo + }, + stdio: ["pipe", "pipe", "pipe"] + }); + + // Write a partial payload but never end stdin (Windows EOF hang repro). + child.stdin.write(`${JSON.stringify({ cwd: repo })}\n`); + + const started = Date.now(); + const [status, stdout, stderr] = await new Promise((resolve, reject) => { + let out = ""; + let err = ""; + const timer = setTimeout(() => { + child.kill("SIGTERM"); + reject(new Error("stop hook hung longer than 3s with review gate disabled")); + }, 3000); + child.stdout.on("data", (chunk) => { + out += chunk; + }); + child.stderr.on("data", (chunk) => { + err += chunk; + }); + child.on("error", (error) => { + clearTimeout(timer); + reject(error); + }); + child.on("close", (code) => { + clearTimeout(timer); + resolve([code, out, err]); + }); + }); + + assert.equal(status, 0, stderr); + assert.equal(stdout.trim(), ""); + assert.ok(Date.now() - started < 2500, `expected fast exit, took ${Date.now() - started}ms`); +}); + test("stop hook allows the stop when the review gate is enabled and the stop-time review task is clean", () => { const repo = makeTempDir(); const binDir = makeTempDir();