fix(runtime): avoid leaking detached cleanup promises

This commit is contained in:
Vincent Koc
2026-04-13 16:42:23 +01:00
parent 74f2c4a56b
commit e157c83c65
4 changed files with 155 additions and 3 deletions

View File

@@ -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([]);
});
});

View File

@@ -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: {

View 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);
});
});

View File

@@ -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;