Skip to content

Commit 0edd043

Browse files
committed
fix(mcp): close acquire-gap races (re-resolve after stale ping, skip closing entries)
1 parent fc2ee4c commit 0edd043

2 files changed

Lines changed: 46 additions & 0 deletions

File tree

apps/sim/lib/mcp/connection-pool.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,44 @@ describe('McpConnectionPool', () => {
284284
await next.release()
285285
})
286286

287+
it('reuses a concurrently-pooled replacement rather than orphaning it (recreate race)', async () => {
288+
// stale's ping is deferred per-call so we can fully resolve one acquire before
289+
// the other's ping settles — the exact interleaving that used to leak.
290+
const releasePing: Array<(alive: boolean) => void> = []
291+
const stale = makeFakeClient()
292+
;(stale.ping as ReturnType<typeof vi.fn>).mockImplementation(
293+
() =>
294+
new Promise((resolve, reject) => {
295+
releasePing.push((alive) => (alive ? resolve({}) : reject(new Error('dead'))))
296+
})
297+
)
298+
const fresh = makeFakeClient()
299+
const create = vi
300+
.fn<() => Promise<McpClient>>()
301+
.mockResolvedValueOnce(stale)
302+
.mockResolvedValueOnce(fresh)
303+
304+
await borrow(pool, params('s1:w1:u1', create))
305+
vi.setSystemTime(Date.now() + 61 * 1000)
306+
307+
const pA = pool.acquire(params('s1:w1:u1', create))
308+
const pB = pool.acquire(params('s1:w1:u1', create))
309+
while (releasePing.length < 2) await Promise.resolve()
310+
311+
// A's ping fails → A retires stale, rebuilds `fresh`, pools it, clears pending.
312+
releasePing[0](false)
313+
for (let i = 0; i < 20; i++) await Promise.resolve()
314+
// B's ping fails → B must reuse the pooled `fresh`, not create a third client.
315+
releasePing[1](false)
316+
317+
const [la, lb] = await Promise.all([pA, pB])
318+
expect(create).toHaveBeenCalledTimes(2)
319+
expect(la.client).toBe(fresh)
320+
expect(lb.client).toBe(fresh)
321+
await la.release()
322+
await lb.release()
323+
})
324+
287325
it('bypasses the pool once disposed (connects without caching)', async () => {
288326
const client = makeFakeClient()
289327
const create = vi.fn(async () => client)

apps/sim/lib/mcp/connection-pool.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ export class McpConnectionPool {
7979
return { client, release: () => client.disconnect().catch(() => {}) }
8080
}
8181
const entry = await this.resolveEntry(params)
82+
// A concurrent evict/release/idle could have retired + started disconnecting
83+
// this entry during the resolve's `await` gap; `closing` (not `retired`, which
84+
// a usable one-shot also sets) means the socket is going away — get a fresh one.
85+
if (entry.closing) return this.acquire(params)
8286
entry.borrowers++
8387
entry.lastActivityAt = Date.now()
8488
return { client: entry.client, release: (poison) => this.release(entry, poison ?? false) }
@@ -92,6 +96,10 @@ export class McpConnectionPool {
9296
if (current) {
9397
const reusable = await this.tryReuse(current)
9498
if (reusable) return reusable
99+
// tryReuse awaited a ping and retired `current`; a concurrent acquire may
100+
// have pooled a replacement meanwhile, so re-resolve rather than blindly
101+
// creating (which would overwrite and leak that replacement).
102+
return this.resolveEntry(params)
95103
}
96104
return this.createEntry(params)
97105
}

0 commit comments

Comments
 (0)