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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user