fix(discord): avoid false heartbeat ACK timeouts

Fix the Discord Gateway heartbeat scheduler so ACK timeout checks are measured from the actual heartbeat send, not from the fixed HELLO-time interval. This prevents late randomized first heartbeats from causing false reconnect loops while the Discord channel is still awaiting readiness.\n\nVerification:\n- pnpm test extensions/discord/src/internal/gateway-lifecycle.test.ts extensions/discord/src/internal/gateway.test.ts\n- pnpm exec oxfmt --check --threads=1 CHANGELOG.md extensions/discord/src/internal/gateway-lifecycle.ts extensions/discord/src/internal/gateway-lifecycle.test.ts extensions/discord/src/internal/gateway.test.ts\n- git diff --check\n- Real behavior proof check passed on PR head bf239b886020c11d55af33f16674e953535f9b4c\n\nFixes #77668.\nSupersedes #77956.\nThanks @bryce-d-greybeard and @NikolaFC.
This commit is contained in:
Bryce D. Greybeard
2026-05-05 22:46:46 -05:00
committed by GitHub
parent 4ee234f8ee
commit b5c33bc204
3 changed files with 139 additions and 10 deletions

View File

@@ -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.

View File

@@ -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();
});
});

View File

@@ -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) {