乐于分享
好东西不私藏

插件删了,为什么系统里还会留下“幽灵状态”?

插件删了,为什么系统里还会留下“幽灵状态”?

DEEPSEEK HARNESS 工程课 · 06

你给 Agent 注册了一个工具,开发时一切正常。模型能发现它、能调用它,日志也清清楚楚。

热更新几次以后,怪事开始出现:同一个工具在列表里重复出现;你在配置里删掉了插件,旧监听器却还在响应事件;定时器还在往控制台打 tick;数据库连接始终没有关闭。

很多人把它归因于“缓存没清”。但真正的问题通常不是缓存,而是:插件贡献了能力,却没有证明自己能够在退出时把这些能力收回来。

本文拆开 Cordis effect 的“创建”与“释放”,说清“幽灵状态”从哪里来、如何根除,并给出一份可直接使用的插件卸载验收清单。

先记住这一句

能装进去只是第一步,能干净地拿出来才是插件系统的及格线。

01

插件真正改变的,不只是目录结构,而是运行环境

很多人理解插件化,只想到把代码拆成多个包:coreplugins/loggerplugins/toolsplugins/storage。但目录拆开,不等于运行状态彼此独立。

插件加载后,通常会改变宿主环境:

· 往注册表加入一个工具;

· 给事件总线添加监听器;

· 修改共享上下文;

· 创建 watcher;

· 打开网络连接;

· 启动后台任务;

· 挂载子插件。

这些都是副作用。副作用并不天然是坏事——没有副作用,插件就无法真正提供能力。真正的问题是:

系统是否知道这些副作用属于谁,以及插件退出时应该如何撤销?

如果不知道,插件卸载就只能删除一段配置,却不能恢复运行环境。

02

传统“手动 stop”为什么总会漏?四个原因

最直接的写法,是在加载时创建资源,在卸载函数里手动清理:一个 start 建定时器,一个 stop 清定时器。这个例子很简单,但系统一复杂,问题就出现了。

四个容易遗漏的原因

1. 创建和清理相隔太远:资源在一个工具函数里创建,却在另一个生命周期函数里清理,后来改创建逻辑的人未必知道要同步改清理逻辑。

2. 加载过程中也可能失败:先建定时器、再开连接、最后注册工具,第三步失败时,前两步是否会被撤销?

3. 卸载来源不止一种:配置被修改、热重载、显式 dispose、所需 Service 消失、父插件卸载——每处各写一套清理,遗漏和顺序错误几乎不可避免。

4. 清理顺序影响正确性:先关日志再停后台任务,任务退出时的日志可能丢失;先销毁父资源再清理子资源,清理本身也可能失败。

因此,生命周期不能只靠“记得写一个 stop 函数”。

03

effect 的核心思想:在哪创建,就在哪声明怎么撤销

Cordis 的做法是一句话——

在创建副作用的位置,同时声明如何撤销它。

ctx.effect(() => {

  const resource = createResource()

  return () => {

    destroyResource(resource)

  }

})

ctx.effect() 里发生两件事:effect 主体在插件加载期间运行、创建资源;返回的 disposer 在插件卸载期间运行、释放资源。创建和清理被放进同一个局部上下文。

这样做的价值不是少写几行代码,而是让运行时能够知道三件事:

· 这个资源由哪个插件创建;

· 这个资源应该怎样清理;

· 插件卸载时必须等待哪些清理完成。

04

写一个最小生命周期插件,看一次“加载→清理→卸载”

下面用一个无需 API Key 的定时器插件观察 effect。创建 tmp/cordis-tutorial/lifecycle.ts,写入:

import type { Context } from '@deepseek-ai/cordis'

export const name = 'lifecycle-demo'

function heartbeat(ctx: Context) {

  console.log('heartbeat plugin loading')

  ctx.effect(() => {

    const timer = setInterval(() => console.log('tick'), 200)

    return () => {

      clearInterval(timer)

      console.log('heartbeat cleaned up')

    }

  })

}

export function apply(ctx: Context) {

  const fiber = ctx.plugin(heartbeat)

  ctx.effect(() => {

    const timer = setTimeout(async () => {

      await fiber.dispose()

      console.log('disposed')

      process.exit(0)

    }, 700)

    return () => clearTimeout(timer)

  })

}

让 cordis.yml 指向这个文件,并运行:

- name: './lifecycle.ts'

node --import tsx ../../vendor/cordis/bin.js

预期可以观察到类似顺序:

heartbeat plugin loading

tick

tick

tick

heartbeat cleaned up

disposed

不同机器上的 tick 次数可能略有差异。真正需要验证的是:

1. 插件加载后定时器开始运行;

2. fiber.dispose() 触发卸载;

3. disposer 执行并清除定时器;

4. 卸载完成后不再出现新的 tick

不要把“必须正好出现三次 tick”当作验收标准——定时调度不是这一讲要证明的重点。

05

Fiber:插件实例的运行时句柄,而不是一个状态标签

ctx.plugin(heartbeat) 会把函数挂载为一个子插件,并返回一个 Fiber。可以先把它理解成:

某个已加载插件实例的运行时句柄。

通过它,运行时知道插件当前处于什么状态,并执行卸载。官方教程给出的状态机是:

PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED

                 ↘ FAILED

· PENDING:已声明,但所需 Service 尚未可用;

· LOADING:正在执行加载逻辑;

· ACTIVE:加载完成,正在运行;

· FAILED:加载或配置校验失败;

· UNLOADING:正在执行 disposer,等待资源清理;

· DISPOSED:插件及其所属资源已经完成拆除。

fiber.dispose() 不只是改一个状态标签:它会等待清理工作完成(包括异步 disposer),并递归卸载由它挂载的子插件。

06

哪些操作已经自动属于 effect?优先用官方 API

开发插件时,不是所有资源都要手写 ctx.effect()。Cordis 的一些 API 已经把注册和 disposer 绑定起来:

· 事件监听ctx.on(event, listener):监听器会在插件卸载时移除;

· 子插件ctx.plugin(child):子插件会随父插件一起 dispose;

· Service 注册:本身属于生命周期管理范围;

· Harness 注册表:例如工具注册 API 会把返回的 disposer 附着到调用插件,卸载时自动撤销注册。

插件开发的第一选择

优先使用 Cordis 或 Harness 已经纳入生命周期的注册 API;只有在管理定时器、连接、watcher 或第三方资源时,再手动使用 ctx.effect()

07

哪些资源必须手动包装?五类常见情况

只要资源不是由 Cordis API 自动管理,就应该明确检查它的释放方式。常见类型:

· 定时器return () => clearInterval(timer)

· 文件 watcher:返回 watcher.close()

· 数据库连接:用 async effect,返回 await connection.close()

· 第三方订阅:返回 unsubscribe()

· 临时子进程:要同时考虑终止信号、等待退出、超时处理、是否还有未读取输出、卸载期间是否允许创建新资源。

真实资源的清理通常比示例复杂。effect 解决的是“归属和触发”,不会自动替你发明正确的关闭协议。

08

两个容易忽略的边界:清理顺序与失败回滚

先看清理顺序。disposer 会按注册顺序的逆序启动:依次注册 A、B、C,加载是 A→B→C,卸载是 C→B→A。后创建的资源通常更依赖先创建的资源,所以应先释放。

但有一个重要边界:多个异步 disposer 可能并发运行。如果两步清理必须严格按顺序,不要拆成两个独立异步 disposer,应放进同一个 disposer 并明确 await

ctx.effect(async () => {

  const connection = await openConnection()

  const worker = await startWorker(connection)

  return async () => {

    await worker.stop()

    await connection.close()

  }

})

这里先停 worker、再关 connection,顺序由同一个 disposer 保证。

再看失败回滚。插件不一定顺利进入 ACTIVE。假设加载过程是“创建 watcher → 打开连接 → 注册工具时抛出异常”,如果系统只在成功加载后记录清理逻辑,前两个资源就会泄漏。Cordis 的 effect 管理强调:effect 的所有权在 setup 执行前就建立,即使 setup 同步失败,已收集的清理也会被回滚。

插件失败不能只留下一个错误,它还必须尽可能恢复已经改变的环境。这也是插件系统能否支持热重载和动态替换的基础。

09

effect 能解决什么、不能解决什么?一张对比表

能自动撤销
不能自动撤销
移除事件监听器
已发送给外部用户的消息
取消定时器
已完成的支付或转账
关闭连接、停止 watcher
已被第三方读取的数据
注销工具和 Service
无法恢复的文件删除
卸载子插件、等待异步清理
不支持取消的外部任务
热重载时撤销旧实例资源
恶意插件突破进程边界后的破坏

这些“不能撤销”的操作,需要幂等设计、补偿事务、延迟提交、人工批准、安全沙箱和外部审计。所以——

可撤销 effect 不是数据库事务,也不是安全沙箱。它处理的是插件控制范围内、能够被追踪并明确释放的运行时资源。

10

插件卸载验收清单(可收藏)

卸载前逐项确认

□ 重复加载测试:加载—卸载—再加载,行为只出现一次,不重复注册;

□ 资源停止测试:定时器不再输出、watcher 不再响应、连接已关闭、子进程已退出、工具不再出现在注册表;

□ 失败注入测试:让插件在加载中途失败,已创建的资源被回滚;

□ 异步等待测试:fiber.dispose() 会等待清理完成,而非提前报告完成;

□ 父子插件测试:卸载父插件,子插件也进入 dispose 流程。

一项资源如果无法说明“由谁创建、由谁释放、失败时怎样恢复”,就不应该被视为生命周期设计完成。

一句话收尾

“一切皆插件”真正难的地方,不是把功能拆成模块,而是保证每个模块都能安全退出。

下一篇

这一篇解决了“插件能不能干净退出”。但同一套核心,为什么既能跑在 Web 页面里,也能作为一次性 headless 任务在终端执行?下一篇拆开 profile、bundle 和 patch 三层组合。

本文教学版本 DeepSeek Harness 0.1.0-rc.5,基于锁定 commit;接口、目录与命令行为如有变化,请优先核对锁定源码。