diff --git a/extensions/whatsapp/src/auto-reply/monitor/last-route.test.ts b/extensions/whatsapp/src/auto-reply/monitor/last-route.test.ts new file mode 100644 index 00000000000..bd5a46c985d --- /dev/null +++ b/extensions/whatsapp/src/auto-reply/monitor/last-route.test.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { trackBackgroundTask } from "./last-route.js"; + +const waitForAsyncCallbacks = async () => { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); +}; + +describe("trackBackgroundTask", () => { + const unhandledRejections: unknown[] = []; + const onUnhandledRejection = (reason: unknown) => { + unhandledRejections.push(reason); + }; + + afterEach(() => { + process.off("unhandledRejection", onUnhandledRejection); + unhandledRejections.length = 0; + }); + + it("does not leak unhandled rejections when a tracked task fails", async () => { + process.on("unhandledRejection", onUnhandledRejection); + const backgroundTasks = new Set>(); + let rejectTask!: (reason?: unknown) => void; + const task = new Promise((_resolve, reject) => { + rejectTask = reject; + }); + + trackBackgroundTask(backgroundTasks, task); + expect(backgroundTasks.size).toBe(1); + + rejectTask(new Error("boom")); + await waitForAsyncCallbacks(); + + expect(backgroundTasks.size).toBe(0); + expect(unhandledRejections).toEqual([]); + }); +}); diff --git a/extensions/whatsapp/src/auto-reply/monitor/last-route.ts b/extensions/whatsapp/src/auto-reply/monitor/last-route.ts index 2ef762c0f8c..6edcb2d01de 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/last-route.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/last-route.ts @@ -9,9 +9,10 @@ export function trackBackgroundTask( task: Promise, ) { backgroundTasks.add(task); - void task.finally(() => { + const cleanup = () => { backgroundTasks.delete(task); - }); + }; + task.then(cleanup, cleanup); } export function updateLastRouteInBackground(params: { diff --git a/src/mcp/channel-server.shutdown-unhandled-rejection.test.ts b/src/mcp/channel-server.shutdown-unhandled-rejection.test.ts new file mode 100644 index 00000000000..0b4cfc2fedd --- /dev/null +++ b/src/mcp/channel-server.shutdown-unhandled-rejection.test.ts @@ -0,0 +1,114 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const transportState = vi.hoisted(() => ({ + lastTransport: null as { onclose?: (() => void) | undefined } | null, +})); +const serverState = vi.hoisted(() => ({ + connect: vi.fn(async () => {}), + close: vi.fn(async () => {}), +})); +const bridgeState = vi.hoisted(() => ({ + start: vi.fn(async () => {}), + close: vi.fn(async () => { + throw new Error("close boom"); + }), + setServer: vi.fn(), + handleClaudePermissionRequest: vi.fn(async () => {}), +})); + +vi.mock("@modelcontextprotocol/sdk/server/stdio.js", () => ({ + StdioServerTransport: class MockStdioServerTransport { + onclose?: () => void; + + constructor() { + transportState.lastTransport = this; + } + }, +})); + +vi.mock("@modelcontextprotocol/sdk/server/mcp.js", () => ({ + McpServer: class MockMcpServer { + server = { + setNotificationHandler: vi.fn(), + }; + + async connect(transport: unknown) { + return serverState.connect(transport); + } + + async close() { + return serverState.close(); + } + }, +})); + +vi.mock("../config/config.js", () => ({ + loadConfig: vi.fn(() => ({})), +})); + +vi.mock("../version.js", () => ({ + VERSION: "test", +})); + +vi.mock("./channel-bridge.js", () => ({ + OpenClawChannelBridge: class MockOpenClawChannelBridge { + setServer(server: unknown) { + bridgeState.setServer(server); + } + + async start() { + return bridgeState.start(); + } + + async close() { + return bridgeState.close(); + } + + async handleClaudePermissionRequest(payload: unknown) { + return bridgeState.handleClaudePermissionRequest(payload); + } + }, +})); + +vi.mock("./channel-shared.js", () => ({ + ClaudePermissionRequestSchema: {}, +})); + +vi.mock("./channel-tools.js", () => ({ + getChannelMcpCapabilities: vi.fn(() => undefined), + registerChannelMcpTools: vi.fn(), +})); + +describe("serveOpenClawChannelMcp shutdown", () => { + const unhandledRejections: unknown[] = []; + const onUnhandledRejection = (reason: unknown) => { + unhandledRejections.push(reason); + }; + + afterEach(() => { + process.off("unhandledRejection", onUnhandledRejection); + unhandledRejections.length = 0; + transportState.lastTransport = null; + serverState.connect.mockClear(); + serverState.close.mockClear(); + bridgeState.start.mockClear(); + bridgeState.close.mockClear(); + bridgeState.setServer.mockClear(); + bridgeState.handleClaudePermissionRequest.mockClear(); + }); + + it("does not leak unhandled rejections when shutdown close fails", async () => { + process.on("unhandledRejection", onUnhandledRejection); + const { serveOpenClawChannelMcp } = await import("./channel-server.js"); + + const servePromise = serveOpenClawChannelMcp({ verbose: false }); + await Promise.resolve(); + + transportState.lastTransport?.onclose?.(); + await servePromise; + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(unhandledRejections).toEqual([]); + expect(bridgeState.close).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/mcp/channel-server.ts b/src/mcp/channel-server.ts index e2b8d24a234..9dccb16fa59 100644 --- a/src/mcp/channel-server.ts +++ b/src/mcp/channel-server.ts @@ -82,7 +82,7 @@ export async function serveOpenClawChannelMcp(opts: OpenClawMcpServeOptions = {} process.off("SIGINT", shutdown); process.off("SIGTERM", shutdown); transport["onclose"] = undefined; - void close().finally(resolveClosed); + close().then(resolveClosed, resolveClosed); }; transport["onclose"] = shutdown;