Skip to content

Commit 03bf721

Browse files
committed
πŸ› fix(agent): clear AgentClient timers when an agent is removed
Addresses code-review findings on feature/v1.5-rc23. - πŸ› fix: add an idempotent AgentClient.stop() that clears both stableConnectionTimer and reconnectTimer; removeAgent now calls it before splicing, so an abandoned client no longer keeps an armed timer alive - βœ… test: cover the stream-error reconnect path with the stability timer and the new stop() teardown
1 parent 9b79de7 commit 03bf721

4 files changed

Lines changed: 122 additions & 6 deletions

File tree

β€Žapp/agent/AgentClient.test.tsβ€Ž

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1275,6 +1275,82 @@ describe('AgentClient', () => {
12751275

12761276
expect(reconnectDelays).toEqual([4_000]);
12771277
});
1278+
1279+
test('should escalate backoff when stream error triggers reconnect before SSE_STABLE_CONNECTION_MS (#362 error path)', async () => {
1280+
// Mirror of the end-path regression test but using stream.emit('error') instead of 'end'.
1281+
axios.mockImplementation(() => Promise.resolve({ data: new EventEmitter() }));
1282+
1283+
const setTimeoutSpy = vi.spyOn(global, 'setTimeout');
1284+
1285+
// Cycle 1: startSse β†’ 200 β†’ stream error immediately β†’ scheduleReconnect (delay=1000)
1286+
client.startSse();
1287+
await vi.advanceTimersByTimeAsync(0);
1288+
const r1 = await (axios.mock.results[0].value as Promise<{ data: EventEmitter }>);
1289+
r1.data.emit('error', new Error('connection reset'));
1290+
await vi.advanceTimersByTimeAsync(1_000);
1291+
1292+
// Cycle 2: startSse β†’ 200 β†’ stream error immediately β†’ scheduleReconnect (delay=2000)
1293+
await vi.advanceTimersByTimeAsync(0);
1294+
const r2 = await (axios.mock.results[1].value as Promise<{ data: EventEmitter }>);
1295+
r2.data.emit('error', new Error('connection reset'));
1296+
await vi.advanceTimersByTimeAsync(2_000);
1297+
1298+
// Cycle 3: startSse β†’ 200 β†’ stream error immediately β†’ scheduleReconnect (delay=4000)
1299+
await vi.advanceTimersByTimeAsync(0);
1300+
const r3 = await (axios.mock.results[2].value as Promise<{ data: EventEmitter }>);
1301+
r3.data.emit('error', new Error('connection reset'));
1302+
await vi.advanceTimersByTimeAsync(4_000);
1303+
1304+
// Collect only reconnect delays (filter out the 30_000 stability-timer calls)
1305+
const reconnectDelays = setTimeoutSpy.mock.calls
1306+
.map(([, delay]) => delay)
1307+
.filter((delay): delay is number => typeof delay === 'number' && delay !== 30_000);
1308+
1309+
expect(reconnectDelays).toEqual([1_000, 2_000, 4_000]);
1310+
});
1311+
});
1312+
1313+
describe('stop', () => {
1314+
test('should clear an armed stableConnectionTimer', async () => {
1315+
const stream = new EventEmitter();
1316+
axios.mockResolvedValue({ data: stream });
1317+
1318+
client.startSse();
1319+
await vi.advanceTimersByTimeAsync(0); // stability timer is now armed
1320+
1321+
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
1322+
const timerBefore = (client as any).stableConnectionTimer;
1323+
expect(timerBefore).not.toBeNull();
1324+
1325+
client.stop();
1326+
1327+
expect(clearTimeoutSpy).toHaveBeenCalledWith(timerBefore);
1328+
expect((client as any).stableConnectionTimer).toBeNull();
1329+
});
1330+
1331+
test('should clear an armed reconnectTimer', () => {
1332+
const spy = vi.spyOn(client, 'startSse').mockImplementation(() => {});
1333+
client.scheduleReconnect(5_000); // arms reconnectTimer
1334+
const timerBefore = (client as any).reconnectTimer;
1335+
expect(timerBefore).not.toBeNull();
1336+
1337+
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
1338+
client.stop();
1339+
1340+
expect(clearTimeoutSpy).toHaveBeenCalledWith(timerBefore);
1341+
expect((client as any).reconnectTimer).toBeNull();
1342+
// Confirm the reconnect never fires after stop()
1343+
vi.advanceTimersByTime(10_000);
1344+
expect(spy).not.toHaveBeenCalled();
1345+
});
1346+
1347+
test('should be safe to call when both timers are already null', () => {
1348+
expect((client as any).stableConnectionTimer).toBeNull();
1349+
expect((client as any).reconnectTimer).toBeNull();
1350+
expect(() => client.stop()).not.toThrow();
1351+
expect((client as any).stableConnectionTimer).toBeNull();
1352+
expect((client as any).reconnectTimer).toBeNull();
1353+
});
12781354
});
12791355

12801356
describe('handleEvent', () => {

β€Žapp/agent/AgentClient.tsβ€Ž

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,14 @@ export class AgentClient {
559559
}
560560
}
561561

562+
stop() {
563+
this.clearStableConnectionTimer();
564+
if (this.reconnectTimer) {
565+
clearTimeout(this.reconnectTimer);
566+
this.reconnectTimer = null;
567+
}
568+
}
569+
562570
private getNextReconnectDelayMs(): number {
563571
const nextDelay = Math.min(
564572
INITIAL_SSE_RECONNECT_DELAY_MS * 2 ** this.reconnectAttempts,

β€Žapp/agent/manager.test.tsβ€Ž

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,14 @@ describe('manager', () => {
1414
});
1515

1616
test('addAgent should add a client', () => {
17-
const client = { name: 'agent1' };
17+
const client = { name: 'agent1', stop: vi.fn() };
1818
manager.addAgent(client);
1919
expect(manager.getAgents()).toHaveLength(1);
2020
expect(manager.getAgents()[0]).toBe(client);
2121
});
2222

2323
test('getAgent should return client by name', () => {
24-
const client = { name: 'agent1' };
24+
const client = { name: 'agent1', stop: vi.fn() };
2525
manager.addAgent(client);
2626
expect(manager.getAgent('agent1')).toBe(client);
2727
});
@@ -31,8 +31,8 @@ describe('manager', () => {
3131
});
3232

3333
test('addAgent should support multiple agents', () => {
34-
const c1 = { name: 'a1' };
35-
const c2 = { name: 'a2' };
34+
const c1 = { name: 'a1', stop: vi.fn() };
35+
const c2 = { name: 'a2', stop: vi.fn() };
3636
manager.addAgent(c1);
3737
manager.addAgent(c2);
3838
expect(manager.getAgents()).toHaveLength(2);
@@ -41,8 +41,8 @@ describe('manager', () => {
4141
});
4242

4343
test('removeAgent should remove a client by name', () => {
44-
const c1 = { name: 'a1' };
45-
const c2 = { name: 'a2' };
44+
const c1 = { name: 'a1', stop: vi.fn() };
45+
const c2 = { name: 'a2', stop: vi.fn() };
4646
manager.addAgent(c1);
4747
manager.addAgent(c2);
4848

@@ -55,4 +55,35 @@ describe('manager', () => {
5555
test('removeAgent should return false when client does not exist', () => {
5656
expect(manager.removeAgent('missing-agent')).toBe(false);
5757
});
58+
59+
test('removeAgent should call stop() on each removed client before splicing', () => {
60+
const stop1 = vi.fn();
61+
const stop2 = vi.fn();
62+
const c1 = { name: 'target', stop: stop1 };
63+
const c2 = { name: 'other', stop: stop2 };
64+
manager.addAgent(c1);
65+
manager.addAgent(c2);
66+
67+
manager.removeAgent('target');
68+
69+
expect(stop1).toHaveBeenCalledTimes(1);
70+
expect(stop2).not.toHaveBeenCalled();
71+
expect(manager.getAgents()).toHaveLength(1);
72+
expect(manager.getAgent('target')).toBeUndefined();
73+
});
74+
75+
test('removeAgent should call stop() on all matching clients when duplicates exist', () => {
76+
const stop1 = vi.fn();
77+
const stop2 = vi.fn();
78+
const c1 = { name: 'dup', stop: stop1 };
79+
const c2 = { name: 'dup', stop: stop2 };
80+
manager.addAgent(c1);
81+
manager.addAgent(c2);
82+
83+
manager.removeAgent('dup');
84+
85+
expect(stop1).toHaveBeenCalledTimes(1);
86+
expect(stop2).toHaveBeenCalledTimes(1);
87+
expect(manager.getAgents()).toHaveLength(0);
88+
});
5889
});

β€Žapp/agent/manager.tsβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export function removeAgent(name: string): boolean {
3636
const initialCount = clients.length;
3737
for (let index = clients.length - 1; index >= 0; index -= 1) {
3838
if (clients[index].name === name) {
39+
clients[index].stop();
3940
clients.splice(index, 1);
4041
}
4142
}

0 commit comments

Comments
Β (0)