Skip to content

feat: waiting for an elicitation should not count against a shell tool timeout#6973

Merged
bolinfest merged 1 commit intomainfrom
pr6973
Nov 21, 2025
Merged

feat: waiting for an elicitation should not count against a shell tool timeout#6973
bolinfest merged 1 commit intomainfrom
pr6973

Conversation

@bolinfest
Copy link
Copy Markdown
Collaborator

@bolinfest bolinfest commented Nov 20, 2025

Previously, we were running into an issue where we would run the shell tool call with a timeout of 10s, but it fired an elicitation asking for user approval, the time the user took to respond to the elicitation was counted agains the 10s timeout, so the shell tool call would fail with a timeout error unless the user is very fast!

This PR addresses this issue by introducing a "stopwatch" abstraction that is used to manage the timeout. The idea is:

  • Stopwatch::new() is called with the real timeout of the shell tool call.
  • process_exec_tool_call() is called with the Cancellation variant of ExecExpiration because it should not manage its own timeout in this case
  • the Stopwatch expiration is wired up to the cancel_rx passed to process_exec_tool_call()
  • when an elicitation for the shell tool call is received, the Stopwatch pauses
  • because it is possible for multiple elicitations to arrive concurrently, it keeps track of the number of "active pauses" and does not resume until that counter goes down to zero

I verified that I can test the MCP server using @modelcontextprotocol/inspector and specify git status as the command with a timeout of 500ms and that the elicitation pops up and I have all the time in the world to respond whereas previous to this PR, that would not have been possible.


Stack created with Sapling. Best reviewed with ReviewStack.

Copy link
Copy Markdown
Contributor

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread codex-rs/exec-server/src/posix/mcp.rs Outdated
Comment on lines +94 to +98
let effective_timeout_ms = params.timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS);
let stopwatch = Stopwatch::new(Some(effective_timeout_ms));
let cancel_rx = stopwatch.cancellation_receiver();
let process_timeout_ms = if cancel_rx.is_some() {
Some(u64::MAX)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid overflowing tokio timeout in stopwatch path

Shell requests now set process_timeout_ms to Some(u64::MAX) whenever a stopwatch is created (mcp.rs lines 94-98). That value flows into core/src/exec.rs where it becomes the Duration passed to tokio::time::timeout, and Tokio panics if the timeout exceeds its MAX_DURATION (≈584 years). u64::MAX ms (~584 billion years) will therefore panic the exec path before the cancellation receiver can fire, crashing the shell tool as soon as this path is exercised; use a bounded duration or skip the timeout instead of an overflowing value.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is valid

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, fixed on the latest version. Now:

        // We want a large timeout that is effectively infinite, because the
        // real timeout is enforced by the stopwatch and cancellation receiver.
        // While we could use u64::MAX here, using a more reasonable value
        // avoids potential issues with libraries that might not handle extreme
        // values well.
        let process_timeout_ms: u64 = 365 * 24 * 60 * 60 * 1000;

Comment thread codex-rs/exec-server/src/posix/mcp.rs Outdated
// While we could use u64::MAX here, using a more reasonable value
// avoids potential issues with libraries that might not handle extreme
// values well.
let process_timeout_ms: u64 = 365 * 24 * 60 * 60 * 1000;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would not call this value "reasonable" regarding the scope of codex but fair enough :D

Comment thread codex-rs/exec-server/src/posix/mcp.rs Outdated
Comment thread codex-rs/exec-server/src/posix/mcp.rs Outdated
Comment on lines +98 to +103
// We want a large timeout that is effectively infinite, because the
// real timeout is enforced by the stopwatch and cancellation receiver.
// While we could use u64::MAX here, using a more reasonable value
// avoids potential issues with libraries that might not handle extreme
// values well.
let process_timeout_ms: u64 = 365 * 24 * 60 * 60 * 1000;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not use None for the timeout?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because then it will default to 10s:

const DEFAULT_TIMEOUT_MS: u64 = 10_000;

Comment on lines +52 to +53
timeout_ms: Option<u64>,
cancel_rx: Option<oneshot::Receiver<()>>,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems wrong to have both a timeout and a cancel token.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess I could try to make them exclusive in a follow-up?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not only have a cancel token? Leave it to the caller to set a timeout.

Comment on lines +57 to +59
if remaining.is_zero() {
break;
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

duplicative with elapsed >= limit above?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great catch!

}

/// Runs `fut`, pausing the stopwatch while the future is pending. The clock resumes
/// automatically when the future completes. Nested calls are reference-counted so the
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// automatically when the future completes. Nested calls are reference-counted so the
/// automatically when the future completes. Nested/overlapping calls are reference-counted so the

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Amended.

@bolinfest bolinfest requested a review from jif-oai November 21, 2025 00:25
bolinfest added a commit that referenced this pull request Nov 21, 2025
…6972)

This updates `ExecParams` so that instead of taking `timeout_ms:
Option<u64>`, it now takes a more general cancellation mechanism,
`ExecExpiration`, which is an enum that includes a
`Cancellation(tokio_util::sync::CancellationToken)` variant.

If the cancellation token is fired, then `process_exec_tool_call()`
returns in the same way as if a timeout was exceeded.

This is necessary so that in #6973, we can manage the timeout logic
external to the `process_exec_tool_call()` because we want to "suspend"
the timeout when an elicitation from a human user is pending.








---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/6972).
* #7005
* #6973
* __->__ #6972
Base automatically changed from pr6972 to main November 21, 2025 00:29
@bolinfest bolinfest merged commit 8e5f38c into main Nov 21, 2025
47 of 50 checks passed
@bolinfest bolinfest deleted the pr6973 branch November 21, 2025 00:45
@github-actions github-actions Bot locked and limited conversation to collaborators Nov 21, 2025
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants