mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 19:50:43 +00:00
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:
115
extensions/discord/src/monitor/gateway-plugin.test.ts
Normal file
115
extensions/discord/src/monitor/gateway-plugin.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user