Cordis 如何启动:插件树与热更新
Cordis 没有自己的进程。一次 dsh 启动先建出一个根 Context,再把 Loader 挂成第一个服务,然后用 Include 去读一份 entry 数组。数组里的每一行导入一个模块,变成一条 fiber。fiber 要等 inject 点名的服务都出现,才会执行插件的 apply。apply 里登记的监听、服务和副作用都记在这条 fiber 上,卸载时按登记的反序撤销。热更新用的就是这一次卸载和再加载,再加上 Node 模块缓存的淘汰。
下面按启动、建树、单行激活、热更新四段走完这条路径。代码位置以仓库里的 vendored Cordis 和 packages/boot 为准。
总览
热更新不另开一条加载器。配置变更重新算出 entry 数组,仍交给 EntryGroup.update 按 id 对齐。源码变更先清掉模块缓存,再让 Loader 按当前文件重新 import。
1. 从进程到根 Context
dsh 进程的入口是 apps/cli/src/bin.ts 的 runCli。dsh --profile 和 dsh 都落在 mode: 'profile' 这一支,动态导入 profile-boot.ts 并调用 runProfile。
runProfile 调用 packages/boot/app-boot 的 boot()。Cordis 从这里开始。vendor/cordis/bin.js 是这个包自己的 bin,教程用它直接读当前目录的 cordis.yml。产品进程不执行这个文件,但 boot() 保留了它的三步,并在中间插入宿主准备。两边放在一起看:
js
// vendor/cordis/bin.js
const ctx = new Context()
ctx.baseUrl = pathToFileURL(process.cwd()).href + '/'
await ctx.plugin(Loader)
await ctx.loader.create({
name: '@deepseek-ai/cordis-plugin-include',
config: { path: './cordis.yml' },
})
ts
// packages/boot/app-boot 的 boot()
const ctx = new Context()
ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/'
await ctx.plugin(Loader)
await prepare?.(ctx)
await mountRootInclude(ctx, absoluteConfigPath, patches, bareModuleBaseUrl, binName)
await ctx.get('loader')?.await()
await auditStartupEntries(ctx, binName)
| 步骤 | vendor/cordis/bin.js |
boot() |
|---|---|---|
| 根 Context | new Context(),baseUrl 是进程当前目录 |
同样建根 Context,baseUrl 是配置文件所在目录 |
| Loader | ctx.plugin(Loader),得到 ctx.loader |
同一调用。根 fiber 的 uid 是 0,构造时就是 ACTIVE,没有插件回调 |
| 挂上 Include | loader.create 一行,模块名是 @deepseek-ai/cordis-plugin-include,只带 path: './cordis.yml' |
prepare 先注入 profile、模块解析和命令行;mountRootInclude 再把 Include 注册成 builtin cordis:include,套上分层 patch,id 固定为 include |
| 结束条件 | create 返回后进程就留在事件循环里 |
loader.await() 等 entry 工作排空,再 auditStartupEntries。必需插件起不来就拆掉整棵树 |
Context、Loader、Include 各管一件事
三者里只有 Loader 和 Include 是插件。Context 是容器,用 new Context() 直接构造,不经过 ctx.plugin,也不出现在 cordis.yml 里。
Context 存放服务和当前插件的 fiber。插件把能力登记成 ctx 上的一个名字,其他插件用 inject 按这个名字等待它,不必 import 具体实现。
Loader 管理 entry 表。它按每一行的模块名加载代码,创建或停掉对应的 fiber。ctx.loader 就是这个服务。
Include 读配置文件,把 patch 叠成最终的 entry 列表,再交给 Loader 去挂载。它自己是 Loader 表里的一行,依赖 loader 先就绪。配置文件里有哪些插件、每行的配置是什么,由 Include 决定;模块怎么加载、fiber 怎么启动,由 Loader 完成。
Loader 和 Include 都继承 EntryTree,套在一起是这样:
text
Context(根 fiber,uid 0)
└─ Loader 插件,provide('loader'),内存中的根 EntryTree
└─ entry id=include,插件是 Include
└─ Include 的 EntryTree(entry.subtree)
└─ cordis.yml 与 patch 叠出来的业务插件
2. 空的根文件和分层 patch
profile 目录里的 cordis.yml 不是插件清单。profile-boot.ts 把它写成一个空数组:
yaml
# dsh profile root --- an empty entry list. The tree is composed as patches.
[]
真正的行来自 readProfilePatches(packages/boot/app-boot/src/profile-context.ts)。它把下面几层拼成一份 patch 数组,交给 applyPatches 叠到这个空数组上。顺序固定,后一层改前一层已经放进去的行:
dsh.profile.bundles里每个 bundle 的 patch,按清单顺序拼接。- 该 profile 自己的
cordis.patch.yml。 - Harness 主目录的
cordis.patch.yml。 - 命令行
--patch,按 argv 顺序。 - 若环境要求关闭遥测,并且组合结果里已经有遥测那一行,再追加一条
{ id, disabled: true }。
前三层的文件不在同一处,生成方式也不一样。默认 Harness 主目录是 ~/.dsh,可用 DSH_HOME 换掉。
- bundle 的 patch 跟着包走。
$DSH_HOME/profiles//package.json的dsh.profile.bundles是一组包名。每个包再用自己的dsh.bundle.patch指向包内文件,通常是该包目录下的cordis.patch.yml。启动时从 dsh 的安装目录或这个 profile 的node_modules解析到它。仓库里@deepseek-ai/dsh-base对应packages/bundle/base/cordis.patch.yml。这份文件由 bundle 作者写好,随包发布,启动时不生成。 - 每个 profile 另有一份只属于这个 profile 的 patch:
$DSH_HOME/profiles//cordis.patch.yml,用来写本机要改的行。web、headless、sdk、sdk-minimal、acp是安装自带的五个名字。第一次执行dsh web这类命令时,profiles/web/里还没有package.json,initProfile就建这个目录:package.json写入该模板的 bundle 列表,cordis.patch.yml写成[],表示这一层还没有任何修改。这两份文件之后再启动都不再重写,所以改过的内容还在。这五个名字以外的 profile 不会自动建目录,要先执行dsh plugin --profile add。 - 主目录的 patch 是
$DSH_HOME/cordis.patch.yml,与profiles/并列。启动器不创建它;文件不存在就没有这一层。写上之后对每个 profile 都生效,并盖过该 profile 自己的那一份。
每一层都是 patch 操作,不是另一份完整插件清单。applyPatches(vendor/include/src/patch.ts)认两种写法:
insert:把新 entry 追加进当前列表。dsh-base对空根做一次 insert,放进 timer、hmr、llm 这些共享行。{ id, ...字段 }按 id 找到已有的那一行,用列出的字段换掉同名字段。没列出的字段保留。config一旦出现,整份替换,不按键合并。name只用来核对,对不上就跳过这一条。找不到 id 时告警并跳过。
3. Include 如何把文件变成 entry 树
Include 读 $DSH_HOME/profiles//cordis.yml,得到空数组 []。分层 patch 已经放在它的 patches 字段上。applyPatches 把这些 patch 叠到空数组上,得到最终的 entry 列表。列表里的每一项成为 Include 这棵 EntryTree 中的一个节点,按 id 存放。
一个节点是一条 entry,字段是:
| 字段 | 作用 |
|---|---|
id |
这一行在树里的身份。后一层 patch、热更新时的增删,都按它对齐 |
name |
要加载的模块。包名、相对路径,或 cordis: 内置名 |
config |
交给这个插件的配置 |
disabled |
为真时这一行留在树上,但不启动 |
inject |
这一行额外要求的服务 |
group |
为真时自己不跑业务插件,config 是另一份 entry 列表,形成子树 |
普通 profile 叠完之后,Include 的子树大致是这样:
text
entry id=include(插件是 Include)
└─ Include 的 EntryTree
├─ id: timer name: @deepseek-ai/cordis-plugin-timer
├─ id: hmr name: @deepseek-ai/dsh-hmr
├─ id: llm name: @deepseek-ai/dsh-llm
├─ ... 其余由 bundle 插入、再被后一层改过的行
└─ id: 某个组 group: true
├─ id: 子行-a
└─ id: 子行-b
web、headless、sdk 的差别就在这棵子树上:同一个空文件,叠上的 patch 不同,节点的 id、name 和 config 就不同。下一节才把每个未禁用的节点加载成 fiber。
4. 一行 entry 如何变成一条 fiber
entry 是配置树上的一个节点。fiber 是这个节点在进程里的一次运行(vendor/cordis/src/fiber.ts)。
new Context() 时就有一条根 fiber,uid 是 0,没有对应的插件。之后每 ctx.plugin(...) 一次,就在父 fiber 下面长出一条子 fiber。一条 fiber 带着自己的 context、这份插件的配置,以及 apply 里登记的监听和服务。卸载它时,这些登记按反序撤销。
它的状态是:PENDING(在等 inject 点名的服务)、LOADING(正在执行 apply)、ACTIVE(已经对外提供服务)、FAILED(配置或 apply 抛错)、UNLOADING(正在撤销)、DISPOSED(已经移除,不能再启动)。
entry 未禁用、并且还没有 fiber 时,Entry.update 调用 init。init 在 vendor/loader/src/config/entry.ts:
ts
moduleNamespace = await this.parent.tree.import(this.options.name, this.getOuterStack)
const plugin = this.loader.unwrapExports(moduleNamespace)
await this._patchContext([])
this.fiber = this.ctx.registry.plugin(plugin, this.options.config, this.getOuterStack)
this.moduleNamespace = moduleNamespace
entry 上的 name 只是一个模块名,例如 @deepseek-ai/dsh-llm。进程里还没有这个插件的函数,所以先 import。导入失败就没有 fiber,这一行留在树上,启动审计会把它算进失败项。
import 得到的是模块的导出对象,不是插件本身。ESM 的插件多半在 default 上,经过打包或 CJS 互操作后有时还要再剥一层 default。unwrapExports 做的就是这件事:取出那个插件值。它必须是函数、类,或者带 apply 的对象,后面才能把它跑起来。
registry.plugin(plugin, config) 用这个插件和 entry 的 config 在当前 context 下新建一条 fiber,并记到 entry.fiber。inject 里的服务还没齐时,这条 fiber 停在 PENDING,apply 不执行。服务齐了才执行 apply(ctx, config),类插件则是 new Plugin(ctx, config)。apply 里登记的服务和监听都属于这条 fiber,卸载时一并撤销。所以同一条 entry 可以停掉再加载,而不必逐个删掉它注册过的东西。同一批 entry 一起启动,谁先 apply 取决于依赖何时满足,不取决于它在配置里的行号。
5. 启动什么时候算完成
boot() 不会在 Include 刚读到文件时返回。它要等 entry 树已经按 patch 生成,并且这些 entry 当前这次的加载、更新或卸载都做完。做完之后,某个插件仍可能停在 PENDING:它自己的 inject 还没有提供者。这是依赖没满足,不是树还在往上挂。
等完之后 auditStartupEntries 扫一遍没有激活的 entry。全局必需名单包括 agent-loop、两种 tool executor、webserver、web-runtime、modules、connection,以及 headless、ACP、SDK 各自的端点。必需行未激活时,boot() 抛 StartupError,诊断里带上全部失败插件和它们在等的服务,然后拆掉整棵树。可选行失败只打警告,已经 ACTIVE 的兄弟继续跑。缺一个可选提供者,仍可能让必需的消费者起不来,审计按消费者的结果判断。
这一轮审计只在启动时做。后面的配置热更新不再把「必需插件失败」升级成整进程退出,也不回滚整次更新。
6. 热更新
热更新没有另一套加载器,用的仍是第 4 节那一次卸载和再加载。卸掉 fiber 会撤销它登记的服务和监听,新 fiber 再按 inject 接上并重新 apply。插件作者不用写热更新回调。被禁用、被拒绝或仍在等依赖的行不会执行新代码。
@deepseek-ai/dsh-hmr 把下面三条路径放进同一条队列,避免改配置和换源码交错。装包、删包不进这条队列。关掉或省略时,改动要等下次进程启动。
base 把这个插件插进共享树。没有 profileContext 时这一行不启动。config.root 是空数组,所以只监视配置,不监视源码目录:
yaml
- id: hmr
name: '@deepseek-ai/dsh-hmr'
disabled: !!js "!ctx.get('profileContext')"
config:
root: []
headless、SDK、ACP 排在 base 后面,只把这一行关掉,name 和 config 仍是上面那一份。sdk-minimal 的树里没有 id: hmr 这一行。
yaml
- id: hmr
disabled: true
要在保存源码时换模块,在该 profile 的 cordis.patch.yml 里再盖一层。这里的 config 会整份换掉 base 写下的 config:
yaml
- id: hmr
disabled: false
config:
root: ["."]
插件自己的 config 就是这四个字段。base 只写了 root,其余用默认值:
yaml
config:
base: "." # 解析 root 的基准目录,默认是当前 context 的 baseUrl
root: ["."] # 要监视的源码目录;[] 表示不监视源码
ignored: # 不参与源码替换的路径
- "**/node_modules"
- "**/.*"
- cache
- data
debounce: 100 # 把连续的文件变更合成一次,单位毫秒
启动时那次「必需插件失败就退出」不会在热更新里再做。运行中一次更新失败,已经成功的插件继续跑。
三条路径换的东西不同。分发都在 packages/boot/hmr/src/index.ts。
配置重放 管 profile 的 package.json、该 profile 的 cordis.patch.yml,以及主目录的 cordis.patch.yml。package.json 只有 dsh.profile.bundles 变了才处理。这些文件要等这次启动的树就绪之后才读。
HMR 启动时登记这三份文件。内容变了就调用 reconcileProfile:
ts
const manifestPath = join(profile.dir, 'package.json')
const patchFiles = [profile.patchPath, join(profile.home, PROFILE_PATCH_FILENAME)]
const refresh = async (manifestOnly: boolean): Promise<void> => {
const bundles = JSON.stringify(readProfileManifest('dsh', profile.dir).dsh?.profile?.bundles ?? [])
if (manifestOnly && bundles === lastBundles) return
const inputs = JSON.stringify([bundles, /* 两份 cordis.patch.yml 的当前文本 */])
if (inputs === lastInputs) return
await this.reconcileProfile()
}
for (const filename of patchFiles) await this.watchConfig(filename, () => refresh(false))
await this.watchConfig(manifestPath, () => refresh(true))
reconcileProfile 先 readProfilePatches,按第 2 节的顺序重读各层,再交给 recomposeProfile。新的 entry 列表按 id 和正在跑的树对齐:消失的行卸载,新的行挂上,同一 id 上变了的配置让那条 fiber 卸掉再 apply。同一次还会比较 profile 目录和 link 包里已经加载的文件。文件变了就清掉模块缓存,停掉对应 entry,用磁盘上的当前文件重新 import。所以 root 即使是空的,profile 里重新构建过的包也会在下一次改 patch 或改 bundle 列表时生效。从 dsh 进程入口引进来的 Cordis、Loader 不换;碰到它们就记成需要重启,进程继续跑旧代码。新列表还没开始替换就失败时,删掉的行会装回去。缓存已经清掉之后新代码若加载失败,旧实现不再运行,下一次重放再试。
重读 Include 的文件 管 root 监视到的、正好被某个 Include 读着的那份文件,例如直接改 cordis.yml。两份 cordis.patch.yml 已经由上面的 watchConfig 盯着,这条循环碰到它们会 continue,不在这里处理。
root 里的一次变更先按文件归类。路径对上某个 Include 的 filename 时,调用它的 refresh():
ts
if (this.configPaths.has(filename)) continue
const include = [...loader.entries()]
.map(entry => entry.subtree)
.find(tree => tree?.filename === filename)
if (include !== undefined) includes.add(include)
// ...
for (const include of includes) await include.refresh()
refresh() 在 vendor/include/src/index.ts 里把该文件标成待重读,再走和第 3 节相同的 applyPatches,仍按 id 对齐。文件解析失败或写到一半时,正在跑的树保持不动。
替换已加载的源码 只在 root 非空时发生。同一次归类里,已经在模块缓存中的文件进入 partialReload;进程入口自己链接着的模块不换,只调用 loader.exit(),HMR 不把进程拉起来。root: [] 时这条路径看不到源码;profile 里已安装包的文件变化,要等配置重放去比较磁盘。
ts
if (this.externals.has(url)) { loader.exit(); return }
if (this.esmHas(url)) this.stashed.add(url)
// ...
if (this.stashed.size > 0) await this.partialReload()
partialReload 只顺着静态 import 找到要换的插件入口。动态 import() 不在这条边上:下次调用读到新文件,调用方继续跑旧的。换一个入口就是清掉 Node 的模块缓存、再 import、卸掉旧插件、用原来的 config 重新挂上。导入失败则 rollback() 把缓存和旧插件装回去:
ts
const rollback = this.evict(this.accepted)
const moduleNamespace = await this.ctx.loader.import(filename, this.getOuterStack)
const replacement = this.ctx.loader.unwrapExports(moduleNamespace)
this.ctx.registry.delete(plugin)
await reload(replacement, fibers) // 内部是 registry.plugin(replacement, 原来的 config)
// 导入或启动失败:
rollback()
7. 值得借鉴的编码经验与设计模式
热更新:单写者
热更新是单写者。配置重放、重读 Include、替换源码都进 Hmr.runExclusive,后面的更新等前面那次的 Promise。AsyncLocalStorage 标出正在执行的这一次;已经在事务里再调用 runExclusive 直接拒绝。一次失败被队列接住,后面的更新还能继续排。reconcileProfile 自己再查这个标记,必须跑在这条队列里。装包、删包仍不进这条队列。
ts
private operations: Promise<unknown> = Promise.resolve()
private readonly executing = new AsyncLocalStorage<boolean>()
runExclusive(operation) {
if (this.executing.getStore()) return Promise.reject(new Error('HMR transactions cannot be nested'))
const task = this.operations.then(async () => {
if (this.closing) throw new Error('HMR is disposed')
return this.executing.run(true, operation)
})
this.operations = task.catch(() => {})
return task
}
operations 从一只已经完成的 Promise 开始。每次调用用 .then 把下一笔挂到当前链尾,得到 task,再把 this.operations 改成 task.catch(() => {})。调用方拿回原来的 task,失败仍会传到调用方;链上那份把拒绝吞掉,前一笔失败后,后面的 .then 照样执行。两笔从外部同时进来的更新都过得了 getStore() 那一关,按调用顺序接在同一条链上,后一笔等前一笔的回调结束才开始。
Java 的 ThreadLocal 给每个线程一块自己的槽。线程 A 执行 set(true) 之后,同一线程里后续的 get() 都读到 true;线程 B 的 get() 读的是自己那块槽,是空的。值跟着「现在跑在哪个线程」走。
operation 跑在 executing.run(true, operation) 里面,当前这条链的槽已经是 true,和线程里 ThreadLocal.set(true) 之后一样。这个 operation 若再调用 runExclusive,新的 task 会挂到 this.operations 的链尾。链尾要等当前这笔事务的回调结束才开始,而当前这笔事务正等着这个 operation 返回。内层任务等外层结束,外层等内层的 Promise,这次热更新停住,排在它后面的更新也不再开始。
函数开头的 this.executing.getStore() 读到这块槽里的 true,就返回拒绝,this.operations 仍指向当前这笔事务。调用方得到 HMR transactions cannot be nested,队列上没有多出一笔要等自己的任务。