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.
This commit is contained in:
Harjoth Khara
2026-07-31 23:03:02 -07:00
committed by GitHub
parent 137050cedd
commit 6fb334fe84

View File

@@ -3413,13 +3413,22 @@ describe("createMcpLoopbackServerConfig", () => {
if (!oldRuntime) {
throw new Error("expected old MCP loopback runtime");
}
let stalledRequest: ReturnType<typeof request> | undefined;
let resolveSocketReady: () => void = () => {};
let rejectSocketReady: (error: Error) => void = () => {};
const socketReady = new Promise<void>((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<void>((resolve, reject) => {
resolveRequestStarted = resolve;
rejectRequestStarted = reject;
});
beginMcpLoopbackToolCallCapture({
captureKey,
onRequestStart: () => resolveRequestStarted(),
onToolCallResult: vi.fn(),
});
let stalledRequest: ReturnType<typeof request> | undefined;
const responsePromise = new Promise<void>((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<void> | undefined;
try {
await socketReady;
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
await requestStarted;
let closeSettled = false;
oldClose = closeMcpLoopbackServer().finally(() => {
closeSettled = true;
});
expect(getActiveMcpLoopbackRuntime()).toBeUndefined();
await new Promise<void>((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);