diff --git a/.changeset/fix-plugin-initialization-idempotency.md b/.changeset/fix-plugin-initialization-idempotency.md index db3f8086..9d91cc37 100644 --- a/.changeset/fix-plugin-initialization-idempotency.md +++ b/.changeset/fix-plugin-initialization-idempotency.md @@ -4,4 +4,5 @@ Prevent plugins from initializing twice through `initializeCoco()`, and make plugin lifecycle hooks idempotent across repeated or concurrent init and ready -calls. +calls. Registering the same plugin instance more than once now throws instead +of creating an unbalanced dispose lifecycle. diff --git a/packages/core/plugins/PluginHost.ts b/packages/core/plugins/PluginHost.ts index e2d5afc5..5a396242 100644 --- a/packages/core/plugins/PluginHost.ts +++ b/packages/core/plugins/PluginHost.ts @@ -1,10 +1,11 @@ import type { Plugin, ServiceKey, ServiceMap } from './types.ts'; -import { ExtensionRegistrationError } from './types.ts'; +import { DuplicatePluginRegistrationError, ExtensionRegistrationError } from './types.ts'; export class PluginHost { private readonly plugins: Plugin[] = []; private readonly cleanups: Array<() => void | Promise> = []; private readonly extensions: Record = {}; + private readonly registeredPlugins = new WeakSet(); private readonly initializedPlugins = new WeakSet(); private readonly readyPlugins = new WeakSet(); private readonly initPromises = new WeakMap>(); @@ -14,6 +15,11 @@ export class PluginHost { private readyPhase = false; use(plugin: Plugin): void { + if (this.registeredPlugins.has(plugin)) { + throw new DuplicatePluginRegistrationError(plugin.name); + } + + this.registeredPlugins.add(plugin); this.plugins.push(plugin); if (this.initialized && this.services) { const services = this.services; diff --git a/packages/core/plugins/types.ts b/packages/core/plugins/types.ts index 395a563c..ad2b5e0d 100644 --- a/packages/core/plugins/types.ts +++ b/packages/core/plugins/types.ts @@ -90,3 +90,13 @@ export class ExtensionRegistrationError extends Error { this.name = 'ExtensionRegistrationError'; } } + +/** + * Error thrown when the same plugin instance is registered more than once. + */ +export class DuplicatePluginRegistrationError extends Error { + constructor(pluginName: string) { + super(`Plugin "${pluginName}" is already registered`); + this.name = 'DuplicatePluginRegistrationError'; + } +} diff --git a/packages/core/test/unit/Manager.test.ts b/packages/core/test/unit/Manager.test.ts index ec9f801e..1ce666d9 100644 --- a/packages/core/test/unit/Manager.test.ts +++ b/packages/core/test/unit/Manager.test.ts @@ -331,6 +331,27 @@ describe('initializeCoco', () => { await manager.disableProofStateWatcher(); await manager.disableMintOperationProcessor(); }); + + it('should reject duplicate plugin instance registration', async () => { + const manager = await initializeCoco({ + ...baseConfig, + watchers: { + mintOperationWatcher: { disabled: true }, + proofStateWatcher: { disabled: true }, + }, + processors: { + mintOperationProcessor: { disabled: true }, + }, + }); + const plugin = { + name: 'duplicate-plugin', + required: [] as const, + }; + + manager.use(plugin); + + expect(() => manager.use(plugin)).toThrow('Plugin "duplicate-plugin" is already registered'); + }); }); describe('edge cases', () => { diff --git a/packages/core/test/unit/PluginHost.test.ts b/packages/core/test/unit/PluginHost.test.ts index 2d0b6687..fccb651f 100644 --- a/packages/core/test/unit/PluginHost.test.ts +++ b/packages/core/test/unit/PluginHost.test.ts @@ -1,5 +1,6 @@ import { describe, it, beforeEach, afterEach, expect } from 'bun:test'; import { PluginHost } from '../../plugins/PluginHost.ts'; +import { DuplicatePluginRegistrationError } from '../../plugins/types.ts'; import type { Plugin } from '../../plugins/types.ts'; describe('PluginHost', () => { @@ -93,6 +94,34 @@ describe('PluginHost', () => { expect(calls).toEqual({ init: 1, ready: 1 }); }); + it('rejects duplicate plugin instance registration', async () => { + const calls = { init: 0, ready: 0, dispose: 0 }; + const plugin: Plugin<['logger']> = { + name: 'duplicate', + required: ['logger'], + onInit: () => { + calls.init += 1; + }, + onReady: () => { + calls.ready += 1; + }, + onDispose: () => { + calls.dispose += 1; + }, + }; + + host.use(plugin); + + expect(() => host.use(plugin)).toThrow(DuplicatePluginRegistrationError); + expect(() => host.use(plugin)).toThrow('Plugin "duplicate" is already registered'); + + await host.init(services); + await host.ready(); + await host.dispose(); + + expect(calls).toEqual({ init: 1, ready: 1, dispose: 1 }); + }); + it('coalesces concurrent init and ready calls', async () => { const calls = { init: 0, ready: 0 }; const plugin: Plugin<['logger']> = {