test(mcp-client): pin give-up cleanup ordering

The failure-cap path already appends tool disposal to syncChain, but the existing tests only covered give-up after settled discovery. They could not detect a future change that disposed the old set immediately and then allowed a blocked re-sync to publish a new leaked generation.

Hold a list_changed fetch open, drive the reconnect budget to exhaustion, then release a different tool list. The test proves final cleanup runs after that in-flight swap and removes both the previous and late-published tool names while creating no attempt beyond the configured cap.
This commit is contained in:
Tianyi Cui
2026-08-11 00:01:31 +08:00
parent 442f0ef839
commit bdd0e6a709
@@ -195,6 +195,32 @@ describe('reconnect supervisor', () => {
expect(mockConnect).toHaveBeenCalledTimes(3)
})
it('gives up behind an in-flight re-sync and removes the generation it publishes', async () => {
const { errors } = captureLogs(ctx)
await apply(ctx, stdioConfig({ initialDelayMs: 2, maxDelayMs: 8, maxAttempts: 1 }))
await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() })
const gate: PromiseWithResolvers<unknown> = Promise.withResolvers()
mockListTools.mockImplementation(() => gate.promise)
const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise<void>
const resync = handler()
await vi.waitFor(() => { expect(mockListTools).toHaveBeenCalledTimes(2) })
mockConnect.mockRejectedValue(new Error('server gone'))
instances[0]!.onclose?.()
await vi.waitFor(() => {
expect(errors.some(line => line.includes('giving up after 1 consecutive failed reconnect attempts'))).toBe(true)
})
gate.resolve(listing('late'))
await resync
await vi.waitFor(() => {
expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined()
expect(ctx.tools.get('mcp__srv__late')).toBeUndefined()
})
expect(mockConnect).toHaveBeenCalledTimes(2)
})
it('does not start a replacement until a failed generation reports that it closed', async () => {
mockConnect.mockRejectedValueOnce(new Error('initialize failed'))
// Model the SDK's fire-and-forget close after initialize fails: the