Skip to content

Bound the Codex turn await: fix unbounded hang on stalled/dead app-server + make turn budget configurable#376

Open
russjhammond wants to merge 2 commits into
openai:mainfrom
russjhammond:fix/bound-codex-turn-timeout
Open

Bound the Codex turn await: fix unbounded hang on stalled/dead app-server + make turn budget configurable#376
russjhammond wants to merge 2 commits into
openai:mainfrom
russjhammond:fix/bound-codex-turn-timeout

Conversation

@russjhammond

Copy link
Copy Markdown

Problem

A foreground Codex task can hang until the caller's external process timeout kills it with no output. Two distinct defects in scripts/lib/codex.mjs:

1. Unbounded, un-rejectable completion await. captureTurn ends in return await state.completion — a Promise resolved only by completeTurn() (on a turn/completed notification, or a 250ms inferred fallback). Its rejectCompletion counterpart is wired into the turn-capture state but has zero call sites — dead code. So once startRequest() resolves with inProgress, if the app-server dies or simply stalls, nothing rejects the await. It hangs until the caller (e.g. a host harness with a ~2-minute shell ceiling) SIGKILLs the process — surfacing as "hangs every time, returns nothing."

2. Turn budget read at import time. const TURN_TIMEOUT_MS = Number(process.env.CODEX_TURN_TIMEOUT_MS) || 600000 is evaluated when the module is imported. The companion sets the env var after import (e.g. a foreground budget below the caller's ceiling, or via a flag), so the override never takes effect — the budget is frozen at the default and effectively inert.

Fix

  • Wire rejectCompletion to client.exitPromise so a mid-turn process exit rejects the await immediately with a structured error instead of hanging.
  • Race state.completion against a hard deadline timer (cleared in a finally).
  • Resolve the budget at call timeresolveTurnTimeoutMs(options): explicit option → CODEX_TURN_TIMEOUT_MS env → default — so runtime overrides actually apply.
  • codex-companion.mjs: set a foreground turn budget just below the caller's external ceiling and add a --turn-timeout-ms flag. Background/detached jobs inherit the full default.

Verification

  • node --check passes on both files.
  • task --turn-timeout-ms 3000 on a long-generation prompt now aborts at ~3s with codex turn exceeded the 3000ms turn budget (previously ran unbounded — the flag was inert).
  • A normal foreground turn still completes cleanly (exit 0).

🤖 Generated with Claude Code

@russjhammond
russjhammond requested a review from a team June 16, 2026 01:20

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5415c43684

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread plugins/codex/scripts/lib/codex.mjs Outdated
Comment on lines +590 to +592
state.rejectCompletion(
client.exitError ?? new Error("codex app-server exited before the turn completed.")
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid rejecting an unobserved completion promise

When startRequest() rejects before the code reaches the Promise.race below (for example, a broker-busy error from turn/start/review/start, or the app-server exiting while that request is pending), withAppServer closes the client and exitPromise resolves, so these lines reject state.completion even though no handler has been attached to that promise. On supported Node versions this becomes an unhandled rejection that can terminate the CLI or preempt the intended direct retry/original error reporting; defer this rejection until the completion promise is being raced, or explicitly handle it on early failures.

Useful? React with 👍 / 👎.

Comment on lines +793 to +797
const explicitTurnTimeout = Number(options["turn-timeout-ms"]);
if (Number.isFinite(explicitTurnTimeout) && explicitTurnTimeout > 0) {
process.env.CODEX_TURN_TIMEOUT_MS = String(explicitTurnTimeout);
} else if (!process.env.CODEX_TURN_TIMEOUT_MS) {
process.env.CODEX_TURN_TIMEOUT_MS = String(FOREGROUND_TURN_TIMEOUT_MS);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply the foreground turn budget to reviews too

This default foreground budget is only installed in handleTask, but handleReviewCommand still runs /codex:review --wait and /codex:adversarial-review --wait through the same foreground runForegroundCommand path without setting CODEX_TURN_TIMEOUT_MS. Those review turns therefore fall back to the 600000ms default in codex.mjs, so under Claude Code's ~120000ms Bash ceiling they can still be SIGKILLed with an empty result instead of returning the structured timeout this change is meant to provide.

Useful? React with 👍 / 👎.

@russjhammond

Copy link
Copy Markdown
Author

Thanks for the review — both P2s addressed in 688192a:

  1. Unobserved completion rejection — moved the client.exitPromise → rejectCompletion wiring to after startRequest() resolves. An early startRequest() rejection (broker-busy, app-server exit while the request is pending) now propagates directly and state.completion is never observed, so it can't surface as an unhandled rejection. Mid-turn-death coverage is unchanged (handler is in place before the completion is awaited).

  2. Review turns — extracted applyForegroundTurnBudget(options) and now call it from both handleTask and handleReviewCommand, so --wait review / adversarial-review turns get the foreground budget instead of falling back to 600000ms. Also accept --turn-timeout-ms on the review command; guarded with !options.background so detached workers keep the full default.

Verified: node --check on both files; --turn-timeout-ms 3000 aborts a long turn at ~3s with the structured error; a normal turn still completes (exit 0).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 688192a624

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +648 to +650
deadlineTimer = setTimeout(() => {
reject(new Error(`codex turn exceeded the ${turnTimeoutMs}ms turn budget.`));
}, turnTimeoutMs);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Render timeout failures to stdout

When the new deadline fires, this rejects out of captureTurn; runTrackedJob then takes its catch path and main().catch prints only the error message to stderr, so runForegroundCommand never writes a rendered result to stdout. In the foreground slash-command paths that return the command stdout verbatim, a timed-out turn still yields empty stdout instead of the structured timeout/partial output this budget is meant to surface; return a failed turn state/rendered result or render the timeout from the catch path.

Useful? React with 👍 / 👎.

@russjhammond

Copy link
Copy Markdown
Author

Fair point — a scoping note on this PR vs. a follow-up.

The primary goal here is eliminating the unbounded hang: a stalled or dead app-server previously blocked on await state.completion until the host's external timeout SIGKILLed the process with no signal. After this change a timed-out turn rejects at the budget and exits non-zero with codex turn exceeded the <n>ms turn budget on stderr — already a strict improvement over a silent kill.

Rendering the timeout (plus any partial turn output) to stdout as a structured result is a reasonable next step, but it touches the foreground render path (runForegroundCommand / result rendering) and the right shape depends on this repo's output conventions, which you'd know better than a drive-by patch would. Happy to extend this PR (or open a follow-up) to render a structured timeout result if that's the direction you'd prefer — I just didn't want to guess at the rendering contract here.

axisrow added a commit to axisrow/codex-plugin-cc that referenced this pull request Jul 20, 2026
…) (#26)

Closes fork#25. If Codex CLI can't reach the OpenAI API (network down, DNS
misconfigured), captureTurn's 'await state.completion' hangs forever — no
turn-level timeout, rejectCompletion was dead code (zero call sites).

Ported from @russjhammond's openai#376:
- Wire rejectCompletion to client.exitPromise: app-server death after
  startRequest rejects the await immediately instead of hanging.
- Add deadline via Promise.race: hard per-turn budget (default 600s bg,
  110s foreground — just below Claude Code Bash tool ceiling).
- resolveTurnTimeoutMs reads CODEX_TURN_TIMEOUT_MS at CALL time, not import
  time (companion sets env after import — import-time read was inert).
- applyForegroundTurnBudget: foreground commands get 110s budget so a
  stalled turn returns a structured error instead of being SIGKILLed.
- --turn-timeout-ms flag on review/adversarial-review/task for manual override.

Verified: 166 tests, 162 pass / 4 pre-existing (unchanged).

Refs fork#25, openai#49, openai#489, openai#376 (@russjhammond).

Co-authored-by: axisrow <axisrow@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@axisrow

axisrow commented Jul 20, 2026

Copy link
Copy Markdown

@russjhammond — thank you for this PR. I forked openai/codex-plugin-cc and ported your fix into my fork (axisrow#26), so this comment is sharing back what I found while adapting it.

Your diagnosis was spot-on — rejectCompletion as dead code with zero call sites was the exact root cause. The port went through two adversarial review cycles (Codex + Claude subagent) and your code held up well. Three things came out of the review that I think are worth sharing:

  1. The foreground/background env-inheritance hazard is real and worth knowing about. Your applyForegroundTurnBudget mutates process.env.CODEX_TURN_TIMEOUT_MS, and the detached background worker inherits env: process.env. In my fork this was safe because handleTask returns early on the background branch before the call — but it's a fragile invariant. If anyone later moves the background-return or reorders the handler tail, the bare call would leak the 110s foreground budget into background workers. A cleaner path that eliminates this entirely: thread turnTimeoutMs through the options object that already flows from handleTaskexecuteTaskRunrunAppServerTurncaptureTurn. Your resolveTurnTimeoutMs already checks options.turnTimeoutMs first — the precedence chain is ready, the callers just never feed it. The env read stays as a fallback for operator-configured overrides.

  2. exitRaceSettled is redundant. I dropped it in a cleanup pass. JS promises silently ignore any settle after the first, so rejectCompletion on an already-settled state.completion is already a no-op. state.completed is the load-bearing guard (it protects against a late-arriving exitPromise.then after normal completion). exitRaceSettled was the weaker half — same protection the settled-promise semantics give for free.

  3. Three Codex findings that are real but out of scope for this PR (follow-up improvements, not bugs in your fix):

    • A timed-out broker turn keeps executing after the job is reported failed (the deadline rejects the await but doesn't send turn/interrupt).
    • The deadline is armed only after turn/start responds — a stalled turn/start request itself remains unbounded.
    • --turn-timeout-ms is silently ignored for background runs.

My fork PR with attribution to this PR: axisrow#26. Happy to talk through any of it — and equally happy to leave it here.

@axisrow

axisrow commented Jul 20, 2026

Copy link
Copy Markdown

Follow-up on my earlier comment — the three items I flagged as "out of scope for this PR" are now closed in my fork, in case the notes are useful to anyone picking them up later.

Following the scoping Russ drew in his comment above — this PR's job is eliminating the unbounded hang, and the three below were consciously deferred — I split them into a separate follow-up PR (axisrow#28) so #376's scope stays clean. No ask that you take any of it.

  1. Interrupt the turn on deadline. When the deadline rejects the await, the broker keeps executing a write-capable turn after the job is already reported failed. Fix: wrap the Promise.race in try/catch; on the budget-error, best-effort interruptAppServerTurn(cwd, { threadId, turnId }) (bounded 2s await, swallow failures) before re-throwing. Needs cwd threaded into captureTurn options.

  2. Arm the deadline before turn/start. Your exitPromise wiring runs only after startRequest() resolves, so a stalled turn/start (app-server alive, never responds) stays unbounded. Fix: hoist the deadline + the exitPromise → rejectCompletion wiring above startRequest(), and race the entire lifecycle (startRequest + completion) against the one deadline. Trade-off: if the deadline fires during startRequest there's no turnId to interrupt yet — but the process no longer hangs.

  3. --turn-timeout-ms for background runs. I replaced the process.env mutation (applyForegroundTurnBudget, foreground-only, gated by if (!options.background)) with plain options threading — resolveTurnTimeoutMsFromOptions(options) resolves flag → env → default and flows through buildTaskRequest → handleTaskWorker → executeTaskRun → runAppServerTurn → captureTurn. Background gets the full 600s default (no external ceiling to collide with). The env read stays as a fallback for operator-configured overrides. This also retires the env-inheritance hazard I worried about in point 1 of my first comment.

Verified TDD-first: a stalled-turn-start fake-codex behavior (never answers turn/start) timed out via --turn-timeout-ms 3000 instead of hanging. 162/166 tests green (4 pre-existing failures unchanged).

Fork PR with attribution back to this one: axisrow#28. Pure sharing — take it, adapt it, or set it aside.

russjhammond and others added 2 commits July 21, 2026 22:02
…rever

captureTurn ended in `return await state.completion`, a Promise resolved only
by completeTurn() (on turn/completed or a 250ms inferred fallback). Its
rejectCompletion counterpart was wired into state but never called — dead
code. So if the app-server died mid-turn or simply stalled, nothing rejected
the await: the turn hung until the caller's external timeout SIGKILLed the
process with no output.

Separately, the per-turn budget was read at import time
(`const TURN_TIMEOUT_MS = Number(process.env.CODEX_TURN_TIMEOUT_MS) || 600000`),
so any value set at runtime (a foreground budget below the caller's ceiling,
or --turn-timeout-ms) was ignored — the budget was frozen at the default and
effectively inert.

Fixes:
- Wire rejectCompletion to client.exitPromise so a mid-turn process exit
  surfaces immediately as an error instead of hanging.
- Race state.completion against a hard deadline timer (cleared in finally).
- Resolve the turn budget at call time (resolveTurnTimeoutMs: option >
  CODEX_TURN_TIMEOUT_MS env > default) so the companion's foreground budget
  and --turn-timeout-ms actually take effect.
- companion: set a foreground turn budget below the caller's ceiling and add a
  --turn-timeout-ms flag; background jobs keep the full default.

Verified: node --check on both files; --turn-timeout-ms 3000 now aborts a long
turn at ~3s with a structured error; a normal turn still completes cleanly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two P2 fixes from the automated review:

1. codex.mjs — register the client.exitPromise -> rejectCompletion handler
   AFTER startRequest() resolves, not before. If startRequest() rejects (e.g.
   broker-busy from turn/start, or the app-server exiting while the request is
   pending), the error now propagates directly and state.completion is never
   observed. Previously the early-registered handler could reject an unobserved
   state.completion and surface as an unhandled rejection that can terminate the
   CLI or preempt the intended error reporting. The mid-turn-death coverage is
   unchanged (the handler is in place before the completion is awaited).

2. codex-companion.mjs — apply the foreground turn budget to review turns too.
   Extracted applyForegroundTurnBudget(options) and call it from both handleTask
   and handleReviewCommand. /codex:review --wait and adversarial-review --wait
   run foreground through runForegroundCommand but previously never set
   CODEX_TURN_TIMEOUT_MS, so they fell back to the 600000ms default and could be
   SIGKILLed at the host Bash ceiling with an empty result. Also accept
   --turn-timeout-ms on the review command. Guarded with !options.background so a
   detached worker still inherits the full default budget.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@russjhammond

Copy link
Copy Markdown
Author

@axisrow — this is the good kind of open-source citizenship, and I appreciate it. Forking, porting through two adversarial review cycles, and then bringing the findings back here instead of quietly keeping them in your fork is exactly how this is supposed to work.

Your three points all land:

  1. Options-threading over env mutation — cleaner than my process.env.CODEX_TURN_TIMEOUT_MS write. resolveTurnTimeoutMs already checks options.turnTimeoutMs first, so the precedence chain was ready and the callers just never fed it. Threading it retires the background env-inheritance hazard entirely.
  2. exitRaceSettled is redundant — agreed. state.completed is the load-bearing guard, and settled-promise semantics make a second reject a no-op.
  3. The three deferred items (interrupt-on-deadline, arming the deadline before turn/start, and --turn-timeout-ms for background runs) are real and well-scoped as a follow-up.

I've picked this PR back up: rebased onto current main (trivial overlap with the #374 transfer-command constants, resolved by keeping both) and I'm folding your findings in with attribution to axisrow#26 and #28. Thanks again for the rigor. 🙏

@axisrow

axisrow commented Jul 22, 2026

Copy link
Copy Markdown

Awesome — really glad this one's moving again, and thanks for the attribution. Happy it was useful.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b8b46ee7a4

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

// Foreground turn budget (see applyForegroundTurnBudget). Runs only on the
// foreground path; the background branch returned above and its detached
// worker inherits the parent env unchanged (full default budget).
applyForegroundTurnBudget(options);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor the stop-gate review timeout

When the stop-review gate invokes this same task subcommand (plugins/codex/scripts/stop-review-gate-hook.mjs:105-109), it is not running under Claude Code's 120s Bash ceiling; it gives the child process STOP_REVIEW_TIMEOUT_MS (15 minutes). Applying the 110000ms foreground default here causes any enabled stop-gate review that legitimately runs longer than ~110s to fail/block early with a turn-budget error instead of honoring the hook's 15-minute timeout. Pass an explicit larger budget from the hook or avoid the Bash-specific default for that internal caller.

Useful? React with 👍 / 👎.

async function handleTask(argv) {
const { options, positionals } = parseCommandInput(argv, {
valueOptions: ["model", "effort", "cwd", "prompt-file"],
valueOptions: ["model", "effort", "cwd", "prompt-file", "turn-timeout-ms"],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Propagate background turn timeout flags

Accepting --turn-timeout-ms for task makes task --background --turn-timeout-ms 3000 look supported, but the background branch returns before applyForegroundTurnBudget() and buildTaskRequest() only stores cwd/model/effort/prompt/write/resumeLast/jobId, so the detached task-worker inherits no CLI override and falls back to CODEX_TURN_TIMEOUT_MS or the 600000ms default. Users trying to bound a background turn will still get the default budget unless they set the environment variable manually.

Useful? React with 👍 / 👎.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants