Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/api_productions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
const mockIngestManager = {
load: jest.fn().mockResolvedValue(undefined),
startPolling: jest.fn()
} as any;

Check warning on line 65 in src/api_productions.test.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type

const mockCoreFunctions = {
getAllLinesResponse: jest.fn().mockImplementation((production) =>
Expand Down Expand Up @@ -177,7 +177,10 @@
.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', () => {
Expand Down Expand Up @@ -492,13 +495,19 @@
callback();
}
});
mockProductionManager.off = jest.fn();
const response = await server.inject({
method: 'POST',
url: '/api/v1/production/1/line/1/participants'
});
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
Expand Down
22 changes: 16 additions & 6 deletions src/api_productions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -918,19 +918,29 @@ const apiProductions: FastifyPluginCallback<ApiProductionsOptions> = (
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<void>((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;
Expand Down
4 changes: 4 additions & 0 deletions src/production_manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};
}
Expand Down
Loading