Skip to content

工具取消与超时

追踪工具任务取消、捕获式命令超时、Unified Exec yield、进程控制与网络拒绝的不同终止边界。

基于rust-v0.150.0
CodexRustToolsCancellation

工具取消与超时 ​

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取消ToolCallRuntimeabort dispatch取决于是否已存入 manageraborted output
捕获命令超时ExecExpiration等待结束kill process grouptimeout error
Unified yield结束process manager collector正常返回保持存活process_id
write_stdin interruptprocess transport正常继续发送 interrupt后续 poll 观察
网络拒绝Deferred approval monitor失败或稍后失败fail_and_terminatedenial 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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
ExecExpiration::TimeoutOrCancellation {
    timeout,
    cancellation: existing,
} => ExecExpiration::TimeoutOrCancellation {
    timeout,
    cancellation: cancel_when_either(existing, cancellation),
}

源码位置:codex-rs/core/src/exec.rs :: ExecExpiration::wait_with_outcome

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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_timing
  • codex-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_token
  • codex-rs/core/src/exec_tests.rs :: process_exec_tool_call_cancellation_allows_sigterm_cleanup
  • codex-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 运行:

bash
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。