FN-7005: close cached plugin store with TaskStore

TaskStore shutdown now tears down its cached plugin store to avoid leaking plugin database handles.

- Dispose the cached PluginStore during TaskStore.close(), clear the cache, and remove listeners before closing.
- Keep close idempotent when no plugin store was created or after a prior teardown.
- Add regression coverage for direct TaskStore.close() and disk-backed harness reopen lifecycle.

Files changed:
 .../src/__tests__/store-plugin-store-close.test.ts | 63 ++++++++++++++++++++++
 packages/core/src/store.ts                         | 15 ++++++
 2 files changed, 78 insertions(+)

Fusion-Task-Id: FN-7005
Fusion-Task-Lineage: fe2242d8-7004-4d29-904d-58ee77861d99
This commit is contained in:
gsxdsm
2026-06-25 04:54:59 -07:00
parent e42e17023a
commit ae98ec37a6
2 changed files with 78 additions and 0 deletions

View File

@@ -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<T>(
fn: (harness: ReturnType<typeof createTaskStoreTestHarness>) => Promise<T>,
): Promise<T> {
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();
});
});
});

View File

@@ -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 {