From 154941fe20fd6a0ef677da7761530e3d05499a22 Mon Sep 17 00:00:00 2001 From: Emanuele Santonastaso Date: Tue, 31 Mar 2026 03:05:28 +0200 Subject: [PATCH] fix(#586): clean up old EventSource on reconnect to prevent listener leak Close previous EventSource and clear reconnect timer at the start of connect() to ensure old listeners are removed before creating new ones. Generated by Hephaestus (Aegis dev agent) --- .../__tests__/resilient-eventsource.test.ts | 36 +++++++++++++++++++ dashboard/src/api/resilient-eventsource.ts | 10 ++++++ 2 files changed, 46 insertions(+) diff --git a/dashboard/src/__tests__/resilient-eventsource.test.ts b/dashboard/src/__tests__/resilient-eventsource.test.ts index 588d7f40..135cd869 100644 --- a/dashboard/src/__tests__/resilient-eventsource.test.ts +++ b/dashboard/src/__tests__/resilient-eventsource.test.ts @@ -202,4 +202,40 @@ describe('ResilientEventSource', () => { expect(createCount).toBe(1); expect(connections[0].close).toHaveBeenCalled(); }); + + it('should not leak event listeners on reconnect', () => { + let createCount = 0; + const connections: Array<{ onopen: any; onerror: any; close: () => void }> = []; + + vi.stubGlobal('EventSource', class MockEventSource { + constructor() { + createCount++; + const conn = { onmessage: null as any, onopen: null as any, onerror: null as any, close: vi.fn() }; + connections.push(conn); + return conn as any; + } + }); + + const res = new ResilientEventSource('/v1/events', vi.fn()); + + // Simulate multiple reconnect cycles + for (let i = 0; i < 5; i++) { + connections[i].onerror?.(); + vi.advanceTimersByTime(100); + const delay = 1000 * Math.pow(2, i); + vi.advanceTimersByTime(delay + 500); + } + + // After 5 reconnects, we should have exactly 6 connections (initial + 5 reconnects) + expect(createCount).toBe(6); + + // Each previous connection should have been closed + // Only the last connection (index 5) should not be closed yet + for (let i = 0; i < 5; i++) { + expect(connections[i].close).toHaveBeenCalled(); + } + expect(connections[5].close).not.toHaveBeenCalled(); + + res.close(); + }); }); diff --git a/dashboard/src/api/resilient-eventsource.ts b/dashboard/src/api/resilient-eventsource.ts index be4f553f..77bc64c4 100644 --- a/dashboard/src/api/resilient-eventsource.ts +++ b/dashboard/src/api/resilient-eventsource.ts @@ -35,6 +35,16 @@ export class ResilientEventSource { private connect(): void { if (this.destroyed) return; + // Clean up previous connection and pending reconnect before creating a new one + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + if (this.eventSource) { + this.eventSource.close(); + this.eventSource = null; + } + this.eventSource = new EventSource(this.url); this.eventSource.onmessage = this.onMessage; this.eventSource.onopen = () => {