diff --git a/packages/core/src/__tests__/store-plugin-store-close.test.ts b/packages/core/src/__tests__/store-plugin-store-close.test.ts new file mode 100644 index 0000000000..7081e9657c --- /dev/null +++ b/packages/core/src/__tests__/store-plugin-store-close.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from "vitest"; +import { PluginStore } from "../plugin-store.js"; +import { createTaskStoreTestHarness } from "./store-test-helpers.js"; + +async function withTaskStoreHarness( + fn: (harness: ReturnType) => Promise, +): Promise { + const harness = createTaskStoreTestHarness(); + await harness.beforeEach(); + try { + return await fn(harness); + } finally { + await harness.afterEach(); + } +} + +describe("TaskStore pluginStore close lifecycle", () => { + it("closes and nulls the cached plugin store when TaskStore.close runs", async () => { + await withTaskStoreHarness(async (harness) => { + const store = harness.store(); + const pluginStore = store.getPluginStore(); + await pluginStore.listPlugins(); + const closeSpy = vi.spyOn(pluginStore, "close"); + + await store.close(); + + expect(closeSpy).toHaveBeenCalledTimes(1); + expect((store as any).pluginStore).toBeNull(); + + await expect(store.close()).resolves.toBeUndefined(); + expect(closeSpy).toHaveBeenCalledTimes(1); + }); + }); + + it("does not attempt plugin-store teardown when the cached store was never created", async () => { + await withTaskStoreHarness(async (harness) => { + const store = harness.store(); + const prototypeCloseSpy = vi.spyOn(PluginStore.prototype, "close"); + + await expect(store.close()).resolves.toBeUndefined(); + + expect(prototypeCloseSpy).not.toHaveBeenCalled(); + expect((store as any).pluginStore).toBeNull(); + prototypeCloseSpy.mockRestore(); + }); + }); + + it("closes disk-backed plugin stores during reopenDiskBackedStore", async () => { + await withTaskStoreHarness(async (harness) => { + await harness.reopenDiskBackedStore(); + const originalStore = harness.store(); + const pluginStore = originalStore.getPluginStore(); + await pluginStore.listPlugins(); + const closeSpy = vi.spyOn(pluginStore, "close"); + + await expect(harness.reopenDiskBackedStore()).resolves.toBeUndefined(); + + expect(closeSpy).toHaveBeenCalledTimes(1); + expect((originalStore as any).pluginStore).toBeNull(); + expect((harness.store() as any).pluginStore).toBeNull(); + }); + }); +}); diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 20c0aaf76c..56c7611f4f 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -16534,6 +16534,21 @@ ${stepsSection}`; this.secretsCentralCore = null; } this.secretsStore = null; + if (this.pluginStore) { + /** + * FNXC:Plugins 2026-06-25-00:00: + * FN-7005 requires TaskStore.close() to own the cached PluginStore lifecycle because PluginStore has separate local and central SQLite connections. + * Dispose it here so long-running processes and tests outside shared reset helpers do not leak handles after TaskStore shutdown; PluginStore.close() follows FN-7003's null-safe handle teardown. + */ + const pluginStore = this.pluginStore; + this.pluginStore = null; + pluginStore.removeAllListeners(); + try { + pluginStore.close(); + } catch (err) { + console.warn(`[fusion] Could not close plugin store on TaskStore close:`, err); + } + } } get fts5Available(): boolean {