diff --git a/plugins/fusion-plugin-grok-runtime/src/__tests__/process-lifecycle.test.ts b/plugins/fusion-plugin-grok-runtime/src/__tests__/process-lifecycle.test.ts new file mode 100644 index 0000000000..57a07f58e8 --- /dev/null +++ b/plugins/fusion-plugin-grok-runtime/src/__tests__/process-lifecycle.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const EVENTS = ["exit", "beforeExit", "SIGTERM", "SIGINT"] as const; + +function listenerCounts(): Record<(typeof EVENTS)[number], number> { + return Object.fromEntries(EVENTS.map((event) => [event, process.listenerCount(event)])) as Record< + (typeof EVENTS)[number], + number + >; +} + +describe("Grok plugin process lifecycle", () => { + afterEach(() => { + vi.resetModules(); + }); + + it("keeps its process cleanup owner bounded across repeated module evaluation", async () => { + const baseline = listenerCounts(); + const warnings: Error[] = []; + const onWarning = (warning: Error) => warnings.push(warning); + process.on("warning", onWarning); + + try { + for (let iteration = 0; iteration < 15; iteration += 1) { + vi.resetModules(); + await import("../index.js"); + } + await new Promise((resolve) => setImmediate(resolve)); + } finally { + process.off("warning", onWarning); + } + + const after = listenerCounts(); + expect(after.exit - baseline.exit).toBeLessThanOrEqual(1); + expect(after.beforeExit - baseline.beforeExit).toBe(0); + expect(after.SIGTERM - baseline.SIGTERM).toBe(0); + expect(after.SIGINT - baseline.SIGINT).toBe(0); + expect(warnings.filter((warning) => warning.name === "MaxListenersExceededWarning")).toEqual([]); + + const manager = await import("../acp/process-manager.js"); + const child = { + killed: false, + exitCode: null, + kill: vi.fn(), + on: vi.fn(), + }; + manager.registerProcess(child as never); + for (const cleanup of process.listeners("exit")) { + if (cleanup.name === "killAllProcesses") cleanup(0); + } + expect(child.kill).toHaveBeenCalledWith("SIGKILL"); + expect(manager.activeProcessCount()).toBe(0); + }); +}); diff --git a/plugins/fusion-plugin-grok-runtime/src/acp/process-manager.ts b/plugins/fusion-plugin-grok-runtime/src/acp/process-manager.ts index 7b73a73076..fd6922c8d6 100644 --- a/plugins/fusion-plugin-grok-runtime/src/acp/process-manager.ts +++ b/plugins/fusion-plugin-grok-runtime/src/acp/process-manager.ts @@ -21,8 +21,22 @@ function debugLog(message: string): void { console.error(`[grok-acp] ${message}`); } +/* +FNXC:ProcessLifecycle 2026-07-16-07:00: +Vitest resets the Grok plugin module graph while retaining the worker's `process`. +Keep the ACP child registry on `process` so the one guarded exit listener also +reaps children registered by later module evaluations; adding one listener per +evaluation causes MaxListenersExceededWarning in the dashboard backfill lane. +*/ +const ACTIVE_PROCESSES_KEY = Symbol.for("fusion.plugin.grok-runtime.activeProcesses"); +const processWithActiveProcesses = process as typeof process & { + [key: symbol]: Set | undefined; +}; + /** Registry of active agent subprocesses for teardown. Self-cleans on exit. */ -const activeProcesses = new Set(); +const activeProcesses = + processWithActiveProcesses[ACTIVE_PROCESSES_KEY] ?? + (processWithActiveProcesses[ACTIVE_PROCESSES_KEY] = new Set()); /** * Register a subprocess in the agent process registry. diff --git a/plugins/fusion-plugin-grok-runtime/src/index.ts b/plugins/fusion-plugin-grok-runtime/src/index.ts index 7cef661205..2e26ecda6f 100644 --- a/plugins/fusion-plugin-grok-runtime/src/index.ts +++ b/plugins/fusion-plugin-grok-runtime/src/index.ts @@ -25,9 +25,20 @@ fusion-plugin-acp-runtime, so bundled Grok does not depend on the experimental ACP example plugin package. */ -// Reap Grok ACP agent subprocesses on hard process exit (registry SIGKILL is -// authoritative). Scoped to ACP-tracked agent children only — never port 4040. -process.on("exit", killAllProcesses); +/* +FNXC:ProcessLifecycle 2026-07-16-07:00: +The dashboard backfill worker repeatedly evaluates this plugin through +`vi.resetModules()` while retaining the process singleton. Install one exit +listener per Grok lifecycle owner and use the process-shared registry in the +ACP manager so it reaps children from every evaluation. Do not appease this +with `setMaxListeners`; the listener must stay bounded. +*/ +const PROCESS_EXIT_HOOK_KEY = Symbol.for("fusion.plugin.grok-runtime.exitCleanup"); +const processWithExitHook = process as typeof process & { [key: symbol]: boolean | undefined }; +if (!processWithExitHook[PROCESS_EXIT_HOOK_KEY]) { + process.on("exit", killAllProcesses); + processWithExitHook[PROCESS_EXIT_HOOK_KEY] = true; +} const plugin: FusionPlugin = definePlugin({ manifest: { diff --git a/plugins/fusion-plugin-omp-runtime/src/__tests__/process-lifecycle.test.ts b/plugins/fusion-plugin-omp-runtime/src/__tests__/process-lifecycle.test.ts new file mode 100644 index 0000000000..ea7768bed8 --- /dev/null +++ b/plugins/fusion-plugin-omp-runtime/src/__tests__/process-lifecycle.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const EVENTS = ["exit", "beforeExit", "SIGTERM", "SIGINT"] as const; + +function listenerCounts(): Record<(typeof EVENTS)[number], number> { + return Object.fromEntries(EVENTS.map((event) => [event, process.listenerCount(event)])) as Record< + (typeof EVENTS)[number], + number + >; +} + +describe("OMP plugin process lifecycle", () => { + afterEach(() => { + vi.resetModules(); + }); + + it("keeps its process cleanup owner bounded across repeated module evaluation", async () => { + const baseline = listenerCounts(); + const warnings: Error[] = []; + const onWarning = (warning: Error) => warnings.push(warning); + process.on("warning", onWarning); + + try { + for (let iteration = 0; iteration < 15; iteration += 1) { + vi.resetModules(); + await import("../index.js"); + } + await new Promise((resolve) => setImmediate(resolve)); + } finally { + process.off("warning", onWarning); + } + + const after = listenerCounts(); + expect(after.exit - baseline.exit).toBeLessThanOrEqual(1); + expect(after.beforeExit - baseline.beforeExit).toBe(0); + expect(after.SIGTERM - baseline.SIGTERM).toBe(0); + expect(after.SIGINT - baseline.SIGINT).toBe(0); + expect(warnings.filter((warning) => warning.name === "MaxListenersExceededWarning")).toEqual([]); + + const manager = await import("../acp/process-manager.js"); + const child = { + killed: false, + exitCode: null, + kill: vi.fn(), + on: vi.fn(), + }; + manager.registerProcess(child as never); + for (const cleanup of process.listeners("exit")) { + if (cleanup.name === "killAllProcesses") cleanup(0); + } + expect(child.kill).toHaveBeenCalledWith("SIGKILL"); + expect(manager.activeProcessCount()).toBe(0); + }); +}); diff --git a/plugins/fusion-plugin-omp-runtime/src/acp/process-manager.ts b/plugins/fusion-plugin-omp-runtime/src/acp/process-manager.ts index 7b73a73076..88980fffd8 100644 --- a/plugins/fusion-plugin-omp-runtime/src/acp/process-manager.ts +++ b/plugins/fusion-plugin-omp-runtime/src/acp/process-manager.ts @@ -21,8 +21,22 @@ function debugLog(message: string): void { console.error(`[grok-acp] ${message}`); } +/* +FNXC:ProcessLifecycle 2026-07-16-07:00: +Vitest resets the OMP plugin module graph while retaining the worker's `process`. +Keep the ACP child registry on `process` so the one guarded exit listener also +reaps children registered by later module evaluations; adding one listener per +evaluation causes MaxListenersExceededWarning in the dashboard backfill lane. +*/ +const ACTIVE_PROCESSES_KEY = Symbol.for("fusion.plugin.omp-runtime.activeProcesses"); +const processWithActiveProcesses = process as typeof process & { + [key: symbol]: Set | undefined; +}; + /** Registry of active agent subprocesses for teardown. Self-cleans on exit. */ -const activeProcesses = new Set(); +const activeProcesses = + processWithActiveProcesses[ACTIVE_PROCESSES_KEY] ?? + (processWithActiveProcesses[ACTIVE_PROCESSES_KEY] = new Set()); /** * Register a subprocess in the agent process registry. diff --git a/plugins/fusion-plugin-omp-runtime/src/index.ts b/plugins/fusion-plugin-omp-runtime/src/index.ts index 3dc8bfa9b9..8325afb63d 100644 --- a/plugins/fusion-plugin-omp-runtime/src/index.ts +++ b/plugins/fusion-plugin-omp-runtime/src/index.ts @@ -17,9 +17,20 @@ download or bundle it. Upstream: https://omp.sh/docs/acp https://github.com/can1357/oh-my-pi */ -// Reap OMP ACP agent subprocesses on hard process exit (registry SIGKILL is -// authoritative). Scoped to ACP-tracked agent children only — never port 4040. -process.on("exit", killAllProcesses); +/* +FNXC:ProcessLifecycle 2026-07-16-07:00: +The dashboard backfill worker repeatedly evaluates this plugin through +`vi.resetModules()` while retaining the process singleton. Install one exit +listener per OMP lifecycle owner and use the process-shared registry in the +ACP manager so it reaps children from every evaluation. Do not appease this +with `setMaxListeners`; the listener must stay bounded. +*/ +const PROCESS_EXIT_HOOK_KEY = Symbol.for("fusion.plugin.omp-runtime.exitCleanup"); +const processWithExitHook = process as typeof process & { [key: symbol]: boolean | undefined }; +if (!processWithExitHook[PROCESS_EXIT_HOOK_KEY]) { + process.on("exit", killAllProcesses); + processWithExitHook[PROCESS_EXIT_HOOK_KEY] = true; +} const plugin: FusionPlugin = definePlugin({ manifest: {