Unified Exec失败与竞态
前九篇分别讲了创建、状态、输入、等待、watcher、截断和回收。本篇不再介绍单个机制,而是从失败分支反向检查整个 Unified Exec 是否保持不变量:没有进程的错误是否释放 ID,进程失败是否保留根因,sandbox denial 是否带回输出,网络拒绝是否等 reader 与 watcher 收尾,旧 Arc 是否会误删新会话。
本文面向已读过Unified Exec创建进程、Unified Exec会话状态机和AsyncWatcher后台监控并理解异步竞态的读者。范围是 UnifiedExecError、manager 的失败转换、process 的失败状态和现有竞态测试;不展开安全策略本身。读完后,你应能按“请求前、spawn 后、写入中、退出后”定位错误类型和清理责任。
1. 错误分层
1.1 无进程错误
CreateProcess、MissingCommandLine 和 ForeignPath 发生在 child 建立前;UnknownProcessId 是 store 查找失败;StdinClosed 表示 pipe 会话拒绝普通输入。这些错误不能被解释成 child 的 exit code。
源码位置:codex-rs/core/src/unified_exec/errors.rs :: UnifiedExecError
pub(crate) enum UnifiedExecError {
CreateProcess { message: String },
ProcessFailed { message: String },
UnknownProcessId { process_id: i32 },
WriteToStdin,
StdinClosed,
MissingCommandLine,
SandboxDenied { message: String, output: ExecToolCallOutput, .. },
ForeignPath { path: PathUri },
}1.2 有进程错误
ProcessFailed 表示 transport、网络或终止路径需要传播的失败;SandboxDenied 还携带输出、原始 token 估计和省略字节元数据,允许模型同时看到拒绝原因与命令输出。
源码位置:codex-rs/core/src/unified_exec/errors.rs :: with_output_collection_metadata
pub(crate) fn with_output_collection_metadata(
self,
original_token_count: usize,
output_omitted_bytes: Option<NonZeroUsize>,
) -> Self {
match self {
Self::SandboxDenied { message, output, .. } => Self::SandboxDenied {
message,
output,
original_token_count: Some(original_token_count),
output_omitted_bytes,
},
other => other,
}
}2. 创建失败
manager 在 open_session_with_sandbox 或 open_session_with_prepared_exec_env 出错时,调用方负责 release_process_id。本地 foreign path、空命令、spawn error 和远程 inherited fd 拒绝都不会插入可交互条目。
源码位置:codex-rs/core/src/unified_exec/process_manager.rs :: exec_command、open_session_with_prepared_exec_env
let process = self
.open_session_with_sandbox(&request, cwd.clone(), context)
.await;
let (attempt, mut deferred_network_approval) = match process {
Ok((attempt, deferred_network_approval)) => (attempt, deferred_network_approval),
Err(err) => {
self.release_process_id(request.process_id).await;
return Err(err);
}
};
let UnifiedExecAttempt {
process,
metrics_sidecar,
} = attempt;
let process = Arc::new(process);这个分支的证明重点是资源回收,不是错误文本:即使 backend 返回详细错误,ID 也不能继续占用。只有 runtime 已经返回 UnifiedExecAttempt 后,manager 才接管进程与 metrics sidecar;创建失败之前不存在需要后台 watcher 收尾的 sidecar。
3. Sandbox与网络
3.1 denial输出
sandbox denial 可能在进程已经产生输出后才被识别。manager 将输出、退出码、token 估计和 omission bytes 一起包装为 SandboxDenied,不把它伪装成普通 ProcessFailed。
源码位置:codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs :: ExecCommandHandler::handle_call
Err(UnifiedExecError::SandboxDenied {
output,
original_token_count,
output_omitted_bytes,
..
}) => {
let output_text = output.aggregated_output.text;
let original_token_count =
original_token_count.unwrap_or_else(|| approx_token_count(&output_text));
Ok(boxed_tool_output(ExecCommandToolOutput {
event_call_id: context.call_id.clone(),
chunk_id: generate_chunk_id(),
wall_time: output.duration,
raw_output: output_text.into_bytes(),
truncation_policy: turn.model_info.truncation_policy.into(),
max_output_tokens,
process_id: None,
exit_code: Some(output.exit_code),
original_token_count: Some(original_token_count),
output_omitted_bytes,
hook_command: Some(hook_command),
}))
}3.2 网络拒绝
网络拒绝 monitor 监听 deferred approval token 与 process exit。若拒绝先到,调用 fail_and_terminate;若 process 先退出,仍等待短暂 late-denial grace,避免把拒绝误分类为成功。
源码位置:codex-rs/core/src/unified_exec/process_manager.rs :: terminate_process_on_network_denial
let denied = tokio::select! {
_ = network_cancelled.cancelled() => true,
_ = process_exited.cancelled() => {
wait_for_late_network_denial(Some(network_cancelled.clone())).await
}
};
if denied {
let session = session.upgrade();
let message = network_denial_message_for_session(session.as_ref(), Some(deferred)).await;
process.fail_and_terminate(message);
}3.3 未入store的失败
进程可能已经创建,但在首次状态判断时不再存活,因此不会进入 ProcessStore,也没有后台 exit watcher 负责发结束事件。emit_failed_initial_exec_end_if_unstored 只在这种情况下补发 failed end;已经入 store 的活进程直接返回,由 watcher 保证唯一终态。
源码位置:codex-rs/core/src/unified_exec/process_manager.rs :: emit_failed_initial_exec_end_if_unstored
if process_started_alive {
return;
}
emit_failed_exec_end_for_unified_exec(
Arc::clone(&context.session),
Arc::clone(&context.step_context.turn),
context.call_id.clone(),
request.command.clone(),
cwd,
Some(request.process_id.to_string()),
plugin_attribution,
transcript,
fallback_output,
message,
wall_time,
)
.await;这条路径避免两种错误:短命令失败却没有结束事件,以及已存储后台命令由 manager 和 watcher 各发一次结束事件。
4. 写入竞态
write_stdin 在同一进程上持有 interaction lock。写入返回错误后,它先刷新状态:如果进程已经退出,继续形成退出响应;只有仍活跃且是 ProcessFailed 才终止并释放 ID。这样 transport race 不会丢掉可用 exit code。
源码位置:codex-rs/core/src/unified_exec/process_manager.rs :: write_stdin
Err(err) => {
let status = self.refresh_process_state(process_id).await;
if matches!(status, ProcessStatus::Exited { .. }) {
status_after_write = Some(status);
} else if matches!(err, UnifiedExecError::ProcessFailed { .. }) {
process.terminate();
self.release_process_id(process_id).await;
return Err(err);
} else {
return Err(err);
}
}远程 UnknownProcess 或 StdinClosed 还会先把统一状态标记为 exited;这使后续 refresh 有机会走正常终态分支。
5. 旧句柄竞态
poll 先从 store 克隆 Arc,等待期间条目可能被 refresh 或 terminate 移除。再次准备句柄时用 Arc::ptr_eq 校验;poll 手中的旧进程若已经退出,最终仍可返回 exit metadata,否则报告 Unknown。
源码位置:codex-rs/core/src/unified_exec/process_manager.rs :: prepare_process_handles、write_stdin
if !Arc::ptr_eq(&entry.process, expected_process) {
return Err(UnifiedExecError::UnknownProcessId { process_id });
}6. 失败根因与资源
6.1 首个根因
fail_and_terminate 只写入第一个 failure message,之后终止底层 process;fail_process_with_message 如果已有 failure,则保留原根因而不被后续清理错误覆盖。
源码位置:codex-rs/core/src/unified_exec/process.rs :: fail_and_terminate、codex-rs/core/src/unified_exec/process_manager.rs :: fail_process_with_message
pub(super) fn fail_and_terminate(&self, message: String) {
let state = self.state_rx.borrow().clone();
if state.failure_message.is_none() {
let _ = self.state_tx.send_replace(state.failed(message));
}
self.terminate();
}
fn fail_process_with_message(process: &UnifiedExecProcess, message: String) -> UnifiedExecError {
if let Some(message) = process.failure_message() {
process.terminate();
return UnifiedExecError::process_failed(message);
}
process.fail_and_terminate(message.clone());
UnifiedExecError::process_failed(process.failure_message().unwrap_or(message))
}6.2 Metrics收尾
后台进程的 plugin metrics sidecar 由 ProcessEntry 和 exit watcher 共享。watcher 在终态屏障后 take 一次:成功分支用真实 exit code 完成测量;失败分支直接 drop,不把统一失败码 -1 当成插件正常退出结果。
源码位置:codex-rs/core/src/unified_exec/async_watcher.rs :: spawn_exit_watcher
let plugin_metrics_sidecar = plugin_metrics_sidecar
.as_ref()
.and_then(take_plugin_metrics_sidecar);
if let Some(message) = process.failure_message() {
drop(plugin_metrics_sidecar);
emit_failed_exec_end_for_unified_exec(
session_ref,
turn_ref,
call_id,
command,
cwd,
Some(process_id.to_string()),
plugin_attribution,
transcript,
String::new(),
message,
duration,
)
.await;
} else {
let exit_code = process.exit_code().unwrap_or(-1);
finish_and_track_measurements(
plugin_metrics_sidecar,
exit_code,
&session_ref,
&turn_ref,
&call_id,
)
.await;
}sidecar 的 Option::take 还防止首次 exec_command 与 watcher 同时观察到快速退出时重复完成测量。失败分类因此不仅决定事件内容,也决定哪些附属资源允许提交结果。
7. 四组验证
7.1 Process局部状态
process 模块的 5 项测试验证 UnknownProcess/StdinClosed 会标记 exited、第二个失败不会覆盖首个根因、远程 terminate 只有成功才改变状态,以及 executor 报告的 sandbox type 会被统一进程保留。
源码位置:codex-rs/core/src/unified_exec/process_tests.rs
cd codex-rs
cargo test -p codex-core --lib 'unified_exec::process_tests::' -- --test-threads=17.2 首次失败与grace
fallback 测试断言 Session 不可用时仍返回明确的 sandbox network proxy 消息;grace 测试在 exit 后 10ms 才取消 token,断言窗口能观察拒绝;未入 store 测试断言 fallback output 与 denial message 合并成唯一 failed item。
源码位置:
codex-rs/core/src/unified_exec/process_manager_tests.rs :: network_denial_fallback_message_names_sandbox_network_proxycodex-rs/core/src/unified_exec/process_manager_tests.rs :: late_network_denial_grace_observes_cancellation_after_exitcodex-rs/core/src/unified_exec/process_manager_tests.rs :: failed_initial_end_for_unstored_process_uses_fallback_output
cd codex-rs
cargo test -p codex-core --lib network_denial_fallback_message_names_sandbox_network_proxy -- --test-threads=1
cargo test -p codex-core --lib late_network_denial_grace_observes_cancellation_after_exit -- --test-threads=1
cargo test -p codex-core --lib failed_initial_end_for_unstored_process_uses_fallback_output -- --test-threads=17.3 异步竞态
exit_watcher_waits_for_late_network_denial_before_classifying_end 让 denial monitor 晚于 process exit,断言结束事件等待 monitor 后才分类;terminating_during_stdin_poll_returns_exited_response 验证 store 移除后旧 Arc 已退出仍能返回终态。
源码位置:codex-rs/core/src/unified_exec/async_watcher_tests.rs :: exit_watcher_waits_for_late_network_denial_before_classifying_end、codex-rs/core/src/unified_exec/mod_tests.rs :: terminating_during_stdin_poll_returns_exited_response
cd codex-rs
cargo test -p codex-core --lib unified_exec::async_watcher::tests::exit_watcher_waits_for_late_network_denial_before_classifying_end -- --test-threads=1
cargo test -p codex-core --lib terminating_during_stdin_poll_returns_exited_response -- --test-threads=17.4 真实网络拒绝
两个集成测试分别让命令先进入后台 store,以及在首次等待内快速结束。两种输入都会经 managed proxy 触发拒绝,并断言最终状态 Failed、exit code -1、聚合输出包含 Network access,且只发布对应命令的结束事件。
源码位置:
codex-rs/core/tests/suite/unified_exec.rs :: unified_exec_network_denial_emits_failed_background_end_eventcodex-rs/core/tests/suite/unified_exec.rs :: unified_exec_short_lived_network_denial_emits_failed_end_event
cd codex-rs
RUST_MIN_STACK=8388608 cargo test -p codex-core --test all unified_exec_network_denial_emits_failed_background_end_event -- --test-threads=1
RUST_MIN_STACK=8388608 cargo test -p codex-core --test all unified_exec_short_lived_network_denial_emits_failed_end_event -- --test-threads=1这些测试没有传入真实 plugin metrics sidecar,因此失败分支 drop sidecar、成功分支按 exit code 完成测量的结论来自当前所有权源码,没有在本组测试中单独断言。
8. 故障排查
rg -n "enum UnifiedExecError|SandboxDenied|with_output_collection_metadata" codex-rs/core/src/unified_exec
rg -n "fail_process_with_message|terminate_process_on_network_denial|refresh_process_state" codex-rs/core/src/unified_exec/process_manager.rs
rg -n "late_network_denial|terminating_during_stdin_poll|preserves_failure" codex-rs/core/src/unified_exec看到没有 process_id 的错误,先查 reserved ID 是否释放;看到 sandbox denial,查 output 和 omission metadata;看到失败原因被覆盖,查 failure_message 首写规则;看到 Unknown,查第一次 store 查找、ptr_eq 和旧 Arc 是否已经退出。
