2026-03-11 20:10:34 +09:00
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 < TValue > = {
promise : Promise < TValue >
resolve : ( value : TValue ) = > void
reject : ( error : Error ) = > void
}
const pendingConnects : Deferred < void > [ ] = [ ]
const trackedStates : SkillMcpManagerState [ ] = [ ]
const createdClients : MockClient [ ] = [ ]
const createdTransports : MockStdioClientTransport [ ] = [ ]
class MockClient {
readonly close = mock ( async ( ) = > { } )
constructor (
_clientInfo : { name : string ; version : string } ,
_options : { capabilities : Record < string , never > }
) {
createdClients . push ( this )
}
async connect ( _transport : MockStdioClientTransport ) : Promise < void > {
const pendingConnect = pendingConnects . shift ( )
if ( pendingConnect ) {
await pendingConnect . promise
}
}
}
class MockStdioClientTransport {
readonly close = mock ( async ( ) = > { } )
constructor ( _options : { command : string ; args? : string [ ] ; env? : Record < string , string > ; 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 < TValue > ( ) : Deferred < TValue > {
let resolvePromise : ( ( value : TValue ) = > void ) | null = null
let rejectPromise : ( ( error : Error ) = > void ) | null = null
const promise = new Promise < TValue > ( ( 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 ( ) ,
2026-03-11 21:16:37 +09:00
disconnectedSessions : new Map ( ) ,
2026-03-11 20:10:34 +09:00
authProviders : new Map ( ) ,
cleanupRegistered : false ,
cleanupInterval : null ,
cleanupHandlers : [ ] ,
idleTimeoutMs : 5 * 60 * 1000 ,
2026-03-11 21:16:37 +09:00
shutdownGeneration : 0 ,
2026-03-12 00:28:19 +09:00
inFlightConnections : new Map ( ) ,
2026-03-12 00:51:02 +09:00
disposed : false ,
2026-03-11 20:10:34 +09:00
}
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 < void > ( )
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 )
2026-03-12 00:28:19 +09:00
expect ( state . disconnectedSessions . has ( info . sessionID ) ) . toBe ( false )
2026-03-11 20:10:34 +09:00
expect ( createdClients ) . toHaveLength ( 1 )
expect ( createdClients [ 0 ] ? . close ) . toHaveBeenCalledTimes ( 1 )
expect ( createdTransports [ 0 ] ? . close ) . toHaveBeenCalledTimes ( 1 )
} )
2026-03-12 00:28:19 +09:00
it ( "#given session A in disconnectedSessions #when new connection completes with no remaining pending #then disconnectedSessions entry is cleaned up" , async ( ) = > {
2026-03-11 20:10:34 +09:00
const state = createState ( )
const info = createClientInfo ( "session-a" )
const clientKey = createClientKey ( info )
2026-03-11 21:16:37 +09:00
state . disconnectedSessions . set ( info . sessionID , 1 )
2026-03-11 20:10:34 +09:00
const client = await getOrCreateClient ( { state , clientKey , info , config : stdioConfig } )
2026-03-12 00:28:19 +09:00
expect ( state . disconnectedSessions . has ( info . sessionID ) ) . toBe ( false )
2026-03-11 20:10:34 +09:00
expect ( state . clients . get ( clientKey ) ? . client ) . toBe ( client )
expect ( createdClients [ 0 ] ? . close ) . not . toHaveBeenCalled ( )
} )
2026-03-11 20:39:03 +09:00
it ( "#given no pending connections #when disconnectSession is called #then no errors occur and session is not added to disconnectedSessions" , async ( ) = > {
2026-03-11 20:10:34 +09:00
const state = createState ( )
await expect ( disconnectSession ( state , "session-a" ) ) . resolves . toBeUndefined ( )
2026-03-11 20:39:03 +09:00
expect ( state . disconnectedSessions . has ( "session-a" ) ) . toBe ( false )
2026-03-11 20:10:34 +09:00
expect ( state . pendingConnections . size ) . toBe ( 0 )
expect ( state . clients . size ) . toBe ( 0 )
} )
} )
2026-03-11 21:16:37 +09:00
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 < void > ( )
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 < void > ( )
const pendingConnect2 = createDeferred < void > ( )
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 )
2026-03-12 00:28:19 +09:00
expect ( state . disconnectedSessions . has ( "session-a" ) ) . toBe ( false )
2026-03-11 21:16:37 +09:00
} )
} )