A repeated `turn/interrupt` can stay pending indefinitely after its turn was interrupted (aborted turns stay "active" in app-server thread state)

Open 💬 1 comment Opened Aug 4, 2026 by matias-casal

What version of Codex CLI is running?

codex-cli 0.144.1 (the freeze was observed here). Root cause and the fail-before/pass-after reproduction below were both re-verified against origin/main @ 3149fa4b99.

What subscription do you have?

Pro

Which model were you using?

gpt-5.6-sol

What platform is your computer?

Darwin 25.5.0 arm64 arm (macOS 26.5.1, Apple Silicon)

Codex doctor report

Relevant excerpt (codex doctor --json on 0.146.0):

codexVersion: 0.146.0
auth: chatgpt (file storage)   config.toml: parsed ok   mcp servers: 0
app-server: ephemeral, not running

(The full report also flags installation / updates.status as failing, but that is a local artifact of having two global npm installs on this machine — unrelated to this bug.)

What issue are you seeing?

Pressing <kbd>Esc</kbd> twice in quick succession during a long-running turn permanently wedged the TUI: the screen stayed frozen on the pre-abort frame (• Working (14h 46m 41s • esc to interrupt)), keystrokes were ignored, and nothing repainted again — while the process stayed alive and healthy (core kept pumping events, sub-agent threads kept finishing, telemetry kept flowing) for 4+ hours until I killed it.

The freeze itself is the TUI symptom of a defect that is still present on main: an app-server turn/interrupt request for a turn that has already been interrupted is accepted and queued for a terminal event that the core will not emit, so it stays pending indefinitely (until some later terminal event on that thread drains the queue, or the connection closes).

Forensic timeline from the process' own logs (~/.codex/logs_2.sqlite, thread 019fab1c-…, times local):

| time | event |
|---|---|
| 10:29:38.463 | Op::Interrupt #1 dispatched |
| 10:29:38.464 | core: TRACE aborting running task task_kind=Regular → the task existed and was aborted |
| 10:29:38.468 | EventMsg::TurnAborted emitted and persisted to the rollout |
| 10:29:39.097 | Op::Interrupt #2 dispatched → core logs interrupt received: abort current task, if any but no aborting running task line: there was nothing left to abort |
| — | no further TurnAborted/TurnComplete was ever emitted for that thread; the rollout ends at 10:29:39.090 |

sample(1) of the hung process matched exactly: main thread parked in pthread_join (codex-rs/arg0/src/lib.rs), the codex-main thread parked inside Runtime::block_on, all tokio workers idle, and no thread reading the terminal.

What steps can reproduce the bug?

A. TUI (reproduces the full freeze on 0.144.1; the inline wait that caused it was removed in rust-v0.146.0 by #35000, see below)

  1. Start a turn that takes a while (e.g. a long shell command).
  2. Press <kbd>Esc</kbd> to interrupt it.
  3. Press <kbd>Esc</kbd> again within ~1 s.
  4. The UI stops responding to input and stops repainting, permanently.

B. app-server (still reproduces on main)

  1. thread/start, then turn/start with something long-running.
  2. turn/interrupt for that turn id → responds, and turn/completed arrives with status: "interrupted".
  3. Send turn/interrupt again with the same turn id.
  4. That second request receives no response — not a result, not an error — and stays pending.

Observed against main (3149fa4b99) with an app-server integration test that follows exactly those steps; with the one-line fix below it answers immediately, without it the request never arrives:

… Notification turn/completed  {"turn": {"id": "019fc921-f69d-…", "status": "interrupted", …}}
… Request      turn/interrupt  {"threadId": "019fc921-f675-…", "turnId": "019fc921-f69d-…"}
Error: deadline has elapsed          ← no response and no error; the request is still pending

Compare with the already-covered completed-turn case (turn_interrupt_rejects_completed_turn in codex-rs/app-server/tests/suite/v2/turn_interrupt.rs), which correctly returns an invalid_request error. The interrupted-turn equivalent has no coverage, which is how this slipped through.

What is the expected behavior?

The second turn/interrupt should be answered — with no active turn to interrupt (invalid_request), exactly like the completed-turn case. No JSON-RPC request should be left waiting on an event that will not be emitted, and an interrupted turn should not keep being reported as the thread's active turn.

Additional information

Root causeThreadHistoryBuilder::handle_turn_aborted does not close the turn:

  • codex-rs/app-server-protocol/src/protocol/thread_history.rshandle_turn_aborted sets TurnStatus::Interrupted and returns; unlike its sibling handle_turn_complete, it never calls finish_current_turn(). current_turn therefore stays Some(..) after an abort.
  • codex-rs/app-server/src/thread_state.rsThreadState::track_current_turn_event records last_terminal_turn_id for both terminal events, but only resets its ThreadHistoryBuilder if !self.current_turn_history.has_active_turn(). That condition is true after a completion and false after an abort, so after an abort the in-memory history is never cleared and active_turn_snapshot() keeps returning the interrupted turn as the active one until a later TurnStarted supersedes it.
  • codex-rs/app-server/src/request_processors/turn_processor.rsturn_interrupt_inner checks that stale snapshot first (active_turn.id != turn_id → error, otherwise accept), so the last_terminal_turn_id == turn_id branch that would reject the duplicate is never reached: the repeat interrupt is pushed into ThreadState::pending_interrupts and the handler returns Ok(None) = "answer later".
  • codex-rs/app-server/src/bespoke_event_handling.rspending_interrupts is drained only from the TurnComplete and TurnAborted arms (respond_to_pending_interrupts).
  • codex-rs/core/src/tasks/mod.rsSession::abort_all_tasks only runs handle_task_abort (codex-rs/core/src/tasks/mod.rs:479-489 on 3149fa4b99), and that helper is what emits EventMsg::TurnAborted (:913-920). A redundant interrupt with no task to abort emits no terminal turn event at all.

Net: the response is owed to a terminal event that this interrupt will not produce. Because pending_interrupts holds bare request ids and any terminal event drains the whole queue, the request either waits indefinitely or is eventually acknowledged by an unrelated turn's completion.

Why it froze the TUI on 0.144.1: try_submit_active_thread_op_via_app_server awaited turn_interrupt inline inside the single select! loop in App::run, with no timeout (app-server-client's request_typed has none), so the loop never iterated again — no input, no draws. That part is already fixed on main by 62ba648136 "Make TUI turn interrupts nonblocking" (#35000), first released in rust-v0.146.0: interrupts are now dispatched in a background task and coalesced. The server-side leak and the stale active turn are untouched by that commit and still reproduce on main via path B — a client that awaits the response without its own timeout (IDE extension, SDK, app) can stay blocked indefinitely.

Related — #24287. A comment there already describes the queued-interrupt half of this: turn/interrupt is pushed into pending_interrupts and answered only from the TurnComplete/TurnAborted arms, so with no terminal event the Stop request hangs. That analysis reaches the stranded queue through a stalled rollout writer (the turn never ends), and its fix outline (d) suggests emitting TurnAborted for the ActiveTurn { task: None } window.

This issue is a second, independent path to the same stranded request that needs no stall and reproduces in three keystrokes: the turn did end normally, and the redundant interrupt is admitted only because the app-server still reports the finished turn as active. That reachability — handle_turn_aborted not closing the turn — has not been identified before, and it can be closed without fabricating terminal events for turns that never had a task.

Suggested fix (high level), smallest coherent change first:

  1. Make handle_turn_aborted close the aborted turn (finish_current_turn()) in the two branches that apply the abort to the current turn (exact turn-id match, and the fallback for events with no or unknown turn id), mirroring handle_turn_complete. That restores the invariant "the active turn is a turn that has not reached a terminal status", which several call sites already work around by re-checking status == InProgress (e.g. snapshot_turn_state in codex-rs/core/src/thread_manager.rs). With that, ThreadState clears its in-memory history, active_turn_snapshot() stops returning the finished turn, and the existing last_terminal_turn_id guard in turn_interrupt_inner rejects the repeat interrupt instead of queueing it.
  2. Add the missing app-server coverage: an interrupted-turn twin of turn_interrupt_rejects_completed_turn (it times out without the fix).
  3. Optional TUI polish: treat the no active turn to interrupt error as a benign no-op instead of surfacing Failed to interrupt turn: … as a warning — there is precedent in active_turn_steer_race/ActiveTurnSteerRace::Missing in codex-rs/tui/src/app.rs.
  4. Worth considering separately, because (1) narrows the window but does not close the class of bug:
  • pending_interrupts stores bare request ids and any terminal event drains the whole queue, so an interrupt can be acknowledged by a different turn's completion. Storing (request_id, turn_id)record_request_turn_id already tracks that association for the outgoing sender — and answering only the matching entries would make the handshake precise.
  • turn_interrupt_inner reads thread.agent_status() before taking the ThreadState lock, so a request can still be admitted on a stale Running when it targets a turn id that is neither the active one nor last_terminal_turn_id.
  • Nothing answers pending_interrupts if the thread listener tears down or errors out. Responding to them during teardown (rather than keying off AgentStatus leaving Running, which core updates before the terminal event is delivered) would make the protocol self-healing.

I have a working patch for 1–3 with tests (the app-server test fails by timeout before the change and passes after). Happy to open a PR if the team wants it — per docs/contributing.md I'm not opening one uninvited.

View original on GitHub ↗

1 Comment

matias-casal · 23 days ago

The patch for steps 1–3 is pushed and kept rebased on main (currently a603d7ca5c), in case it is useful for triage:

7 files, +465/−41. Production change is two finish_current_turn() calls in handle_turn_aborted; the rest is the TUI treating no active turn to interrupt as the benign race it now is, plus tests.

Verified locally on that same commit (macOS, Rust 1.95.0), removing only the two production lines and leaving every test in place:

without the fix
  cargo test -p codex-app-server-protocol --lib aborting_          FAILED  0 passed; 2 failed
  cargo test -p codex-app-server-protocol --lib replayed_rollout   FAILED  0 passed; 1 failed
  cargo test -p codex-app-server --lib aborted_turn_stops           FAILED  0 passed; 1 failed
  cargo test -p codex-app-server --test all turn_interrupt_rejects_already_interrupted_turn
                                                                   FAILED  0 passed; 1 failed
                                                                   (Error: deadline has elapsed)
with the fix
  cargo test -p codex-app-server-protocol --lib                    ok  288 passed
  cargo test -p codex-app-server --lib                             ok  264 passed
  cargo test -p codex-tui --lib active_turn_interrupt              ok    3 passed
  cargo test -p codex-app-server --test all turn_interrupt         ok    4 passed
  cargo fmt --check / cargo clippy --tests                         exit 0

Not opening a PR, per docs/contributing.md. Two things I would still want before it could merge, and I am happy to do either: behavioural TUI coverage for the three Missing paths (the harness in app/tests/safety_buffering.rs already supports it), and splitting the TUI half into its own change if only the app-server fix is wanted.