FN-6488: centralize dashboard beforeExit cleanup

Centralize dashboard cleanup registration to prevent repeated module imports from accumulating beforeExit listeners.

- Add a Symbol.for-backed dashboard process lifecycle registry with one shared beforeExit listener.
- Register existing cleanup intervals through the shared lifecycle helper across dashboard modules.
- Cover repeated module evaluation and multi-cleanup dispatch with Vitest regression tests.

Files changed:
 .../src/__tests__/process-lifecycle.test.ts        | 76 ++++++++++++++++++++++
 packages/dashboard/src/agent-generation.ts         |  3 +-
 packages/dashboard/src/ai-refine.ts                |  3 +-
 .../dashboard/src/milestone-slice-interview.ts     |  3 +-
 packages/dashboard/src/mission-interview.ts        |  3 +-
 packages/dashboard/src/planning.ts                 |  3 +-
 packages/dashboard/src/process-lifecycle.ts        | 63 ++++++++++++++++++
 packages/dashboard/src/server.ts                   |  3 +-
 packages/dashboard/src/subtask-breakdown.ts        |  3 +-
 9 files changed, 153 insertions(+), 7 deletions(-)

Fusion-Task-Id: FN-6488
Fusion-Task-Lineage: 3ec47f17-ae38-4ee3-a3fa-3132d23a2b12
This commit is contained in:
gsxdsm
2026-06-15 08:25:19 -07:00
parent a38752c6bb
commit d08ec053a6
9 changed files with 153 additions and 7 deletions

View File

@@ -0,0 +1,76 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const DASHBOARD_MODULES_WITH_BEFORE_EXIT_CLEANUP = [
"../agent-generation.js",
"../ai-refine.js",
"../planning.js",
"../subtask-breakdown.js",
"../mission-interview.js",
"../milestone-slice-interview.js",
"../server.js",
] as const;
async function importProcessLifecycle() {
return import("../process-lifecycle.js");
}
async function resetDashboardBeforeExitRegistry(): Promise<void> {
const lifecycle = await importProcessLifecycle();
lifecycle.__resetBeforeExitRegistryForTests();
}
describe("dashboard process lifecycle cleanup", () => {
beforeEach(async () => {
await resetDashboardBeforeExitRegistry();
vi.resetModules();
});
afterEach(async () => {
await resetDashboardBeforeExitRegistry();
vi.resetModules();
});
it("keeps one dashboard beforeExit listener across repeated module evaluation", async () => {
const warnings: Error[] = [];
const onWarning = (warning: Error) => {
warnings.push(warning);
};
process.on("warning", onWarning);
const baselineListeners = process.listenerCount("beforeExit");
try {
for (let iteration = 0; iteration < 15; iteration += 1) {
vi.resetModules();
for (const modulePath of DASHBOARD_MODULES_WITH_BEFORE_EXIT_CLEANUP) {
await import(modulePath);
}
}
} finally {
process.off("warning", onWarning);
}
const addedListeners = process.listenerCount("beforeExit") - baselineListeners;
const maxListenerWarnings = warnings.filter(
(warning) => warning.name === "MaxListenersExceededWarning"
);
expect(addedListeners).toBeLessThanOrEqual(1);
expect(maxListenerWarnings).toEqual([]);
});
it("runs every cleanup registered behind the shared beforeExit listener", async () => {
const lifecycle = await importProcessLifecycle();
const cleanupOne = vi.fn();
const cleanupTwo = vi.fn();
lifecycle.registerBeforeExitCleanup(cleanupOne);
lifecycle.registerBeforeExitCleanup(cleanupTwo);
expect(lifecycle.__getBeforeExitCleanupCount()).toBe(2);
lifecycle.__runBeforeExitCleanupsForTests();
expect(cleanupOne).toHaveBeenCalledOnce();
expect(cleanupTwo).toHaveBeenCalledOnce();
});
});

View File

@@ -14,6 +14,7 @@
import { randomUUID } from "node:crypto";
import { createSessionDiagnostics, nonfatal } from "./ai-session-diagnostics.js";
import { registerBeforeExitCleanup } from "./process-lifecycle.js";
// Dynamic import for @fusion/core to get prompt override resolution
@@ -218,7 +219,7 @@ export function __runAgentGenerationCleanupForTests(): void {
const cleanupInterval = setInterval(cleanupExpiredSessions, CLEANUP_INTERVAL_MS);
cleanupInterval.unref?.();
process.on("beforeExit", () => {
registerBeforeExitCleanup(() => {
clearInterval(cleanupInterval);
});

View File

@@ -15,6 +15,7 @@ import type { PromptOverrideMap } from "@fusion/core";
import { resolvePrompt } from "@fusion/core";
import { createFnAgent as engineCreateFnAgent } from "@fusion/engine";
import { registerBeforeExitCleanup } from "./process-lifecycle.js";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const createFnAgent: any = engineCreateFnAgent;
@@ -200,7 +201,7 @@ const cleanupInterval = setInterval(cleanupExpiredRateLimits, CLEANUP_INTERVAL_M
cleanupInterval.unref?.();
// Handle graceful shutdown
process.on("beforeExit", () => {
registerBeforeExitCleanup(() => {
clearInterval(cleanupInterval);
});

View File

@@ -20,6 +20,7 @@ import { randomUUID } from "node:crypto";
import { EventEmitter } from "node:events";
import type { AiSessionStore, AiSessionRow } from "./ai-session-store.js";
import { SessionEventBuffer, type SessionBufferedEvent } from "./sse-buffer.js";
import { registerBeforeExitCleanup } from "./process-lifecycle.js";
import {
extractJsonCandidate,
repairJson,
@@ -536,7 +537,7 @@ function cleanupExpiredSessions(): void {
const cleanupInterval = setInterval(cleanupExpiredSessions, CLEANUP_INTERVAL_MS);
cleanupInterval.unref?.();
process.on("beforeExit", () => clearInterval(cleanupInterval));
registerBeforeExitCleanup(() => clearInterval(cleanupInterval));
// ── Stream Manager ──────────────────────────────────────────────────────────

View File

@@ -21,6 +21,7 @@ import { randomUUID } from "node:crypto";
import { EventEmitter } from "node:events";
import type { AiSessionStore, AiSessionRow, AiSessionStatus, AiSessionSummary } from "./ai-session-store.js";
import { SessionEventBuffer, type SessionBufferedEvent } from "./sse-buffer.js";
import { registerBeforeExitCleanup } from "./process-lifecycle.js";
import {
createSessionDiagnostics,
resetDiagnosticsSink,
@@ -469,7 +470,7 @@ function cleanupExpiredSessions(): void {
const cleanupInterval = setInterval(cleanupExpiredSessions, CLEANUP_INTERVAL_MS);
cleanupInterval.unref?.();
process.on("beforeExit", () => clearInterval(cleanupInterval));
registerBeforeExitCleanup(() => clearInterval(cleanupInterval));
// ── Stream Manager ──────────────────────────────────────────────────────────

View File

@@ -26,6 +26,7 @@ import { randomUUID } from "node:crypto";
import { EventEmitter } from "node:events";
import type { AiSessionStore, AiSessionRow } from "./ai-session-store.js";
import { SessionEventBuffer, type SessionBufferedEvent } from "./sse-buffer.js";
import { registerBeforeExitCleanup } from "./process-lifecycle.js";
import {
createSessionDiagnostics,
resetDiagnosticsSink,
@@ -594,7 +595,7 @@ const cleanupInterval = setInterval(cleanupExpiredSessions, CLEANUP_INTERVAL_MS)
cleanupInterval.unref?.();
// Handle graceful shutdown
process.on("beforeExit", () => {
registerBeforeExitCleanup(() => {
clearInterval(cleanupInterval);
});

View File

@@ -0,0 +1,63 @@
type BeforeExitCleanup = () => void;
type BeforeExitRegistry = {
cleanups: Set<BeforeExitCleanup>;
listener?: () => void;
};
const BEFORE_EXIT_REGISTRY_SYMBOL = Symbol.for("fusion.dashboard.beforeExit");
function getBeforeExitRegistry(): BeforeExitRegistry {
const globalWithRegistry = globalThis as typeof globalThis & {
[BEFORE_EXIT_REGISTRY_SYMBOL]?: BeforeExitRegistry;
};
globalWithRegistry[BEFORE_EXIT_REGISTRY_SYMBOL] ??= {
cleanups: new Set<BeforeExitCleanup>(),
};
return globalWithRegistry[BEFORE_EXIT_REGISTRY_SYMBOL];
}
function runBeforeExitCleanups(registry: BeforeExitRegistry): void {
for (const cleanup of Array.from(registry.cleanups)) {
cleanup();
}
}
/**
* FNXC:ProcessLifecycle 2026-06-15-08:09:
* Dashboard modules create unref'd cleanup intervals at import time, and Vitest can re-evaluate those modules while the process singleton survives.
* Register cleanup callbacks behind one Symbol.for-backed beforeExit listener so repeated imports do not accumulate EventEmitter listeners or hide the leak with setMaxListeners appeasement.
*/
export function registerBeforeExitCleanup(cleanup: BeforeExitCleanup): void {
const registry = getBeforeExitRegistry();
registry.cleanups.add(cleanup);
if (registry.listener) {
return;
}
registry.listener = () => runBeforeExitCleanups(registry);
process.on("beforeExit", registry.listener);
}
/** @internal Test-only helper for deterministic process-lifecycle assertions. */
export function __getBeforeExitCleanupCount(): number {
return getBeforeExitRegistry().cleanups.size;
}
/** @internal Test-only helper for deterministic process-lifecycle assertions. */
export function __runBeforeExitCleanupsForTests(): void {
runBeforeExitCleanups(getBeforeExitRegistry());
}
/** @internal Test-only helper for deterministic process-lifecycle assertions. */
export function __resetBeforeExitRegistryForTests(): void {
const registry = getBeforeExitRegistry();
if (registry.listener) {
process.off("beforeExit", registry.listener);
}
registry.cleanups.clear();
registry.listener = undefined;
}

View File

@@ -33,6 +33,7 @@ import type { BadgePubSub } from "./badge-pubsub.js";
import { createBadgePubSub, type BadgePubSubMessage } from "./badge-pubsub.js";
import { createRuntimeLogger, type RuntimeLogger } from "./runtime-logger.js";
import { registerGithubTrackingHook } from "./github-tracking-hook.js";
import { registerBeforeExitCleanup } from "./process-lifecycle.js";
import { createTerminalWebSocketDiagnostics } from "./terminal-websocket-diagnostics.js";
import {
AiSessionStore,
@@ -149,7 +150,7 @@ function clearAiSessionCleanupInterval(): void {
aiSessionCleanupIntervalHandle = undefined;
}
process.on("beforeExit", () => {
registerBeforeExitCleanup(() => {
clearAiSessionCleanupInterval();
});

View File

@@ -4,6 +4,7 @@ import { randomUUID } from "node:crypto";
import { EventEmitter } from "node:events";
import type { AiSessionStore, AiSessionRow } from "./ai-session-store.js";
import { SessionEventBuffer, type SessionBufferedEvent } from "./sse-buffer.js";
import { registerBeforeExitCleanup } from "./process-lifecycle.js";
import {
createSessionDiagnostics,
resetDiagnosticsSink,
@@ -307,7 +308,7 @@ function cleanupExpiredSessions(): void {
const cleanupInterval = setInterval(cleanupExpiredSessions, CLEANUP_INTERVAL_MS);
cleanupInterval.unref?.();
process.on("beforeExit", () => {
registerBeforeExitCleanup(() => {
clearInterval(cleanupInterval);
});