
字节笔记本
2026年10月6日 · 约 11 分钟读完
默认导出弄丢 inject:ACP 服务上线即崩复盘
单元测试 178 个全绿,行覆盖率 100%,功能却在真实用户接入的第一秒崩溃。这不是虚构的段子,而是一起真实事故的完整复盘:一个基于 Cordis 插件体系的 agent 框架,它的 ACP(Agent Client Protocol)服务器在 Zed 编辑器接入的瞬间就返回错误,session/new 请求报出 cannot get property "agents" without inject,session/load 则在 sessionPersistence 上抛出同样的错误。事后发现,背后藏着两个互相独立的 bug,而测试套件漏掉它们的理由也是同一个:所有测试都通过一条不会触及插件真实加载方式和服务真实解析方式的路径去挂载插件。
事故影响与时间线
影响非常直接:编辑器最先调用的两个 RPC 全部失败,任何把 agent 接入 Zed 的用户都会立刻撞上硬故障。好在崩溃发生在任何持久化动作之前,没有数据丢失,代价是功能完全不可用,外加两轮定位原因的调试时间。
时间线值得细看。bridge 方案落地时带了一套完整的单元测试,覆盖编解码器、内存传输、生成的协议消息、失败路径和热更新,另有一个需要真实 API key 的端到端测试和一个免 key 的 stdout 纯净性测试,全部通过,覆盖率报表无可挑剔。但真实的 Zed 会话在 session/new 上立刻失败。调查最初沿着 Cordis 的 traceable 与 shadow 机制展开,这个理论看起来相当优雅,而且它后来被证明确实对应第二个 bug。开发者在框架的 fiber 遍历逻辑里插入跟踪日志,并运行真实子进程,结果显示异常在 apply() 执行期间、也就是插件加载时抛出,位置在根 fiber 且没有 shadow 参与,这推翻了 shadow 理论对 session/new 的解释。删掉那行多余的代码后 session/new 恢复正常,但 session/load 仍然报错,第二个独立机制这才浮出水面。
根因一:默认导出弄丢了 inject

出问题的插件是一个标准的命名空间插件:它把 name、inject、Config、apply 作为独立的命名导出,这与仓库里其他所有插件的写法一致。但它额外多了一行其他插件都没有的代码:
export const name = 'acp'
export const inject = ['agents', 'sessions', 'sessionPersistence']
export function apply(ctx: Context, config: AcpConfig): void { /* ... */ }
// 多余的一行,事故源头
export default apply插件从配置加载时,Cordis 的 Loader 会用 unwrapExports 对导入的模块做规范化:
unwrapExports(exports: any) {
if (isNullable(exports)) return exports
// 注意:优先取 .default
exports = exports.default ?? exports
if (!exports.__esModule) return exports
return exports.default ?? exports
}只要存在默认导出,exports.default ?? exports 就会解析成裸的 apply 函数。裸函数身上没有 inject、没有 name、也没有 Config,这些属性都以同级命名导出的形式挂在模块命名空间上,解包到 .default 等于把整个命名空间扔掉了。Loader 随后用一个空的 inject 构建了插件的 fiber。于是 apply 的第一行 const agents = ctx.agents 在 fiber 树上从 ROOT 一路走到根都找不到 agents,直接抛错。崩溃发生在插件加载时,而不是后续的请求处理器里,第一个请求只是恰好触发了加载。
修复极其简单:删掉 export default apply。Loader 改用模块命名空间,inject、name、Config 全部恢复生效,apply 从此运行在真正注入了所声明服务的 fiber 里。
根因二:可选服务读取踩中 shadow 边界

修掉第一个问题后,session/load 仍然抛出 cannot get property "sessionPersistence" without inject。这一次才是 Cordis 可追踪代理与 shadow 机制的真正登场。
session/load 会调用 agents.resume(...),最终委托给 AgentLoop.resume(),其中读取了 this.ctx.sessionPersistence。AgentLoop 的静态 inject 故意不包含 sessionPersistence:如果声明注入,非持久化的演示会永远挂起,等待一个永远不会加载的后端。这个服务由一个兄弟插件 fiber 提供,属于机会性读取。
Cordis 的服务访问经由上下文代理完成。当服务方法通过一个来自外部 fiber 的可追踪代理被调用时,代理会把 this 重新绑定到一个 shadow 对象,其上下文携带指向 AgentLoop 自身构造上下文的标记。于是在 resume 内部,属性读取的解析从 shadow 的 fiber 开始向上遍历:
// reflect.ts 的 get 处理器,遍历只向祖先方向进行
let fiber = (ctx[symbols.shadow] as Context ?? ctx).fiber
while (true) {
const impl = fiber.store?.[prop]
if (impl) return getTraceable(ctx, impl.value)
if (prop in fiber.inject) { /* 非活跃上下文错误 */ }
if (!fiber.runtime) throw error // 到达根 fiber,抛错
if (fiber.parent[symbols.isolate][prop] !== key) throw error
fiber = fiber.parent.fiber
}这条遍历路径只沿祖先方向走。sessionPersistence 既不在 AgentLoop 自己的 fiber store 里,也不在通往根节点的任何祖先上,它位于一条兄弟分支,于是遍历到根后抛错。
为什么内存 harness 里的恢复测试没有抓住它?因为那些测试从测试代码直接调用 ctx.agents.resume(...),处在任何插件 fiber 之外。此时 ctx.fiber.runtime 为 null,代理处理器走了一条提前绕行的路径,直接在按 isolate 键组织的全局服务 store 里查找,完全忽略 fiber 拓扑,自然能找到服务。从顶层测试读取成功,从真实插件 fiber 内部经 shadow 到达则抛错,而 bridge 恰好属于后者。
修复方式是改用 ctx.get('sessionPersistence'):它走全局 store 查找且保留活跃状态检查,拓扑无关。对于已声明注入集合内的服务,直接属性读取仍然是正确写法。
为什么所有测试都失守
两个 bug 共享同一个流程缺口:没有任何测试通过插件的真实加载路径或真实调用拓扑去驱动它。
内存 harness 通过手工构建插件对象挂载 bridge,手动提供了 inject,因此永远无法复现第一个 bug,unwrapExports 只会被 Loader 调用,就算换成传入命名空间导入也一样。同一个 harness 把所有东西平铺挂在一个根上下文上,恢复调用要么运行在顶层走绕行路径,要么经 shadow 解析仍然落在根上,掩盖了第二个 bug 的祖先遍历失败。唯一一个免 key 的端到端测试只发送 initialize 并检查 stdout 纯净性,从不去触及工厂函数,两个 bug 都安然通过。唯一真正驱动 session/new 与 session/load 的测试需要 API key,CI 没有这个 key 就跳过它;而它在本地之所以显示通过,只是因为一个陈旧的构建产物恰好满足了模块解析。
100% 行覆盖率从头到尾都是真的。覆盖率只能证明代码行被执行过,不能证明功能按交付方式正常工作。
加固措施与教训
事故之后落地的守则包括四条。第一,命名空间插件与默认导出在 Cordis Loader 下互斥,写了 name、inject、Config、apply 的命名空间形式,就绝不要再加 export default。第二,对未声明注入但机会性读取的服务,一律用 ctx.get(name),不要用属性代理读取。第三,新增一个免 key 的 session/new 端到端测试,以子进程方式通过真实 Loader 启动示例并断言调用成功,把那行默认导出加回去它就会立刻失败,这一点已被验证;同时给测试子进程显式指定 tsconfig 路径,避免模块解析静默回退到陈旧构建产物。第四,把「测试真实入口路径,行覆盖率不等于行为覆盖率」写进团队的测试规范。
最后一条教训关于排查方法本身:优雅的理论不等于根因。shadow 机制的解释是真实存在的,但它只是第二个 bug;第一个 bug 只是一行导出错误,在数小时貌似合理的推理之后,一个 fiber 遍历的跟踪日志几分钟就找到了它。相信 trace,而不是相信理论。



