Files
rea/src/server/shutdownRegistration.test.ts
T
Edgar Arakelyan 60bd646478 feat(mcp): add opt-in streamable HTTP transport (#4955)
Add an explicit Streamable HTTP entry for environment/gateway hosts while keeping stdio registrations and default behavior. Preserve the shared provider/session owner across DELETE and replacement; fence and drain tool/completion work before handover or teardown.

Bound HTTP input and plain-response admission, cancel refused bodies, join whole exchanges and asynchronous response cleanup before provider hooks, gate startup readiness, and reject queued HTTP/1.1 pipelining while retaining sequential keep-alive.

Validation: 8 static/build tasks, 83 adapter tests and registry metadata pass. A real installed gh binary produces seven equivalent CLI/stdio/HTTP reports with DELETE/replacement and clean signal shutdown. Broader local native-provider/ownership failures reproduce on clean main and are documented with limitations in the PR.

Co-authored-by: Edgar Arakelyan <edgar.arakelyan@scale.com>
Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
2026-10-19 23:58:51 +08:00

304 lines
9.1 KiB
TypeScript

import { EventEmitter } from "node:events";
import { expect, it } from "vitest";
import pino from "pino";
import { createShutdown } from "../main/shutdown.js";
import {
createExitStatus,
latchSignalShutdown,
registerProcessShutdown,
registerSignalShutdown,
} from "../main/processShutdown.js";
import type { McpServerHandle, RuntimeDependencies } from "../main/types.js";
import { createManagedBinarySession } from "../composition/binary.js";
it("registers only signals for a listener and reports the signal exit status", () => {
const signals = new EventEmitter();
const statuses: number[] = [];
const unregister = registerSignalShutdown(
(status) => statuses.push(status),
signals,
);
signals.emit("SIGTERM");
signals.emit("SIGINT");
expect(statuses).toEqual([143, 130]);
unregister();
signals.emit("SIGTERM");
expect(statuses).toEqual([143, 130]);
expect(signals.listenerCount("SIGTERM")).toBe(0);
});
it("names the signal that fired, passes none for stdin, and ignores stdin when the events omit it", () => {
const signals = new EventEmitter();
const input = new EventEmitter();
const triggers: (string | undefined)[] = [];
const withInput = registerProcessShutdown((signal) => triggers.push(signal), {
signals,
stdin: input,
});
signals.emit("SIGINT");
input.emit("end");
expect(triggers).toEqual(["SIGINT", undefined]);
withInput();
const signalsOnly = registerProcessShutdown(
(signal) => triggers.push(signal),
{ signals },
);
signals.emit("SIGTERM");
input.emit("end");
input.emit("close");
expect(triggers).toEqual(["SIGINT", undefined, "SIGTERM"]);
signalsOnly();
signals.emit("SIGINT");
expect(triggers).toEqual(["SIGINT", undefined, "SIGTERM"]);
expect(input.listenerCount("end")).toBe(0);
});
const shutdownEvents = ["SIGINT", "SIGTERM", "end", "close"] as const;
const flush = async (): Promise<void> => {
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
};
it.each(shutdownEvents)(
"retries failed cleanup after %s and unregisters every shutdown event on success",
async (event) => {
const signals = new EventEmitter();
const input = new EventEmitter();
const output: string[] = [];
const exitCodes: number[] = [];
let unregisterCount = 0;
let transportCloseCalls = 0;
let androidCloseCalls = 0;
let optionalCloseCalls = 0;
let sessionCloseCalls = 0;
const session = createManagedBinarySession();
const originalSessionClose = session.close.bind(session);
session.close = async (...args) => {
sessionCloseCalls += 1;
return originalSessionClose(...args);
};
const dependencies: RuntimeDependencies = {
env: {},
serve: () => {
throw new Error("unused fixture transport");
},
writeStderr: (text) => output.push(text),
setExitCode: (code) => exitCodes.push(code),
registerShutdown: (handler) => {
const unregister = registerProcessShutdown(handler, {
signals,
stdin: input,
});
return () => {
unregisterCount += 1;
unregister();
};
},
};
const handle: McpServerHandle = {
close: async () => {
transportCloseCalls += 1;
if (transportCloseCalls === 1)
throw new Error("first transport close failed");
},
};
const lifecycle = createShutdown({
handle,
closeAndroid: async () => {
androidCloseCalls += 1;
},
closeOptionalProviders: async () => {
optionalCloseCalls += 1;
},
session,
dependencies,
serverLogger: pino({ enabled: false }),
});
if (event === "SIGINT" || event === "SIGTERM") signals.emit(event);
else input.emit(event);
await flush();
expect([
transportCloseCalls,
androidCloseCalls,
optionalCloseCalls,
]).toEqual([1, 1, 1]);
expect(sessionCloseCalls).toBe(1);
expect(output).toHaveLength(1);
if (event === "SIGINT" || event === "SIGTERM") signals.emit(event);
else input.emit(event);
await lifecycle.shutdown();
expect([
transportCloseCalls,
androidCloseCalls,
optionalCloseCalls,
]).toEqual([2, 1, 1]);
expect(sessionCloseCalls).toBe(1);
expect(unregisterCount).toBe(1);
expect(signals.listenerCount("SIGINT")).toBe(0);
expect(signals.listenerCount("SIGTERM")).toBe(0);
expect(input.listenerCount("end")).toBe(0);
expect(input.listenerCount("close")).toBe(0);
signals.emit("SIGINT");
input.emit("end");
await flush();
expect([
transportCloseCalls,
androidCloseCalls,
optionalCloseCalls,
]).toEqual([2, 1, 1]);
},
);
it("keeps shared provider owners open when the transport preparation barrier fails", async () => {
const events: string[] = [];
let attempts = 0;
const session = createManagedBinarySession();
const closeSession = session.close.bind(session);
session.close = async (...args) => {
events.push("session");
return closeSession(...args);
};
const lifecycle = createShutdown({
handle: {
prepareShutdown: async () => {
events.push("prepare");
attempts += 1;
if (attempts === 1) throw new Error("tool cleanup is still running");
},
close: async () => {
events.push("transport");
},
},
closeOptionalProviders: async () => {
events.push("provider");
},
session,
dependencies: {
env: {},
serve: () => {
throw new Error("unused transport");
},
writeStderr: () => undefined,
setExitCode: () => undefined,
registerShutdown: () => () => undefined,
},
serverLogger: pino({ enabled: false }),
});
await expect(lifecycle.shutdown()).rejects.toThrow(
"tool cleanup is still running",
);
expect(events).toEqual(["prepare"]);
await lifecycle.shutdown();
expect(events).toEqual([
"prepare",
"prepare",
"transport",
"provider",
"session",
]);
});
it("joins concurrent shutdown events and retries only failed owners", async () => {
const signals = new EventEmitter();
const input = new EventEmitter();
const output: string[] = [];
let transportCloseCalls = 0;
let androidCloseCalls = 0;
let sessionCloseCalls = 0;
let releaseFirstClose: ((error: Error) => void) | undefined;
const session = createManagedBinarySession();
const originalSessionClose = session.close.bind(session);
session.close = async (...args) => {
sessionCloseCalls += 1;
return originalSessionClose(...args);
};
const dependencies: RuntimeDependencies = {
env: {},
serve: () => {
throw new Error("unused fixture transport");
},
writeStderr: (text) => output.push(text),
setExitCode: () => undefined,
registerShutdown: (handler) =>
registerProcessShutdown(handler, { signals, stdin: input }),
};
const handle: McpServerHandle = {
close: async () => {
transportCloseCalls += 1;
if (transportCloseCalls === 1)
await new Promise<never>((_resolve, reject) => {
releaseFirstClose = reject;
});
},
};
createShutdown({
handle,
closeAndroid: async () => {
androidCloseCalls += 1;
},
session,
dependencies,
serverLogger: pino({ enabled: false }),
});
signals.emit("SIGINT");
signals.emit("SIGTERM");
await flush();
expect([transportCloseCalls, androidCloseCalls, sessionCloseCalls]).toEqual([
1, 1, 1,
]);
if (releaseFirstClose === undefined)
throw new Error("First close attempt did not start");
releaseFirstClose(new Error("first transport close failed"));
await flush();
input.emit("end");
await flush();
expect([transportCloseCalls, androidCloseCalls, sessionCloseCalls]).toEqual([
2, 1, 1,
]);
expect(output.length).toBeGreaterThan(0);
expect(signals.listenerCount("SIGINT")).toBe(0);
expect(signals.listenerCount("SIGTERM")).toBe(0);
expect(input.listenerCount("end")).toBe(0);
expect(input.listenerCount("close")).toBe(0);
});
it("latches the first startup signal, exits at once on a second, and hands the status over on release", () => {
const signals = new EventEmitter();
const exits: number[] = [];
const latch = latchSignalShutdown(signals, (status) => exits.push(status));
expect(signals.listenerCount("SIGINT")).toBe(1);
signals.emit("SIGTERM");
expect(exits).toEqual([]);
signals.emit("SIGINT");
expect(exits).toEqual([143]);
expect(latch.release()).toBe(143);
expect(signals.listenerCount("SIGINT")).toBe(0);
expect(signals.listenerCount("SIGTERM")).toBe(0);
const quiet = latchSignalShutdown(signals, (status) => exits.push(status));
expect(quiet.release()).toBeUndefined();
});
it("keeps a signal's exit status through the entry's normal return and yields to a real failure", () => {
const codes: number[] = [];
const exit = createExitStatus((code) => codes.push(code));
exit.set(0);
exit.signal(143);
exit.set(0);
expect(codes).toEqual([0, 143, 143]);
exit.set(1);
expect(codes.at(-1)).toBe(1);
const plain = createExitStatus((code) => codes.push(code));
plain.set(0);
expect(codes.at(-1)).toBe(0);
});