Cordis Effect System Automatic Cleanup: How Plugins Self-Dispose
Cordis implements a robust effect system that automatically disposes of resources when a plugin is unloaded by registering lifecycle hooks as disposable effects that execute cleanup functions in reverse order when the parent fiber enters a DISPOSED state.
The Cordis framework (cordiverse/cordis) provides a sophisticated plugin architecture where resource management is handled automatically through its effect system. When a plugin initializes, every resource it creates—timers, database connections, or event listeners—registers as an effect that cleans itself up when the plugin unloads. Understanding how Cordis effect system automatic cleanup works at the source code level is essential for building reliable, leak-free plugins.
Core Effect Registration in the Fiber Constructor
Every plugin creates a root effect that wraps its entire lifecycle. In packages/core/src/fiber.ts, the Fiber constructor registers a disposal effect via parent.fiber.effect(...) (lines 70-100). This registration associates the plugin's lifecycle with the parent fiber's cleanup chain.
The disposal effect returns an async cleanup function that executes when the plugin's fiber is disposed. According to the source code, this function:
- Emits the
'internal/plugin'event to signal teardown - Removes the plugin from the internal registry
- Resets the epoch to
INACTIVE - Awaits any pending inertia work before completing
This ensures that when a plugin is unloaded, the framework systematically tears down the plugin's resources before finalizing the disposal.
The Fiber.effect Method and Active State Validation
The Fiber.effect method serves as the public entry point for creating effects in packages/core/src/fiber.ts (lines 75-106). Before creating any effect, the system validates that the current fiber is active by calling assertActive. This validation prevents resource leaks by ensuring effects cannot be created on already-disposed fibers.
Once validated, the method builds a disposable wrapper that intercepts the user's effect function and manages its lifecycle. This wrapper is what enables the automatic cleanup semantics that Cordis provides.
Automatic Disposal Chain and Execution Order
Inside the effect method, the wrapper collects all disposables returned by the user's effect function. When the wrapper itself is invoked—or when the parent fiber is disposed—the disposal chain executes with specific guarantees defined in packages/core/src/fiber.ts (lines 22-30):
- Reverse order execution – Disposables run in LIFO (last-in-first-out) order, ensuring that resources created last are cleaned up first
- Promise chaining – Any returned promises are properly chained to ensure asynchronous cleanup operations complete sequentially
- Epoch flag clearing – The internal
runner.epochflag is cleared to prevent double-execution of cleanup code
This mechanism ensures that nested resources are disposed of safely, with inner resources always cleaned up before outer resources that might depend on them.
Effect Metadata and State Management
Each disposable in the Cordis effect system is tagged with symbols.effect metadata, defined in the utility files. This metadata enables introspection capabilities through the getEffects() method (lines 42-46 in packages/core/src/fiber.ts), which enumerates the current effect tree.
The fiber tracks its lifecycle through the FiberState enumeration. When dispose runs, the state transitions to DISPOSED, causing any remaining effects to be marked inactive. This state change prevents new effect creation; attempts to call ctx.effect on an inactive fiber throw CordisError.INACTIVE_EFFECT.
Practical Implementation Examples
The following examples demonstrate how to leverage the automatic cleanup mechanism in Cordis plugins.
Registering a Simple Timer Effect
This example shows how to register a cleanup function for a setInterval timer that automatically clears when the plugin unloads:
import { Context } from 'cordis'
export default function (ctx: Context) {
// This effect runs when the plugin is disposed
ctx.effect(() => {
const timer = setInterval(() => console.log('tick'), 1000)
// Return a cleanup function – Cordis will call it automatically
return () => clearInterval(timer)
}, 'my-plugin:timer')
}
Async Effects with Database Connections
For asynchronous resources, return an async cleanup function. Cordis will await the cleanup during the disposal phase:
import { Context } from 'cordis'
export default async function (ctx: Context) {
ctx.effect(async () => {
const conn = await createDatabaseConnection()
// When the plugin is unloaded, Cordis will await this cleanup
return async () => await conn.close()
}, 'db-connection')
}
Inspecting the Effect Tree for Debugging
You can enumerate active effects using the getEffects() method to monitor resource usage:
import { Context } from 'cordis'
export default function (ctx: Context) {
ctx.effect(() => {
// ... some work
}, 'debug:example')
// Retrieve meta-information about all active effects
console.log(ctx.fiber.getEffects())
}
Summary
- Automatic disposal is guaranteed for all resources registered via
ctx.effect()when the owning plugin unloads, eliminating manual teardown logic - Reverse execution order ensures that dependent resources are cleaned up safely, with inner disposables executing before outer ones
- Async safety is built-in through promise chaining, allowing database connections and other async resources to close properly before the fiber terminates
- State protection via
FiberStateandCordisError.INACTIVE_EFFECTprevents resource leaks by blocking new effects on disposed fibers - Metadata introspection through
symbols.effectandgetEffects()enables debugging and monitoring of the active effect tree inpackages/core/src/fiber.ts
Frequently Asked Questions
How does Cordis handle asynchronous cleanup functions?
Cordis automatically detects when an effect's cleanup function returns a Promise. According to the implementation in packages/core/src/fiber.ts, the disposal chain chains these promises sequentially, ensuring that async resources like database connections or file handles close completely before the fiber state transitions to DISPOSED. This prevents race conditions where a plugin might terminate while cleanup operations are still pending.
Can I inspect which effects are currently active in my plugin?
Yes. The getEffects() method in packages/core/src/fiber.ts (lines 42-46) returns metadata about all active effects tagged with symbols.effect. You can call ctx.fiber.getEffects() to enumerate the current effect tree, which is useful for debugging memory leaks or verifying that resources are properly registered. This introspection capability is used internally by the framework to report status and trigger cascading clean-ups.
What happens if I try to create an effect after a plugin starts unloading?
The system throws CordisError.INACTIVE_EFFECT. When a fiber's state transitions to DISPOSED during the unload cycle, the assertActive check inside Fiber.effect fails, preventing new resource registration. This safety mechanism ensures that developers cannot accidentally create dangling resources during plugin teardown, as the source code explicitly validates fiber state before allowing effect creation.
Where is the Context interface defined if I want to type my plugin correctly?
The Context interface, which includes the effect method signature, is declared in packages/core/src/context.ts. This file defines the public API surface for creating effects, while the core implementation logic resides in packages/core/src/fiber.ts. For utility types and symbols like symbols.effect, reference packages/core/src/utils.ts.
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 →