mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:10:44 +00:00
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:
committed by
GitHub
parent
4ee234f8ee
commit
b5c33bc204
@@ -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.
|
||||
|
||||
114
extensions/discord/src/internal/gateway-lifecycle.test.ts
Normal file
114
extensions/discord/src/internal/gateway-lifecycle.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user