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:
76
packages/dashboard/src/__tests__/process-lifecycle.test.ts
Normal file
76
packages/dashboard/src/__tests__/process-lifecycle.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
63
packages/dashboard/src/process-lifecycle.ts
Normal file
63
packages/dashboard/src/process-lifecycle.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user