FN-8096: bound runtime process cleanup listeners

Keep Grok and OMP ACP cleanup listeners bounded when Vitest reloads plugin modules.

- Store active ACP process registries on the process singleton.
- Guard each plugin's exit cleanup hook against duplicate module evaluation.
- Add lifecycle regressions that reject listener leaks and preserve child cleanup.

Files changed:
 .../src/__tests__/process-lifecycle.test.ts        | 54 ++++++++++++++++++++++
 .../src/acp/process-manager.ts                     | 16 ++++++-
 plugins/fusion-plugin-grok-runtime/src/index.ts    | 17 +++++--
 .../src/__tests__/process-lifecycle.test.ts        | 54 ++++++++++++++++++++++
 .../src/acp/process-manager.ts                     | 16 ++++++-
 plugins/fusion-plugin-omp-runtime/src/index.ts     | 17 +++++--
 6 files changed, 166 insertions(+), 8 deletions(-)

Fusion-Task-Id: FN-8096

Fusion-Task-Lineage: c8a5daba-e13c-464b-b7e6-11c07d264864

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-16 07:06:48 -07:00
parent 85b854882d
commit 6a1b53f9b3
6 changed files with 166 additions and 8 deletions

View File

@@ -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<void>((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);
});
});

View File

@@ -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<ChildProcess> | undefined;
};
/** Registry of active agent subprocesses for teardown. Self-cleans on exit. */
const activeProcesses = new Set<ChildProcess>();
const activeProcesses =
processWithActiveProcesses[ACTIVE_PROCESSES_KEY] ??
(processWithActiveProcesses[ACTIVE_PROCESSES_KEY] = new Set<ChildProcess>());
/**
* Register a subprocess in the agent process registry.

View File

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

View File

@@ -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<void>((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);
});
});

View File

@@ -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<ChildProcess> | undefined;
};
/** Registry of active agent subprocesses for teardown. Self-cleans on exit. */
const activeProcesses = new Set<ChildProcess>();
const activeProcesses =
processWithActiveProcesses[ACTIVE_PROCESSES_KEY] ??
(processWithActiveProcesses[ACTIVE_PROCESSES_KEY] = new Set<ChildProcess>());
/**
* Register a subprocess in the agent process registry.

View File

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