技术5/22/2026·8 min

用 Rust typestate 根治进程握手死锁

agent 运行时启动时挂死 30 秒,根因是一个进程在另一个进程开始读取之前就发了 IPC。修复手段不是 sleep,而是用 typestate 的 bootstrap 链 + 显式 IPC 预算,把错误的时序变成编译期错误。

现象

loopal 是一个多进程 agent 运行时:一个轻量父进程以 --hub-only 模式拉起 hub 子进程,hub 再拉起 agent-server。父进程在子进程的 stdout 上等一行握手,拿到才算启动完成。当用户配置了 MCP server 时,启动会挂死 30 秒,然后报错退出:

hub child did not produce a handshake within 30s

这个 30 秒是 hub_spawn.rs 里父进程侧的 HANDSHAKE_TIMEOUT。撞上它意味着子进程根本没打印握手——不是慢,是彻底卡住。

根因:先发后收

修复前的 bootstrap 顺序如下(hub_bootstrap.rs):

let root_session_id = client.start_agent(&params).await?;   // (1) 阻塞

let (root_conn, incoming_rx) = client.into_parts();
loopal_agent_hub::agent_io::start_agent_io(                  // (2) 启动消费者
    hub.clone(), ROOT_AGENT_NAME, root_conn, incoming_rx,
);

start_agent 是发给 agent-server 的一个请求。agent-server 在处理它的过程中,会反向向 hub 发 IPC——hub/mcp/snapshothub/secret/*——去枚举 MCP 工具、解析密钥。这些反向请求落进 incoming_rx

incoming_rx 只有 start_agent_io 在消费,也就是步骤 (2);而步骤 (2) 必须等步骤 (1) 返回才会执行。于是 agent-server 阻塞着等 hub 回应它的反向调用,hub 阻塞在 start_agent().await 等 agent-server 回应——典型的双方死锁。没配 MCP 时无可枚举,反向调用不会触发,所以这个缺陷一直藏着。

start_agent 前加一个 tokio::time::sleep 或加重试,都只能掩盖它。这些都没消除时序隐患——下一个在 bootstrap 路径上加反向调用的人,会再次把挂死引回来。

修复一:连接在开始读之前不能发

IPC 的 Connection 现在把生命周期带进了类型里。两个零尺寸标记类型,一个泛型参数:

pub struct Inactive;
pub struct Listening;

pub struct Connection<S = Inactive> {
    transport: Arc<dyn Transport>,
    pending: PendingMap,
    next_id: AtomicI64,
    _state: PhantomData<S>,
}

send_requestrespondrespond_error Connection<Listening> 实现。拿到 Listening 连接的唯一途径,是消费掉一个 Inactive 连接——而这个构造函数正是启动 reader loop 的地方:

impl Connection<Inactive> {
    pub fn into_listening(self) -> (Arc<Connection<Listening>>, mpsc::Receiver<Incoming>) {
        let rx = spawn_reader_loop(self.transport.clone(), self.pending.clone());
        (Arc::new(Connection { /* state: Listening */ ... }), rx)
    }
}

你无法在一个 reader 还没启动的连接上调 send_request——Connection<Inactive> 上压根没有这个方法。旧的、游离的 start() 后门被删掉了。"在 reader 起来之前发送"从一个运行时挂死,变成了一个类型错误。

修复二:bootstrap 顺序就是一条 typestate 链

同样的思路,上移一层。bootstrap 现在是五个状态,每个一个独立 struct,每次状态转移都消费 self:

HubBuilt → ListenerBound → DispatcherReady → AgentSpawned → Ready
let bs = HubBuilt::new(cwd, config).await;
let bs = bs.bind_listener().await?;          // -> ListenerBound
if let Some(tx) = alive_tx {
    let _ = tx.send(bs.alive_info());        // listener 一绑定就发 ALIVE
}
let bs = bs.register_handlers(cli).await?;   // -> DispatcherReady
let bs = bs.spawn_agent_process().await?;    // -> AgentSpawned(消费者已在排空)
let bs = bs.start_root_agent(&params).await?; // -> Ready

你无法跳过或重排任何一步:start_root_agent 只存在于 AgentSpawned 上,而 AgentSpawned 只由 spawn_agent_process 产出,后者只存在于 DispatcherReady 上,以此类推。编译器替你把这条链走一遍。

与死锁直接相关的那一步在 spawn_agent_process 里:它先启动 agent IO loop,并等消费者确认已在排空,然后下一个状态才能发出 start_agent:

loopal_agent_hub::agent_io::start_agent_io(
    hub.clone(), dispatcher, ROOT_AGENT_NAME, conn.clone(), incoming_rx,
    Some(ready_tx),                 // 消费者通过这个 oneshot 报告就绪
);
wait_consumer_ready(ready_rx).await?;   // 2s 预算;反向 IPC 通道已在排空
// …… 到这里 AgentSpawned::start_root_agent 才发 agent/start

alive_info() 被刻意做成只有 ListenerBound 才有的方法。ALIVE 握手在 TCP listener 绑定的那一刻就发出,不再被 uplink 连接或 agent 拉起卡在后面——agent 启动慢,永远不会拖累存活信号。

修复三:IPC 成本必须声明,绝不缺省

导致死锁的那些反向调用是无预算的——它们会永远等下去。所以现在每个 IPC 调用点都要传一个显式预算,且这个类型没有 Default:

#[derive(Debug, Clone, Copy)]
pub enum IpcBudget {
    Allowed(Duration),
    Forbidden,
}

pub const HUB_RPC_BUDGET: IpcBudget = IpcBudget::Allowed(Duration::from_secs(8));
// 刻意不提供 `Default` 实现:调用方必须在
// `Allowed(d)`(带一个有依据的超时)和 `Forbidden` 之间显式二选一。

Forbidden 给延迟敏感路径上的代码用——比如反向通道排空之前的 bootstrap。远程 MCP provider 会立即拒绝它,而不是阻塞:

IpcBudget::Forbidden => {
    return Err(format!("{method} rejected: IpcBudget::Forbidden on critical path"));
}

这些数字是分层的,好让最内层的失败最先暴露:proxy(8s) < start_agent(20s) < HANDSHAKE(30s)。一个卡住的反向调用会撞上 8s 的 proxy 预算,错误信息直接点名真正出问题的那一层,而不是冒泡成一个笼统的 30s 握手超时。

结果

无 MCP、无密钥、ephemeral session 下实测启动:

  • LOOPAL_HUB_ALIVE:243 ms(listener 已绑定)
  • LOOPAL_HUB_READY:488 ms(root agent 已启动)

大约是旧的 30s 上限的 1/60。握手本身被拆成两段——LOOPAL_HUB_ALIVE <addr> <token>LOOPAL_HUB_READY <session_id>——为向后兼容仍保留旧的单行形式。

三个回归测试钉住了这套行为:

  • hub_only_mcp_deadlock_test.rs——用一个本质上就是 sleep 60 的 MCP server 启动 --hub-only,断言 5s 内出握手行。
  • bootstrap_typestate_e2e_test.rs——驱动一个真实子进程,断言 ALIVE 在 3s 内、READY 在 8s 内。
  • ipc_budget_test.rs——一个 send_request 永远 pending() 的 hub;Forbidden 必须在 50ms 内返回。

可迁移的结论

这次修复加的是状态和一个不带缺省值的枚举,没加一行 sleep 或重试。两条约束可以推广到这个仓库之外:

  • 如果某个顺序是正确性的前提,就让错误的顺序无法被表达出来。 reader 必须先于 writer、listener 必须先于 send——把这个前置条件编码成一个、被下一步操作消费掉的类型,而不是一句注释或一个运行时断言。代价是几个零尺寸 struct;回报是这个回归根本编译不过。
  • 让资源成本在调用点显式,且无缺省。 一个可能永久阻塞的 IPC 调用就是一个潜伏的死锁。强迫每个调用方在 Allowed(d)Forbidden 之间选,就把"我没想过这里会不会阻塞"从一个静默挂死,变成了 reviewer 一眼能看见的一行代码。