From 61533bd816bcff8c9ffbb39226429a469ee70234 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Mon, 16 Feb 2026 19:39:29 -0800 Subject: [PATCH] fix(companion): dedupe heartbeat loop scheduled timers --- docs/plans/state.json | 12 ++++++++++++ src/companion/heartbeatLoop.test.ts | 17 +++++++++++++++++ src/companion/heartbeatLoop.ts | 4 ++++ 3 files changed, 33 insertions(+) diff --git a/docs/plans/state.json b/docs/plans/state.json index 8d0f1bc..37d28cf 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -760,6 +760,18 @@ ], "test_status": "pnpm test:run src/companion/platformClients.integration.test.ts src/companion/platformClients.test.ts src/companion/runtimeClient.test.ts src/companion/heartbeatLoop.test.ts + pnpm typecheck passing" }, + "companion-heartbeat-loop-timer-dedup": { + "status": "completed", + "date": "2026-02-17", + "updated": "2026-02-17", + "summary": "Prevented duplicate pending timers in `CompanionHeartbeatLoop` by clearing any existing scheduled timeout before re-scheduling, with regression coverage for `tickNow()` during active loops.", + "files_modified": [ + "src/companion/heartbeatLoop.ts", + "src/companion/heartbeatLoop.test.ts", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/companion/heartbeatLoop.test.ts src/companion/platformClients.test.ts src/companion/runtimeClient.test.ts src/companion/platformClients.integration.test.ts + pnpm typecheck passing" + }, "browser-tools-activation-clarity": { "status": "completed", "date": "2026-02-17", diff --git a/src/companion/heartbeatLoop.test.ts b/src/companion/heartbeatLoop.test.ts index 8e9931b..57741b6 100644 --- a/src/companion/heartbeatLoop.test.ts +++ b/src/companion/heartbeatLoop.test.ts @@ -217,4 +217,21 @@ describe('CompanionHeartbeatLoop', () => { lastFailure: null, }); }); + + it('tickNow while running does not accumulate duplicate scheduled timers', async () => { + const publishHeartbeat = vi.fn(async () => buildStatusResult()); + const loop = new CompanionHeartbeatLoop({ publishHeartbeat }, { intervalMs: 1000 }); + + loop.start(false); + expect(vi.getTimerCount()).toBe(1); + + await loop.tickNow(); + expect(vi.getTimerCount()).toBe(1); + expect(publishHeartbeat).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1000); + expect(publishHeartbeat).toHaveBeenCalledTimes(2); + + loop.stop(); + }); }); diff --git a/src/companion/heartbeatLoop.ts b/src/companion/heartbeatLoop.ts index 68dd7bb..7d890a9 100644 --- a/src/companion/heartbeatLoop.ts +++ b/src/companion/heartbeatLoop.ts @@ -138,6 +138,10 @@ export class CompanionHeartbeatLoop { if (!this.started) { return; } + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } const delay = this.computeDelayMs(); this.timer = setTimeout(() => { void this.tick();