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();