From 6fb334fe8465960a2c8ca280aac2ea05ecc50a63 Mon Sep 17 00:00:00 2001 From: Harjoth Khara Date: Fri, 31 Jul 2026 23:03:02 -0700 Subject: [PATCH] test(gateway): pin MCP loopback drain proof to a server-side barrier (#117181) The drain test asserted "close has not settled" after a fixed 20ms sleep. Node only exempts a connection from close()'s idle sweep once its parser has begun a message, so a client-side connect barrier let a loaded machine reach close() before the server parsed the request: the socket was swept as idle, the client saw ECONNRESET, and the drain settled inside the window. Wait on capture admission (onRequestStart) instead, which fires server-side after the headers are accepted, and move the assertion to just before the successor is used so it proves the successor was minted mid-drain. --- src/gateway/mcp-http.test.ts | 44 ++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/src/gateway/mcp-http.test.ts b/src/gateway/mcp-http.test.ts index 39e0264a5ec0..f9358bb1734b 100644 --- a/src/gateway/mcp-http.test.ts +++ b/src/gateway/mcp-http.test.ts @@ -3413,13 +3413,22 @@ describe("createMcpLoopbackServerConfig", () => { if (!oldRuntime) { throw new Error("expected old MCP loopback runtime"); } - let stalledRequest: ReturnType | undefined; - let resolveSocketReady: () => void = () => {}; - let rejectSocketReady: (error: Error) => void = () => {}; - const socketReady = new Promise((resolve, reject) => { - resolveSocketReady = resolve; - rejectSocketReady = reject; + // Node only exempts a connection from close()'s idle sweep once its parser has + // begun a message, so the drain is pinned by the server-side request start, not + // by the client-side connect. Capture admission is that server-side signal. + const captureKey = "capture-stalled-drain"; + let resolveRequestStarted: () => void = () => {}; + let rejectRequestStarted: (error: Error) => void = () => {}; + const requestStarted = new Promise((resolve, reject) => { + resolveRequestStarted = resolve; + rejectRequestStarted = reject; }); + beginMcpLoopbackToolCallCapture({ + captureKey, + onRequestStart: () => resolveRequestStarted(), + onToolCallResult: vi.fn(), + }); + let stalledRequest: ReturnType | undefined; const responsePromise = new Promise((resolve, reject) => { const req = request( { @@ -3431,6 +3440,7 @@ describe("createMcpLoopbackServerConfig", () => { authorization: `Bearer ${oldRuntime.ownerToken}`, connection: "close", "content-type": "application/json", + "x-openclaw-cli-capture-key": captureKey, }, }, (res) => { @@ -3438,15 +3448,8 @@ describe("createMcpLoopbackServerConfig", () => { res.once("end", resolve); }, ); - req.once("socket", (socket) => { - if (!socket.connecting) { - resolveSocketReady(); - return; - } - socket.once("connect", resolveSocketReady); - }); req.once("error", (error) => { - rejectSocketReady(error); + rejectRequestStarted(error); reject(error); }); req.write("{"); @@ -3462,20 +3465,13 @@ describe("createMcpLoopbackServerConfig", () => { }; let oldClose: Promise | undefined; try { - await socketReady; - await new Promise((resolve) => { - setImmediate(resolve); - }); + await requestStarted; let closeSettled = false; oldClose = closeMcpLoopbackServer().finally(() => { closeSettled = true; }); expect(getActiveMcpLoopbackRuntime()).toBeUndefined(); - await new Promise((resolve) => { - setTimeout(resolve, 20); - }); - expect(closeSettled).toBe(false); const successor = await startLoopbackServerForTest(); const successorGrant = mintMcpLoopbackClientGrant({ @@ -3487,6 +3483,9 @@ describe("createMcpLoopbackServerConfig", () => { runtimeOwnerToken: successor.runtime.ownerToken, captureKey: "capture-successor", }); + // The unfinished body still holds the old connection, so the successor was + // minted mid-drain: exactly the window where a late close could fence it. + expect(closeSettled).toBe(false); finishStalledRequest(); await responsePromise; @@ -3503,6 +3502,7 @@ describe("createMcpLoopbackServerConfig", () => { ).status, ).toBe(200); } finally { + clearMcpLoopbackToolCallCapture(captureKey); finishStalledRequest(); await responsePromise.catch(() => undefined); await (oldClose ?? oldServer.close()).catch(() => undefined);