DSH plugin lifecycle: Fiber states, nesting and dispose
A DSH plugin does not simply stay loaded once it starts; a six-state Fiber machine governs it — it waits in PENDING until dependencies are ready, enters FAILED when apply throws, and passes through UNLOADING before DISPOSED. That state machine explains the most common confusion in plugin development: "the plugin is installed but never runs" usually means inject dependencies are not satisfied and the Fiber never reached apply. The other half comes from teardown: registrations made through ctx are cleaned automatically, but asynchronous cleanup has no ordering guarantee, so ordered teardown has to be composed yourself.
DSH plugin Fiber states: six states and three transitions
Bottom line: the six states answer one question — can this plugin run, where did it fail, and how far did unload get.
| State | Meaning | Typical cause |
|---|---|---|
| PENDING | Declared, dependencies not ready | inject services not provided yet |
| LOADING | Dependencies ready, running apply | Normal load |
| ACTIVE | Plugin running | Load succeeded |
| FAILED | apply threw | Synchronous errors, initialization failure |
| UNLOADING | Releasing resources | dispose, dependency loss, hot reload |
| DISPOSED | Fully unloaded | Teardown finished |
- Normal path: PENDING → LOADING → ACTIVE.
- Failure path: LOADING → FAILED, where
applythrew. - Unload path: ACTIVE → UNLOADING → DISPOSED, only after cleanup finishes.
Diagnostic rule: when "the plugin does nothing," check which state its Fiber is in — PENDING is a dependency problem, FAILED is a code problem, and the fixes differ completely.
DSH plugin inject-gated loading: when apply actually runs
Bottom line: a DSH plugin declaring inject waits for every required service, so "not running" often means queued on a dependency rather than not installed.
export const inject = ['tools', 'llm']
export function apply(ctx: Context) {
// ctx.tools and ctx.llm are guaranteed ready here
}
- Declare real dependencies only. Every unrelated service in inject is another dependency to wait for.
- Confirm the provider is loaded. If the plugin providing the service is absent, you stay in PENDING forever.
- Know what dependency loss does. If a providing service disappears (for example during a provider swap), the DSH plugin is unloaded automatically (ACTIVE → DISPOSED) and reloads when the service returns — this is by design.
- Verify: temporarily remove inject and run once. If the plugin runs, the problem is the dependency, not the code.
DSH plugin nested contexts: ctx.plugin and child Fibers
Bottom line: ctx.plugin() creates a child plugin with its own Fiber that shares the parent context and unloads with it — the lightest way to split internal responsibilities.
- Register a child:
export function apply(ctx: Context) {
ctx.plugin(childPlugin) // the child owns its own Fiber
}
- Understand inheritance: the child inherits the parent context but has an independent lifecycle; unloading the parent recursively unloads the child, and the child can also be disposed early.
- Use it to split internals: when one plugin holds several capabilities, separate their state machines with child plugins instead of piling logic into one
apply. - Know the boundary: a child plugin is not another package and shares the process and context; real isolation needs profiles or separate plugin packages.
DSH plugin dispose: cleanup order and the concurrency trap
Bottom line: dispose clears registrations, recursively unloads children and waits for async cleanup; but async disposers run concurrently, so ordered teardown belongs in one effect.
- What is cleaned automatically:
ctx.onlisteners,ctx.tools.registertools,ctx.llm.registerAdapteradapters and anyctx.effectresource. - Ordering rule: disposers start in reverse registration order.
- Concurrency trap: multiple asynchronous disposers run concurrently and are not guaranteed to finish one by one. Order-dependent cleanup must live inside one
ctx.effect()disposer, which awaits its steps itself. - Terminate an instance manually:
const fiber = ctx.plugin(myPlugin)
await fiber.dispose() // wait for cleanup to finish
- Verify removal: after unloading, fire the old event and call the old tool once — nothing should respond. If something does, a registration bypassed ctx.
DSH plugin lifecycle debugging: FAILED, PENDING and hot reload
Bottom line: three symptoms map to three states — not running is PENDING, errors are FAILED, unchanged behavior after edits means HMR never unloaded the old instance.
- Not running: check inject dependencies and their providers, not the code.
- Errors: README the direct cause of FAILED and look first at synchronous errors before the first
awaitinapply, which come with little stack context. - Edits not taking effect: with
@deepseek-ai/dsh-hmrloaded, editing a source file runs unload → reload → new apply; without it, restart the process. - Infer whether cleanup happened: automatic registration cleanup is the precondition for hot replacement; leftover registrations show up as "two copies of the logic running."
- A minimal check:
export function apply(ctx: Context) {
console.log('plugin loading')
ctx.effect(() => {
console.log('effect registered')
return () => console.log('effect cleaned up')
})
}
Loading prints the first two lines, unloading prints the third — this minimal chain proves the state machine and cleanup both work.
DSH plugin lifecycle troubleshooting
Bottom line: locate the state first, then discuss code — the state machine already wrote the answer down.
- PENDING is not a failure. It means dependencies are not ready; check service names and providers.
- FAILED does not retry. Reload or restart after fixing.
- Automatic unload after dependency loss is by design — do not treat it as a crash.
- Async cleanup has no ordering guarantee. Compose dependent teardown into one effect.
- Hot reload is conditional: only with the HMR plugin loaded, and only because registrations can be undone automatically.
- Repair status, stated honestly: the states, cleanup semantics and three dispose guarantees come from the official Plugins and lifecycle page; confirm exact ctx signatures against the official Cordis API page and the types of your version.
Sources: Plugins and lifecycle, Cordis core API · Context, Your first plugin.
FAQ
A DSH plugin in PENDING means its declared inject dependencies are not all ready, not that its code is wrong. Check that the service names in inject are spelled correctly and that the plugins providing them are loaded; once dependencies are satisfied, the Fiber enters LOADING and runs apply.
A DSH plugin whose apply throws enters FAILED and is not retried automatically. Fixing the code and triggering hot reload or restarting the process is what runs the load sequence again; check synchronous errors before the first await in apply first.
DSH plugin disposers start in reverse registration order, but multiple asynchronous disposers run concurrently and are not guaranteed to finish one by one. Put order-dependent cleanup inside a single ctx.effect disposer and have that disposer await its own steps.
Yes, disposing a DSH plugin removes every registration it owns and recursively unloads its child plugins. The returned promise settles after all asynchronous cleanup finishes, so you can await it before continuing.
DSH plugin hot reload unloads the old plugin, reloads the new code, then runs the new apply. Because registrations are cleaned up automatically, hot replacement never keeps the old instance's registrations and you never call removeListener by hand.
Related Terms
- Fiber
- Fiber is the runtime scope Cordis creates for each loaded DSH plugin, holding its state machine and all of its registrations. It decides when apply runs, when the plugin unloads, and which side effects are cleaned up.— https://deepseek-harness.github.io/deepseek-harness/develop/framework/
- PENDING
- PENDING is the initial Fiber state meaning a plugin is declared but its inject dependencies are not all ready. It is the state most often mistaken for a failure, when the framework is simply waiting on dependencies.— https://deepseek-harness.github.io/deepseek-harness/develop/framework/
- dispose
- dispose terminates one plugin instance early: it removes all of its registrations, recursively unloads child plugins, and returns a promise that settles after asynchronous cleanup completes.— https://deepseek-harness.github.io/deepseek-harness/reference/cordis-api/context
- ctx.effect
- ctx.effect registers a custom cleanup for resources the framework cannot track itself, such as network connections and timers: you pass a function that returns a disposer, and the framework calls it when the plugin unloads.— https://deepseek-harness.github.io/deepseek-harness/develop/basic/
Sources
- DeepSeek Harness docs — Plugins and lifecycle (Fiber states, dispose, HMR)· deepseek-harness.github.io
- DeepSeek Harness docs — Cordis core API: Context· deepseek-harness.github.io
- DeepSeek Harness docs — Your first plugin (apply, ctx and auto cleanup)· deepseek-harness.github.io