diff --git a/CHANGELOG.md b/CHANGELOG.md index 584558653ea..4a508fdfbd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -101,6 +101,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Discord/gateway: measure heartbeat ACK timeouts from the actual heartbeat send, preventing late initial heartbeats from triggering false reconnect loops while the channel is still awaiting readiness. Fixes #77668. (#78087) Thanks @bryce-d-greybeard and @NikolaFC. - Control UI/Sessions: make the compaction count a compact `N Checkpoint(s)` disclosure and show expanded session-level details with modern checkpoint history cards across responsive table layouts. Thanks @BunsDev. - Control UI/performance: keep chat and channel tabs responsive while history payloads and channel probes are slow, label partial channel status, and record slow chat/config render timings in the event log. Thanks @BunsDev. - Control UI/sessions: fire the documented `/new` command and lifecycle hooks only for explicit Control UI session creation, restoring session-memory and custom hook capture without changing SDK parent-session creates. Fixes #76957. Thanks @BunsDev. diff --git a/extensions/discord/src/internal/gateway-lifecycle.test.ts b/extensions/discord/src/internal/gateway-lifecycle.test.ts new file mode 100644 index 00000000000..735cdad1d51 --- /dev/null +++ b/extensions/discord/src/internal/gateway-lifecycle.test.ts @@ -0,0 +1,114 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { GatewayHeartbeatTimers } from "./gateway-lifecycle.js"; + +describe("GatewayHeartbeatTimers", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it("does not false-timeout when the first heartbeat fires near the interval boundary", () => { + vi.useFakeTimers(); + + const onHeartbeat = vi.fn(); + const onAckTimeout = vi.fn(); + const isAcked = vi.fn().mockReturnValue(false); + const timers = new GatewayHeartbeatTimers(); + + timers.start({ + intervalMs: 45_000, + isAcked, + onAckTimeout, + onHeartbeat, + random: () => 0.95, + }); + + vi.advanceTimersByTime(42_750); + expect(onHeartbeat).toHaveBeenCalledTimes(1); + expect(onAckTimeout).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(2_250); + expect(onAckTimeout).not.toHaveBeenCalled(); + + isAcked.mockReturnValue(true); + vi.advanceTimersByTime(42_750); + expect(onHeartbeat).toHaveBeenCalledTimes(2); + expect(onAckTimeout).not.toHaveBeenCalled(); + + timers.stop(); + }); + + it("fires an ACK timeout when a heartbeat is genuinely not acknowledged", () => { + vi.useFakeTimers(); + + const timers = new GatewayHeartbeatTimers(); + const onHeartbeat = vi.fn(); + const onAckTimeout = vi.fn(); + + timers.start({ + intervalMs: 45_000, + isAcked: () => false, + onAckTimeout, + onHeartbeat, + random: () => 0, + }); + + vi.advanceTimersByTime(0); + expect(onHeartbeat).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(45_000); + expect(onAckTimeout).toHaveBeenCalledTimes(1); + + timers.stop(); + }); + + it("sends heartbeats at regular intervals after the initial random delay", () => { + vi.useFakeTimers(); + + const timers = new GatewayHeartbeatTimers(); + const onHeartbeat = vi.fn(); + const onAckTimeout = vi.fn(); + + timers.start({ + intervalMs: 10_000, + isAcked: () => true, + onAckTimeout, + onHeartbeat, + random: () => 0.5, + }); + + vi.advanceTimersByTime(5_000); + expect(onHeartbeat).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(10_000); + expect(onHeartbeat).toHaveBeenCalledTimes(2); + + vi.advanceTimersByTime(10_000); + expect(onHeartbeat).toHaveBeenCalledTimes(3); + expect(onAckTimeout).not.toHaveBeenCalled(); + + timers.stop(); + }); + + it("stop cancels all pending timers", () => { + vi.useFakeTimers(); + + const timers = new GatewayHeartbeatTimers(); + const onHeartbeat = vi.fn(); + const onAckTimeout = vi.fn(); + + timers.start({ + intervalMs: 10_000, + isAcked: () => true, + onAckTimeout, + onHeartbeat, + random: () => 0.5, + }); + + timers.stop(); + vi.advanceTimersByTime(100_000); + + expect(onHeartbeat).not.toHaveBeenCalled(); + expect(onAckTimeout).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/discord/src/internal/gateway-lifecycle.ts b/extensions/discord/src/internal/gateway-lifecycle.ts index 2cf45f1655f..37afda13fec 100644 --- a/extensions/discord/src/internal/gateway-lifecycle.ts +++ b/extensions/discord/src/internal/gateway-lifecycle.ts @@ -4,6 +4,24 @@ export class GatewayHeartbeatTimers { heartbeatInterval?: GatewayTimer; firstHeartbeatTimeout?: GatewayTimer; + private scheduleHeartbeatCycle(params: { + intervalMs: number; + isAcked: () => boolean; + onAckTimeout: () => void; + onHeartbeat: () => void; + }): void { + this.heartbeatInterval = setTimeout(() => { + this.heartbeatInterval = undefined; + if (!params.isAcked()) { + params.onAckTimeout(); + return; + } + params.onHeartbeat(); + this.scheduleHeartbeatCycle(params); + }, params.intervalMs); + this.heartbeatInterval.unref?.(); + } + start(params: { intervalMs: number; isAcked: () => boolean; @@ -14,23 +32,19 @@ export class GatewayHeartbeatTimers { this.stop(); const random = params.random ?? Math.random; this.firstHeartbeatTimeout = setTimeout( - params.onHeartbeat, + () => { + this.firstHeartbeatTimeout = undefined; + params.onHeartbeat(); + this.scheduleHeartbeatCycle(params); + }, Math.max(0, params.intervalMs * random()), ); this.firstHeartbeatTimeout.unref?.(); - this.heartbeatInterval = setInterval(() => { - if (!params.isAcked()) { - params.onAckTimeout(); - return; - } - params.onHeartbeat(); - }, params.intervalMs); - this.heartbeatInterval.unref?.(); } stop(): void { if (this.heartbeatInterval) { - clearInterval(this.heartbeatInterval); + clearTimeout(this.heartbeatInterval); this.heartbeatInterval = undefined; } if (this.firstHeartbeatTimeout) {