How Cordis Implements Effect Tracking and Automatic Disposal

Cordis implements effect tracking through a fiber-based architecture that registers disposables in a hierarchical tree, automatically cleaning up resources when plugins unload, hot-reload, or explicitly dispose.

Cordis is a progressive TypeScript framework for building modular applications, and its effect tracking and automatic disposal system ensures that plugins can register cleanup logic that executes reliably when contexts are destroyed. The implementation centers on the Fiber class in packages/core/src/fiber.ts, which manages the lifecycle of plugin code through a structured disposable pattern.

The Fiber Architecture and Effect Creation

Each plugin in Cordis operates within a Context that exposes an effect method. This method originates from Fiber.effect (lines 75‑78 in packages/core/src/fiber.ts), which first validates that the current fiber is active using assertActive before proceeding. This validation ensures that disposal operations cannot be initiated on already-terminated fibers.

When a plugin calls ctx.effect(), the framework creates an EffectMeta object that tracks parent-child relationships between nested effects using the symbols.effect symbol. This metadata structure forms a tree that enables cascading disposal—when a parent effect is disposed, all its children are automatically cleaned up.

Collecting and Registering Disposables

The DisposableList and EffectMeta Tree

Inside the effect runner, the collect function (lines 99‑106 in packages/core/src/fiber.ts) pushes each returned disposable into this._disposables, which is an instance of DisposableList defined in packages/core/src/utils.ts. Simultaneously, the system links the disposable into the EffectMeta tree via meta.children.push(dispose[symbols.effect]), establishing the parent-child hierarchy that enables automatic propagation of disposal operations.

Handling Different Disposable Types

The _execute method (lines 29‑73 in packages/core/src/fiber.ts) handles various return types from effect functions, including synchronous functions, promises, iterables, and async iterables. This flexibility allows developers to return simple cleanup functions, arrays of disposables, or async teardown logic, all of which are normalized and registered for automatic cleanup.

Automatic Disposal Lifecycle

The _unload Method

When disposal is triggered, the fiber invokes _unload (lines 39‑50 in packages/core/src/fiber.ts). This method iterates over _disposables.clear(), executing each registered disposable sequentially. The implementation uses composeError to catch and log any errors during cleanup, swallowing exceptions to prevent unhandled rejections while ensuring all disposables run to completion.

Disposal Triggers

Cordis triggers automatic disposal through several mechanisms:

  • Plugin Self-Dispose: When a plugin calls dispose(), it invokes ctx.fiber.dispose(), starting the _unload sequence.
  • Hot-Module Replacement (HMR): During reloads, the HMR system changes the fiber's epoch via _setEpoch, which initiates _unload for the old fiber before loading the new code (implemented in packages/hmr/src/index.ts).
  • Manual Restart: The Fiber.restart method forces a new epoch marked as INACTIVE, then runs _reload, which ultimately calls _unload for the previous epoch.

Practical Implementation Examples

The following examples demonstrate how to use ctx.effect() to register disposables that clean up automatically when the plugin unloads:

// Example 1 – Simple effect with manual cleanup
export function myPlugin(ctx: Context) {
  // Register a timeout that will be cleared automatically
  const disposeTimer = ctx.effect(() => {
    const id = setTimeout(() => console.log('tick'), 1000)
    // Return a disposable that clears the timer
    return () => clearTimeout(id)
  }, 'myTimer')
}
// Example 2 – Effect that returns an async disposable
export function anotherPlugin(ctx: Context) {
  ctx.effect(async () => {
    const conn = await db.connect()
    // Return an async disposable that closes the connection
    return async () => await conn.close()
  }, 'dbConnection')
}
// Example 3 – Nested effects – child disposables are auto‑disposed
export function nestedPlugin(ctx: Context) {
  ctx.effect(() => {
    // Child effect
    ctx.effect(() => {
      const subId = setInterval(() => console.log('sub'), 500)
      return () => clearInterval(subId)
    }, 'subTimer')
    // Parent disposable
    const id = setTimeout(() => console.log('parent'), 2000)
    return () => clearTimeout(id)
  }, 'parentTimer')
}

When any of these plugins are unloaded via HMR, ctx.fiber.dispose(), or manual restart, all timers and database connections are automatically cleared because they were registered through ctx.effect.

Summary

  • Cordis uses a fiber-based architecture where Fiber.effect (in packages/core/src/fiber.ts) creates disposable effects that validate the active state before registration.
  • The system maintains a hierarchical tree of EffectMeta objects (using symbols.effect) to track parent-child relationships between nested effects, ensuring children dispose when parents dispose.
  • Disposables are stored in a DisposableList and executed sequentially by _unload (lines 39‑50), which catches and logs errors via composeError without halting cleanup.
  • Automatic disposal triggers through plugin unload events, HMR epoch changes, or manual Fiber.restart, guaranteeing resource cleanup across hot-reloads and module replacements.
  • The _execute method handles synchronous functions, promises, iterables, and async iterables, providing flexibility for various cleanup patterns including database connections and timers.

Frequently Asked Questions

What happens if an effect throws an error during disposal?

The _unload method (lines 39‑50 in packages/core/src/fiber.ts) wraps each disposable execution in error handling using composeError. If an error occurs during cleanup, the framework logs the error and continues executing the remaining disposables, ensuring that one failed cleanup does not prevent others from running.

Can effects be nested in Cordis?

Yes, Cordis supports nested effects through the EffectMeta tree structure. When you call ctx.effect() inside another effect, the system registers the child effect using meta.children.push(dispose[symbols.effect]) (lines 103‑105). This establishes a parent-child relationship where disposing the parent automatically disposes all nested children.

How does HMR trigger automatic disposal in Cordis?

During hot-module replacement, the HMR system (in packages/hmr/src/index.ts) changes the fiber's epoch via _setEpoch. This epoch change signals that the old fiber is inactive, triggering _unload to clear all registered disposables before the new module code loads, preventing resource leaks between reloads.

What types of values can be returned from an effect function?

The _execute method (lines 29‑73 in packages/core/src/fiber.ts) accepts multiple return types: synchronous functions, promises that resolve to disposables, iterables of disposables, and async iterables. This allows you to return simple cleanup functions, arrays of cleanup tasks, or async teardown operations, all normalized for automatic cleanup.

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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →