ByteNoteByteNote
ACP 连接即崩复盘:一行多余导出丢掉 inject
字

字节笔记本

2026年10月6日 · 约 9 分钟读完

ACP 连接即崩复盘:一行多余导出丢掉 inject

API中转
¥120

一个测试全绿、行覆盖率 100% 的插件运行时,接上真实编辑器却在第一次请求时崩溃,这类事故最值得复盘的地方在于:测试到底测了什么,又漏掉了什么。本文要讲的是 deepseek-harness 项目 docs/postmortem 目录下的第 0001 号事故记录,主角是它的 ACP(Agent Client Protocol)服务器:Zed 编辑器接入的瞬间,session/new 与 session/load 两个 RPC 连续失败,报错都是同一句 cannot get property "agents" without inject。事后查明,背后是两个互相独立的 bug,修复已合入 PR #41(分支 feat/acp-2-bridge)。

现象:编辑器一接入就崩

ACP 服务器位于仓库的 examples/acp-agent 示例与 @deepseek-ai/dsh-acp 包中,作用是把 agent 接入 Zed 这类支持 Agent Client Protocol 的编辑器。真实编辑器连上的瞬间,第一个 session/new 请求直接返回 Internal error: cannot get property "agents" without inject,紧接着 session/load 对 sessionPersistence 抛出同样的错误。尽管当时仓库已有 178 个单元测试、行覆盖率 100%,bridge 在真实环境下却完全不可用。影响范围很清楚:无法创建或加载任何会话,而这正是编辑器最先调用的两个 RPC;好在崩溃前没有任何内容被持久化,没有数据丢失,代价是功能完全不可用,外加两轮定位原因的调试时间。

Bug 1 的触发链路:多余的 export default 让 Loader 丢掉整个命名空间

根因一:一行多余的 export default

packages/acp/acp/src/index.ts 是一个命名空间插件:name、inject、Config、apply 全部以命名导出的形式存在,仓库里其他插件(invariants、llm-deepseek、tool-bash、tui 等)也都是这个写法。问题在于,这个文件比其他所有插件多出了一行 export default apply。

插件由 Cordis Loader 从 cordis.yml 加载时,会先经过 unwrapExports 对导入模块做规范化。这段代码的逻辑是 exports.default ?? exports:只要模块上存在 default 导出,Loader 拿到的就是那个裸的 apply 函数,而 name、inject、Config 这些挂在模块命名空间上的同级导出被整体丢弃。Loader 随后基于空的 inject 构建插件 fiber,apply 便在一个没有注入任何服务的 fiber 里运行,第一行 ctx.agents 就会在遍历完整棵 fiber 树、抵达根 fiber 之后抛出那句错误。

值得强调的是,崩溃发生在插件加载时,而不是请求处理器里,请求只是恰好触发了加载。修复方法就是删掉这一行:Loader 重新拿到模块命名空间,正确识别 inject、name 与 Config,apply 在一个确实注入了全部声明服务的 fiber 中运行,session/new 随即恢复。

根因二:可选服务读取踩中 shadow 遍历

删掉那行导出之后,session/new 正常了,session/load 却仍在 sessionPersistence 上抛错。这一次才轮到 Cordis 的可追踪代理与 shadow 机制登场,也值得讲得更精确一些。

session/load 会调用 agents.resume(),其中读取 this.ctx.sessionPersistence。AgentLoop 的 static inject 故意不包含这个服务,因为一旦声明注入,非持久化的演示会话会永远挂起,等待一个永远不会加载的后端。这个服务由一个兄弟插件提供,属于机会性读取。

Cordis 的服务访问经由上下文代理完成。当 bridge fiber 调用 ctx.agents.resume 时,注册表返回的 AgentLoop 实例会被重新包装成绑定到调用方的 traceable 代理,createShadowMethod 再把 this 重新绑到一个 shadow 对象上,其 ctx 指向 AgentLoop 自身的构造上下文。于是 sessionPersistence 的解析从 AgentLoop 的 fiber 起步,沿祖先方向一路向上:这个属性既不在 AgentLoop 的 store 里(它不在 static inject 中),也不在通往根 fiber 的任何祖先上(它挂在兄弟分支),遍历到根后只能抛错。这条遍历路径只向祖先走,永远不会拐进兄弟分支。

内存中的恢复测试之所以没抓住它,是因为测试代码从顶层直接调用 ctx.agents.resume(),此时 ctx.fiber.runtime 为 null,代理处理器走了一条提前绕行的路径:直接查基于 isolate 的全局服务 store,完全无视 fiber 拓扑,自然找得到服务。bridge 恰好是从真实插件 fiber 内部、经由 shadow 到达的那条路径。修复是把属性读取改成 ctx.get('sessionPersistence'),这个方法做的是拓扑无关的全局查找,同时保留活跃状态检查。

Bug 2 的机制:shadow 遍历只向祖先走,兄弟分支上的服务永远找不到

为什么 178 个测试全都没拦住

两个 bug 同源:没有任何测试通过插件的真实加载路径或真实调用拓扑去驱动它。内存 harness 手工构建插件对象挂载 bridge,手动提供了 inject,而 unwrapExports 只有 Loader 会调用,ctx.plugin 根本不经过它,Bug #1 在结构上就无法复现;同一个 harness 把所有东西平铺挂在一个根上下文上,AgentLoop 的恢复要么运行在顶层(触发绕行路径),要么经由 shadow 但 origin 仍解析到根,掩盖了 Bug #2 的祖先遍历失败。唯一的无 key e2e 只发送 initialize 并检查 stdout 纯净度,而 initialize 不会触达 factory,两个 bug 都安然通过;唯一驱动 session/new 与 session/load 的测试需要 API key,CI 无 key 直接跳过,本地它之所以显示通过,只是一份陈旧的已构建 lib 恰好满足了模块解析。

行覆盖率自始至终是 100%。覆盖率只能证明代码行被执行过,证明不了功能是否按交付方式正常工作。

修复与新增的防线

修复本体只有两处:删除多余的 export default apply;AgentLoop.resume 改用 ctx.get('sessionPersistence') 读取可选服务,并附上注释说明 shadow 遍历的陷阱。防线则是成套补上的。一个无需 API key 的 session/new e2e 测试,以子进程方式通过真实 stdio 与真实 Loader 启动示例,断言 session/new 正常返回,且已验证恢复那行导出时它会失败。e2e 的子进程 spawn 显式设置 TSX_TSCONFIG_PATH,让 tsx 不再依赖 cwd 向上搜索 tsconfig 的 paths 映射,杜绝测试静默回退到陈旧构建产物的问题。项目测试文档也把「测试真实入口路径,行覆盖率不等于行为覆盖率」写成了对所有未来插件生效的规则。

三条可以带走的经验

第一,命名空间插件与 default export 在 Cordis Loader 下互斥。选定了 name、inject、Config、apply 的命名空间形式,就不要再补一行 export default,unwrapExports 会把整个命名空间丢掉。第二,插件机会性读取、又没在 static inject 里声明的服务,一律用 ctx.get(name),不要用属性代理。属性代理的解析沿祖先方向的 fiber 遍历进行,经由外部 shadow 时会失败;ctx.get 是拓扑无关的查找,且默认严格模式,非活跃后端只会读到 undefined。第三,手动构建插件的测试验证不了插件的加载方式,至少要有一个测试端到端驱动真实的 Loader 与导出路径;当核心操作不调用模型时,这样的测试不需要 API key,理应放进 CI,而不是留在 key 门控之后。这次复盘里还有一句朴素的方法论:相信跟踪结果,不要迷信理论。数小时看似合理的 shadow 推演没有找到第一个 bug,一行 fiber 遍历的调试输出几分钟就找到了它。

相关文章

分享: