乐于分享
好东西不私藏

Cordis 深度剖析:把插件系统做成可撤销、可重组的运行时

Cordis 深度剖析:把插件系统做成可撤销、可重组的运行时

Cordis 深度剖析:把插件系统做成可撤销、可重组的运行时

作者:SOTA AI

从可逆 Effect、响应式 Coeffect、Fiber 生命周期、Context Proxy、服务隔离、配置树调和与 HMR 回滚,拆解 Cordis 的时空可组合性运行时。

传统插件系统通常解决一个问题:让第三方代码能够在启动时注册功能。它很少真正解决另一个更难的问题:当插件、依赖、配置或代码在运行中变化时,如何让旧状态完整退出、新状态正确接管,并且不留下失效监听器、悬挂定时器、过期服务实例或不可解释的依赖关系。

Cordis 对这个问题的回答不是再增加一层 dependency injection,而是将动态组合本身作为运行时语义。它把组件向上下文施加的变化定义为可追踪的 effect,把组件对服务可用性的依赖定义为可响应的 coeffect,再用 Fiber、Context、Service、Event 和 Loader 把这两类机制落实为一套 TypeScript runtime。DeepSeek Harness 所谓“Everything is a Plugin”,底层依赖的正是这套机制。

Cordis 仍处于活跃开发阶段,官方明确表示 API 尚不稳定。本文分析公开仓库中核心包、loader 和 HMR 的实现策略,重点讨论它已经解决了哪些运行时问题,以及在生产采用时必须自行补上的边界。

一、从插件 API 到动态组合:Cordis 想解决的不是“加载”,而是“撤销与重组”

把插件抽象成 apply(ctx) 并不困难。困难在于插件实际做的事情远多于返回一个对象:它可能注册服务、事件监听器、计时器、文件观察器、网络连接、配置项和下游插件。若卸载时只调用一个约定式 dispose(),就很难证明所有副作用都被覆盖;若依赖服务中途消失,消费者往往继续持有过期引用;若配置更新只能整进程重启,插件化就失去动态价值。

Cordis 将问题拆成两个正交维度。

维度Cordis 的术语运行时问题核心机制
时间Temporal Composability组件退出后如何撤销它造成的全部变化可逆 effect、Fiber disposer 栈、逆序异步清理
空间Spatial Composability组件依赖什么,依赖变化后如何重新建立正确关系Context service、inject、可用性检查、响应式 reload

这一区分非常重要。大多数框架只有空间的一半:有服务注入,但没有服务撤销后自动收缩的组件生命周期。也有框架只有时间的一半:有 cleanup hook,但不知道哪些消费者应因 provider 更换而重新激活。Cordis 将两条链路放到同一个 Context/Fiber 模型中,目标是让“插件被移除”与“依赖图被重算”成为可组合的基础操作。

论文草案用 effect 与 coeffect 的语言描述这件事:每个 context transformation 都要带有运行时可追踪的逆操作;每次 context 改变都应根据组件声明的 coeffect 触发响应。需要注意的是,这是一篇持续修订的 preprint,应视为实现设计的理论框架,而不是对任意业务插件自动正确的证明。

二、Context Proxy:服务访问本身就是一种受约束的解析过程

Cordis 的 Context 不是普通对象。根 Context 由 Proxy 包裹,读取 ctx.someService 时并非简单查找属性,而是经过反射层解析服务定义、当前 Fiber、注入声明和 isolation scope。

这带来三条关键约束。

服务不是任意全局变量。 一个插件若想直接读取某服务,通常必须在 inject 中声明它。服务不存在或当前 Fiber 尚未 active 时,运行时会抛出带调用栈增强的信息,而不是静默返回一个不确定的值。

服务有所有者。 ctx.provide(name, value) 产生的实现记录包含服务名、value、提供者 Fiber 和可选可用性检查;写入同名服务必须由同一个 Fiber 所有,避免多插件对同一 service slot 的无序覆盖。

Context 不是共享可变配置袋。 extend() 建出基于原 Context 的 child view;intercept() 叠加面向特定服务的配置;isolate() 为服务名映射新的 scope label。父上下文不被就地修改,子作用域通过原型链继承可见能力。

因此,Context 是一个“带作用域和生命周期的依赖视图”,而非 service locator。它让运行时有机会回答:这个服务从哪里来、在哪个隔离域有效、提供者销毁时谁需要被通知、某次访问是否满足已声明依赖。

三、Fiber:每个插件实例都是一台可观测的生命周期状态机

真正承载 Cordis 运行时语义的是 Fiber。一次 ctx.plugin() 并不只是在函数上调用一次回调,而是创建一个 Fiber:它保存原始与验证后的 config、注入依赖快照、effect disposer、运行时身份、父 Context 和当前状态。

核心状态可以理解为:

PENDING -> LOADING -> ACTIVE \-> FAILED ACTIVE -> UNLOADING -> DISPOSED

PENDING 不是失败,而是“依赖还未满足”。当每个 inject 所需服务都有 active provider 时,Fiber 计算依赖实现的 epoch,启动插件 callback 并进入 ACTIVE。一旦 provider 变化、可用性检查失败或 config 更新,epoch 改变,运行时会卸载旧 effect,再根据新依赖条件重新执行。这样,插件的存活不再由单一启动顺序决定,而由当前 Context 中真实可用的服务集合决定。

Fiber 的另一个工程细节是 reentrancy。插件启动时可能同步触发监听器,监听器又可能请求卸载该插件或其父级。Cordis 会先将 child disposer 纳入父 Fiber 所有权,再发布内部 plugin 事件;effect 也在执行用户代码之前先对 owner 可见。这避免“初始化执行到一半,父对象已经销毁但 cleanup 尚未登记”的经典资源泄漏。

对于异步 effect,Fiber 还维护 setup barrier 与 inertia:当异步初始化尚未结束就被 dispose,外层清理可以等待 setup 完成后再执行统一撤销,而不是出现初始化在后台继续运行、资源在前台已被认为释放的竞态。这类实现并不华丽,却决定热更新和长连接插件在压力下是否可靠。

四、Effect:把副作用变成一棵可以反向执行的树

Cordis 的 temporal composability 不是通过“希望作者记得清理”实现的。插件在 Fiber 上注册的操作会被收集为 effect;effect body 可返回 disposer、Promise、同步或异步 generator。运行时记录这些 disposer,在显式 dispose 或 Fiber 卸载时按注册的逆序执行,并等待异步清理完成。

这形成了一个实用的不变量:一个插件在 active 期间新增的 runtime ownership,应通过同一 Fiber 的 effect tree 被找到并撤销。

常见操作都自然落在这棵树上:

ctx.on() 注册事件监听器时,将取消监听器作为当前 Fiber 的 effect。

ctx.provide() 注册服务时,将删除 provider、通知依赖者并等待其状态收敛作为 cleanup。

ctx.effect() 可包裹文件 watcher、timer、socket、child process 或其它外部句柄。

• 插件 Fiber 自身也是父 Fiber 的一个 effect,因此父级卸载会递归收拢子级。

逆序清理并非只是惯例。设插件先打开连接,再注册依赖该连接的监听器;正确销毁顺序应先撤监听器、再关连接。LIFO disposer 栈正好表达这种依赖反向关系。对业务作者而言,关键要求是不要绕过 ctx.effect() 在模块级创建不可追踪资源;框架无法替你撤销它从未拥有的外部状态。

五、Coeffect 的工程形态:服务变化驱动依赖者收缩与重启

“reactive coeffect”听起来抽象,在 Cordis 里对应一条很具体的调用链。

1. provider 通过 provide 注册或注销一个 service implementation。 2. 反射层 notify(names) 遍历已注册 runtime 的 Fiber,筛选声明了这些 service 的依赖者,并检查其 isolation label 是否匹配。 3. 被命中的 Fiber 重新检查 implementation 与可用性 predicate,重算依赖 epoch。 4. epoch 改变时,旧插件 effect 被卸载;若依赖重新满足,则以新快照 reload。 5. service event 以 scope filter 广播,使只关心该隔离域的观察者看到更新。

这里最有价值的地方是“先收缩,再重建”。当一个服务被撤销,provide 的 cleanup 会先删除 store 中的 provider,再通知并等待相关 Fiber settle,最后才删除 provider Fiber 自己的 store 引用。这避免消费者在 provider 已不可用但自身还未卸载的窗口内继续访问它。

这也解释了 Cordis 为何禁止未声明 inject 的任意服务访问:只有依赖图显式化,运行时才知道哪些 Fiber 需要在 provider 变化时被重算。隐式 import 单例、闭包缓存或模块全局对象虽然方便,却会绕过 coeffect 机制,形成 HMR 后仍指向旧实现的“僵尸引用”。

六、Isolation 与 Intercept:多租户和局部替换不必复制整棵应用

动态系统经常需要“同名服务的不同版本”:一个子 Agent 使用受限工具,一个测试树使用 mock 数据库,一个租户有自己的配置,一个插件组需要独立 logger。若靠复制全局 container,实现成本和状态泄漏风险都会急剧上升。

Cordis 的 ctx.isolate(name, label?) 不会复制所有服务,而是仅为指定 name 在 child Context 的 isolation map 中改写 scope label。服务解析时以这个 label 查找实现。两个 child Context 若复用同一 label,就加入同一服务域;否则得到彼此隔离的同名服务。由于 Context 保持原型继承,未隔离服务仍直接来自父域。

intercept(name, config) 解决的是另一件事:在不替换 service implementation 的情况下,向该服务的 per-plugin config 叠加局部配置。Service 会沿 Context 链收集 intercept entry,再使用自身 merge 逻辑或对象合并得到有效配置。Isolation 决定“用谁”,Intercept 决定“同一个能力在此处如何配置”,两者不应混用。

这套模型非常适合 Agent runtime:在 child Context 中 isolate toolssandboxllm 等服务,就能把子任务能力限定为独立域;父级仍保留观测和生命周期所有权。它同样适用于测试和灰度,但隔离并不等于安全边界。共享进程内的内存、网络凭据、Node 全局状态和原生模块仍需由容器、权限系统或 provider 自身约束。

七、Event Bus:不同控制语义需要不同分发模式

很多框架只有 emit,然后让每个监听器自己决定是否 await、如何短路、是否调用 next。Cordis 把控制语义编码为不同 dispatch mode:

模式执行方式适合的场景
emit同步触发,不等待 Promise日志、观察、非关键通知
parallel并发执行,等待全部 settled多个独立 side task
serial按序 await,首个 bail 终止有优先级的决策链
bail同步顺序执行,首个有效返回值终止快速查找、拦截器
waterfalllistener 包裹 next()鉴权、转换、middleware around 行为

waterfall 比一般 middleware 更具约束性:listener 不调用 next(),则后续 listener 与内建行为都不会发生。因此它适合需要明确 veto 点的生命周期、权限和配置更新。Cordis 还给每个 listener 绑定其注册时的 Context,Fiber 卸载时自动移除;dispatch 可以按 Context.filter 过滤可见 listener。事件不再是独立于生命周期的全局总线,而是 effect tree 的一部分。

事件模式解决顺序与短路,服务注入解决依赖与可用性。不要用 event 代替 service,也不要用 service update 假装成 event:前者适合能力所有权和动态依赖,后者适合一次性或流式协作。混用会让系统的因果关系无法推断。

八、Loader:配置文件不是启动参数,而是可调和的组件树

Cordis 的 loader 将 declarative composition 带到运行时。配置树中的 Entry 拥有稳定 ID、模块名、config、inject、disabled 和 group 属性;EntryGroup 根据新旧 ID 映射调和:存在于新树的 entry 创建或更新,消失的 entry dispose,嵌套 group 自己拥有子树。

一个 config 更新的关键流程是:

config diff -> Entry.update -> partial-dispose event -> patch Context prototype / isolation map -> Fiber.update(config) or re-init -> persist normalized config

这不是简单的 deep merge。Entry 先比较实际变更,再让 loader/patch-context waterfall 给 isolate 等扩展点参与;已经运行的 Fiber 在 config 或 group 变化时更新,disabled entry 直接卸载。Loader 还会追踪一个 Fiber 归属哪个 Entry:若用户代码自行 dispose 某个被 loader 管理的根 Fiber,且不是 HMR、依赖失效或祖先 group 禁用所致,loader 会将该 entry 标记为 disabled 并写回配置,避免下次启动悄悄复活一个用户主动关闭的插件。

这里的工程洞见是:配置调和必须与资源生命周期共享同一 identity。 稳定 entry ID 让系统知道“这是同一个组件的新 config”还是“旧组件被删除、新组件被创建”。没有 ID 的配置数组只能按位置猜测,热更新与局部恢复最终都会退化为全量重启。

九、HMR:先分析依赖闭包,再重载;失败时恢复缓存与旧 Fiber

Cordis 的 HMR 不把“文件变了”直接等价为“重新 import 一下”。它先利用 Node 的 module load cache 计算变化的依赖图,并将文件分为三类。

• 外部依赖:CLI worker 的框架依赖闭包,发生变化时执行 full reload。

• accepted:变更文件或依赖 accepted 文件的插件闭包,允许 partial reload。

• declined:全部依赖都拒绝更新、外部模块或不可安全重载的文件,不参与局部重载。

在 partial reload 中,HMR 先备份并清除 ESM load cache 与 CJS require cache,再尝试重新导入所有受影响插件入口。只有 import 全部成功后,才删除旧 plugin runtime 并按旧 Fiber 的 parent、config 和 Entry 重新注册新实现。若导入或 reload 过程失败,它恢复缓存,并尝试重新注册旧 plugin。这个顺序的目标是避免只更新一半依赖图。

不过不应把它理解为事务性零停机升级。旧 runtime 在新 Fiber 被创建前会被 delete,新的插件也可能在启动后暴露外部副作用;rollback 能恢复 module cache 与注册关系,却无法自动补偿一个业务插件已发送的消息、写入的数据库或建立的外部会话。因此 HMR 的正确适用范围是开发期和可重入、可撤销的组件;生产配置更新仍应配合幂等性、drain、健康检查、流量切换和业务级补偿。

十、采用 Cordis 的设计准则:让运行时拥有你希望动态改变的一切

Cordis 很强,但并不会自动修复架构问题。要获得它的时空可组合性,插件必须遵守运行时 ownership 模型。

1. 所有可释放资源都在 ctx.effect() 或由其派生的 API 中创建。模块顶层 socket、裸 setInterval、全局 emitter listener 是最常见的逃逸点。

2. 所有动态依赖都以 inject 申明;不要把 service value 在初始化时偷存为跨生命周期单例。provider 变化后应允许 Fiber 重建,而不是假设引用永远有效。

3. 为配置树提供稳定 ID,并将“升级 config”“停用组件”“删除组件”视为不同语义。不要通过空对象或隐式默认值把三者混在一起。

4. 用 isolation 管理能力域,用 intercept 管理局部参数;敏感能力仍要由 provider、沙箱和权限控制强制执行。

5. 为每个可热更新插件测试四种故障:启动失败、依赖撤销、异步初始化途中卸载、更新后回滚。只测正常 reload 不足以证明生命周期可靠。

6. 固定 Cordis 版本,并围绕公开 API 写集成测试。官方明确 API 仍可能发生不兼容变化,直接依赖内部 symbol、Fiber 私有字段或 Node load cache 细节会放大升级风险。

结语:插件化的终点不是扩展点,而是可证明的退出路径

Cordis 的独特之处不在于它能注册插件,而在于它要求每个插件的存在都有一条反向路径:它提供了什么服务、注册了什么监听器、依赖了哪些能力、何时应被重载、被移除后如何收敛。Effect 让副作用变得可撤销,Coeffect 让依赖变化变得可响应,Fiber 把两者收束为状态机,Loader 与 HMR 则把这套语义延伸到配置与代码变化。

这对 Agent runtime、IDE、长时间运行的 Node 服务、多租户工具平台尤其有价值,因为它们真正面对的是一个持续变化的系统,而不是一次性初始化。代价同样明确:作者需要严格交出资源 ownership,团队需要为 provider 切换、配置调和和 HMR rollback 建立测试。Cordis 不是消除复杂性,而是把原本隐蔽的复杂性提升为运行时可见、可管理的结构。

资料链接

Cordis 官方仓库:https://github.com/cordiverse/cordis

Cordis Primer:https://deepseek-harness.github.io/deepseek-harness/reference/cordis-primer

时空可组合性论文草案:https://github.com/cordiverse/paper