- Replace legacy turn-window logic with stable per insert - Require non-empty stream IDs; seed order keys per (kind,id) - Pair out-of-order ExecEnd/Begin and drop canceled execs - Surface init/submit errors as BackgroundEvent diagnostics - Improve status panes, borders, and preview truncation by display width - Update release workflow; docs: AGENTS.md, prompt_coder.md; add MERGE.md and postmortem Build: ./build-fast.sh passes locally with no warnings.
3.9 KiB
Interrupt → Resume Postmortem
This document summarizes the root cause, symptoms, and fixes for the "Esc then send never resumes" issue in the Code (Codex CLI, Rust) TUI/core stack.
Summary
Pressing Esc (or Ctrl+C) to interrupt a running turn, then immediately entering
a new message, frequently resulted in no new model turn starting. The UI showed
the user message, but there was no TaskStarted, and sometimes the system
appeared to hang until forcibly terminated.
Two independent problems contributed to this behavior:
- A deadlock in
Session::abort()in the core. - The TUI queuing policy after interrupts, which could strand messages.
Symptoms
- After Esc, the next user message shows in history but no assistant output or
TaskStartedappears. - A second Ctrl+C exits the TUI, but the background task may linger.
- In earlier runs, messages sent while a task was running were queued waiting
for
TaskComplete(which never arrives after anInterrupt).
Root Cause
1) Deadlock in Session::abort()
Session::abort() held the session mutex (self.state.lock()) and then called
agent.abort(). The AgentAgent::abort() emitted a protocol event using
Session::make_event(), which also attempted to lock self.state. This
re-entrant lock acquisition resulted in a deadlock:
Session::abort()acquires the lock.- Calls
agent.abort()while still holding the lock. agent.abort()→make_event()tries to acquire the same lock → blocks forever.
Result: the submission loop remained stuck in the interrupt path and never got
to process the next UserInput turn.
2) Stranded messages after interrupts
Historically the TUI queued new user messages while a task was running, and
dispatched them on TaskComplete. After an Interrupt, there is no
TaskComplete, so the queued message could be left stranded indefinitely.
Fixes
-
Remove the deadlock in
Session::abort():- Take (
take()) thecurrent_agentwhile holding the mutex, then drop the lock before callingagent.abort(). This ensuresmake_event()can safely acquire the lock again and avoids the self-deadlock.
- Take (
-
Stronger atomicity around new input after interrupts:
- For
Op::UserInput, abort synchronously first (to ensure the prior agent is gone), then spawn and set the new agent. This prevents races where an async abort could kill the newly spawned agent.
- For
-
TUI sending policy:
- Always send user messages immediately, even if a task appears to be
running. The core’s
UserInputpath aborts the prior agent and starts a fresh turn, so messages are never stranded waiting for aTaskComplete.
- Always send user messages immediately, even if a task appears to be
running. The core’s
-
UX guards (non-functional):
- Ignore late deltas after an interrupt so partial output does not trickle in post-cancel.
- Drop late
ExecEndevents for commands that were already marked as cancelled to avoid duplicate cells.
Lessons Learned
- Never call back into code that may re-acquire a mutex you currently hold. Move side-effects (like emitting events) outside the lock’s critical section.
- Prefer a simple, predictable policy after interrupts: abort first, then spawn the next turn. Avoid queuing by frontends unless they can prove delivery.
- When diagnosing event-ordered systems, add short-lived diagnostics at the critical edges (submit, loop recv, abort begin/end, agent spawn, run begin) and then remove them once the root cause is fixed.
Files/Areas Changed
-
core/src/codex.rsSession::abort()releases the lock before callingagent.abort().Op::UserInputaborts synchronously before spawning the next agent.
-
tui/src/chatwidget.rs- Always send user input immediately (no queuing after interrupts).
- Ignore late deltas after cancel; drop late ExecEnd for cancelled calls.
These changes resolved the “Esc then send never resumes” issue and restored a reliable cancel→resume experience.