From d08ec053a6495eee8a9bc470e50c563ed56e732c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 15 Jun 2026 08:25:19 -0700 Subject: [PATCH] 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 --- .../src/__tests__/process-lifecycle.test.ts | 76 +++++++++++++++++++ packages/dashboard/src/agent-generation.ts | 3 +- packages/dashboard/src/ai-refine.ts | 3 +- .../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(-) create mode 100644 packages/dashboard/src/__tests__/process-lifecycle.test.ts create mode 100644 packages/dashboard/src/process-lifecycle.ts diff --git a/packages/dashboard/src/__tests__/process-lifecycle.test.ts b/packages/dashboard/src/__tests__/process-lifecycle.test.ts new file mode 100644 index 0000000000..3df3414b6a --- /dev/null +++ b/packages/dashboard/src/__tests__/process-lifecycle.test.ts @@ -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 { + 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(); + }); +}); diff --git a/packages/dashboard/src/agent-generation.ts b/packages/dashboard/src/agent-generation.ts index b9286f6cf9..408b70a9ec 100644 --- a/packages/dashboard/src/agent-generation.ts +++ b/packages/dashboard/src/agent-generation.ts @@ -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); }); diff --git a/packages/dashboard/src/ai-refine.ts b/packages/dashboard/src/ai-refine.ts index f3a7320c3f..f39e9a6c4c 100644 --- a/packages/dashboard/src/ai-refine.ts +++ b/packages/dashboard/src/ai-refine.ts @@ -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); }); diff --git a/packages/dashboard/src/milestone-slice-interview.ts b/packages/dashboard/src/milestone-slice-interview.ts index 32b9bdd1bd..7584767b87 100644 --- a/packages/dashboard/src/milestone-slice-interview.ts +++ b/packages/dashboard/src/milestone-slice-interview.ts @@ -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 ────────────────────────────────────────────────────────── diff --git a/packages/dashboard/src/mission-interview.ts b/packages/dashboard/src/mission-interview.ts index c926fe8b1d..15b376479b 100644 --- a/packages/dashboard/src/mission-interview.ts +++ b/packages/dashboard/src/mission-interview.ts @@ -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 ────────────────────────────────────────────────────────── diff --git a/packages/dashboard/src/planning.ts b/packages/dashboard/src/planning.ts index 6e2c18d2d3..9ea875173e 100644 --- a/packages/dashboard/src/planning.ts +++ b/packages/dashboard/src/planning.ts @@ -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); }); diff --git a/packages/dashboard/src/process-lifecycle.ts b/packages/dashboard/src/process-lifecycle.ts new file mode 100644 index 0000000000..35b9cb654b --- /dev/null +++ b/packages/dashboard/src/process-lifecycle.ts @@ -0,0 +1,63 @@ +type BeforeExitCleanup = () => void; + +type BeforeExitRegistry = { + cleanups: Set; + 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(), + }; + + 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; +} diff --git a/packages/dashboard/src/server.ts b/packages/dashboard/src/server.ts index 7a95bf7982..360a8eec8a 100644 --- a/packages/dashboard/src/server.ts +++ b/packages/dashboard/src/server.ts @@ -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(); }); diff --git a/packages/dashboard/src/subtask-breakdown.ts b/packages/dashboard/src/subtask-breakdown.ts index d42c1c00c0..3de9486e7a 100644 --- a/packages/dashboard/src/subtask-breakdown.ts +++ b/packages/dashboard/src/subtask-breakdown.ts @@ -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); });