Преглед изворни кода

fix: clean up dashboard shutdown resources

Zerdeşt Taifour пре 1 месец
родитељ
комит
c7c61f52b1
2 измењених фајлова са 127 додато и 10 уклоњено
  1. 84 1
      src/interview/dashboard.test.ts
  2. 43 9
      src/interview/dashboard.ts

+ 84 - 1
src/interview/dashboard.test.ts

@@ -1,6 +1,6 @@
 import { describe, expect, test } from 'bun:test';
 import * as fs from 'node:fs/promises';
-import { createServer } from 'node:http';
+import { createServer, get } from 'node:http';
 import * as path from 'node:path';
 import { createDashboardServer } from './dashboard';
 
@@ -47,7 +47,90 @@ async function createTempInterviewDir() {
   return tempDir;
 }
 
+async function openSseConnection(
+  baseUrl: string,
+  authToken: string,
+  interviewId: string,
+) {
+  return new Promise<{
+    firstChunk: Promise<string>;
+    closed: Promise<void>;
+  }>((resolve, reject) => {
+    const request = get(
+      `${baseUrl}/api/interviews/${interviewId}/events?token=${authToken}`,
+      (response) => {
+        response.setEncoding('utf8');
+
+        let buffer = '';
+        const firstChunk = new Promise<string>((resolveFirst) => {
+          response.on('data', (chunk: string) => {
+            buffer += chunk;
+            if (buffer.includes('event: state')) {
+              resolveFirst(buffer);
+            }
+          });
+        });
+
+        const closed = new Promise<void>((resolveClosed) => {
+          response.once('close', resolveClosed);
+        });
+
+        response.once('error', reject);
+        resolve({ firstChunk, closed });
+      },
+    );
+
+    request.once('error', reject);
+  });
+}
+
 describe('dashboard server', () => {
+  describe('server lifecycle', () => {
+    test('close is safe before start and repeated', () => {
+      const dashboard = createDashboardServer({
+        port: 0,
+        outputFolder: 'interview',
+      });
+
+      expect(() => dashboard.close()).not.toThrow();
+      expect(() => dashboard.close()).not.toThrow();
+    });
+
+    test('closes active SSE responses on shutdown', async () => {
+      const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
+      try {
+        dashboard.pushState({
+          interviewId: 'lifecycle-sse',
+          sessionID: 'session-lifecycle',
+          idea: 'Lifecycle SSE',
+          mode: 'awaiting-user',
+          summary: 'Test',
+          title: 'Lifecycle SSE',
+          questions: [],
+          pendingAnswers: null,
+          lastUpdatedAt: Date.now(),
+          filePath: '',
+          nudgeAction: null,
+        });
+
+        const { firstChunk, closed } = await openSseConnection(
+          baseUrl,
+          authToken,
+          'lifecycle-sse',
+        );
+
+        expect(await firstChunk).toContain('event: state');
+
+        dashboard.close();
+        dashboard.close();
+
+        await closed;
+      } finally {
+        cleanup();
+      }
+    });
+  });
+
   describe('health endpoint', () => {
     test('returns 200 with status ok and counts', async () => {
       const { baseUrl, cleanup } = await startDashboard();

+ 43 - 9
src/interview/dashboard.ts

@@ -263,15 +263,21 @@ export function createDashboardServer(config: DashboardConfig): {
   ]);
   const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
   const CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
-  const cleanupTimer = setInterval(() => {
-    const cutoff = Date.now() - CACHE_TTL_MS;
-    for (const [id, entry] of stateCache) {
-      if (TERMINAL_MODES.has(entry.mode) && entry.lastUpdatedAt < cutoff) {
-        stateCache.delete(id);
+  function createCleanupTimer(): ReturnType<typeof setInterval> {
+    const timer = setInterval(() => {
+      const cutoff = Date.now() - CACHE_TTL_MS;
+      for (const [id, entry] of stateCache) {
+        if (TERMINAL_MODES.has(entry.mode) && entry.lastUpdatedAt < cutoff) {
+          stateCache.delete(id);
+        }
       }
-    }
-  }, CLEANUP_INTERVAL_MS);
-  cleanupTimer.unref();
+    }, CLEANUP_INTERVAL_MS);
+    timer.unref();
+    return timer;
+  }
+
+  let cleanupTimer: ReturnType<typeof setInterval> | null =
+    createCleanupTimer();
 
   // File scan cache (TTL 10s)
   let fileCache: { items: InterviewFileItem[]; at: number } | null = null;
@@ -1288,6 +1294,10 @@ export function createDashboardServer(config: DashboardConfig): {
   function start(): Promise<string> {
     if (baseUrl) return Promise.resolve(baseUrl);
 
+    if (!cleanupTimer) {
+      cleanupTimer = createCleanupTimer();
+    }
+
     return new Promise((resolve, reject) => {
       const server = createServer((request, response) => {
         handleRequest(request, response).catch((error: unknown) => {
@@ -1325,8 +1335,32 @@ export function createDashboardServer(config: DashboardConfig): {
   }
 
   function close(): void {
+    if (cleanupTimer) {
+      clearInterval(cleanupTimer);
+      cleanupTimer = null;
+    }
+
+    for (const clients of sseClients.values()) {
+      for (const response of clients) {
+        try {
+          if (!response.writableEnded && !response.destroyed) {
+            response.end();
+          }
+        } catch {
+          try {
+            response.destroy();
+          } catch {
+            // Ignore cleanup errors
+          }
+        }
+      }
+    }
+
+    sseClients.clear();
+
+    removeAuthFile(config.port);
+
     if (activeServer) {
-      removeAuthFile(config.port);
       activeServer.closeAllConnections();
       activeServer.close();
       activeServer = null;