Skip to content

Commit 1017ea5

Browse files
fix(discord): prevent stale gateway error listeners after restart (#101617)
* fix(discord): remove gateway error listener on dispose * fix(discord): preserve late gateway error guard * fix(discord): retain late gateway diagnostics --------- Co-authored-by: Peter Steinberger <[email protected]>
1 parent 1252a3d commit 1017ea5

3 files changed

Lines changed: 73 additions & 46 deletions

File tree

extensions/discord/src/monitor/gateway-supervisor.test.ts

Lines changed: 33 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,17 @@
11
// Discord tests cover gateway supervisor plugin behavior.
22
import { EventEmitter } from "node:events";
33
import { describe, expect, it, vi } from "vitest";
4+
5+
const { gatewayLogError } = vi.hoisted(() => ({ gatewayLogError: vi.fn() }));
6+
7+
vi.mock("openclaw/plugin-sdk/runtime-env", async (importOriginal) => {
8+
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/runtime-env")>();
9+
return {
10+
...actual,
11+
createSubsystemLogger: () => ({ error: gatewayLogError }),
12+
};
13+
});
14+
415
import {
516
classifyDiscordGatewayEvent,
617
DiscordGatewayLifecycleError,
@@ -115,44 +126,29 @@ describe("createDiscordGatewaySupervisor", () => {
115126
supervisor.dispose();
116127
});
117128

118-
it("keeps suppressing late gateway errors after dispose", () => {
119-
const emitter = new EventEmitter();
120-
const runtime = { error: vi.fn() };
121-
const supervisor = createDiscordGatewaySupervisor({
122-
gateway: { emitter },
123-
isDisallowedIntentsError: () => false,
124-
runtime: runtime as never,
125-
});
126-
127-
supervisor.dispose();
128-
129-
emitter.emit("error", new Error("Max reconnect attempts (0) reached after close code 1005"));
130-
expect(runtime.error).toHaveBeenCalledTimes(1);
131-
expect(String(firstErrorArg(runtime))).toContain(
132-
"suppressed late gateway reconnect-exhausted error after dispose",
133-
);
134-
});
135-
136-
it("dedupes identical late gateway errors after dispose", () => {
129+
it("keeps a single late error guard after repeated dispose", () => {
137130
const emitter = new EventEmitter();
138-
const runtime = { error: vi.fn() };
139-
const supervisor = createDiscordGatewaySupervisor({
140-
gateway: { emitter },
141-
isDisallowedIntentsError: () => false,
142-
runtime: runtime as never,
143-
});
144-
145-
supervisor.dispose();
146-
const first = new TypeError();
147-
first.stack = "TypeError\n at gatewayCrash (discord-gateway.js:12:34)";
148-
const second = new TypeError();
149-
second.stack = "TypeError\n at gatewayCrash (discord-gateway.js:12:34)";
150-
emitter.emit("error", first);
151-
emitter.emit("error", second);
152-
153-
expect(runtime.error).toHaveBeenCalledTimes(1);
154-
expect(String(firstErrorArg(runtime))).toContain(
155-
"suppressed late gateway fatal error after dispose: TypeError @ gatewayCrash (discord-gateway.js:12:34)",
131+
gatewayLogError.mockClear();
132+
133+
for (let index = 0; index < 3; index += 1) {
134+
const supervisor = createDiscordGatewaySupervisor({
135+
gateway: { emitter },
136+
isDisallowedIntentsError: () => false,
137+
runtime: { error: vi.fn() } as never,
138+
});
139+
140+
expect(emitter.listenerCount("error")).toBe(1);
141+
supervisor.dispose();
142+
expect(emitter.listenerCount("error")).toBe(1);
143+
const error = new Error(`late gateway error ${index}`);
144+
expect(() => emitter.emit("error", error)).not.toThrow();
145+
emitter.emit("error", error);
146+
}
147+
148+
expect(emitter.listenerCount("error")).toBe(1);
149+
expect(gatewayLogError).toHaveBeenCalledTimes(3);
150+
expect(gatewayLogError).toHaveBeenLastCalledWith(
151+
"suppressed late gateway error after dispose: Error: late gateway error 2",
156152
);
157153
});
158154
});

extensions/discord/src/monitor/gateway-supervisor.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Discord plugin module implements gateway supervisor behavior.
22
import type { EventEmitter } from "node:events";
3-
import { danger } from "openclaw/plugin-sdk/runtime-env";
3+
import { createSubsystemLogger, danger } from "openclaw/plugin-sdk/runtime-env";
44
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
55
import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime";
66

@@ -41,6 +41,37 @@ export type DiscordGatewaySupervisor = {
4141

4242
type GatewaySupervisorPhase = "active" | "buffering" | "disposed" | "teardown";
4343

44+
const discordGatewayLog = createSubsystemLogger("discord/gateway");
45+
const discordGatewayLateErrorGuards = new WeakMap<EventEmitter, (err: unknown) => void>();
46+
47+
function removeDiscordGatewayLateErrorGuard(emitter: EventEmitter): void {
48+
const guard = discordGatewayLateErrorGuards.get(emitter);
49+
if (!guard) {
50+
return;
51+
}
52+
emitter.off("error", guard);
53+
discordGatewayLateErrorGuards.delete(emitter);
54+
}
55+
56+
function ensureDiscordGatewayLateErrorGuard(emitter: EventEmitter): void {
57+
if (emitter.listenerCount("error") > 0) {
58+
return;
59+
}
60+
const seenMessages = new Set<string>();
61+
// Keep the emitter safe after its supervisor is gone without retaining the disposed runtime.
62+
// A module-owned logger preserves one diagnostic per distinct late error until the next start.
63+
const guard = (err: unknown) => {
64+
const message = formatDiscordGatewayErrorMessage(err);
65+
if (seenMessages.has(message)) {
66+
return;
67+
}
68+
seenMessages.add(message);
69+
discordGatewayLog.error(`suppressed late gateway error after dispose: ${message}`);
70+
};
71+
discordGatewayLateErrorGuards.set(emitter, guard);
72+
emitter.on("error", guard);
73+
}
74+
4475
function readFirstStackFrame(err: Error): string | undefined {
4576
const stack = err.stack;
4677
if (!stack) {
@@ -169,6 +200,7 @@ export function createDiscordGatewaySupervisor(params: {
169200
pending.push(event);
170201
}
171202
};
203+
removeDiscordGatewayLateErrorGuard(emitter);
172204
emitter.on("error", onGatewayError);
173205

174206
return {
@@ -198,6 +230,8 @@ export function createDiscordGatewaySupervisor(params: {
198230
if (phase === "disposed") {
199231
return;
200232
}
233+
emitter.off("error", onGatewayError);
234+
ensureDiscordGatewayLateErrorGuard(emitter);
201235
lifecycleHandler = undefined;
202236
phase = "disposed";
203237
pending.length = 0;

extensions/discord/src/monitor/provider.test.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,7 @@ describe("monitorDiscordProvider", () => {
364364
expect(createdBindingManagers[0]?.stop).toHaveBeenCalledTimes(1);
365365
});
366366

367-
it("disconnects the shared gateway and suppresses late gateway errors when startup fails before lifecycle begins", async () => {
367+
it("disconnects the shared gateway and keeps a late error guard when startup fails before lifecycle begins", async () => {
368368
const disconnect = vi.fn();
369369
const emitter = new EventEmitter();
370370
const gateway = { emitter, disconnect, isConnected: false };
@@ -385,13 +385,10 @@ describe("monitorDiscordProvider", () => {
385385

386386
expect(monitorLifecycleMock).not.toHaveBeenCalled();
387387
expect(disconnect).toHaveBeenCalledTimes(1);
388-
expect(
389-
emitter.emit("error", new Error("Max reconnect attempts (0) reached after code 1005")),
390-
).toBe(true);
391-
expectMockLogContains(
392-
runtime.error,
393-
"suppressed late gateway reconnect-exhausted error after dispose",
394-
);
388+
expect(emitter.listenerCount("error")).toBe(1);
389+
expect(() =>
390+
emitter.emit("error", new Error("late gateway error after cleanup")),
391+
).not.toThrow();
395392
});
396393

397394
it("fails closed before lifecycle when Discord bot identity fetch rejects", async () => {

0 commit comments

Comments
 (0)