fix(discord): guard gateway cleanup races

This commit is contained in:
Tak Hoffman
2026-03-26 16:16:34 -05:00
committed by Peter Steinberger
parent f406b20e50
commit a79c9d50f7
4 changed files with 59 additions and 5 deletions

View File

@@ -85,4 +85,25 @@ describe("createDiscordGatewaySupervisor", () => {
expect(() => supervisor.dispose()).not.toThrow();
expect(() => supervisor.dispose()).not.toThrow();
});
it("keeps suppressing late gateway errors after dispose", () => {
const emitter = new EventEmitter();
const runtime = { error: vi.fn() };
const supervisor = createDiscordGatewaySupervisor({
client: {
getPlugin: vi.fn(() => ({ emitter })),
} as never,
isDisallowedIntentsError: () => false,
runtime: runtime as never,
});
supervisor.dispose();
expect(() =>
emitter.emit("error", new Error("Max reconnect attempts (0) reached after code 1005")),
).not.toThrow();
expect(runtime.error).toHaveBeenCalledWith(
expect.stringContaining("suppressed late gateway reconnect-exhausted error after dispose"),
);
});
});

View File

@@ -94,14 +94,20 @@ export function createDiscordGatewaySupervisor(params: {
),
);
};
const logLateDisposedEvent = (event: DiscordGatewayEvent) => {
params.runtime.error?.(
danger(`discord: suppressed late gateway ${event.type} error after dispose: ${event.message}`),
);
};
const onGatewayError = (err: unknown) => {
if (disposed) {
return;
}
const event = classifyDiscordGatewayEvent({
err,
isDisallowedIntentsError: params.isDisallowedIntentsError,
});
if (phase === "disposed") {
logLateDisposedEvent(event);
return;
}
if (phase === "active" && lifecycleHandler) {
lifecycleHandler(event);
return;
@@ -145,7 +151,6 @@ export function createDiscordGatewaySupervisor(params: {
lifecycleHandler = undefined;
phase = "disposed";
pending.length = 0;
emitter.removeListener("error", onGatewayError);
},
};
}

View File

@@ -225,6 +225,26 @@ describe("monitorDiscordProvider", () => {
expect(createdBindingManagers[0]?.stop).toHaveBeenCalledTimes(1);
});
it("disconnects the gateway when startup fails before lifecycle begins after client creation", async () => {
const disconnect = vi.fn();
clientGetPluginMock.mockImplementation((name: string) =>
name === "gateway" ? { emitter: new EventEmitter(), disconnect, isConnected: false } : undefined,
);
createDiscordMessageHandlerMock.mockImplementationOnce(() => {
throw new Error("handler init failed");
});
await expect(
monitorDiscordProvider({
config: baseConfig(),
runtime: baseRuntime(),
}),
).rejects.toThrow("handler init failed");
expect(monitorLifecycleMock).not.toHaveBeenCalled();
expect(disconnect).toHaveBeenCalledTimes(1);
});
it("does not double-stop thread bindings when lifecycle performs cleanup", async () => {
await monitorDiscordProvider({
config: baseConfig(),

View File

@@ -797,6 +797,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
let gatewaySupervisor: ReturnType<typeof createDiscordGatewaySupervisor> | undefined;
let deactivateMessageHandler: (() => void) | undefined;
let autoPresenceController: ReturnType<typeof createDiscordAutoPresenceController> | null = null;
let lifecycleGateway: GatewayPlugin | undefined;
let earlyGatewayEmitter = gatewaySupervisor?.emitter;
let onEarlyGatewayDebug: ((msg: unknown) => void) | undefined;
try {
@@ -954,7 +955,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
runtime,
});
const lifecycleGateway = client.getPlugin<GatewayPlugin>("gateway");
lifecycleGateway = client.getPlugin<GatewayPlugin>("gateway");
earlyGatewayEmitter = gatewaySupervisor.emitter;
onEarlyGatewayDebug = (msg: unknown) => {
if (!(isVerboseForTesting ?? isVerbose)()) {
@@ -1181,6 +1182,13 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
if (onEarlyGatewayDebug) {
earlyGatewayEmitter?.removeListener("debug", onEarlyGatewayDebug);
}
if (!lifecycleStarted) {
try {
lifecycleGateway?.disconnect();
} catch (err) {
runtime.error?.(danger(`discord: failed to disconnect gateway during startup cleanup: ${String(err)}`));
}
}
gatewaySupervisor?.dispose();
if (!lifecycleStarted) {
threadBindings.stop();