Turn cancellation (Stop button) takes 10-20s when model is in a tool-call loop

Resolved 💬 1 comment Opened Jun 26, 2026 by kemier Closed Jun 26, 2026

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

  1. Start a session with a model that generates many consecutive tool calls (e.g., a coding agent reading multiple files in sequence)
  2. While the model is actively executing tool calls, click Stop
  3. 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

  1. Add a cancellation check at the top of the run_step loop:
loop {
    if cancellation_token.is_cancelled() {
        return Err(CodexErr::TurnAborted);
    }
    // ... rest of loop ...
}
  1. Add cancellation checking to drain_in_flight (or use tokio::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

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗