A repeated `turn/interrupt` can stay pending indefinitely after its turn was interrupted (aborted turns stay "active" in app-server thread state)
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)
- Start a turn that takes a while (e.g. a long shell command).
- Press <kbd>Esc</kbd> to interrupt it.
- Press <kbd>Esc</kbd> again within ~1 s.
- The UI stops responding to input and stops repainting, permanently.
B. app-server (still reproduces on main)
thread/start, thenturn/startwith something long-running.turn/interruptfor that turn id → responds, andturn/completedarrives withstatus: "interrupted".- Send
turn/interruptagain with the same turn id. - 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 cause — ThreadHistoryBuilder::handle_turn_aborted does not close the turn:
codex-rs/app-server-protocol/src/protocol/thread_history.rs—handle_turn_abortedsetsTurnStatus::Interruptedand returns; unlike its siblinghandle_turn_complete, it never callsfinish_current_turn().current_turntherefore staysSome(..)after an abort.codex-rs/app-server/src/thread_state.rs—ThreadState::track_current_turn_eventrecordslast_terminal_turn_idfor both terminal events, but only resets itsThreadHistoryBuilderif !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 andactive_turn_snapshot()keeps returning the interrupted turn as the active one until a laterTurnStartedsupersedes it.codex-rs/app-server/src/request_processors/turn_processor.rs—turn_interrupt_innerchecks that stale snapshot first (active_turn.id != turn_id→ error, otherwise accept), so thelast_terminal_turn_id == turn_idbranch that would reject the duplicate is never reached: the repeat interrupt is pushed intoThreadState::pending_interruptsand the handler returnsOk(None)= "answer later".codex-rs/app-server/src/bespoke_event_handling.rs—pending_interruptsis drained only from theTurnCompleteandTurnAbortedarms (respond_to_pending_interrupts).codex-rs/core/src/tasks/mod.rs—Session::abort_all_tasksonly runshandle_task_abort(codex-rs/core/src/tasks/mod.rs:479-489on3149fa4b99), and that helper is what emitsEventMsg::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:
- Make
handle_turn_abortedclose 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), mirroringhandle_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-checkingstatus == InProgress(e.g.snapshot_turn_stateincodex-rs/core/src/thread_manager.rs). With that,ThreadStateclears its in-memory history,active_turn_snapshot()stops returning the finished turn, and the existinglast_terminal_turn_idguard inturn_interrupt_innerrejects the repeat interrupt instead of queueing it. - Add the missing app-server coverage: an interrupted-turn twin of
turn_interrupt_rejects_completed_turn(it times out without the fix). - Optional TUI polish: treat the
no active turn to interrupterror as a benign no-op instead of surfacingFailed to interrupt turn: …as a warning — there is precedent inactive_turn_steer_race/ActiveTurnSteerRace::Missingincodex-rs/tui/src/app.rs. - Worth considering separately, because (1) narrows the window but does not close the class of bug:
pending_interruptsstores 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_idalready tracks that association for the outgoing sender — and answering only the matching entries would make the handshake precise.turn_interrupt_innerreadsthread.agent_status()before taking theThreadStatelock, so a request can still be admitted on a staleRunningwhen it targets a turn id that is neither the active one norlast_terminal_turn_id.- Nothing answers
pending_interruptsif the thread listener tears down or errors out. Responding to them during teardown (rather than keying offAgentStatusleavingRunning, 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.
1 Comment
The patch for steps 1–3 is pushed and kept rebased on
main(currentlya603d7ca5c), in case it is useful for triage:main: https://github.com/openai/codex/compare/main...matias-casal:codex:fix/turn-interrupt-freeze7 files, +465/−41. Production change is two
finish_current_turn()calls inhandle_turn_aborted; the rest is the TUI treatingno active turn to interruptas 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:
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 threeMissingpaths (the harness inapp/tests/safety_buffering.rsalready supports it), and splitting the TUI half into its own change if only the app-server fix is wanted.