fix(discord): clear stale heartbeat timers in SafeGatewayPlugin.connect() (#65087)

* fix(discord): clear stale heartbeat timers in SafeGatewayPlugin.connect()

The @buape/carbon@0.15.0 heartbeat setup has a race where stopHeartbeat()
runs before heartbeatInterval is assigned, leaving a stale setInterval with
a closed reconnectCallback. When the stale interval fires ~41s later it
throws an uncaught exception that bypasses the EventEmitter error path and
crashes the gateway process via process.on('uncaughtException').

Add a connect() override in SafeGatewayPlugin that unconditionally clears
both heartbeatInterval and firstHeartbeatTimeout before calling super. The
parent's connect() only calls stopHeartbeat() when isConnecting=false; when
isConnecting=true it returns early without clearing — this override fills
that gap.

Fixes #65009. Related: #64011, #63387, #62038.

* test(discord): assert super.connect() delegation in SafeGatewayPlugin tests

* fix(ci): update raw-fetch allowlist line numbers for gateway-plugin.ts

The connect() override added in the heartbeat fix shifted the two
pre-existing fetch() callsites from lines 370/436 to 387/453.

* docs(changelog): add discord heartbeat crash note

* test(cli): align plugin registry load-context mock

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
saram ali
2026-04-12 22:40:04 +05:00
committed by GitHub
parent 1094b3d372
commit 7995e408ce
5 changed files with 181 additions and 15 deletions

View File

@@ -0,0 +1,115 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const { baseConnectSpy, GatewayIntents, GatewayPlugin } = vi.hoisted(() => {
const baseConnectSpy = vi.fn<(resume: boolean) => void>();
const GatewayIntents = {
Guilds: 1 << 0,
GuildMessages: 1 << 1,
MessageContent: 1 << 2,
DirectMessages: 1 << 3,
GuildMessageReactions: 1 << 4,
DirectMessageReactions: 1 << 5,
GuildPresences: 1 << 6,
GuildMembers: 1 << 7,
} as const;
class GatewayPlugin {
options: unknown;
gatewayInfo: unknown;
heartbeatInterval: ReturnType<typeof setInterval> | undefined = undefined;
firstHeartbeatTimeout: ReturnType<typeof setTimeout> | undefined = undefined;
isConnecting: boolean = false;
constructor(options?: unknown) {
this.options = options;
}
async registerClient(_client: unknown): Promise<void> {}
connect(resume = false): void {
baseConnectSpy(resume);
}
}
return { baseConnectSpy, GatewayIntents, GatewayPlugin };
});
vi.mock("@buape/carbon/gateway", () => ({ GatewayIntents, GatewayPlugin }));
vi.mock("@buape/carbon/dist/src/plugins/gateway/index.js", () => ({
GatewayIntents,
GatewayPlugin,
}));
vi.mock("openclaw/plugin-sdk/proxy-capture", () => ({
captureHttpExchange: vi.fn(),
captureWsEvent: vi.fn(),
resolveEffectiveDebugProxyUrl: () => undefined,
resolveDebugProxySettings: () => ({ enabled: false }),
}));
vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
danger: (value: string) => value,
}));
describe("SafeGatewayPlugin.connect()", () => {
let createDiscordGatewayPlugin: typeof import("./gateway-plugin.js").createDiscordGatewayPlugin;
beforeAll(async () => {
({ createDiscordGatewayPlugin } = await import("./gateway-plugin.js"));
});
beforeEach(() => {
baseConnectSpy.mockClear();
});
function createPlugin() {
return createDiscordGatewayPlugin({
discordConfig: {},
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
},
});
}
it("clears stale heartbeatInterval before delegating to super when isConnecting=true", () => {
const plugin = createPlugin();
const staleInterval = setInterval(() => {}, 99_999);
try {
plugin.heartbeatInterval = staleInterval;
// isConnecting is private on GatewayPlugin — cast required.
(plugin as unknown as { isConnecting: boolean }).isConnecting = true;
plugin.connect(false);
expect(plugin.heartbeatInterval).toBeUndefined();
expect(baseConnectSpy).toHaveBeenCalledWith(false);
} finally {
clearInterval(staleInterval);
}
});
it("clears stale firstHeartbeatTimeout before delegating to super when isConnecting=true", () => {
const plugin = createPlugin();
const staleTimeout = setTimeout(() => {}, 99_999);
try {
plugin.firstHeartbeatTimeout = staleTimeout;
// isConnecting is private on GatewayPlugin — cast required.
(plugin as unknown as { isConnecting: boolean }).isConnecting = true;
plugin.connect(false);
expect(plugin.firstHeartbeatTimeout).toBeUndefined();
expect(baseConnectSpy).toHaveBeenCalledWith(false);
} finally {
clearTimeout(staleTimeout);
}
});
});

View File

@@ -254,6 +254,23 @@ function createGatewayPlugin(params: {
super(params.options);
}
public override connect(resume = false): void {
// Guard against stale heartbeat timers from the @buape/carbon
// firstHeartbeatTimeout race (openclaw/openclaw#65009, #64011, #63387).
// Parent connect() only calls stopHeartbeat() when isConnecting=false.
// If isConnecting=true it returns early — leaving a stale setInterval
// that fires with a closed reconnectCallback and crashes the process.
if (this.heartbeatInterval !== undefined) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = undefined;
}
if (this.firstHeartbeatTimeout !== undefined) {
clearTimeout(this.firstHeartbeatTimeout);
this.firstHeartbeatTimeout = undefined;
}
super.connect(resume);
}
override async registerClient(
client: Parameters<carbonGateway.GatewayPlugin["registerClient"]>[0],
) {