夜雨聆风学习资料网

ARTICLE · 1226995

Codex 源码-Exec Server 远程执行服务

Codex 源码-Exec Server 远程执行服务

Codex 源码解析系列

第 28 讲:Exec Server 远程执行服务

基于 OpenAI Codex 源码 · 2026-10-22

💡 本讲一句话:Codex 把"在哪台机器上跑命令"抽象成一个 Environment——本地进程和远程 exec-server 共用同一个 ExecBackend trait;懒连接、重连合并、按 seq 排序的输出流、RAII 防僵尸会话,30K 行代码全在为这一件事服务。

一、一个 Environment,三套抽象

前几讲我们拆过 unified_exec(第 16-17 讲):core 里的进程管理器只认 ExecProcess、StartedExecProcess 这类接口,从不关心命令到底跑在本机还是远端。这个"不关心"能成立,靠的就是 exec-server crate(30K LOC)提供的统一门面——Environment。

看它的定义就明白设计意图:进程执行、文件系统、HTTP 出站三条能力,全部收敛到同一个结构体里。core 拿到一个 Environment,就等于同时拿到了"能跑命令的机器 + 能读写的文件 + 能发请求的网络"。

📄 codex-rs/exec-server/src/environment.rs (第 667-680 行)

pub struct Environment { // 一个"执行环境" = 进程 + 文件系统 + HTTP,三套抽象打包在一起    remote_client: Option<LazyRemoteExecServerClient>, // 远程执行客户端;None 表示这是纯本地环境    ready_info: Arc<ArcSwapOption<EnvironmentReadyInfo>>, // 就绪信息用 ArcSwap 热替换:读方永不阻塞写方    provisioning_status_tx: Option<watch::Sender<Option<Result<(), String>>>>, // 供给进度广播通道(Pending / Ready / Failed)    startup_task: Arc<Mutex<Option<AbortOnDropHandle<()>>>>, // 后台启动任务句柄:Environment 被 drop 时自动取消,防泄漏    exec_backend: Arc<dyn ExecBackend>, // 统一执行后端 trait:LocalProcess 或 RemoteProcess 二选一    filesystem: Arc<dyn ExecutorFileSystem>, // 文件系统抽象(本地 / 远程 / 沙箱化三种实现)    http_client: Arc<dyn HttpClient>, // HTTP 出站通道,同样走这个环境——网络策略天然统一    local_runtime_paths: Option<ExecServerRuntimePaths>, // 本地模式下的运行时路径(shell、工具链位置)}

为什么这样设计:注意 exec_backend 是 trait object——LocalProcess(本机 fork)和 RemoteProcess(走 RPC 到远端 exec-server)实现同一个 ExecBackend。core 的 unified_exec 层因此完全无感:审批、超时、输出消费逻辑写一遍,本地和远程通用。而把 filesystem 和 http_client 也塞进 Environment,是因为沙箱和网络策略必须跟着"执行位置"走——命令在哪台机器上跑,文件读写和网络访问就必须受同一套策略约束,三者拆开就会出现"命令在远端、网络却在本地放行"的策略漏洞。

二、懒连接与重连合并:LazyRemoteExecServerClient

远程执行的第一道难题是连接管理。Codex 的答案是一个"懒客户端":LazyRemoteExecServerClient 本身不持有任何连接,它持有的是连接参数 + 三个共享槽位(startup / current_client / reconnect)。所有调用者 clone 的都是同一个 Arc 内核。

📄 codex-rs/exec-server/src/client.rs (第 386-403 行)

pub(crate) fn new( // 构造"懒"远程客户端:此刻不建任何连接,首次使用时才连    transport_params: ExecServerTransportParams, // 传输参数:WebSocket / Noise 会合 / Stdio 命令三种形态    http_client_factory: HttpClientFactory, // HTTP 工厂:流式响应与进程输出共用同一条连接的通告通道) -> Self {    Self {        transport_params: Some(transport_params), // 保存传输参数:连接时才知道走 WebSocket / Noise / Stdio        http_client_factory, // HTTP 工厂留待首次使用时创建客户端        recovery_policy: RecoveryPolicy::Wait, // 默认 Wait 策略:断线时等待重连,而不是立刻报错        startup: Arc::new(ConnectionAttempt::default()), // 首次连接槽位:所有调用者共享同一个结果(get_or_init 语义)        current_client: Arc::new(StdMutex::new(None)), // 最近一次成功的客户端缓存;每次重连成功后被替换        reconnect: Arc::new(StdMutex::new(None)), // 进行中的重连槽位:同一故障的多个调用者共享同一次尝试        refresh_lock: Arc::new(Mutex::new(())), // 串行化显式刷新操作,防止并发重建        environment_connection_state_tx: watch::channel(EnvironmentConnectionState::Disconnected).0, // 连接状态广播通道(初值 Disconnected)    }}

为什么这样设计:三个槽位对应三种生命周期:startup 只填一次(首次连接的结果被所有等待者共享,避免 N 个调用者同时发起 N 次握手);current_client 是"当前可用"的缓存;reconnect 是瞬态槽位——重连完成后立刻清空,让下一次故障还能再试。这种"结果共享 + 槽位轮换"的组合,把并发连接问题变成了简单的原子操作。

真正体现功力的是 get()——每次取客户端都要先过一遍决策树:

📄 codex-rs/exec-server/src/client.rs (第 486-521 行)

pub(crate) async fn get(&self) -> Result<ExecServerClient, ExecServerError> { // 取一个可用客户端:全 crate 的入口    if matches!(self.recovery_policy, RecoveryPolicy::FailFast) { // FailFast 模式(status 探测用):只查缓存,绝不触发连接或重连        let client = match self.cached_client() { // FailFast 分支第一步:先看有没有现成缓存            Some(client) => client, // 有缓存直接用            None => match self.startup.result.get() { // 没有缓存:再看首次启动槽位的结果(成功/失败/未开始)                Some(Ok(client)) => client.clone(), // 首次启动已成功:复用那个结果                Some(Err(error)) => return Err(ExecServerError::ConnectionAttempt(Arc::clone(error))), // 首次启动已失败:原样上报,不重试                None => return Err(ExecServerError::Disconnected("exec-server environment is not ready".to_string())), // 还没人发起过连接:明确报"未就绪"            }, // 启动槽位三态(成功/失败/未开始)都处理完        }; // FailFast 分支的 client 已确定,交给下面统一返回        return client.fail_fast(); // 返回的客户端也继承 FailFast——后续 RPC 失败不触发恢复    }    if let Some(client) = self.connected_client() { // 快路径:已有连接且健康,直接返回(热路径零锁竞争)        return Ok(client); // 命中快路径,直接交付    }    let Some(cached_client) = self.cached_client() else { // 没有任何缓存客户端(首次使用):走 initial_client        let client = self.initial_client().await?; // 首次调用:等 startup 槽位完成(所有并发者共享同一次连接)        if !client.is_disconnected() || !self.can_reconnect() { // 判断新客户端是否可以直接交付            return Ok(client); // 健康,或传输类型不支持重连(如 Stdio):直接交付        }        return self.reconnect().await; // 断线且可重连:走合并重连    }; // else 分支结束:手里拿着一个已断线的缓存客户端    if !self.can_reconnect() { // 传输不支持重连(Stdio):旧客户端原样交出去        return Ok(cached_client); // 不可重连的传输:把旧客户端交出去,让调用方自己决定怎么报错    }    self.reconnect().await // 常规路径:缓存已断线 → 合并重连后返回新客户端}

为什么这样设计:RecoveryPolicy::Wait / FailFast 的区分很关键——status()(环境健康探测)必须是非侵入式的:它不能因为"顺手探一下"就触发一次真实连接或重连,否则 doctor/status 命令会改变系统状态。所以 fail_fast() 只是 clone 一个改了策略的壳,内核共享不变。而 can_reconnect() 白名单(Deferred / WebSocketUrl / NoiseRendezvous)把 Stdio 排除在外——Stdio 是"启动一个子进程当服务器",断了就是死了,重连没有意义。

🔹 重连合并(reconnect coalescing):reconnect() 先拿锁检查槽位——若已有进行中的 ConnectionAttempt,直接 clone 它的 Arc 并 await 同一个结果;完成后只有"槽位里还是我这次尝试"时才清空。效果:一次网络抖动期间,100 个并发请求只产生 1 次真实重连。

三、进程启动握手:RAII 守卫防僵尸会话

start_process()(client.rs 第 974-1096 行)是客户端最复杂的函数之一。它要处理一堆竞态:RPC 发出去了但连接断了、会话已注册进表但调用方还没拿到 Session、客户端整体被替换(retired)……Codex 的解法不是 if/else 补丁,而是两个 Drop 守卫:

📄 codex-rs/exec-server/src/client.rs (第 344-367 行)

struct ActiveProcessStart { // RAII 计数器:这条连接上"正在启动中"的进程数    inner: Arc<Inner>, // 持有客户端内核:drop 时通过它减计数}impl Drop for ActiveProcessStart { // 实现 Drop:作用域退出即触发清理,不依赖调用纪律    fn drop(&mut self) { // 任何退出路径(成功/失败/panic)都会走到这里        self.inner.finish_process_start(); // 无论成功失败、panic 还是提前 return,退出作用域自动减计数——每个返回点都不用手动清理    }}struct PendingProcessStartSession { // "已注册但未确认"的会话守卫:start_process 的核心安全网    inner: Arc<Inner>, // 客户端内核:回滚时通过它操作路由表    process_id: ProcessId, // 要摘除的进程 id    state: Arc<SessionState>, // 会话状态指针:摘除前比对,防止误删同 id 的新会话    armed: bool, // armed=true 表示会话已登记进路由表、且尚未被调用方接收——drop 时必须回滚}impl Drop for PendingProcessStartSession { // 实现 Drop:守卫被丢弃 = 调用方没接住 Session    fn drop(&mut self) { // 异常退出(超时/取消/panic)都会触发这里        if self.armed { // armed=true:会话已登记进路由表且未被接收——必须回滚            self.inner.remove_session_if(&self.process_id, &self.state); // 调用方没拿到 Session 就丢弃了:自动从路由表摘除,防僵尸会话        }    }}

为什么这样设计:竞态场景是这样的:客户端先把 (process_id → SessionState) 登记进路由表(因为远端可能在 RPC 响应回来之前就推送输出通告),然后才发 EXEC_METHOD。如果此刻调用方超时放弃、或连接断开,这个"半注册"的会话就成了僵尸——后续通告会路由到一个没人消费的状态对象。PendingProcessStartSession 把回滚逻辑绑在 drop 上:成功路径里显式 pending_start.armed = false 解除武装;任何异常退出(包括 panic)都会触发 drop 自动摘除。这是 Rust 惯用法对分布式竞态的精准打击——清理不依赖调用纪律,而依赖类型系统。

另外两个细节值得注意:其一,RPC 响应任务里用 result_tx.send(Ok(session)) + result_received_rx.await 做"发送-确认"双通道——即使调用方在收到 Session 前就取消,也能触发远端进程的清理(cleanup_process_start),避免远端留下孤儿进程;其二,整个启动包在一个 loop 里,配合 begin_process_start() 的原子检查——若连接状态在检查与使用之间变成了 Recovering,就 continue 重试拿新连接。

四、服务端:seq 编号与保留输出缓冲

镜头切到服务端 local_process.rs(2095 行)。每个运行中的进程对应一个 RunningProcess,它是整个输出流水线的状态中心:

📄 codex-rs/exec-server/src/local_process.rs (第 105-125 行)

struct RunningProcess { // 服务端每进程状态:输出缓冲 + seq 计数器 + 生命周期标志全在这里    session: ExecCommandSession, // 底层子进程句柄(pty 或 pipe 两种形态)    tty: bool, // 是否 TTY 会话(影响信号处理和输出语义)    pipe_stdin: bool, // stdin 是否管道化——决定要不要接受 write 请求    accepted_stdin_write_ids: Arc<Mutex<AcceptedStdinWriteIds>>, // stdin 写幂等缓存:重连后客户端重试同一 write_id,不重复写入子进程    output: VecDeque<RetainedOutputChunk>, // 保留输出环形缓冲:断线重连 / resume 时用来回放    retained_bytes: usize, // 当前缓冲字节数,与 chunk 数量双限淘汰旧数据    next_seq: u64, // 单调递增序号:output / exited / closed 三类事件共用同一编号空间    exit_code: Option<i32>, // 退出码;None = 还在运行(Closed 的前置条件之一)    wake_tx: watch::Sender<u64>, // 唤醒通道:本地 process/read 轮询循环靠它感知新数据    events: ExecProcessEventLog, // 事件日志 = 有界回放 + 实时广播,本地与远程消费者各取所需    output_notify: Arc<Notify>, // 一次性通知:退出路径用它等"最后一块输出冲刷完"再发 Exited    open_streams: usize, // 仍打开的 stdout/stderr 流计数(全部关闭且已退出 → 才发 Closed)    closed: bool, // 终态标志:Closed 事件是否已发出,防止重复发送    metrics: Option<ProcessMetricGuard>, // OTEL 指标守卫:drop 时自动上报耗时与结果分类    termination_requested: bool, // 是否被主动终止——影响 success / error 的指标归类    sandbox: SandboxType, // 本进程使用的沙箱类型(None / seatbelt / bwrap …)    sandbox_denied: bool, // "疑似沙箱拦截"启发式判定结果,随 Exited 事件回传供审批/重试决策    network_proxy_handle: Option<NetworkProxyHandle>, // 每进程网络代理句柄:Closed 时关闭(第 27 讲的产物)    network_policy_shutdown: Option<CancellationToken>, // 网络策略决策回调的取消令牌:进程结束即作废未决请求}

为什么这样设计:三个字段是理解整个 crate 的钥匙。next_seq:output、exited、closed 三类事件共用一个编号空间且严格递增——这是客户端能重排序的前提(下节展开)。open_streams + exit_code 双条件:进程退出 ≠ 输出结束,stdout/stderr 管道可能还有残余数据在冲刷,所以 Closed 必须等"已退出 且 所有流关闭"。accepted_stdin_write_ids:远程客户端断线重连后会重试未确认的 write,服务端用这个 LRU 缓存(4096 条)识别重复请求——幂等性不是协议层的承诺,而是状态层的实现。

输出泵 stream_output() 是每块数据的必经之路:

📄 codex-rs/exec-server/src/local_process.rs (第 973-1024 行,节选)

while let Some(chunk) = receiver.recv().await { // 从子进程 stdout/stderr 的 mpsc 通道循环取块    let notification = { // 临界区开始:只改状态不发 IO,网络通告放锁外        let mut processes = inner.processes.lock().await; // 拿进程表异步锁(临界区极短:只改状态不发 IO)        let Some(entry) = processes.get_mut(&process_id) else { break }; // 进程条目已被清理:停止读取,防 use-after-remove        let ProcessEntry::Running(process) = entry else { break }; // 条目还在 Starting(启动中):等它变成 Running 再说        let seq = process.next_seq; // 取当前序号……        process.next_seq += 1; // ……再自增:保证 output/exited/closed 全局单调递增        process.retained_bytes += chunk.len(); // 记账:当前保留缓冲总字节数        process.output.push_back(RetainedOutputChunk { seq, stream, chunk: chunk.clone() }); // 写入保留缓冲(重连回放用)        while process.retained_bytes > RETAINED_OUTPUT_BYTES_PER_PROCESS || process.output.len() > RETAINED_OUTPUT_CHUNKS_PER_PROCESS { // 双限淘汰:1MiB 或 5 万块,先到先触发            let Some(evicted) = process.output.pop_front() else { break }; // 弹出最旧一块;缓冲空了就停            process.retained_bytes = process.retained_bytes.saturating_sub(evicted.chunk.len()); // 扣减字节账,saturating 防下溢        }        let _ = process.wake_tx.send(seq); // 唤醒本地 reader(process/read 的轮询循环)        process.events.publish(ExecProcessEvent::Output(output.clone())); // 写入事件日志:回放历史 + 实时广播,双通道分发        ExecOutputDeltaNotification { process_id: process_id.clone(), seq, stream, chunk } // 组装发给远程客户端的 RPC 通告体    };    output_notify.notify_waiters(); // 通知"等待最后冲刷"的退出路径(watch_exit 里等 20ms)    if let Some(notifications) = notification_sender(&inner) {        let _ = notifications.notify(EXEC_OUTPUT_DELTA_METHOD, &notification).await; // 经共享连接的通告流发出——所有进程共用一条管道    }}

为什么这样设计:注意锁的边界:临界区里只做状态变更(seq、缓冲、标志位),notify() 网络 IO 放在锁外——高吞吐输出下,持锁发 RPC 会把整个进程表卡死。而 RETAINED_OUTPUT_CHUNKS_PER_PROCESS = 50_000 这个看似随意的数字,源码注释给了理由:每块 chunk 在 JSON-RPC 里要占 4 个值(seq/stream/chunk/包装),5 万块 × 4 ≈ 20 万,正好压在共享的 256K 值解码预算之内——缓冲上限不是拍脑袋,而是被下游解析器反推出来的。

🔹 ExecProcessEventLog(process.rs):"有界回放 + 实时广播"双通道——新订阅者先 drain 一段历史缓冲,再切到 broadcast 活流。历史上限同时受事件数和字节数约束:数量防"海量小事件",字节防"少数巨型输出块"。Failed 事件被刻意设计为无序号(seq() → None),因为它是客户端在会话/传输失败时合成的,不属于进程自己的编号空间。

五、客户端重排序:OrderedSessionEvents

服务端按序发出,但传输层不保证到达顺序——output、exited、closed 由不同任务产生(源码注释原话:"emitted by different tasks and can reach the client out of order")。客户端的解法是每个会话一个 OrderedSessionEvents:BTreeMap 暂存乱序事件,只按严格递增顺序投递。

📄 codex-rs/exec-server/src/client.rs (第 1357-1403 行)

fn publish_ordered_event(&self, event: ExecProcessEvent) -> Result<bool, String> { // 远程事件入口:先按 seq 重排,再投递    let Some(seq) = event.seq() else { // 无序号事件(Failed)走快速通道        self.events.publish(event); // Failed 无序号(客户端合成):直接发布,不参与排序        return Ok(false);    };    let mut ordered_events = self.ordered_events.lock().unwrap_or_else(std::sync::PoisonError::into_inner); // 拿会话级排序锁(poison 时取内层继续,不 panic)    if ordered_events.failure.is_some() || ordered_events.closed_published || seq <= ordered_events.last_published_seq { // 已失败 / 已关闭 / 重复或过期事件:直接丢弃,不污染状态        return Ok(false);    }    ordered_events.insert_pending(event)?; // 放入 BTreeMap 暂存区(带条数/字节双限,超限即报错)    Ok(self.publish_ready(&mut ordered_events)) // 尝试从 last_published_seq+1 开始连续排空}fn publish_ready(&self, ordered_events: &mut OrderedSessionEvents) -> bool { // 排空循环:只按严格 seq 顺序投递,绝不跳号    let mut published_closed = false; // 记录本轮是否投递了 Closed(给调用方判断终态)    loop {        let next_seq = ordered_events.last_published_seq.saturating_add(1); // 下一个期望序号        let Some(event) = ordered_events.pending.remove(&next_seq) else { break }; // 缺口未补齐:停下等待,宁可晚也不乱序        ordered_events.last_published_seq = next_seq; // 推进水位线        ordered_events.exit_published |= matches!(&event, ExecProcessEvent::Exited { .. }); // 记录 Exited 是否已投递        let is_closed = matches!(&event, ExecProcessEvent::Closed { .. }); // 判断本事件是否为终态 Closed        ordered_events.closed_published |= is_closed;        published_closed |= is_closed;        if is_closed && ordered_events.exit_published { // Exited 与 Closed 都到齐:作废该进程所有未决的网络策略决策请求            self.network_policy.cancellation.record(NetworkRequestCancellationReason::ProcessFinished); // 记录取消原因:进程结束(审计用)        }        self.events.publish(event); // 最终发布进事件日志,唤醒 unified_exec / TUI 等所有本地订阅者    } // pending 出现缺口:退出循环等待补齐    published_closed // 返回本轮是否投递了 Closed}

为什么这样设计:"缺口未补齐就 break"是核心不变量——如果 seq=5 还没到就先投了 seq=6,消费者看到的输出顺序就是错的。代价是尾部延迟:一个丢失的 seq 会卡住后面所有事件,所以 insert_pending 设了硬上限(256 条 / 1MiB),超限直接报错而不是无限缓冲——用显式失败换内存安全。另一个精妙处:closed_published 之后一切事件都被丢弃,包括迟到的 output。因为 Closed 是终态语义,"进程已关闭后还收到输出"只可能是协议异常,接受它只会让状态机更乱。

常量
值
作用
RETAINED_OUTPUT_BYTES_PER_PROCESS
1 MiB
服务端每进程保留输出字节上限,超限淘汰最旧块
RETAINED_OUTPUT_CHUNKS_PER_PROCESS
50,000
块数上限:压在 JSON-RPC 解码器 256K 值预算内
MAX_PENDING_PROCESS_EVENTS / BYTES
256 条 / 1 MiB
客户端重排序暂存区双限,超限显式报错
RETAINED_STDIN_WRITE_IDS_PER_PROCESS
4,096
stdin 写幂等 LRU:重连重试去重,防重复写入子进程
NOTIFICATION_CHANNEL_CAPACITY
256
通告发送通道容量,提供背压
EXITED_PROCESS_RETENTION
30 s(测试 25 ms)
Closed 后进程条目保留窗口,迟到读取仍能拿到终态

六、退出与关闭:watch_exit + maybe_emit_closed

进程结束路径有两个函数配合。watch_exit()(local_process.rs 第 1026-1135 行)负责 Exited:拿到退出码后,若进程带沙箱,会先等 output_notify 最多 20ms——让最后一块输出冲刷完;然后把保留缓冲里的全部输出按 stdout/stderr 聚合,跑一次 is_likely_sandbox_denied() 启发式判定(比如"网络请求被拦 + 非零退出码"),把结果写进 sandbox_denied 字段随 Exited 事件回传——core 的审批层靠它决定是重试、换策略还是直接报给用户。

maybe_emit_closed()(第 1152-1208 行)负责终态 Closed,它的触发条件最见功力:

📄 codex-rs/exec-server/src/local_process.rs (第 1152-1208 行,节选)

async fn maybe_emit_closed(process_id: ProcessId, inner: Arc<Inner>) { // 只有"已退出且所有流关闭"才发 Closed,否则静默返回    let (notification, output_notify, network_proxy_handle) = { // 临界区:凑齐终态条件后打包出锁,锁外再做 IO        let mut processes = inner.processes.lock().await; // 拿进程表异步锁(临界区极短)        let Some(ProcessEntry::Running(process)) = processes.get_mut(&process_id) else { return }; // 进程已不在表里:无事可做        if process.closed || process.open_streams != 0 || process.exit_code.is_none() { // 三条件任一不满足就返回——之后流关闭/退出时还会再调本函数            return; // 条件未齐:静默返回,等下次状态变化再试        }        process.closed = true; // 置终态标志,后续重复调用直接短路(Closed 只发一次)        if let Some(network_policy_shutdown) = process.network_policy_shutdown.take() { // 取出并置空:取消所有未决的网络策略决策回调            network_policy_shutdown.cancel(); // 进程结束:取消所有未决的网络策略决策回调(第 27 讲的闭环)        }        let seq = process.next_seq;        process.next_seq += 1; // Closed 也占一个序号——与 output/exited 同属一条排序链,客户端才能确定"它之后没有事件了"        let _ = process.wake_tx.send(seq); // 唤醒本地 reader:Closed 也要让它感知        process.events.publish(ExecProcessEvent::Closed { seq }); // 写入事件日志:本地订阅者(unified_exec)也能收到终态        (ExecClosedNotification { process_id: process_id.clone(), seq }, Arc::clone(&process.output_notify), process.network_proxy_handle.take()) // 把锁内取到的东西打包带出去,锁外再做 IO    };    if let Some(network_proxy_handle) = network_proxy_handle && let Err(err) = network_proxy_handle.shutdown().await { // 关闭每进程网络代理(在锁外!避免持锁做异步 IO)        tracing::warn!("failed to shut down executor network proxy: {err}"); // 代理关闭失败只告警不致命:进程已终态,不能因清理失败卡死    }    output_notify.notify_waiters(); // 唤醒所有等待者:输出已彻底结束    tokio::spawn(async move { // 延迟清理任务:Closed 后条目不立即删除        tokio::time::sleep(EXITED_PROCESS_RETENTION).await; // 保留 30s——窗口内迟到的 read / write 重试仍能查到会话并拿到终态,而不是"进程不存在"        let mut processes = cleanup_inner.processes.lock().await; // 拿锁做最终清理        match processes.entry(cleanup_process_id) { // 按 id 查条目:存在且已 closed 才删            Entry::Occupied(entry) => { if matches!(entry.get(), ProcessEntry::Running(process) if process.closed) { entry.remove(); } } // 只删确认 closed 的条目;若同 id 已被新会话占用则不动            Entry::Vacant(_) => {} // 已被别人删了:无事发生        }    });}

为什么这样设计:"maybe"前缀暴露了真实语义——这是个可重入的收敛函数:exited 先到、streams 后关,或反过来,每次状态变化都调一次,谁凑齐三条件谁触发。而 30s 保留窗口是分布式系统的经典手法:process/read 请求可能正在路上,如果条目立刻消失,客户端会收到"进程不存在"这种误导性错误;保留期内它总能拿到 closed=true + exit_code 的终态响应。清理任务里那个 if process.closed 检查同样关键——防止误删同 id 的新会话(process_id 可复用)。

七、数据流:一块输出如何从子进程走到 TUI

输出端到端路径(远程执行)

① 子进程管道:stdout/stderr → mpsc::Receiver

stream_output 循环取块,每块都是原始字节。

▼

② 服务端编号:next_seq += 1 → 保留缓冲 + EventLog

seq 全局单调;回放历史与实时广播双通道分发。

▼

③ RPC 通告:EXEC_OUTPUT_DELTA 走共享连接

所有进程共用一条通告流,按 process_id 区分。

▼

④ 客户端路由:handle_server_notification → SessionState

ArcSwap 会话表 O(1) 查找,读路径零锁竞争。

▼

⑤ 重排序:OrderedSessionEvents BTreeMap 暂存

乱序事件入 pending,缺口补齐才继续;256 条/1MiB 双限。

▼

⑥ 投递消费:events.publish → unified_exec / TUI

严格 seq 顺序唤醒订阅者;Closed 到齐后取消未决网络决策。

为什么这样设计:整条链路只有一个全局不变量——seq 单调递增且消费端严格保序。服务端单点编号(②)保证唯一性,客户端 BTreeMap 重排(⑤)容忍乱序,两端用同一套上限常量防止任何一方无限缓冲。本地执行时跳过 ③④⑤ 直接走 EventLog 广播,但事件模型完全一致——这就是"一套接口两个世界"能成立的底层原因。

八、总结:本地 vs 远程,一张表看清

维度
本地 LocalProcess
远程 RemoteProcess
连接模型
无连接,直接函数调用
LazyRemoteExecServerClient:懒连接 + 重连合并
输出投递
EventLog 广播直达订阅者
RPC 通告 → seq 重排序 → EventLog
顺序保证
单点编号,天然有序
多任务乱序可能,BTreeMap pending 补洞
断线语义
进程随服务死亡,无恢复路径
Wait/FailFast 双策略;stdin 写幂等去重
会话清理
Closed + 30s 保留窗口后删除
同协议;另加 RAII 守卫防半注册僵尸会话
沙箱判定回传
watch_exit 启发式 + sandbox_denied 字段
同一协议字段透传,core 审批层无感

本讲带走三件事:🔹 Environment 把进程/文件/网络绑成一个策略单元,core 因此对执行位置完全无感;🔹 seq 编号 + 客户端重排序是"远程输出流可靠投递"的全部秘密——两端共享同一套上限常量,用显式失败换内存安全;🔹 Rust 的 Drop 守卫(ActiveProcessStart / PendingProcessStartSession)把分布式竞态清理变成了类型系统保证,这是读 Codex 源码时最值得借鉴的手法。

📚 系列导航

← 第 27 讲:Network Proxy 网络代理引擎

→ 第 29 讲:State Runtime Store

关注公众号「AI技术推荐官」获取更多源码解析内容

相关学习资料