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 = {}; }