|
9 | 9 | * ping-cached; reconnect is demand-driven (callers' retries re-acquire). |
10 | 10 | * |
11 | 11 | * Connections are ref-counted. Eviction *retires* an entry — removes it from the |
12 | | - * pool so no new borrower can take it — but the socket is closed only once the |
| 12 | + * pool so later acquires stop reusing it — but the socket is closed only once the |
13 | 13 | * last in-flight borrower releases, so a sibling failure, idle sweep, or config |
14 | 14 | * change never tears down a connection mid-request. Retirement is identity-checked, |
15 | 15 | * so a stale `onClose` can't disconnect a replacement stored under the same key. |
@@ -78,30 +78,29 @@ export class McpConnectionPool { |
78 | 78 | const client = await params.create() |
79 | 79 | return { client, release: () => client.disconnect().catch(() => {}) } |
80 | 80 | } |
81 | | - 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) |
| 81 | + // A concurrent evict/release/idle could have started disconnecting the resolved |
| 82 | + // entry during an `await` gap; `closing` (not `retired`, which a usable one-shot |
| 83 | + // also sets) means the socket is going away, so resolve again. |
| 84 | + let entry = await this.resolveEntry(params) |
| 85 | + while (entry.closing) entry = await this.resolveEntry(params) |
86 | 86 | entry.borrowers++ |
87 | 87 | entry.lastActivityAt = Date.now() |
88 | 88 | return { client: entry.client, release: (poison) => this.release(entry, poison ?? false) } |
89 | 89 | } |
90 | 90 |
|
91 | 91 | private async resolveEntry(params: AcquireParams): Promise<PoolEntry> { |
92 | | - const pending = this.pending.get(params.key) |
93 | | - if (pending) return pending |
| 92 | + while (true) { |
| 93 | + const pending = this.pending.get(params.key) |
| 94 | + if (pending) return pending |
| 95 | + |
| 96 | + const current = this.entries.get(params.key) |
| 97 | + if (!current) return this.createEntry(params) |
94 | 98 |
|
95 | | - const current = this.entries.get(params.key) |
96 | | - if (current) { |
97 | 99 | const reusable = await this.tryReuse(current) |
98 | 100 | 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) |
| 101 | + // tryReuse awaited a ping and retired `current`; loop to re-check pending/entries — |
| 102 | + // a concurrent acquire may have pooled a replacement we must reuse, not overwrite. |
103 | 103 | } |
104 | | - return this.createEntry(params) |
105 | 104 | } |
106 | 105 |
|
107 | 106 | /** Return `entry` if in-age, connected, and live; else retire it and return null. */ |
@@ -258,6 +257,7 @@ export class McpConnectionPool { |
258 | 257 | for (const entry of entries) entry.retired = true |
259 | 258 | this.entries.clear() |
260 | 259 | this.pending.clear() |
| 260 | + this.serverGenerations.clear() |
261 | 261 | void Promise.allSettled(entries.map((entry) => entry.client.disconnect())) |
262 | 262 | } |
263 | 263 | } |
|
0 commit comments