mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 14:10:51 +00:00
fix(discord): supervise gateway registration failures
This commit is contained in:
@@ -65,6 +65,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
- Plugin SDK/tool-result transforms: bound middleware `details`, validate in-place result mutations, and mark fail-closed middleware fallbacks with canonical `error` status. Thanks @vincentkoc.
|
||||
- Discord/gateway: prevent startup from getting stuck at `awaiting gateway readiness` when Carbon gateway registration races with a lifecycle reconnect. Fixes #52372. (#68159) Thanks @IVY-AI-gif.
|
||||
- Discord/gateway: supervise Carbon's async gateway registration promise so fatal Discord metadata failures surface through startup instead of process-level unhandled rejections. (#62451) Thanks @safzanpirani.
|
||||
- Plugins/cache: restore plugin command and interactive handler registries on loader cache hits without resetting interactive callback dedupe, so cached external plugins keep slash commands and callback handlers available after reloads. Fixes #71100. Thanks @BomBastikDE.
|
||||
- Gateway/OpenAI-compatible: report non-zero token usage for `/v1/chat/completions` when the agent run has only last-call usage metadata available. Fixes #71118. (#71242) Thanks @RenzoMXD.
|
||||
- Plugin SDK/tool-result transforms: restrict harness tool-result middleware to bundled plugins, fail closed on middleware errors, validate rewritten result shapes, preserve Pi per-call ids, and keep Codex media trust checks anchored to raw tool provenance. Thanks @vincentkoc.
|
||||
|
||||
@@ -32,6 +32,7 @@ type DiscordGatewayFetch = (
|
||||
|
||||
type DiscordGatewayMetadataError = Error & { transient?: boolean };
|
||||
type DiscordGatewayWebSocketCtor = new (url: string, options?: { agent?: unknown }) => ws.WebSocket;
|
||||
const registrationPromises = new WeakMap<carbonGateway.GatewayPlugin, Promise<void>>();
|
||||
type CarbonGatewayRegistrationState = {
|
||||
client?: Parameters<carbonGateway.GatewayPlugin["registerClient"]>[0];
|
||||
ws?: unknown;
|
||||
@@ -288,7 +289,17 @@ function createGatewayPlugin(params: {
|
||||
super.connect(resume);
|
||||
}
|
||||
|
||||
override async registerClient(
|
||||
override registerClient(client: Parameters<carbonGateway.GatewayPlugin["registerClient"]>[0]) {
|
||||
const registration = this.registerClientInternal(client);
|
||||
// Carbon 0.16 invokes async plugin hooks from Client construction without
|
||||
// awaiting them. Mark the promise handled immediately, then let OpenClaw
|
||||
// startup await the original promise explicitly.
|
||||
registration.catch(() => {});
|
||||
registrationPromises.set(this, registration);
|
||||
return registration;
|
||||
}
|
||||
|
||||
private async registerClientInternal(
|
||||
client: Parameters<carbonGateway.GatewayPlugin["registerClient"]>[0],
|
||||
) {
|
||||
// Carbon's Client constructor does not await plugin registerClient().
|
||||
@@ -387,6 +398,15 @@ function createGatewayPlugin(params: {
|
||||
return new SafeGatewayPlugin();
|
||||
}
|
||||
|
||||
export function waitForDiscordGatewayPluginRegistration(
|
||||
plugin: unknown,
|
||||
): Promise<void> | undefined {
|
||||
if (typeof plugin !== "object" || plugin === null) {
|
||||
return undefined;
|
||||
}
|
||||
return registrationPromises.get(plugin as carbonGateway.GatewayPlugin);
|
||||
}
|
||||
|
||||
export function createDiscordGatewayPlugin(params: {
|
||||
discordConfig: DiscordAccountConfig;
|
||||
runtime: RuntimeEnv;
|
||||
|
||||
@@ -140,9 +140,11 @@ vi.mock("openclaw/plugin-sdk/proxy-capture", () => ({
|
||||
|
||||
describe("createDiscordGatewayPlugin", () => {
|
||||
let createDiscordGatewayPlugin: typeof import("./gateway-plugin.js").createDiscordGatewayPlugin;
|
||||
let waitForDiscordGatewayPluginRegistration: typeof import("./gateway-plugin.js").waitForDiscordGatewayPluginRegistration;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ createDiscordGatewayPlugin } = await import("./gateway-plugin.js"));
|
||||
({ createDiscordGatewayPlugin, waitForDiscordGatewayPluginRegistration } =
|
||||
await import("./gateway-plugin.js"));
|
||||
});
|
||||
|
||||
function createRuntime() {
|
||||
@@ -190,6 +192,22 @@ describe("createDiscordGatewayPlugin", () => {
|
||||
});
|
||||
}
|
||||
|
||||
function startIgnoredGatewayRegistration(plugin: unknown) {
|
||||
void (
|
||||
plugin as {
|
||||
registerClient: (client: {
|
||||
options: { token: string };
|
||||
registerListener: typeof baseRegisterClientSpy;
|
||||
unregisterListener: ReturnType<typeof vi.fn>;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
).registerClient({
|
||||
options: { token: "token-123" },
|
||||
registerListener: baseRegisterClientSpy,
|
||||
unregisterListener: vi.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
async function expectGatewayRegisterFetchFailure(response: Response) {
|
||||
const runtime = createRuntime();
|
||||
globalFetchMock.mockResolvedValue(response);
|
||||
@@ -326,6 +344,64 @@ describe("createDiscordGatewayPlugin", () => {
|
||||
} as Response);
|
||||
});
|
||||
|
||||
it("keeps Carbon-ignored fatal metadata failures handled for supervised startup", async () => {
|
||||
const runtime = createRuntime();
|
||||
const unhandledReasons: unknown[] = [];
|
||||
const onUnhandledRejection = (reason: unknown) => {
|
||||
unhandledReasons.push(reason);
|
||||
};
|
||||
globalFetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
text: async () => "401: Unauthorized",
|
||||
} as Response);
|
||||
const plugin = createDiscordGatewayPlugin({
|
||||
discordConfig: {},
|
||||
runtime,
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", onUnhandledRejection);
|
||||
try {
|
||||
startIgnoredGatewayRegistration(plugin);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(unhandledReasons).toHaveLength(0);
|
||||
const registration = waitForDiscordGatewayPluginRegistration(plugin);
|
||||
if (!registration) {
|
||||
throw new Error("expected Discord gateway registration promise");
|
||||
}
|
||||
await expect(registration).rejects.toThrow("Failed to get gateway information from Discord");
|
||||
expect(baseRegisterClientSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
process.off("unhandledRejection", onUnhandledRejection);
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes Carbon-ignored successful registrations for startup await", async () => {
|
||||
const runtime = createRuntime();
|
||||
globalFetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => JSON.stringify({ url: "wss://gateway.discord.gg" }),
|
||||
} as Response);
|
||||
const plugin = createDiscordGatewayPlugin({
|
||||
discordConfig: {},
|
||||
runtime,
|
||||
});
|
||||
|
||||
startIgnoredGatewayRegistration(plugin);
|
||||
const registration = waitForDiscordGatewayPluginRegistration(plugin);
|
||||
if (!registration) {
|
||||
throw new Error("expected Discord gateway registration promise");
|
||||
}
|
||||
await registration;
|
||||
|
||||
expect(baseRegisterClientSpy).toHaveBeenCalledTimes(1);
|
||||
expect((plugin as unknown as { gatewayInfo?: { url?: string } }).gatewayInfo?.url).toBe(
|
||||
"wss://gateway.discord.gg",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses proxy agent for gateway WebSocket when configured", async () => {
|
||||
const runtime = createRuntime();
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { Client, Plugin } from "@buape/carbon";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { registerVoiceClientSpy } = vi.hoisted(() => ({
|
||||
const { registerVoiceClientSpy, waitForDiscordGatewayPluginRegistrationMock } = vi.hoisted(() => ({
|
||||
registerVoiceClientSpy: vi.fn(),
|
||||
waitForDiscordGatewayPluginRegistrationMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@buape/carbon/voice", () => ({
|
||||
@@ -51,6 +52,7 @@ vi.mock("./auto-presence.js", () => ({
|
||||
|
||||
vi.mock("./gateway-plugin.js", () => ({
|
||||
createDiscordGatewayPlugin: vi.fn(),
|
||||
waitForDiscordGatewayPluginRegistration: waitForDiscordGatewayPluginRegistrationMock,
|
||||
}));
|
||||
|
||||
vi.mock("./gateway-supervisor.js", () => ({
|
||||
@@ -73,16 +75,50 @@ vi.mock("./presence.js", () => ({
|
||||
import { createDiscordMonitorClient } from "./provider.startup.js";
|
||||
|
||||
describe("createDiscordMonitorClient", () => {
|
||||
it("adds listener compat for legacy voice plugins", () => {
|
||||
beforeEach(() => {
|
||||
registerVoiceClientSpy.mockReset();
|
||||
waitForDiscordGatewayPluginRegistrationMock.mockReset().mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
function createRuntime() {
|
||||
return {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createClientWithPlugins(
|
||||
_options: ConstructorParameters<typeof import("@buape/carbon").Client>[0],
|
||||
handlers: ConstructorParameters<typeof import("@buape/carbon").Client>[1],
|
||||
plugins: Plugin[] = [],
|
||||
) {
|
||||
const pluginRegistry = plugins.map((plugin) => ({ id: plugin.id, plugin }));
|
||||
return {
|
||||
listeners: [...(handlers.listeners ?? [])],
|
||||
plugins: pluginRegistry,
|
||||
getPlugin: (id: string) => pluginRegistry.find((entry) => entry.id === id)?.plugin,
|
||||
} as Client;
|
||||
}
|
||||
|
||||
function createAutoPresenceController() {
|
||||
return {
|
||||
enabled: false,
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
runNow: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
it("adds listener compat for legacy voice plugins", async () => {
|
||||
const gatewayPlugin = {
|
||||
id: "gateway",
|
||||
registerClient: vi.fn(),
|
||||
registerRoutes: vi.fn(),
|
||||
} as Plugin;
|
||||
|
||||
const result = createDiscordMonitorClient({
|
||||
const result = await createDiscordMonitorClient({
|
||||
accountId: "default",
|
||||
applicationId: "app-1",
|
||||
token: "token-1",
|
||||
@@ -91,29 +127,11 @@ describe("createDiscordMonitorClient", () => {
|
||||
modals: [],
|
||||
voiceEnabled: true,
|
||||
discordConfig: {},
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
},
|
||||
createClient: (_options, handlers, plugins = []) => {
|
||||
const pluginRegistry = plugins.map((plugin) => ({ id: plugin.id, plugin }));
|
||||
return {
|
||||
listeners: [...(handlers.listeners ?? [])],
|
||||
plugins: pluginRegistry,
|
||||
getPlugin: (id: string) => pluginRegistry.find((entry) => entry.id === id)?.plugin,
|
||||
} as Client;
|
||||
},
|
||||
runtime: createRuntime(),
|
||||
createClient: createClientWithPlugins,
|
||||
createGatewayPlugin: () => gatewayPlugin as never,
|
||||
createGatewaySupervisor: () => ({ shutdown: vi.fn(), handleError: vi.fn() }) as never,
|
||||
createAutoPresenceController: () =>
|
||||
({
|
||||
enabled: false,
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
runNow: vi.fn(),
|
||||
}) as never,
|
||||
createAutoPresenceController: () => createAutoPresenceController() as never,
|
||||
isDisallowedIntentsError: () => false,
|
||||
});
|
||||
|
||||
@@ -122,4 +140,73 @@ describe("createDiscordMonitorClient", () => {
|
||||
expect.arrayContaining([expect.objectContaining({ type: "legacy-voice-listener" })]),
|
||||
);
|
||||
});
|
||||
|
||||
it("waits for gateway registration before creating the supervisor", async () => {
|
||||
const gatewayPlugin = { id: "gateway" } as Plugin;
|
||||
let resolveRegistration: (() => void) | undefined;
|
||||
const registration = new Promise<void>((resolve) => {
|
||||
resolveRegistration = resolve;
|
||||
});
|
||||
waitForDiscordGatewayPluginRegistrationMock.mockReturnValue(registration);
|
||||
const gatewaySupervisor = { shutdown: vi.fn(), handleError: vi.fn() };
|
||||
const createGatewaySupervisor = vi.fn(() => gatewaySupervisor);
|
||||
|
||||
const resultPromise = createDiscordMonitorClient({
|
||||
accountId: "default",
|
||||
applicationId: "app-1",
|
||||
token: "token-1",
|
||||
commands: [],
|
||||
components: [],
|
||||
modals: [],
|
||||
voiceEnabled: false,
|
||||
discordConfig: {},
|
||||
runtime: createRuntime(),
|
||||
createClient: createClientWithPlugins,
|
||||
createGatewayPlugin: () => gatewayPlugin as never,
|
||||
createGatewaySupervisor: createGatewaySupervisor as never,
|
||||
createAutoPresenceController: () => createAutoPresenceController() as never,
|
||||
isDisallowedIntentsError: () => false,
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(waitForDiscordGatewayPluginRegistrationMock).toHaveBeenCalledWith(gatewayPlugin);
|
||||
expect(createGatewaySupervisor).not.toHaveBeenCalled();
|
||||
|
||||
resolveRegistration?.();
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(createGatewaySupervisor).toHaveBeenCalledTimes(1);
|
||||
expect(result.gatewaySupervisor).toBe(gatewaySupervisor);
|
||||
});
|
||||
|
||||
it("propagates gateway registration failures before supervisor startup", async () => {
|
||||
const gatewayPlugin = { id: "gateway" } as Plugin;
|
||||
const createGatewaySupervisor = vi.fn();
|
||||
const createAutoPresenceControllerForTest = vi.fn(createAutoPresenceController);
|
||||
waitForDiscordGatewayPluginRegistrationMock.mockReturnValue(
|
||||
Promise.reject(new Error("gateway metadata denied")),
|
||||
);
|
||||
|
||||
await expect(
|
||||
createDiscordMonitorClient({
|
||||
accountId: "default",
|
||||
applicationId: "app-1",
|
||||
token: "token-1",
|
||||
commands: [],
|
||||
components: [],
|
||||
modals: [],
|
||||
voiceEnabled: false,
|
||||
discordConfig: {},
|
||||
runtime: createRuntime(),
|
||||
createClient: createClientWithPlugins,
|
||||
createGatewayPlugin: () => gatewayPlugin as never,
|
||||
createGatewaySupervisor: createGatewaySupervisor as never,
|
||||
createAutoPresenceController: createAutoPresenceControllerForTest as never,
|
||||
isDisallowedIntentsError: () => false,
|
||||
}),
|
||||
).rejects.toThrow("gateway metadata denied");
|
||||
|
||||
expect(createGatewaySupervisor).not.toHaveBeenCalled();
|
||||
expect(createAutoPresenceControllerForTest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,10 @@ import type { DiscordGuildEntryResolved } from "./allow-list.js";
|
||||
import { createDiscordAutoPresenceController } from "./auto-presence.js";
|
||||
import type { DiscordDmPolicy } from "./dm-command-auth.js";
|
||||
import type { MutableDiscordGateway } from "./gateway-handle.js";
|
||||
import { createDiscordGatewayPlugin } from "./gateway-plugin.js";
|
||||
import {
|
||||
createDiscordGatewayPlugin,
|
||||
waitForDiscordGatewayPluginRegistration,
|
||||
} from "./gateway-plugin.js";
|
||||
import { createDiscordGatewaySupervisor } from "./gateway-supervisor.js";
|
||||
import {
|
||||
DiscordMessageListener,
|
||||
@@ -107,7 +110,7 @@ export function createDiscordStatusReadyListener(params: {
|
||||
})();
|
||||
}
|
||||
|
||||
export function createDiscordMonitorClient(params: {
|
||||
export async function createDiscordMonitorClient(params: {
|
||||
accountId: string;
|
||||
applicationId: string;
|
||||
token: string;
|
||||
@@ -183,6 +186,7 @@ export function createDiscordMonitorClient(params: {
|
||||
});
|
||||
}
|
||||
const gateway = client.getPlugin<GatewayPlugin>("gateway") as MutableDiscordGateway | undefined;
|
||||
await waitForDiscordGatewayPluginRegistration(gateway);
|
||||
const gatewaySupervisor = params.createGatewaySupervisor({
|
||||
gateway,
|
||||
isDisallowedIntentsError: params.isDisallowedIntentsError,
|
||||
|
||||
@@ -813,8 +813,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
let lifecycleStarted = false;
|
||||
let gatewaySupervisor: ReturnType<typeof createDiscordGatewaySupervisor> | undefined;
|
||||
let deactivateMessageHandler: (() => void) | undefined;
|
||||
let autoPresenceController: ReturnType<
|
||||
typeof createDiscordMonitorClient
|
||||
let autoPresenceController: Awaited<
|
||||
ReturnType<typeof createDiscordMonitorClient>
|
||||
>["autoPresenceController"] = null;
|
||||
let lifecycleGateway: MutableDiscordGateway | undefined;
|
||||
let earlyGatewayEmitter = gatewaySupervisor?.emitter;
|
||||
@@ -934,7 +934,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
gatewaySupervisor: createdGatewaySupervisor,
|
||||
autoPresenceController: createdAutoPresenceController,
|
||||
eventQueueOpts,
|
||||
} = createDiscordMonitorClient({
|
||||
} = await createDiscordMonitorClient({
|
||||
accountId: account.accountId,
|
||||
applicationId,
|
||||
token,
|
||||
|
||||
@@ -470,6 +470,7 @@ vi.mock(buildDiscordSourceModuleId("monitor/exec-approvals.js"), () => ({
|
||||
|
||||
vi.mock(buildDiscordSourceModuleId("monitor/gateway-plugin.js"), () => ({
|
||||
createDiscordGatewayPlugin: () => ({ id: "gateway-plugin" }),
|
||||
waitForDiscordGatewayPluginRegistration: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock(buildDiscordSourceModuleId("monitor/listeners.js"), () => ({
|
||||
|
||||
Reference in New Issue
Block a user