How Cordis Handles Nested Effects for Resource Cleanup
Cordis implements a hierarchical effect system that automatically tracks child resources and disposes them in reverse registration order when a parent effect is cleaned up.
The cordiverse/cordis repository provides a robust plugin architecture where nested effects for resource cleanup are managed through a parent-child relationship tracked in the execution context. When you register resources using ctx.effect(), the framework builds a disposal tree that ensures child effects are always cleaned up before their parents, preventing memory leaks and dangling event listeners during plugin reloads or application shutdown.
The Effect System Architecture
At the core of this mechanism is the Fiber class, which maintains the execution context for every plugin operation. When code invokes ctx.effect(), the system creates an EffectMeta object that serves as the backbone for tracking nested resources.
Effect Registration and Fiber Context
The Fiber.effect method (located in packages/core/src/fiber.ts) initializes each effect with metadata to store child references:
// inside Fiber.effect()
const meta: EffectMeta = { label, children: [] }
This metadata container collects all disposables returned by the effect function. The collect callback within the effect wrapper inspects each disposable for the special symbols.effect metadata, which indicates whether the disposable represents another nested effect:
collect: (dispose) => {
disposables.push(dispose) // store the disposable
this._disposables.delete(dispose) // remove from global list
if (dispose[symbols.effect]) {
meta.children.push(dispose[symbols.effect]) // ← record nested effect
}
},
When an inner effect returns a disposable carrying symbols.effect, the outer effect's EffectMeta records it in the meta.children array, establishing the hierarchical link required for coordinated cleanup.
How Nested Effects Are Tracked
Cordis detects nested effects automatically without requiring manual parent references. When an effect function calls ctx.effect() again to register additional resources, the new effect wrapper inherits the current fiber context and registers itself as a child of the executing parent effect.
This tracking enables the framework to distinguish between standalone disposables (like a simple function that clears a timer) and complex nested effect chains (like a plugin that registers sub-modules, each with their own event listeners).
Child Effect Metadata
The symbols.effect property acts as a brand marker on disposable functions. Only effect wrappers created by Fiber.effect carry this symbol, allowing the collection logic to differentiate between raw cleanup functions and nested effect containers that may themselves have children.
The Disposal Process
When an effect wrapper is invoked (either explicitly or implicitly during plugin teardown), it triggers a recursive cleanup sequence. The disposal process handles both synchronous and asynchronous resources while respecting the parent-child hierarchy.
Reverse Order Execution
The wrapper disposal logic (implemented in the effect runtime) executes disposables in reverse registration order to ensure children are cleaned up before parents:
// wrapper disposal logic
const dispose = () => {
let task!: void | Promise<void>
for (const dispose of disposables.splice(0).reverse()) {
if (task) task = task.then(dispose)
else {
const result = dispose()
if (isObject(result) && 'then' in result) task = result as any
}
}
return task
}
This reverse iteration guarantees that if an outer effect sets up a database connection and an inner effect starts a transaction on that connection, the transaction closes before the connection is terminated.
Implementing Nested Effects in Practice
The following example demonstrates how a plugin establishes nested effects for a timer and an event listener, ensuring both are cleaned up when the plugin unloads:
export default class MyPlugin {
constructor(private readonly ctx: Context) {}
apply() {
// Outer effect – will clean up everything inside it
const outer = this.ctx.effect(() => {
// Set up a timer
const timer = setInterval(() => console.log('tick'), 1000)
// Register a nested effect for a custom event listener
const inner = this.ctx.effect(() => {
const off = this.ctx.on('custom-event', () => console.log('event'))
// Return a disposer for the listener
return () => off()
}, 'event listener')
// Return a disposer that clears the timer
return () => clearInterval(timer)
}, 'plugin timer')
}
}
When the plugin reloads or the application shuts down, calling the outer effect wrapper triggers the following sequence:
- The
innereffect disposes first (removing the event listener) - The outer timer disposes second (clearing the interval)
- Control returns to the caller once all async operations complete
// When the plugin is reloaded or the application shuts down:
// 1. The outer effect's wrapper is called.
// 2. Its disposer runs, which first disposes `inner` (the child effect)
// because it was registered later.
// 3. After all children are disposed, the outer timer is cleared.
await outer()
Key Files and Implementation Details
Understanding the nested effects for resource cleanup implementation requires familiarity with these source files:
packages/core/src/fiber.ts– Contains theFiber.effectmethod and theEffectMetainterface that manages thechildrenarray for tracking nested relationships.packages/core/src/context.ts– Extends theContextclass with the publiceffectAPI that plugins use to register resources.packages/core/tests/dispose.spec.ts– Provides test coverage demonstrating that child effects dispose before parent effects during cleanup cycles.packages/hmr/tests/plugin-a.ts– Shows practical usage of nested effects in hot-module replacement scenarios.
The async support in the disposal logic handles promises, async iterables, and generators by chaining them sequentially using .then(), ensuring that asynchronous cleanup operations complete before the effect is considered fully disposed.
Summary
- Automatic tracking: Cordis registers every effect with the current
Fiberand inspects disposables forsymbols.effectto detect nested relationships. - Hierarchical metadata: The
EffectMetainterface stores child effects in thechildrenarray, creating a tree structure for resource ownership. - Deterministic cleanup: Disposables execute in reverse registration order (LIFO), ensuring child resources release before parent resources.
- Async normalization: The system handles synchronous functions, promises, and async iterables uniformly through sequential chaining.
- Introspection support:
Fiber.getEffects()returns the effect tree (EffectMeta[]) for debugging complex plugin hierarchies.
Frequently Asked Questions
How does Cordis prevent memory leaks when plugins reload?
Cordis prevents memory leaks by automatically disposing all effects registered within a plugin context when that plugin is unloaded. The hierarchical tracking ensures that even deeply nested resources—such as event listeners created inside timer callbacks—are properly cleaned up through the parent-child disposal chain implemented in Fiber.effect.
Can effects return asynchronous cleanup functions?
Yes, effects can return promises, async iterables, or generators. The disposal logic in packages/core/src/fiber.ts checks if the return value is a thenable and chains these promises using .then(), ensuring asynchronous cleanup operations complete before the effect wrapper resolves.
What happens if a child effect throws during disposal?
If a child effect throws an error during disposal, the error propagates up through the promise chain. Because the system executes disposables sequentially in reverse order, a failure in one child does not prevent sibling effects from disposing, though the error will eventually reject the outer disposal promise for proper error handling by the caller.
How can I debug the effect hierarchy in a running application?
You can inspect the current effect tree by calling Fiber.getEffects(), which returns an array of EffectMeta objects representing the active effect hierarchy. Each metadata object includes the effect label (if provided) and the children array, allowing you to visualize which resources are nested under specific plugins or contexts.
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 →