dsh 插件开发学习

19-运行期动态挂载插件

读完这篇你能:不改配置文件、不装包、不重启,往一个正在跑的 DSH 进程里挂一截代码,用完摘掉;并且知道挂上去的插件归谁管、怎么确认它真的生效了。

前置:18-把插件打包成配置包。约 30 分钟。

示例代码:dsh-live-greeter、dsh-self-cordis

DSH 版本:0.2.0-rc.2

到 18-把插件打包成配置包为止,我们见过的插件有一个共同点:都是从磁盘上的配置文件读进来的。dsh plugin add 改 package.json,03-用 patch 覆盖 DSH 的默认配置的 patch 改 YAML 行,07-配置与源码的热重载的热重载盯着文件变化——那条链路的起点永远是文件,终点是 Loader 按行 import 一个模块。

Cordis 本身没有这个限制。Loader 只是一个普通插件,它做的事是”读配置,然后对每一行调用 ctx.plugin(...)“。而 ctx.plugin() 是框架 API,任何插件都能调。

所以”运行中挂插件”不是 DSH 新加的能力,是一直就在那儿、只是没人调的东西。这篇把它调起来:挂一个内存里的插件,看它的生命周期归谁管,再收回它。

目标

  • 一个插件 dsh-live-greeter,在 apply 里把一个内存插件挂进运行中的进程;被挂的插件注册一个模型能调的工具。
  • 说清 ctx.plugin() 返回的是什么、它的状态机怎么走、”挂上了”和”生效了”差在哪。
  • 弄清内存插件的清理归属:为什么它不需要谁记得手动清。
  • 体验 DSH 已经把这套做成产品的那套工具集,并知道它的边界。

DSH 设计

先交代底盘。DSH 底层是一个叫 Cordis 的插件框架:产品的每一部分都是插件,插件挂进一棵运行中的树。架构文档写明「不存在需要打补丁的特权内核:扩展 dsh 的方式是把插件挂载到其他插件旁边」。既然没有特权内核,挂插件这个动作自然也不是谁的专利。ctx.plugin() 是对任何插件开放的框架能力,平时负责读配置文件的「加载器」(Loader)只是它的第一个调用方:逐行读配置、逐行调用。任何正在运行的插件都可以成为第二个调用方。架构文档还把「进程内直接挂载插件」单列为一种被承认的用法类别,与命令行启动器并列。所以这条路不是钻空子。

每挂一次插件,框架就造一个 fiber 来记账。「fiber」是「一次插件应用」的运行时实例,可以理解成这次挂载的档案夹:它跟踪这次挂载的依赖状态、校验过的配置,以及挂载期间登记的所有清理动作。每个 fiber 有一个全局唯一的编号 uid,卸载后编号作废成 null。它还有一台生命周期状态机:PENDING 是等依赖,LOADING 是启动中,ACTIVE 是真的在提供能力,之后还有 FAILED、UNLOADING、DISPOSED 三档。每次状态迁移都会发出一个框架内部通知 internal/status。所以「挂上了」和「生效了」之间隔着一段可观测的过程,判据是 fiber 的状态,不是挂载调用有没有返回。

挂进什么内容算合法?框架对「插件」的认定很宽:函数、类,或者带 apply 方法的对象,三种形态都合法,传别的会在挂载处直接抛错。挂载时还可以带一份配置。框架先按插件声明的 schema 校验,校验不过就在安定点抛出来。配置错误不会变成「挂上了一半」。

ctx.plugin() 的返回值可以直接 await,因为 fiber 同时是一个可等待对象。不 await,它只是「已登记」;await 它,才是「已安定」——配置或启动出错,会在 await 处抛出来。另一个容易忽略的事实是,框架的注册表按插件的回调函数记账,而不是按插件「种类」。同一个回调可以挂多次,每次产生一个 fiber:

const f1 = ctx.plugin(greeter)
const f2 = ctx.plugin(greeter)
// f1.uid !== f2.uid:同一个 greeter,两份各自独立的挂载
ctx.registry.delete(greeter)   // 一次收掉它的全部 fiber

所以 uid 只是「这一次挂载」的编号,不能当插件的长期身份用。要跟踪一个逻辑插件,得用它的回调。

再说等依赖这件事。框架里表达加载顺序的方式只有一种:插件用 inject 声明自己需要哪些服务,然后安静等它们就绪。入门文档的原话是「加载顺序通过服务依赖表达,而非手动编排启动序列」。等不到服务的插件就停在 PENDING。框架语义认为「服务一出现我就激活」是合法状态,所以它不报错、不超时,就停在那里。而且等待不是一次性的门槛:注册表文档对 inject 的说明是「只当所有必需服务可用时才加载」。服务后来消失,依赖它的插件会被卸下;服务再出现,它重新运行。所以排查「挂了但没反应」,第一件事就是看它停在哪个状态、等的是哪个服务。等依赖还有一行简写:ctx.inject(依赖, 回调),等价于挂一个声明了这些依赖的插件。服务齐了就运行回调,任何一个必需服务变化都会卸了重跑。它把「声明依赖、等待、运行」压缩成一行,是运行期代码里最常用的挂载形态。

清理这一头,框架的规矩是「注册是可逆的 effect」:插件挂载期间登记的每个清理函数,都会在它被卸载时按相反顺序执行,是异步的就等它完成。而「挂一个子插件」这个动作本身,框架源码里就是把子 fiber 的卸载函数直接登记成父 fiber 的一笔清理账。推论很直接:父插件卸载时,它挂的子插件自动跟着走,不需要任何人记得手动清理。反过来,在哪个插件的上下文里挂的,子插件的命就长在谁身上。

最后看能力面。入门文档说「上下文是服务的容器」:一个服务占据一个稳定的键,其他插件按键查找,而不是 import 具体实现。运行期挂进来的插件和从配置文件装进来的插件,读的是同一份服务存储。注册工具、提供服务、监听事件,效果与影响力一模一样。「不写配置文件」减掉的只是重启后的存续,不是能力。

mermaid 图表

这张图同时回答两个常见疑问。「配置装的和运行期挂的差在哪」?只差谁调用、输入是文件路径还是内存对象。「谁来回收运行期挂的插件」?父 fiber 的清理账,不需要人记得。

两条挂载路径

  静态路径(前 18-把插件打包成配置包) 动态路径(本篇)
谁调用 Loader 按配置行 你自己的代码
输入 磁盘上的模块路径或包名 内存里的一段函数或 { apply } 对象
存续 跟配置文件走,重启后还在 只在进程内存里,重启即消失
谁清理 Loader 按行 dispose 持有 fiber 的人 dispose

一个挂载模型能帮你少踩一半的坑:ctx.plugin(fn) 约等于 element.appendChild(child)。它把一个子节点挂到当前上下文之下,返回一个句柄,句柄的 dispose() 就是 removeChild;而 ctx.registry 约等于 element.children——运行中检查插件树的入口。

fiber 与 registry

ctx.plugin() 的返回值是 fiber:一次插件应用的运行时实例(vendor/cordis/src/registry.ts)。

plugin(plugin: Plugin, config?: any): Fiber & PromiseLike<Fiber>

它同时是个 thenable,这一点后面专门讲。fiber 上有两个东西要认识:

uid 是注册表给的编号,全局唯一,根 fiber 的 uid 是 0。fiber 被 dispose 之后,uid 会变成 null——这是”这个句柄已经死了”的判据。

state 是它的状态机:

mermaid 图表

registry 是插件注册表,它按插件的回调函数为键,记录这个插件的所有 fiber——同一个回调可以挂多次(每次一个 fiber),所以是「一对多」:

const runtime = { name, callback, fibers: new DisposableList(), Config: plugin.Config }
this._internal.set(callback, runtime)

它同时是运行期检查插件树的入口:keys() / values() / entries() / forEach()。而 registry.delete(plugin) 会把该插件的每一个 fiber 都 dispose 掉再删记录——所以”按插件卸载”是一次收干净,不管你挂了几份。

挂载不等于生效

这是本篇最容易踩的坑,而且它不报错。

ctx.plugin(fn) 返回时,插件只是已登记;要等它安定(依赖都到位、apply 跑完),才是真的在提供能力。中间那个状态就是 PENDING:它在等一个 inject 里写了、但当前没人提供的服务。框架语义认为这是合法的——”服务一出现我就激活”——所以它安安静静地待着,什么都不发生。

三个不同的安定点,值得记住:

你做了 意味着
const fiber = ctx.plugin(fn) 已登记(可能还停在 PENDING)
await fiber(它是 thenable) 已安定(成功了或失败了)
fiber.state === ACTIVE 真的在提供能力

所以挂一个依赖 tools 的插件时,如果 tools 还没起来,你会看到 fiber 停在 PENDING。判断”到底是谁没起来”,要看服务存储而不是当前上下文的可见性:

ctx.get('tools')   // 对:读全局服务存储
ctx.tools          // 错:属性代理是拓扑敏感的

ctx.<service> 这个属性代理只对声明过的注入可靠。要判断”全局存储里有没有这个东西”,用 ctx.get(name)。这一条在排查”挂上了但没生效”时是决定性的。

清理的归属

子插件的 dispose 不是谁手动调的——它是父 fiber 的一个 effect。Cordis 里 new Fiber(...) 的构造函数就这么写的(vendor/cordis/src/fiber.ts):

this.dispose = parent.fiber.effect(() => {
  const remove = runtime.fibers.push(this)
  return async () => {
    /* 摘掉自己、必要时删 runtime、卸载 */
  }
}, 'ctx.plugin()')

这句话是整篇的枢纽:子插件的生死被登记为父 fiber 的一个 effect。所以父 fiber 卸载时,子插件自动跟着走,不需要谁记得清。05-插件生命周期与 ctx.effect讲的”注册了东西必须交给 ctx.effect“在这里换了张脸:动态挂载最容易犯的错和 05-插件生命周期与 ctx.effect一模一样,区别只是触发点从”Loader 卸载一行”变成”你自己调 dispose()“。

顺带一提,清理是反序执行、而且可以 await 的:ctx.effect(fn, label) 收集 fn 返回的 disposer,卸载时倒着跑,返回 Promise 就等它完成。所以”卸载完成”不等于”回调同步返回了”——真要看,await 卸载。

运行中检查插件树

三条路,各自回答不同的问题:

用什么 能回答 出处
ctx.registry 现在到底挂了多少东西(每个回调 + 它的所有 fiber) vendor/cordis/src/registry.ts
ctx.loader.locate(fiber) 这个 fiber 属于配置树的哪一行 vendor/loader/src/index.ts
ctx.get('loader')?.store 配置树还在加载,还是已经安定 vendor/loader/src/config/tree.ts

第二条有个好用的性质:内存挂的插件不属于任何行,locate() 对它返回 undefined。这正好是区分两条路径的判据——”这个插件是配置装进来的,还是运行期挂进来的”,问 Loader 就行。

内存插件

demo 的 patch.yaml 里只有配置行:

# dsh-live-greeter 的 bundle 补丁:只有配置行。
# greeter-tool 是被运行期挂上去的,不在任何 patch 行里——
# 所以 dump 出来的配置树里找不到它。
- insert:
    - id: live-greeter-mount
      name: '/绝对路径/dsh-live-greeter/src/dsh-live-greeter.ts'

配置插件本身只做三件事:

export function apply(ctx: Context): void {
    // 返回的是 fiber,同时是 thenable:不 await 只是「已登记」,
    // await 它才是「已安定」——两者中间夹着 PENDING 这个状态。
    const fiber = ctx.plugin(greeterTool) as Fiber

    console.log(`[${name}] mounted uid=${String(fiber.uid)} state=${STATE_NAMES[fiber.state]}`)

    ctx.effect(() => {
        const off = ctx.on('internal/status', (changed, from) => {
            if (changed !== fiber) return
            // uid 每次重挂都不一样:这是「真的卸掉重挂了」而不是「复用了同一个 fiber」的证据。
            console.log(
                `[${name}] uid=${String(fiber.uid)} ${STATE_NAMES[from]} -> ${STATE_NAMES[changed.state]}`,
            )
        })
        return () => {
            off()
            console.log(`[${name}] unmounting uid=${String(fiber.uid)}`)
        }
    }, 'live-greeter: observe')
}

(STATE_NAMES 是本篇自己留的一份状态名数组——FiberState 是 const enum,编译后被内联,运行时没有那个对象可查。)

internal/status 是框架内部事件(vendor/cordis/src/fiber.ts),每次 fiber 状态迁移时带 (fiber, 旧状态) 发一次。注意 internal/ 前缀:它不是业务事件(09-订阅 DSH 事件与五种分发模式、10-事件进阶讲的那套),是框架自己的状态通知,用来观测最直接。

被挂的内存插件就是普通的插件写法——name、inject、apply 一个不少:

export const name = 'live-greeter'

/** 要工具注册表。没有它,这个 fiber 会停在 PENDING——而且不报错。 */
export const inject = ['tools']

export function apply(ctx: Context): void {
    ctx.tools.register(defineTool({
        name: 'live_greet',
        description: '跟一个人打招呼。由 dsh-live-greeter 在运行期挂上。',
        parameters: {
            whom: { type: 'string', required: true, description: '要打招呼的人。' },
        },
        output: {
            schema: {
                type: 'object',
                additionalProperties: false,
                properties: { whom: { type: 'string', required: true } },
            },
            render: (_args, value) => [{ type: 'text', text: `你好,${value.whom}。` }],
        },
        execute(args) {
            return Promise.resolve({ whom: args.whom })
        },
    }))
}

ctx.plugin(greeterTool) 传的是模块命名空间对象,它上面有 apply,所以合法——ctx.plugin 接受函数、类,或带 apply 的对象;传别的会抛 invalid plugin, expect function or object with an "apply" method, received …。

影响范围

内存插件挂在同一个进程里,用的是同一份服务存储。所以它能 ctx.tools.register 一个新工具(所有会话立刻看得到),也能 provide 一个服务(别的会话读得到)。这不是缺陷,是”同进程”这个词的直接后果。

想收窄影响面,DSH 有作用域机制(dsh-scope):

const scope = createScope(ctx, 'my-key')
// scope.ctx 上挂的东西只在这条作用域链里可见
scope.dispose()   // 异步,等它完成

createScope 内部也是 ctx.plugin(scope)——所以作用域同样是一个插件,同样受父 fiber 的 effect 管。本篇只用它说明”影响面可以收窄”,完整的注册表层机制够单独一篇。

运行期工具集

上面那套是你自己写代码挂插件。packages/extensions/ 是 DSH 把「运行期组合」做成产品的那一组包:进程内的动态定义、运行时的 API 查询、以及浏览器那一半。

四个包各管一段:

包 角色
@deepseek-ai/dsh-tool-cordis 两个只读检查工具,不贡献系统提示
@deepseek-ai/dsh-cordis-host-runner 定义注册表 + node:vm 沙箱 + Host 半生命周期 + 检查注册表(提供 ctx.dynamicCordisRunner 与 ctx.cordisInspect)
@deepseek-ai/dsh-cordis-client-runner 浏览器半的加载与应答
@deepseek-ai/dsh-client-ui-cordis 面板与卡片(人在上面批准、启停)

模型可见的只有那两个只读工具:

  • cordis_inspect_list:列出当前已知的检查提供方——平台、用途、只读方法与它们的输入输出 schema,查询前先拿它选 provider 与 method。
  • cordis_inspect_query:按选中的 provider 与 method 去读真实约定:服务方法、事件模式、插件 Config schema、工具 schema、实时 slot 树。

它们不能调用业务方法,也不能改运行时。

创建和运行动态定义没有模型工具。define / run / stop / undefine 是 ctx.dynamicCordisRunner 上的服务动词,给程序调用方和浏览器面板用;模型要持久化地装插件,走的是 Plugin Manager 那条路(@deepseek-ai/dsh-plugin-manager/tools 那行工具)。定义本身仍然是不可变的包版本:define 只做参数与语法校验、不执行任何代码,mode: "run" 启动当前版本,mode: "update" 换到新版本——出错之后是”追加一个新版本再切过去”,不是”改掉旧版本”。

demo 里那个 dsh-self-cordis 就是一条安装命令:它自己是组合包,cordis.patch.yml 里插三行,把检查工具集的三块挂上。

- insert:
    - id: cordis-host-runner
      name: '@deepseek-ai/dsh-cordis-host-runner'
      config:
        # 沙箱只对动态包函数体的同步部分计时,异步部分逃得出去。
        vmTimeoutMs: 5000

    # Host 检查提供方按进程注册一次:注册表拒绝重复 id,
    # 所以挨着 runner 挂一遍,上面的两个工具就读得到。
    - id: cordis-inspect-providers
      name: '@deepseek-ai/dsh-tool-cordis/host'

    - id: tool-cordis
      name: '@deepseek-ai/dsh-tool-cordis'

这里正好接上 18-把插件打包成配置包的一个推论:dsh-tool-cordis 和它的 runner 都不是组合包(manifest 里没有 dsh 字段),所以 dsh plugin add 对它们只会打印一条 declares no dsh.bundle 的警告——它们是依赖,配置得靠某一层 patch 显式写行。

装之前请把边界读完。 官方 README 的措辞是”对待动态包要像对待 bash 访问一样刻意”:

  • 沙箱是给老实代码用的围栏,不是安全边界。 vmTimeoutMs 只围得住函数体的同步部分;异步的代码逃得出去。沙箱给动态包的是一层不含框架内部机制的 façade,但动态包声明的服务照样触及存活运行时,从而影响同一进程里的其他会话。
  • 定义是会话级、进程本地的。 只有定义它的会话看得见、管得着;其他会话读作不存在。重启即消失,会话日志里只留下 define 的参数与回执,注册表不会从日志里恢复。
  • 带浏览器半的包要人批准,而且会一直等。 run 对这类包返回 status: 'awaiting-approval' 并挂起,由页面依次走 host 半、client 半才结算;没有页面连着的部署会一直挂到提问的那个轮次被取消。纯 Host 的包不受影响。

验证

demo A 的装法照 18-把插件打包成配置包:

cd dsh_plugin/dsh-live-greeter && ./install.sh

然后看三个概念各自对应的现象:

# 1. 配置树里只有配置行——内存插件不在配置树里
dsh --profile test --dump-config | grep -c 'live-greeter-mount'   # 1
dsh --profile test --dump-config | grep -c 'greeter-tool'         # 0

# 2. 起一个会话,看启动日志里的 mounted 那一行
dsh --profile headless "用 live_greet 跟 小王 打个招呼"

日志里应当是 mounted uid=… state=ACTIVE。如果打出来的是 PENDING,去看启动审计里那行 pending——它会写清楚等的是哪个服务(waiting for service: …)。

# 3. 改一行 patch 触发用户层热重载,观察 unmount 与 uid 变化

会看到 unmounting uid=…,然后是新的 mounted uid=…,两个 uid 不一样。这是”真的卸掉重挂了”而不是”复用了同一个 fiber”的肉眼证据(注册表的计数器单调递增)。

demo B 的验证是让模型走一遍:

cd dsh_plugin/dsh-self-cordis && ./install.sh

先确认三行都进了配置树:

dsh --profile test --dump-config | grep -A2 cordis-host-runner
dsh --profile test --dump-config | grep -c cordis-inspect-providers   # 1

然后起会话:

cordis_inspect_list   → 看有哪些检查提供方(Host 的 Config 提供方应当在里面)
cordis_inspect_query  → 挑一个 provider 与 method,读它的方法签名或 slot 树

想体验动态定义就去浏览器面板:在那里定义一个包并运行——带浏览器半的会先要你批准,纯 Host 的直接在本进程激活。最后重启 dsh,面板里就空了:定义只在进程内存里,会话日志不会把它恢复回来。

注意事项

  • 「挂上了」和「生效了」之间夹着 PENDING,而且它不报错。 动态挂载最常见的失败现象就是”什么都没发生”。判据是 fiber.state,不是 ctx.plugin() 有没有返回。
  • 用 ctx.get(name) 查可选服务,不要用 ctx.tools 这种属性代理。 属性代理是拓扑敏感的——它回答的是”我这个位置能不能看见”,而你要问的往往是”全局存储里有没有”。这两者在动态挂载的场景里经常不一致。
  • uid 不是插件的身份。 每次重挂都是新 uid(旧的那个 dispose 后变成 null)。要长期跟踪一个逻辑插件,用 registry 里的回调函数做键,别用 uid。
  • 内存插件写不回配置文件。 它是运行期的产物:重启就没了,--dump-config 里也没有它。要留下来的东西,得走 18-把插件打包成配置包那条路——落进某个包的 patch 或源码。
  • “不写文件”不等于”没影响”。 内存插件照样能注册工具、提供全局服务、占用事件。它在当前进程里的影响力,和从配置装进来的插件没有区别。
  • 注销时看清自己站在哪。 在插件 A 的 apply 里挂的插件,生命跟着 A 的 fiber;在会话上下文里挂的,跟着那条会话的 fiber。registry.delete(callback) 则是”把这个回调的所有 fiber 一起收掉”,适合做兜底清理。