现象
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(¶ms).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/snapshot、hub/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_request、respond、respond_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(¶ms).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 一眼能看见的一行代码。