mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:40:43 +00:00
fix(runtime): avoid leaking detached cleanup promises
This commit is contained in:
@@ -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<Promise<unknown>>();
|
||||
let rejectTask!: (reason?: unknown) => void;
|
||||
const task = new Promise<void>((_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([]);
|
||||
});
|
||||
});
|
||||
@@ -9,9 +9,10 @@ export function trackBackgroundTask(
|
||||
task: Promise<unknown>,
|
||||
) {
|
||||
backgroundTasks.add(task);
|
||||
void task.finally(() => {
|
||||
const cleanup = () => {
|
||||
backgroundTasks.delete(task);
|
||||
});
|
||||
};
|
||||
task.then(cleanup, cleanup);
|
||||
}
|
||||
|
||||
export function updateLastRouteInBackground(params: {
|
||||
|
||||
114
src/mcp/channel-server.shutdown-unhandled-rejection.test.ts
Normal file
114
src/mcp/channel-server.shutdown-unhandled-rejection.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user