From 6f29cbc30b3359667f92509f395f7fcf7b28e106 Mon Sep 17 00:00:00 2001 From: birme Date: Thu, 3 Sep 2026 07:10:48 +0000 Subject: [PATCH] fix: clean up long-poll listener and timer on client disconnect Register a request close handler on the participants long-poll endpoint so the transient 'users:change' listener and its 25s timeout are released when a client disconnects mid-poll, instead of lingering until timeout. A guard boolean ensures cleanup runs exactly once across the change, timeout, and close exit paths so the promise never resolves twice. Also disable maxListeners on the ProductionManager EventEmitter to avoid spurious MaxListenersExceededWarning under >10 concurrent pollers. Co-Authored-By: Claude Sonnet 4.6 --- src/api_productions.test.ts | 11 ++++++++++- src/api_productions.ts | 22 ++++++++++++++++------ src/production_manager.ts | 4 ++++ 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/api_productions.test.ts b/src/api_productions.test.ts index 40d7273..8b801f9 100644 --- a/src/api_productions.test.ts +++ b/src/api_productions.test.ts @@ -177,7 +177,10 @@ const mockProductionManager = { .fn() .mockImplementation((sessionId: string) => sessionId), createUserSession: jest.fn().mockResolvedValue(undefined), - getActiveUsers: jest.fn().mockResolvedValue([]) + getActiveUsers: jest.fn().mockResolvedValue([]), + once: jest.fn(), + on: jest.fn(), + off: jest.fn() } as any; describe('Production API', () => { @@ -492,6 +495,7 @@ describe('Production API', () => { callback(); } }); + mockProductionManager.off = jest.fn(); const response = await server.inject({ method: 'POST', url: '/api/v1/production/1/line/1/participants' @@ -499,6 +503,11 @@ describe('Production API', () => { expect(response.statusCode).toBe(200); const body = response.body ? JSON.parse(response.body) : []; expect(Array.isArray(body)).toBe(true); + // Cleanup must remove the 'users:change' listener on every exit path. + expect(mockProductionManager.off).toHaveBeenCalledWith( + 'users:change', + expect.any(Function) + ); }); test('returns 500 when long poll fails due to internal error', async () => { const sessionsSpy = jest diff --git a/src/api_productions.ts b/src/api_productions.ts index 2914c64..dae30c3 100644 --- a/src/api_productions.ts +++ b/src/api_productions.ts @@ -918,19 +918,29 @@ const apiProductions: FastifyPluginCallback = ( try { const timeoutMs = 25_000; - // Wait until either users:change fires or timeout expires + // Wait until users:change fires, the timeout expires, or the client + // disconnects. Cleanup runs once in every exit path so the listener and + // timer are always released and resolve is never called twice. await new Promise((resolve) => { - const onChange = () => { + let settled = false; + + const cleanup = () => { + if (settled) { + return; + } + settled = true; clearTimeout(timer); + productionManager.off('users:change', onChange); + request.raw.off('close', cleanup); resolve(); }; - const timer = setTimeout(() => { - productionManager.off('users:change', onChange); - resolve(); - }, timeoutMs); + const onChange = () => cleanup(); + + const timer = setTimeout(cleanup, timeoutMs); productionManager.once('users:change', onChange); + request.raw.on('close', cleanup); }); const { productionId, lineId } = request.params; diff --git a/src/production_manager.ts b/src/production_manager.ts index 2beb2a9..c2342ff 100644 --- a/src/production_manager.ts +++ b/src/production_manager.ts @@ -43,6 +43,10 @@ export class ProductionManager extends EventEmitter { constructor(dbManager: DbManager) { super(); + // Long-poll endpoints register a transient 'users:change' listener per + // request, so concurrent pollers can exceed the default maxListeners (10) + // and emit spurious MaxListenersExceededWarning. Disable the limit. + this.setMaxListeners(0); this.dbManager = dbManager; this.userSessions = {}; }