|
|
@@ -23,6 +23,7 @@ let connectShouldHang = false
|
|
|
let connectError = "Mock transport cannot connect"
|
|
|
// Tracks how many Client instances were created (detects leaks)
|
|
|
let clientCreateCount = 0
|
|
|
+// Tracks how many times transport.close() is called across all mock transports
|
|
|
let transportCloseCount = 0
|
|
|
|
|
|
function getOrCreateClientState(name?: string): MockClientState {
|
|
|
@@ -46,12 +47,13 @@ function getOrCreateClientState(name?: string): MockClientState {
|
|
|
return state
|
|
|
}
|
|
|
|
|
|
-// Mock transport that succeeds or fails based on connectShouldFail
|
|
|
+// Mock transport that succeeds or fails based on connectShouldFail / connectShouldHang
|
|
|
class MockStdioTransport {
|
|
|
stderr: null = null
|
|
|
pid = 12345
|
|
|
constructor(_opts: any) {}
|
|
|
async start() {
|
|
|
+ if (connectShouldHang) return new Promise<void>(() => {}) // never resolves
|
|
|
if (connectShouldFail) throw new Error(connectError)
|
|
|
if (connectShouldHang) await new Promise(() => {}) // never resolves
|
|
|
}
|
|
|
@@ -63,18 +65,24 @@ class MockStdioTransport {
|
|
|
class MockStreamableHTTP {
|
|
|
constructor(_url: URL, _opts?: any) {}
|
|
|
async start() {
|
|
|
+ if (connectShouldHang) return new Promise<void>(() => {}) // never resolves
|
|
|
if (connectShouldFail) throw new Error(connectError)
|
|
|
}
|
|
|
- async close() {}
|
|
|
+ async close() {
|
|
|
+ transportCloseCount++
|
|
|
+ }
|
|
|
async finishAuth() {}
|
|
|
}
|
|
|
|
|
|
class MockSSE {
|
|
|
constructor(_url: URL, _opts?: any) {}
|
|
|
async start() {
|
|
|
- throw new Error("SSE fallback - not used in these tests")
|
|
|
+ if (connectShouldHang) return new Promise<void>(() => {}) // never resolves
|
|
|
+ if (connectShouldFail) throw new Error(connectError)
|
|
|
+ }
|
|
|
+ async close() {
|
|
|
+ transportCloseCount++
|
|
|
}
|
|
|
- async close() {}
|
|
|
}
|
|
|
|
|
|
mock.module("@modelcontextprotocol/sdk/client/stdio.js", () => ({
|
|
|
@@ -667,25 +675,77 @@ test(
|
|
|
)
|
|
|
|
|
|
// ========================================================================
|
|
|
-// Test: timed-out local transport is closed (process leak regression)
|
|
|
+// Test: transport leak — local stdio timeout (#19168)
|
|
|
// ========================================================================
|
|
|
|
|
|
test(
|
|
|
"local stdio transport is closed when connect times out (no process leak)",
|
|
|
withInstance({}, async () => {
|
|
|
+ lastCreatedClientName = "hanging-server"
|
|
|
+ getOrCreateClientState("hanging-server")
|
|
|
connectShouldHang = true
|
|
|
|
|
|
- const result = await MCP.add("hanging-server", {
|
|
|
+ const addResult = await MCP.add("hanging-server", {
|
|
|
type: "local",
|
|
|
- command: ["node", "fake-server.js"],
|
|
|
- timeout: 100, // 100ms timeout so test is fast
|
|
|
+ command: ["node", "fake.js"],
|
|
|
+ timeout: 100,
|
|
|
})
|
|
|
|
|
|
- const status = (result.status as any)["hanging-server"] ?? result.status
|
|
|
- expect(status.status).toBe("failed")
|
|
|
- expect(status.error).toContain("timed out")
|
|
|
+ const serverStatus = (addResult.status as any)["hanging-server"] ?? addResult.status
|
|
|
+ expect(serverStatus.status).toBe("failed")
|
|
|
+ expect(serverStatus.error).toContain("timed out")
|
|
|
+ // Transport must be closed to avoid orphaned child process
|
|
|
+ expect(transportCloseCount).toBeGreaterThanOrEqual(1)
|
|
|
+ }),
|
|
|
+)
|
|
|
|
|
|
- // The transport must be closed to kill the child process
|
|
|
+// ========================================================================
|
|
|
+// Test: transport leak — remote timeout (#19168)
|
|
|
+// ========================================================================
|
|
|
+
|
|
|
+test(
|
|
|
+ "remote transport is closed when connect times out",
|
|
|
+ withInstance({}, async () => {
|
|
|
+ lastCreatedClientName = "hanging-remote"
|
|
|
+ getOrCreateClientState("hanging-remote")
|
|
|
+ connectShouldHang = true
|
|
|
+
|
|
|
+ const addResult = await MCP.add("hanging-remote", {
|
|
|
+ type: "remote",
|
|
|
+ url: "http://localhost:9999/mcp",
|
|
|
+ timeout: 100,
|
|
|
+ oauth: false,
|
|
|
+ })
|
|
|
+
|
|
|
+ const serverStatus = (addResult.status as any)["hanging-remote"] ?? addResult.status
|
|
|
+ expect(serverStatus.status).toBe("failed")
|
|
|
+ // Transport must be closed to avoid leaked HTTP connections
|
|
|
expect(transportCloseCount).toBeGreaterThanOrEqual(1)
|
|
|
}),
|
|
|
)
|
|
|
+
|
|
|
+// ========================================================================
|
|
|
+// Test: transport leak — failed remote transports not closed (#19168)
|
|
|
+// ========================================================================
|
|
|
+
|
|
|
+test(
|
|
|
+ "failed remote transport is closed before trying next transport",
|
|
|
+ withInstance({}, async () => {
|
|
|
+ lastCreatedClientName = "fail-remote"
|
|
|
+ getOrCreateClientState("fail-remote")
|
|
|
+ connectShouldFail = true
|
|
|
+ connectError = "Connection refused"
|
|
|
+
|
|
|
+ const addResult = await MCP.add("fail-remote", {
|
|
|
+ type: "remote",
|
|
|
+ url: "http://localhost:9999/mcp",
|
|
|
+ timeout: 5000,
|
|
|
+ oauth: false,
|
|
|
+ })
|
|
|
+
|
|
|
+ const serverStatus = (addResult.status as any)["fail-remote"] ?? addResult.status
|
|
|
+ expect(serverStatus.status).toBe("failed")
|
|
|
+ // Both StreamableHTTP and SSE transports should be closed
|
|
|
+ expect(transportCloseCount).toBeGreaterThanOrEqual(2)
|
|
|
+ }),
|
|
|
+)
|