Turn cancellation (Stop button) takes 10-20s when model is in a tool-call loop
Bug
When the model is in an active tool-call loop (model generates finish_reason=tool_calls → codex executes tools → model generates more tool calls → ...), clicking Stop takes 10-20 seconds (sometimes longer) to actually halt the turn.
Reproduction
- Start a session with a model that generates many consecutive tool calls (e.g., a coding agent reading multiple files in sequence)
- While the model is actively executing tool calls, click Stop
- Observe that the turn does not stop immediately — the model continues generating tool calls and sending new requests for 10-20+ seconds
Expected behavior
The turn should stop within 1-2 seconds of clicking Stop, regardless of whether the model is in a tool-call loop.
Actual behavior
The turn continues for 10-20+ seconds. During this time:
- New HTTP requests are still being sent to the model API
- The model generates new tool calls
- Tool results are processed
Root cause analysis
I traced the issue to codex-rs/core/src/session/turn.rs. There are two gaps in cancellation checking:
1. The run_step loop has no cancellation check at the top
The outer turn loop (around line 224) immediately builds a new prompt and calls run_sampling_request without checking if the cancellation token has been fired:
loop {
// ❌ No cancellation check here
let pending_input = if can_drain_pending_input {
sess.input_queue.get_pending_input(&sess.active_turn).await
} else {
Vec::new()
};
// ... builds prompt, calls run_sampling_request ...
}
While try_run_sampling_request does check cancellation (via .or_cancel() on the stream request and stream.next()), there is a window between the previous iteration completing and the next .or_cancel() firing where the turn continues despite being cancelled. During this window, codex:
- Builds a new prompt from conversation history
- Sends a new HTTP request to the model API
- Waits for the first stream event
This window can be several seconds, especially with large conversation contexts.
2. drain_in_flight does not check cancellation
After the stream completes (response.completed received), drain_in_flight waits for all in-flight tool calls to complete without checking the cancellation token (around line 2358):
async fn drain_in_flight(
in_flight: &mut FuturesOrdered<BoxFuture<'static, CodexResult<ResponseInputItem>>>,
sess: Arc<Session>,
turn_context: Arc<TurnContext>,
) -> CodexResult<()> {
while let Some(res) = in_flight.next().await {
// ❌ No cancellation check — waits for ALL tool calls to complete
match res {
Ok(response_input) => { /* ... */ }
}
}
Ok(())
}
The cancellation check (cancellation_token.is_cancelled()) only happens after drain_in_flight returns (line 2359), by which point all tool calls have already completed and their results recorded.
Suggested fix
- Add a cancellation check at the top of the
run_steploop:
loop {
if cancellation_token.is_cancelled() {
return Err(CodexErr::TurnAborted);
}
// ... rest of loop ...
}
- Add cancellation checking to
drain_in_flight(or usetokio::select!with the cancellation token):
async fn drain_in_flight(
in_flight: &mut FuturesOrdered<...>,
sess: Arc<Session>,
turn_context: Arc<TurnContext>,
cancellation_token: &CancellationToken,
) -> CodexResult<()> {
while let Some(res) = in_flight.next().await {
if cancellation_token.is_cancelled() {
return Err(CodexErr::TurnAborted);
}
// ... process result ...
}
Ok(())
}
Environment
- Codex version: 0.142.2 (npm
@openai/codex) - Used via app-server (codex-acp) in VS Code extension
- OS: Linux x86_64
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗