import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test" import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types" import type { SkillMcpClientInfo, SkillMcpManagerState } from "./types" type Deferred = { promise: Promise resolve: (value: TValue) => void reject: (error: Error) => void } const pendingConnects: Deferred[] = [] const trackedStates: SkillMcpManagerState[] = [] const createdClients: MockClient[] = [] const createdTransports: MockStdioClientTransport[] = [] class MockClient { readonly close = mock(async () => {}) constructor( _clientInfo: { name: string; version: string }, _options: { capabilities: Record } ) { createdClients.push(this) } async connect(_transport: MockStdioClientTransport): Promise { const pendingConnect = pendingConnects.shift() if (pendingConnect) { await pendingConnect.promise } } } class MockStdioClientTransport { readonly close = mock(async () => {}) constructor(_options: { command: string; args?: string[]; env?: Record; stderr?: string }) { createdTransports.push(this) } } mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ Client: MockClient, })) mock.module("@modelcontextprotocol/sdk/client/stdio.js", () => ({ StdioClientTransport: MockStdioClientTransport, })) const { disconnectAll, disconnectSession } = await import("./cleanup") const { getOrCreateClient } = await import("./connection") function createDeferred(): Deferred { let resolvePromise: ((value: TValue) => void) | null = null let rejectPromise: ((error: Error) => void) | null = null const promise = new Promise((resolve, reject) => { resolvePromise = resolve rejectPromise = reject }) if (!resolvePromise || !rejectPromise) { throw new Error("Failed to create deferred promise") } return { promise, resolve: resolvePromise, reject: rejectPromise, } } function createState(): SkillMcpManagerState { const state: SkillMcpManagerState = { clients: new Map(), pendingConnections: new Map(), disconnectedSessions: new Map(), authProviders: new Map(), cleanupRegistered: false, cleanupInterval: null, cleanupHandlers: [], idleTimeoutMs: 5 * 60 * 1000, shutdownGeneration: 0, inFlightConnections: new Map(), disposed: false, } trackedStates.push(state) return state } function createClientInfo(sessionID: string): SkillMcpClientInfo { return { serverName: "race-server", skillName: "race-skill", sessionID, } } function createClientKey(info: SkillMcpClientInfo): string { return `${info.sessionID}:${info.skillName}:${info.serverName}` } const stdioConfig: ClaudeCodeMcpServer = { command: "mock-mcp-server", } beforeEach(() => { pendingConnects.length = 0 createdClients.length = 0 createdTransports.length = 0 }) afterEach(async () => { for (const state of trackedStates) { await disconnectAll(state) } trackedStates.length = 0 pendingConnects.length = 0 createdClients.length = 0 createdTransports.length = 0 }) describe("getOrCreateClient disconnect race", () => { it("#given pending connection for session A #when disconnectSession(A) is called before connection completes #then completed client is not added to state.clients", async () => { const state = createState() const info = createClientInfo("session-a") const clientKey = createClientKey(info) const pendingConnect = createDeferred() pendingConnects.push(pendingConnect) const clientPromise = getOrCreateClient({ state, clientKey, info, config: stdioConfig }) expect(state.pendingConnections.has(clientKey)).toBe(true) await disconnectSession(state, info.sessionID) pendingConnect.resolve(undefined) await expect(clientPromise).rejects.toThrow(/disconnected during MCP connection setup/) expect(state.clients.has(clientKey)).toBe(false) expect(state.pendingConnections.has(clientKey)).toBe(false) expect(state.disconnectedSessions.has(info.sessionID)).toBe(false) expect(createdClients).toHaveLength(1) expect(createdClients[0]?.close).toHaveBeenCalledTimes(1) expect(createdTransports[0]?.close).toHaveBeenCalledTimes(1) }) it("#given session A in disconnectedSessions #when new connection completes with no remaining pending #then disconnectedSessions entry is cleaned up", async () => { const state = createState() const info = createClientInfo("session-a") const clientKey = createClientKey(info) state.disconnectedSessions.set(info.sessionID, 1) const client = await getOrCreateClient({ state, clientKey, info, config: stdioConfig }) expect(state.disconnectedSessions.has(info.sessionID)).toBe(false) expect(state.clients.get(clientKey)?.client).toBe(client) expect(createdClients[0]?.close).not.toHaveBeenCalled() }) it("#given no pending connections #when disconnectSession is called #then no errors occur and session is not added to disconnectedSessions", async () => { const state = createState() await expect(disconnectSession(state, "session-a")).resolves.toBeUndefined() expect(state.disconnectedSessions.has("session-a")).toBe(false) expect(state.pendingConnections.size).toBe(0) expect(state.clients.size).toBe(0) }) }) describe("getOrCreateClient disconnectAll race", () => { it("#given pending connection #when disconnectAll() is called before connection completes #then client is not added to state.clients", async () => { const state = createState() const info = createClientInfo("session-a") const clientKey = createClientKey(info) const pendingConnect = createDeferred() pendingConnects.push(pendingConnect) const clientPromise = getOrCreateClient({ state, clientKey, info, config: stdioConfig }) expect(state.pendingConnections.has(clientKey)).toBe(true) await disconnectAll(state) pendingConnect.resolve(undefined) await expect(clientPromise).rejects.toThrow(/connection completed after shutdown/) expect(state.clients.has(clientKey)).toBe(false) }) }) describe("getOrCreateClient multi-key disconnect race", () => { it("#given 2 pending connections for session A #when disconnectSession(A) before both complete #then both old connections are rejected", async () => { const state = createState() const infoKey1 = createClientInfo("session-a") const infoKey2 = { ...createClientInfo("session-a"), serverName: "server-2" } const clientKey1 = createClientKey(infoKey1) const clientKey2 = `${infoKey2.sessionID}:${infoKey2.skillName}:${infoKey2.serverName}` const pendingConnect1 = createDeferred() const pendingConnect2 = createDeferred() pendingConnects.push(pendingConnect1) pendingConnects.push(pendingConnect2) const promise1 = getOrCreateClient({ state, clientKey: clientKey1, info: infoKey1, config: stdioConfig }) const promise2 = getOrCreateClient({ state, clientKey: clientKey2, info: infoKey2, config: stdioConfig }) expect(state.pendingConnections.size).toBe(2) await disconnectSession(state, "session-a") pendingConnect1.resolve(undefined) await expect(promise1).rejects.toThrow(/disconnected during MCP connection setup/) pendingConnect2.resolve(undefined) await expect(promise2).rejects.toThrow(/disconnected during MCP connection setup/) expect(state.clients.has(clientKey1)).toBe(false) expect(state.clients.has(clientKey2)).toBe(false) expect(state.disconnectedSessions.has("session-a")).toBe(false) }) })