工具取消与超时
Codex 中“命令停了”至少可能指五种事件:Turn cancellation abort 了当前工具 dispatch;捕获式命令的 ExecExpiration 到期; Unified Exec 的本次 yield deadline 结束但进程仍存活;write_stdin 发送 interrupt;网络审批拒绝并终止后台进程。这些事件的 owner、是否杀进程、返回值和生命周期语义都不同。
rust-v0.150.0 的关键变化是:独立 ShellRuntime 已删除,模型命令统一进入 Unified Exec;外层 ToolCallRuntime 也不再提供“等待 runtime cancellation cleanup”的 trait 分支。用户取消通常直接 abort dispatch task,而 Unified Exec 会在初次等待前保存 live process,使已经进入 process store 的后台命令可以跨越当前工具调用继续存在。
本文面向已经读过工具运行时抽象、网络审批与规则保存和 ToolLifecycle事件的读者。读完后,读者应能区分 aborted response、exit code 124、带 process_id 的阶段输出、Ctrl-C 和 network denial。
1. 终止类型
1.1 对照表
| 现象 | Owner | 当前工具调用 | 子进程 | 主要结果 |
|---|---|---|---|---|
| Turn取消 | ToolCallRuntime | abort dispatch | 取决于是否已存入 manager | aborted output |
| 捕获命令超时 | ExecExpiration | 等待结束 | kill process group | timeout error |
| Unified yield结束 | process manager collector | 正常返回 | 保持存活 | process_id |
write_stdin interrupt | process transport | 正常继续 | 发送 interrupt | 后续 poll 观察 |
| 网络拒绝 | Deferred approval monitor | 失败或稍后失败 | fail_and_terminate | denial message |
“工具 future 被 abort”和“进程被 terminate”必须分开。外层 task drop 只能保证 Rust dispatch 不再继续;进程是否仍被 manager、 exec-server 或另一个 Arc 持有,要沿具体 runtime 判断。
2. 外层取消
2.1 Dispatch任务
ToolCallRuntime 把 readiness wait、并行 gate 和 registry dispatch 放进 AbortOnDropHandle。同一个 cancellation token 一份传给 ToolInvocation,另一份由外层 tokio::select! 监听。handler 可以观察 token,但外层是否等待它不再由 handler trait 决定。
源码位置:codex-rs/core/src/tools/parallel.rs :: ToolCallRuntime::handle_tool_call_with_source
let invocation_cancellation_token = cancellation_token.clone();
let mut dispatch_handle = AbortOnDropHandle::new(tokio::spawn(async move {
if let Some(tool_runtime) = tool_runtime
&& let Some(readiness) = tool_runtime.wait_until_ready(&session)
{
readiness.await;
}
let _guard = if supports_parallel {
Either::Left(lock.read().await)
} else {
Either::Right(lock.write().await)
};
router
.dispatch_tool_call_with_terminal_outcome(
session,
step_context,
invocation_cancellation_token,
tracker,
dispatch_call,
source,
dispatch_terminal_outcome_reached,
)
.await
}));取消可能发生在 readiness、并行 gate、PreToolUse、handler、PostToolUse 或 lifecycle 阶段。只有进入 gate 后才设置 execution_started_at,因此取消前等待时间能与 handler 执行时间分开统计。
2.2 Abort分支
token 触发后,outer runtime 先检查 registry 是否已经取得 terminal outcome,或 dispatch task 是否已经完成。如果两者都没有, 它直接 abort() dispatch task,等待 cancelled JoinError,然后构造 aborted output 并发出一次 aborted lifecycle。
源码位置:codex-rs/core/src/tools/parallel.rs :: ToolCallRuntime::handle_tool_call_with_source
tokio::select! {
res = &mut dispatch_handle => {
res.map_err(Self::tool_task_join_error)?
}
_ = cancellation_token.cancelled() => {
if terminal_outcome_reached.load(Ordering::Acquire)
|| dispatch_handle.is_finished()
{
dispatch_handle.await.map_err(Self::tool_task_join_error)?
} else {
let secs = started.elapsed().as_secs_f32().max(0.1);
dispatch_handle.abort();
match dispatch_handle.await {
Ok(result) => return result,
Err(err) if err.is_cancelled() => {}
Err(err) => return Err(Self::tool_task_join_error(err)),
}
let response = Self::aborted_response(&call, secs);
notify_tool_aborted(/* ... */).await;
Ok(response)
}
}
}这里没有旧版的 waits_for_runtime_cancellation 分支。future 被 drop 后的资源清理由 Rust owner、process manager、exec-server 或 Drop 实现负责,外层不会等待 handler 返回“cleanup complete”。
2.3 Aborted输出
AbortedToolOutput 的 success_for_logging 为 false,也没有 PostToolUse payload。默认 namespace 的 exec_command 使用带 wall time 的两行文本,其他工具使用单行 aborted by user after ...。
源码位置:codex-rs/core/src/tools/parallel.rs :: ToolCallRuntime::aborted_response
fn aborted_response(call: &ToolCall, secs: f32) -> AnyToolResult {
AnyToolResult {
call_id: call.call_id.clone(),
payload: call.payload.clone(),
result: Box::new(AbortedToolOutput {
message: Self::abort_message(call, secs),
}),
post_tool_use_payload: None,
}
}
fn abort_message(call: &ToolCall, secs: f32) -> String {
if call.tool_name.is_default_namespace()
&& call.tool_name.name == "exec_command"
{
format!("Wall time: {secs:.1} seconds\naborted by user")
} else {
format!("aborted by user after {secs:.1}s")
}
}3. 终态竞争
3.1 原子仲裁
terminal_outcome_reached 在 outer abort 与 registry finish 之间仲裁。registry 的 notify_tool_finish_if_unclaimed 使用 swap(true);取消分支在 abort 前读取同一原子位。这样已经发布 Completed/Failed 的工具 不会被迟到 cancellation 改写为 Aborted。
源码位置:codex-rs/core/src/tools/registry.rs :: notify_tool_finish_if_unclaimed
if terminal_outcome_reached
.is_some_and(|reached| reached.swap(true, Ordering::AcqRel))
{
return false;
}
notify_tool_finish(invocation, outcome).await;3.2 Gate取消
如果工具还在 readiness 或并行 gate 前,execution_started=false,handler duration 为零,全部耗时归入 dispatch。这个状态说明 handler 根本没开始,不能据此推断其内部 cleanup 或进程终止行为。
3.3 完成后取消
如果 handler 已完成,只是 lifecycle contributor 或 registry 后处理尚未返回,取消分支会看到 finished handle 或 terminal flag, 继续等待原结果。测试覆盖的重点是“迟到取消保留 Completed”,不再包含“等待 handler cleanup 后返回 Aborted”的旧路径。
4. 捕获式执行
4.1 适用范围
ExecExpiration 仍用于 execute_env 这类“等待命令完成并捕获全部输出”的路径,例如 /shell user task、部分 zsh-fork helper 和底层执行 API。它不等同于 Unified Exec 的 yield_time_ms。当前普通 exec_command 由 process manager 启动 PTY 或 exec-server process,不能把下面的 exit code 124 语义自动套到每次 Unified Exec yield。
源码位置:codex-rs/core/src/exec.rs :: ExecExpiration
pub enum ExecExpiration {
Timeout(Duration),
DefaultTimeout,
Cancellation(CancellationToken),
TimeoutOrCancellation {
timeout: Duration,
cancellation: CancellationToken,
},
}4.2 Token合并
with_cancellation 保留已有 timeout,并把多个 token 合成“任一取消即 ready”。TimeoutOrCancellation 使用 biased select, 两者同时 ready 时优先分类为 Cancelled。
源码位置:codex-rs/core/src/exec.rs :: ExecExpiration::with_cancellation
ExecExpiration::TimeoutOrCancellation {
timeout,
cancellation: existing,
} => ExecExpiration::TimeoutOrCancellation {
timeout,
cancellation: cancel_when_either(existing, cancellation),
}源码位置:codex-rs/core/src/exec.rs :: ExecExpiration::wait_with_outcome
tokio::select! {
biased;
_ = cancellation.cancelled() => ExecExpirationOutcome::Cancelled,
_ = tokio::time::sleep(timeout) => ExecExpirationOutcome::TimedOut,
}4.3 Timeout清理
Unix timeout 立即杀进程组并 start_kill child;cancellation 先发送 terminate,等待短 grace period,再对幸存者升级 kill。输出 reader 另有 drain timeout,避免 descendant 持有 pipe 导致调用永久挂起。
源码位置:codex-rs/core/src/exec.rs :: consume_output
Some(ExecExpirationOutcome::TimedOut) => {
kill_child_process_group(&mut child)?;
child.start_kill()?;
(
synthetic_exit_status(EXIT_CODE_SIGNAL_BASE + TIMEOUT_CODE),
true,
)
}
Some(ExecExpirationOutcome::Cancelled) => {
let process_group_id = child.id();
let should_escalate = if let Some(process_group_id) = process_group_id {
terminate_process_group(process_group_id)?
} else {
false
};
// wait grace period, then kill survivors
(synthetic_exit_status_for_code(1), false)
}timeout 最终映射为 timed_out=true、exit code 124 和 SandboxErr::Timeout;cancellation 则 timed_out=false,不会伪装成 timeout。Windows sandbox capture 使用平台自己的 timeout/cancellation 参数,不能外推 Unix process-group 实现。
5. Unified启动
5.1 Runtime输出
Unified Exec runtime 只负责打开进程,返回 UnifiedExecAttempt。它的 ExecOptions 使用默认 expiration,并可附加 network denial token;Turn cancellation token 不在这里合入 ExecExpiration,而是由外层 ToolCallRuntime abort dispatch。
源码位置:codex-rs/core/src/tools/runtimes/unified_exec.rs :: unified_exec_options
fn unified_exec_options(
network_denial_cancellation_token: Option<CancellationToken>,
) -> ExecOptions {
let mut expiration = ExecExpiration::DefaultTimeout;
if let Some(cancellation) = network_denial_cancellation_token {
expiration = expiration.with_cancellation(cancellation);
}
ExecOptions {
expiration,
capture_policy: ExecCapturePolicy::ShellTool,
}
}5.2 先存后等
process manager 在初次 yield wait 前判断进程是否仍存活;若存活,立刻把 process、Deferred approval、monitor、metrics、transcript 和原 command 存入 process store。源码注释明确说明这样做是为了防止 Turn interrupt 丢弃最后一个局部 Arc 后终止后台进程。
源码位置:codex-rs/core/src/unified_exec/process_manager.rs :: UnifiedExecProcessManager::exec_command
let process_started_alive = !process.has_exited()
&& process.exit_code().is_none();
if process_started_alive {
self.store_process(
Arc::clone(&process),
context,
&request.command,
request.hook_command.clone(),
cwd.clone(),
plugin_attribution.clone(),
start,
request.process_id,
request.tty,
deferred_network_approval.clone(),
network_denial_monitor,
metrics_sidecar,
Arc::clone(&transcript),
Arc::clone(&initial_exec_command_active),
)
.await;
}因此外层工具调用被取消时,已经存入 manager 的进程可以继续运行;尚未存储的 UnifiedExecProcess 若失去最后一个 owner,则 Drop 会调用 terminate()。
5.3 Yield边界
yield_time_ms 只控制本次 output collector 的 deadline。deadline 到达后,manager 刷新 process state:仍活着就返回 process_id,已经退出就 finish network approval、检查 sandbox denial 并释放 id。它不是命令 runtime timeout。
6. 继续交互
6.1 Poll与写入
write_stdin 的 deadline 仍只是一次收集窗口。空输入使用可配置 background bounds;非空输入限制最大等待时间,保持交互响应。 TTY 请求写入 PTY;非 TTY 只接受特殊 INTERRUPT 字符串,否则返回 StdinClosed。
源码位置:codex-rs/core/src/unified_exec/process_manager.rs :: UnifiedExecProcessManager::write_stdin
if !request.input.is_empty() {
if !tty {
if request.input == INTERRUPT {
process.interrupt().await?;
} else {
return Err(UnifiedExecError::StdinClosed);
}
} else {
process.write(request.input.as_bytes()).await?;
}
}
let time_ms = request.yield_time_ms.max(MIN_YIELD_TIME_MS);
let yield_time_ms = if request.input.is_empty() {
time_ms.clamp(
MIN_EMPTY_YIELD_TIME_MS,
self.max_write_stdin_yield_time_ms,
)
} else {
time_ms.min(MAX_YIELD_TIME_MS)
};6.2 Pause语义
output collection 接收 Session elicitation pause state。用户交互暂停时,collector 不把暂停期间的时间当作 yield deadline 已消耗; 因此 unified_exec_pause_blocks_yield_timeout 证明的是等待窗口暂停,不是进程 timeout 延长。
6.3 Initial竞争
后台 watcher 可能在初次响应仍收集输出时终止并释放 process。manager 在返回 initial response 前重新读取 state; terminating_initial_exec_command_rechecks_initial_response_state 覆盖这个竞争,避免返回一个已经失效的 live process id。
7. 进程控制
7.1 Interrupt
UnifiedExecProcess::interrupt 对 local PTY 发送 PtyProcessSignal::Interrupt,对 exec-server 发送协议 ProcessSignal::Interrupt。它不等同于 terminate,也不会立即从 process store 删除 entry;调用方随后 poll state。
源码位置:codex-rs/core/src/unified_exec/process.rs :: UnifiedExecProcess::interrupt
match &self.process_handle {
ProcessHandle::Local(process_handle) => {
process_handle.signal(PtyProcessSignal::Interrupt)?
}
ProcessHandle::ExecServer(process_handle) => {
process_handle.signal(ExecServerProcessSignal::Interrupt).await?
}
}7.2 Terminate与Drop
terminate 对 local handle 同步终止,对 exec-server 启动异步 terminate task,然后取消 output token 并 abort output task。 Drop 总会调用 terminate。若需要确认远端终止成功,使用 terminate_confirmed,它只在远端响应成功后更新 exited state。
源码位置:codex-rs/core/src/unified_exec/process.rs :: UnifiedExecProcess::terminate
pub(super) fn terminate(&self) {
match &self.process_handle {
ProcessHandle::Local(process_handle) => process_handle.terminate(),
ProcessHandle::ExecServer(process_handle) => {
let process_handle = Arc::clone(process_handle);
tokio::spawn(async move {
let _ = process_handle.terminate().await;
});
}
}
self.finish_termination();
}
impl Drop for UnifiedExecProcess {
fn drop(&mut self) {
self.terminate();
}
}7.3 Exit token
process.cancellation_token() 属于 output/process 生命周期:进程退出或 output task 收束时触发。它不是 Turn cancellation token。 network monitor 用它判断“进程先退出”,write_stdin 和 watcher 用它等待状态变化。
8. 网络拒绝
8.1 独立Monitor
Unified Exec 成功启动后,process manager 为 Deferred approval 创建独立 monitor,同时等待 network cancellation 与 process exit。 如果 process 先退出,还会等待短暂 late-denial grace,避免把稍晚到达的 policy rejection 错报为普通成功退出。
源码位置:codex-rs/core/src/unified_exec/process_manager.rs :: terminate_process_on_network_denial
let network_cancelled = deferred.cancellation_token();
let process_exited = process.cancellation_token();
tokio::spawn(async move {
let denied = tokio::select! {
_ = network_cancelled.cancelled() => true,
_ = process_exited.cancelled() => {
wait_for_late_network_denial(
Some(network_cancelled.clone()),
)
.await
}
};
if denied {
let message = network_denial_message_for_session(
session.upgrade().as_ref(),
Some(deferred),
)
.await;
process.fail_and_terminate(message);
}
})8.2 Failure优先
fail_and_terminate 只保存第一个 failure message,然后 terminate。初次 exec、write_stdin 和 async watcher 都先检查这个 message,再决定输出与 process-id release。这样第二个错误不会覆盖最早的 network rejection。
源码位置:codex-rs/core/src/unified_exec/process.rs :: UnifiedExecProcess::fail_and_terminate
let state = self.state_rx.borrow().clone();
if state.failure_message.is_none() {
let _ = self.state_tx.send_replace(state.failed(message));
}
self.terminate();9. 错误分类
9.1 Abort与Timeout
aborted by user 来自 outer ToolCallRuntime;SandboxErr::Timeout 来自捕获式 ExecExpiration。前者不说明进程一定已经被杀, 后者明确包含 timed_out=true 和 exit code 124。诊断时不能仅凭文本中出现“time”就归为同一问题。
9.2 Yield与Timeout
Unified Exec 返回 process_id 表示 collector deadline 到达且进程仍活着,这是成功阶段输出。它既不是 SandboxErr::Timeout,也不会触发 Orchestrator 的 sandbox escalation retry。
9.3 Denial与Signal
network denial 通过 failure message 覆盖普通 process exit;Ctrl-C 则只发送 interrupt,由后续状态决定 exit code。直接 terminate 是 manager 的资源清理动作,不等同于用户 interrupt。
10. 测试路径
10.1 外层终态
cancellation_before_dispatch_admission_logs_dispatch_only_timing 验证 gate 前取消时 execution_started=false、handler duration 为 零。cancellation_after_handler_finishes_preserves_completed_lifecycle 验证 handler 完成后的迟到取消不能覆盖 Completed。
相关源码:
codex-rs/core/src/tools/parallel.rs :: cancellation_before_dispatch_admission_logs_dispatch_only_timingcodex-rs/core/src/tools/parallel.rs :: cancellation_after_handler_finishes_preserves_completed_lifecycle
10.2 捕获式清理
底层 exec tests 验证 cancellation 非 timeout、TERM cleanup window 与 timeout 杀孙进程。它们证明 execute_env 的 Unix 捕获式 语义,不证明普通 Unified Exec initial yield 会使用同一终止路径。
相关源码:
codex-rs/core/src/exec_tests.rs :: process_exec_tool_call_respects_cancellation_tokencodex-rs/core/src/exec_tests.rs :: process_exec_tool_call_cancellation_allows_sigterm_cleanupcodex-rs/core/src/exec_tests.rs :: kill_child_process_group_kills_grandchildren_on_timeout
10.3 Unified边界
unified_exec_timeouts 证明短 yield 后可以继续 poll;unified_exec_pause_blocks_yield_timeout 证明 elicitation pause 暂停 collector deadline;terminating_initial_exec_command_rechecks_initial_response_state 覆盖 watcher 与 initial response 竞争。
network tests 则验证 late denial grace、first failure message 和 async watcher 在分类 exit 前等待拒绝。
10.4 定向命令
在 codex-rs workspace 运行:
RUST_MIN_STACK=16777216 cargo test -p codex-core --lib tools::parallel::tests::cancellation_before_dispatch_admission_logs_dispatch_only_timing -- --exact --nocapture
RUST_MIN_STACK=16777216 cargo test -p codex-core --lib tools::parallel::tests::cancellation_after_handler_finishes_preserves_completed_lifecycle -- --exact --nocapture
RUST_MIN_STACK=16777216 cargo test -p codex-core --lib exec::tests::process_exec_tool_call_respects_cancellation_token -- --exact --nocapture
RUST_MIN_STACK=16777216 cargo test -p codex-core --lib exec::tests::process_exec_tool_call_cancellation_allows_sigterm_cleanup -- --exact --nocapture
RUST_MIN_STACK=16777216 cargo test -p codex-core --lib unified_exec::tests::unified_exec_timeouts -- --exact --nocapture
RUST_MIN_STACK=16777216 cargo test -p codex-core --lib unified_exec::process_manager::tests::late_network_denial_grace_observes_cancellation_after_exit -- --exact --nocapture这些测试不证明所有 remote executor 都以相同方式处理 signal,也不证明 Turn cancellation 会终止已经进入 process store 的后台 命令。后者按当前实现恰恰可能继续运行。
11. 诊断实践
看到 aborted by user,先判断 cancellation 发生时进程是否已经进入 manager store;不要假设 outer task abort 等于 process terminate。看到 exit code 124,沿 ExecExpiration 和捕获式执行路径检查。看到命令很快返回且带 process_id,这是 yield deadline,应使用 write_stdin 继续 poll。看到 network denial,检查 Deferred token、first failure message、late-denial grace 和 process-id release。看到 Ctrl-C 后进程仍暂时存在,则继续 poll transport state,而不是把 interrupt 当成同步 terminate。
