2. Lifecycle and effects

A Cordis plugin can be unloaded by a config edit, hot reload, explicit disposal, or loss of a required service. Registrations made through Cordis APIs are effects and are undone when their owning plugin unloads; resources managed outside those APIs must be wrapped in ctx.effect().

Effects

For a resource Cordis does not already manage — a timer, a connection, a watcher — wrap it in ctx.effect() and return a disposer:

Create lifecycle.ts in tmp/cordis-tutorial:

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) {
  // Mount a child plugin and keep its fiber to dispose it later.
  const fiber = ctx.plugin(heartbeat)
  // The demo timer is itself an effect: if THIS plugin is unloaded first,
  // the pending callback is cancelled instead of firing on a dead app.
  ctx.effect(() => {
    const timer = setTimeout(async () => {
      await fiber.dispose()
      console.log('disposed')
      process.exit(0)
    }, 700)
    return () => clearTimeout(timer)
  })
}

Point cordis.yml at it:

- name: './lifecycle.ts'

Run (node --import tsx ../../vendor/cordis/bin.js) and you get:

heartbeat plugin loading
tick
tick
tick
heartbeat cleaned up
disposed

Three things to notice:

  • ctx.plugin(heartbeat) mounts a function from code as a plugin — the same operation the YAML loader performs for each config entry. A function plugin needs no apply method: Cordis calls the function directly and uses its name only for diagnostics. An apply method is required only for the object form, ctx.plugin({ apply(ctx) { /* ... */ } }). The call returns a fiber, the runtime handle for one loaded plugin instance.
  • The effect body runs during load; the disposer it returns runs during unload. You never call the disposer yourself for a plugin-lifetime resource.
  • fiber.dispose() resolves after all of the plugin's cleanup — including async disposers — has finished, and recursively unloads any child plugins it mounted.

The fiber state machine

Every loaded plugin instance owns a fiber that moves through these states:

PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED
                 ↘ FAILED
  • PENDING — declared, but a required service (chapter 3) is not available yet.
  • LOADING / ACTIVEapply is running / has completed.
  • FAILEDapply or config validation threw.
  • UNLOADING / DISPOSED — disposers are running / everything is torn down.

You will meet PENDING again in chapter 6, where it is the usual answer to "why does my plugin print nothing?".

What is already an effect

You rarely write ctx.effect() yourself, because the built-in registration APIs are effects already:

  • ctx.on(event, listener) — the listener is removed on unload (chapter 4).
  • ctx.plugin(child) — the child is disposed with its parent.
  • Service registrations are effects. Harness registries such as ctx.tools.register(...) also attach their returned disposers to the calling plugin, so they unwind automatically (chapter 7).

For a resource Cordis does not manage, acquire it inside ctx.effect() and return a disposer that releases it. Cordis then invokes that release during unloading, including hot reload.

One ordering caveat: disposers start in reverse registration order, but multiple async disposers run concurrently. If teardown steps must run in sequence, keep them in one disposer and await them there.

Next: Services — how plugins share capabilities.

2. 生命周期与 effect

Cordis 插件可能因修改配置、热重载、显式资源释放或所需服务消失而卸载。通过 Cordis API 建立的注册属于 effect,会在所属插件卸载时撤销;在这些 API 之外管理的资源必须包装在 ctx.effect() 中。

Effect

对于 Cordis 尚未管理的资源,例如定时器、连接或 watcher,应将其包装在 ctx.effect() 中并返回 disposer(资源释放函数):

创建 lifecycle.ts,将它放在 tmp/cordis-tutorial 中:

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) {
  // Mount a child plugin and keep its fiber to dispose it later.
  const fiber = ctx.plugin(heartbeat)
  // The demo timer is itself an effect: if THIS plugin is unloaded first,
  // the pending callback is cancelled instead of firing on a dead app.
  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

请留意三点:

  • ctx.plugin(heartbeat) 会把一个来自代码的函数挂载为插件,这与 YAML loader 为每个配置项执行的操作相同。函数插件不需要 apply 方法:Cordis 会直接调用该函数,其名称只用于诊断。只有对象形态才要求 apply 方法,例如 ctx.plugin({ apply(ctx) { /* ... */ } })。调用会返回一个 fiber,即一个已加载插件实例的运行时句柄。
  • effect 主体在加载期间运行;它返回的 disposer 在卸载期间运行。对于生命周期与插件一致的资源,你绝不需要自行调用 disposer。
  • fiber.dispose() 会等该插件的所有清理工作(包括异步 disposer)完成后才结束,并递归卸载它挂载的所有子插件。

Fiber 状态机

每个已加载插件实例都拥有一个 fiber,并在以下状态之间转换:

PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED
                 ↘ FAILED
  • PENDING:已经声明,但所需服务(第 3 章)尚不可用。
  • LOADING / ACTIVEapply 正在运行/已经完成。
  • FAILEDapply 或配置校验抛出异常。
  • UNLOADING / DISPOSED:disposer 正在运行/一切均已拆除。

你会在第 6 章再次遇到 PENDING,它通常就是「为什么我的插件没有输出」的答案。

已经属于 effect 的操作

你很少需要亲自编写 ctx.effect(),因为内置注册 API 本身已经是 effect:

  • ctx.on(event, listener):监听器会在卸载时移除(第 4 章)。
  • ctx.plugin(child):子插件会随父插件一同 dispose(资源释放)。
  • 服务注册属于 effect。ctx.tools.register(...) 等 harness 注册表也会把返回的 disposer 附着到调用插件上,因此会自动撤销(第 7 章)。

对于 Cordis 不管理的资源,应在 ctx.effect() 内获取它,并返回用于释放资源的 disposer。此后 Cordis 会在卸载期间调用该释放逻辑,热重载时也不例外。

有一项顺序注意事项:disposer 会按注册顺序的逆序启动,但多个异步 disposer 会并发运行。如果拆除步骤必须按顺序执行,请把它们放在同一个 disposer 中,并在其中依次等待每步完成。

下一章:服务:插件如何共享功能。