How the Cordis Plugin Registry Handles Registration, Tracking, and Disposal

The Cordis plugin registry uses a centralized RegistryService class that maintains a private Map of plugin runtimes, assigns unique incremental identifiers, tracks active fibers per plugin, and automatically cleans up runtime entries when the last fiber is disposed.

Cordis is a lightweight, modular plugin system for TypeScript applications. At its heart lies the plugin registry, which manages the complete lifecycle of every plugin—from initial registration through active tracking to final disposal. This article examines how RegistryService in packages/core/src/registry.ts implements this orchestration.


Registration: Creating Plugin Runtimes

When you register a plugin via ctx.registry.plugin(...), the registry performs two core operations: it ensures a runtime record exists for that plugin and instantiates a new fiber to run it.

Runtime Record Creation

The registry stores plugin state in a private _internal map typed as Map<Function, Plugin.Runtime>:

// packages/core/src/registry.ts
export class RegistryService {
  private _internal = new Map<Function, Plugin.Runtime>()

  plugin(callback: Plugin, config?: any) {
    let runtime = this._internal.get(callback)
    if (!runtime) {
      runtime = this.createRuntime(callback)
      this._internal.set(callback, runtime)
    }
    // ... fiber creation
  }
}

Each runtime receives a unique identifier through the counter getter, which increments a private _counter field on every access. This provides deterministic ordering for plugin operations.

Fiber Instantiation

Every plugin registration creates a Fiber object (defined in packages/core/src/fiber.ts). The fiber represents the actual execution context of that plugin instance:

// Inside RegistryService.plugin()
const fiber = new Fiber(this.ctx, runtime, config)
await fiber.init()

The fiber immediately registers itself with its runtime:

// packages/core/src/fiber.ts
export class Fiber {
  constructor(...) {
    const remove = runtime.fibers.push(this)
    this.disposers.push(remove)
  }
}

Here, runtime.fibers is a DisposableList<Fiber>—a specialized collection that returns a disposal function (remove) when items are pushed.


Tracking: Monitoring Active Plugins

The registry exposes a complete inspection API that operates on the internal _internal map. This allows applications to query plugin state at any time.

Registry Inspection Methods

Method Purpose
keys() Iterate over registered plugin constructors
values() Iterate over runtime records
entries() Iterate over [plugin, runtime] pairs
forEach(fn) Execute callback for each entry
size Total number of active plugin runtimes
has(plugin) Check if a plugin has an active runtime
get(plugin) Retrieve the runtime for a specific plugin

These methods enable runtime introspection:

// Check registration status
console.log('Active plugins:', ctx.registry.size)
console.log('Has myPlugin?', ctx.registry.has(myPlugin))

// Iterate over all runtimes
ctx.registry.forEach((runtime, plugin) => {
  console.log(plugin.name, 'has', runtime.fibers.length, 'active fibers')
})

Per-Plugin Fiber Tracking

Each runtime maintains its own fibers list, enabling fine-grained tracking of plugin instances:

// Runtime structure (simplified)
interface Plugin.Runtime {
  callback: Plugin
  fibers: DisposableList<Fiber>
  // ... other metadata
}

This design supports multiple concurrent instances of the same plugin—each gets its own fiber, all tracked under a single runtime entry.


Disposal: Cleaning Up Plugin Resources

The disposal system operates bidirectionally: fibers can dispose themselves (triggering registry cleanup), or the registry can forcibly dispose all fibers for a plugin.

Automatic Fiber-Triggered Cleanup

When a fiber's dispose() method is called, it executes a coordinated cleanup sequence:

// packages/core/src/fiber.ts
async dispose() {
  // 1. Execute all registered disposers (effects, listeners, etc.)
  await Promise.all(this.disposers.map(d => d()))
  
  // 2. Remove self from runtime's fiber list
  if (this.ctx.registry.has(runtime.callback)) {
    remove()
    
    // 3. If no fibers remain, delete the runtime from registry
    if (!runtime.fibers.length) {
      this.ctx.registry.delete(runtime.callback)
    }
  }
}

This reference-counting approach ensures that plugin runtimes persist only while active fibers exist.

Registry-Forced Disposal via delete()

Applications can also trigger disposal externally through RegistryService.delete():

// packages/core/src/registry.ts
delete(plugin: Plugin): boolean {
  const runtime = this.resolve(plugin)
  if (!runtime) return false
  
  // Dispose every fiber synchronously
  for (const fiber of runtime.fibers) {
    fiber.dispose()
  }
  
  // Remove runtime from internal map
  return this._internal.delete(runtime.callback)
}

Key characteristics of this method:

  • Eager execution: All fibers are disposed immediately via synchronous iteration
  • Complete cleanup: The runtime entry is removed from _internal regardless of fiber disposal success
  • Boolean return: Indicates whether a runtime was actually found and removed

Complete Lifecycle Example

The following example demonstrates the full registration-to-disposal flow:

import { Context } from '@cordis/core'

const ctx = new Context()

// Step 1: Register a plugin
const myPlugin = (ctx: Context, config: { greeting: string }) => {
  ctx.on('ready', () => {
    console.log(config.greeting)
  })
  return () => console.log('Plugin disposed')
}

await ctx.registry.plugin(myPlugin, { greeting: 'Hello, Cordis!' })
// Runtime created, fiber instantiated and initialized

// Step 2: Inspect registry state
console.log(ctx.registry.size)           // → 1
console.log(ctx.registry.get(myPlugin))  // → Plugin.Runtime { fibers: [Fiber] }

// Step 3: Register same plugin again (creates second fiber)
await ctx.registry.plugin(myPlugin, { greeting: 'Second instance' })
console.log(ctx.registry.get(myPlugin)!.fibers.length)  // → 2

// Step 4: Dispose one fiber manually
const runtime = ctx.registry.get(myPlugin)!
const firstFiber = runtime.fibers[0]
await firstFiber.dispose()
console.log(runtime.fibers.length)       // → 1 (runtime still exists)

// Step 5: Dispose remaining fiber (triggers automatic runtime deletion)
await runtime.fibers[0].dispose()
console.log(ctx.registry.has(myPlugin))  // → false
console.log(ctx.registry.size)           // → 0

// Alternative: Force disposal of all fibers at once
await ctx.registry.plugin(myPlugin, { greeting: 'Temporary' })
ctx.registry.delete(myPlugin)            // Disposes fiber, removes runtime

Implementation Files

File Responsibility
packages/core/src/registry.ts RegistryService class: _internal map, plugin(), delete(), resolve(), inspection API
packages/core/src/fiber.ts Fiber class: instantiation, dispose(), integration with runtime fiber lists
packages/core/src/context.ts Binds registry service to context instances
packages/core/src/reflect.ts Mixin definitions exposing registry methods on Context

Summary

  • Registration creates a Plugin.Runtime on first use, assigns a unique counter ID, and instantiates a Fiber that self-registers with the runtime
  • Tracking occurs through the RegistryService inspection API and per-runtime fibers lists that act as reference counters
  • Disposal supports both fiber-initiated cleanup (automatic runtime deletion when fibers.length reaches zero) and registry-forced disposal via delete() which iterates and disposes all fibers
  • The private _internal map serves as the single source of truth for plugin lifetimes, with automatic garbage collection of empty runtimes

Frequently Asked Questions

How does Cordis prevent memory leaks from disposed plugins?

The registry implements automatic reference counting through the fibers list. When Fiber.dispose() completes, it removes itself from runtime.fibers; if no fibers remain, this.ctx.registry.delete(runtime.callback) executes immediately. This guarantees that plugin runtimes cannot outlive their active instances, and the private _internal map cannot accumulate orphaned entries.

Can multiple instances of the same plugin run simultaneously?

Yes. Each call to ctx.registry.plugin() creates a new Fiber appended to the existing runtime's fibers list. The runtime persists until all fibers are disposed, enabling patterns like plugin reentrancy or scoped sub-instances with independent configurations.

What happens if I call registry.delete() while fibers are still active?

RegistryService.delete() synchronously iterates runtime.fibers and calls fiber.dispose() on each. This triggers the normal disposal sequence for every active instance, ensuring all resources are released before the runtime entry is removed from _internal. The method returns false if the plugin had no registered runtime.

Where is the registry counter used, and can it overflow?

The _counter field increments via the counter getter each time a runtime requests an ID. According to the Cordis source code, this provides sequential identifiers for debugging and ordering purposes. Given JavaScript's Number.MAX_SAFE_INTEGER (9,007,199,254,740,991), overflow is practically impossible in real-world usage.

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 →