How Cordis Plugin Runtime Manages Fibers and Disposal: A Deep Dive into the Lifecycle
Cordis orchestrates plugin lifecycles through Fiber instances that collect disposables via an internal effect system, executing cleanup routines in reverse order during unload or hot-module-replacement while isolating errors to prevent resource leaks.
Cordis is a modern plugin framework where every plugin executes within an isolated runtime context called a fiber. Understanding how the Cordis plugin runtime manages fibers and disposal reveals the architectural patterns that enable hot reloading, deterministic cleanup, and robust error isolation. This analysis examines the actual source implementation to explain how fibers are created, how they track resources, and how they guarantee cleanup even during complex reload scenarios.
Fiber Creation and Plugin Registration
The lifecycle begins in packages/core/src/registry.ts when RegistryService.plugin registers a new plugin. This method serves as the primary entry point for fiber instantiation.
// packages/core/src/registry.ts
const fiber = new Fiber(this.ctx, config, Inject.resolve(plugin.inject), runtime, getOuterStack)
The constructor resolves the plugin's injection dependencies and creates a Fiber instance that immediately registers itself within its parent runtime. Inside packages/core/src/fiber.ts, the constructor establishes the disposal mechanism:
// packages/core/src/fiber.ts
this.dispose = parent.fiber.effect(() => {
const remove = runtime.fibers.push(this)
// ...
return async () => { /* cleanup logic */ }
}, 'ctx.plugin()')
This effect adds the fiber to runtime.fibers—a DisposableList<Fiber>—and returns an async disposal function that will later handle teardown. Each fiber maintains its own child context (ctx) that inherits from the parent, creating an isolated execution environment for the plugin's callback.
The Effect System and Disposable Collection
Cordis implements disposal through an effect system where plugins declare resources via ctx.effect(). Inside Fiber.effect in packages/core/src/fiber.ts, the framework wraps each callback in a runner that captures returned disposables.
// packages/core/src/fiber.ts
const disposables: Disposable[] = []
const runner: EffectRunner<boolean> = {
execute,
epoch: true,
collect: (dispose) => {
disposables.push(dispose)
this._disposables.delete(dispose)
if (dispose[symbols.effect]) meta.children.push(dispose[symbols.effect])
},
// ...
}
let task = this._execute(runner)
The _execute method interprets the callback's return value dynamically. If the callback returns a function, Cordis treats it as a disposal routine. If it returns an iterable or async iterable, each yielded value is collected as a disposable. This flexible design supports multiple return shapes—Disposable, Iterable<Disposable>, or Promise<Disposable>—while storing everything in this._disposables for later cleanup.
Unloading and Disposal Lifecycle
When a fiber stops—whether through manual disposal, deletion, or hot-module-replacement—the disposal sequence activates. The fiber first waits for any pending operations via the inertia property, ensuring no race conditions during teardown.
The dispose effect created in the constructor triggers _unload, defined in packages/core/src/fiber.ts:
// packages/core/src/fiber.ts (lines 39-60)
private async _unload() {
await Promise.all(this._disposables.clear().map(async (dispose) => {
try {
await composeError(async (info) => {
await Promise.resolve()
info.error = new Error()
await dispose()
}, this._runner.getOuterStack)
} catch (reason) {
this.ctx.logger.error(reason)
}
}))
this.store = undefined
// ...
}
Disposables execute in reverse order (LIFO) because this._disposables.clear() returns them in reverse insertion order. Each disposal runs through composeError from packages/core/src/utils.ts, which enriches thrown errors with merged stack traces for debugging. Importantly, errors during disposal are logged but not thrown, ensuring that one failing cleanup routine does not prevent others from executing.
Hot Reload and Epoch Management
Cordis supports dynamic configuration changes without full restarts through the _reload mechanism. When configuration updates occur, Fiber._setEpoch triggers _reload:
// packages/core/src/fiber.ts (lines 17-33)
private async _reload() {
this.store = { ...this._store }
const oldEpoch = this._runner.epoch
try {
await Promise.resolve()
await this._execute(this._runner)
} catch (reason) {
this.ctx.logger.error(reason)
this._error = reason
this._runner.epoch = INACTIVE
}
}
The method snapshots the current store, then re-executes the effect runner to create fresh disposables. If reload fails, the fiber transitions to a FAILED state and stores the error for later retrieval via await fiber. During hot-module-replacement (HMR), Cordis simply calls fiber.restart(), which sets the epoch to INACTIVE and triggers this reload flow, preserving the fiber's identity while replacing its implementation.
Practical API for Plugin Developers
Plugin authors interact with this system through the public Context API. To register a plugin with automatic disposal:
// packages/create/src/index.ts example pattern
import { Context } from 'cordis'
export default function myPlugin(ctx: Context, config: { greeting: string }) {
ctx.effect(() => {
const timer = setInterval(() => console.log(config.greeting), 1000)
return () => clearInterval(timer) // disposable cleanup
}, 'greeting-timer')
}
ctx.plugin(myPlugin, { greeting: 'Hello' }) creates a new fiber and returns a promise-like object resolving to the fiber instance. To manually trigger disposal:
const fiber = await ctx.plugin(myPlugin, { greeting: 'Hi' })
await fiber.dispose() // triggers unload, runs all disposables
The dispose method waits for any ongoing reload (checking this.inertia) before finalizing, ensuring consistent state transitions.
Summary
- Fiber instantiation occurs through
RegistryService.plugininpackages/core/src/registry.ts, which creates a Fiber that registers itself inruntime.fibers. - Effect collection happens via
Fiber.effectinpackages/core/src/fiber.ts, capturing disposables inthis._disposablesregardless of whether they return as functions, iterables, or promises. - Disposal order is strictly LIFO (reverse insertion), executed by
_unload, with each disposable wrapped incomposeErrorfor enhanced stack traces. - Error isolation ensures disposal failures are logged but do not abort the cleanup sequence, preventing resource leaks.
- Hot reloading uses
_reloadto snapshot state and recreate disposables without destroying the fiber identity, enabling HMR workflows.
Frequently Asked Questions
How does Cordis ensure resources are cleaned up when a plugin unloads?
Cordis tracks every resource through the Fiber.effect system, which stores disposables in an internal _disposables list. When the fiber unloads, the _unload method clears this list and invokes each disposable in reverse order. The disposal logic waits for pending work to complete via the inertia property, ensuring that asynchronous operations finish before cleanup begins.
What happens to disposables when a plugin is reloaded?
During reload, triggered by _reload or fiber.restart(), the fiber snapshots its current state and re-executes the effect runner. This creates an entirely new set of disposables while the old ones are cleared and invoked. The fiber maintains its identity and context, but all resources are freshly allocated according to the new configuration or code changes.
How does error handling work during fiber disposal?
Each disposable runs inside a composeError wrapper from packages/core/src/utils.ts, which captures stack traces and merges them with the original execution context. If a disposable throws, Cordis catches the error, logs it via this.ctx.logger.error, and continues executing remaining disposables. This guarantees that one failing cleanup routine does not prevent others from running.
Can plugin authors manually trigger fiber disposal?
Yes. The fiber.dispose method, which is the same effect created in the Fiber constructor, can be awaited to trigger full teardown. This method waits for any ongoing reload operations (checking this.inertia) before invoking _unload, making it safe to call even during active plugin transitions.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →