fix: prevent partial tool leaks and non-blocking dispose

- syncTools: on paginated listTools failure, unregister any tools already
  registered in the current sync before rethrowing (prevents orphans)
- Effect disposer: call client.close() directly without awaiting startup
  completion — aborts a hanging connect promptly on HMR/dispose
This commit is contained in:
lintianle
2026-07-08 13:23:46 +08:00
parent fdd7d1a91c
commit 0c8f2f7daf
2 changed files with 34 additions and 25 deletions

View File

@@ -140,8 +140,9 @@ export function apply(ctx: Context, config: Config): void {
} }
// Connect and set up tools. Errors during connect are logged, not thrown // Connect and set up tools. Errors during connect are logged, not thrown
// (the plugin simply has no tools registered). // (the plugin simply has no tools registered). The IIFE is fire-and-forget;
const ready = (async () => { // disposal closes the client directly without waiting for startup.
void (async () => {
await client.connect(transport) await client.connect(transport)
await resync() await resync()
@@ -156,9 +157,10 @@ export function apply(ctx: Context, config: Config): void {
ctx.logger.error(`mcp-client: failed to connect: ${String(error)}`) ctx.logger.error(`mcp-client: failed to connect: ${String(error)}`)
}) })
// Fiber disposal: close the client (triggers onclose → tools unregistered). // Fiber disposal: close the client immediately (triggers onclose → tools
// unregistered). No `await ready` — if connect is still pending, close aborts
// it promptly rather than blocking until the SDK request times out.
ctx.effect(() => async () => { ctx.effect(() => async () => {
await ready try { await client.close() } catch { /* transport already gone or never connected */ }
try { await client.close() } catch { /* transport already gone */ }
}, 'mcp-client.connection') }, 'mcp-client.connection')
} }

View File

@@ -43,6 +43,7 @@ export async function syncTools(
const disposers: ToolDisposers = new Map() const disposers: ToolDisposers = new Map()
try {
let cursor: string | undefined let cursor: string | undefined
do { do {
const response = await client.listTools(cursor ? { cursor } : undefined) const response = await client.listTools(cursor ? { cursor } : undefined)
@@ -64,6 +65,12 @@ export async function syncTools(
} }
cursor = response.nextCursor cursor = response.nextCursor
} while (cursor) } while (cursor)
} catch (error: unknown) {
// Partial failure (e.g. a later page of listTools failed): unregister any
// tools already registered in this sync to avoid orphaning them.
for (const dispose of disposers.values()) dispose()
throw error
}
return disposers return disposers
} }