fix(mcp-client): distinguish failure from loss
The reconnect supervisor used connection lost for every transition into backoff, including an initial startup attempt that never established a connection and later retry attempts that also failed. That wording implied a previously healthy generation and obscured whether any tools had ever been registered. Capture whether the generation had reached the established state before scheduling recovery. Established disconnects retain connection lost/reconnecting; startup and retry failures now report connection failed/retrying. The reconnect-disabled diagnostic uses the same distinction while preserving its concrete manual-recovery guidance. Unit assertions cover established loss, initial failure, retry failure, and both reconnect-disabled branches. Focused package coverage remains 100%, and the bilingual Agent Note records the observable state vocabulary.
This commit is contained in:
@@ -190,11 +190,12 @@ export function startConnection(ctx: Context, config: Config, policy: ResolvedRe
|
||||
}
|
||||
|
||||
function scheduleReconnect(): void {
|
||||
const lostEstablishedConnection = connectedAt !== undefined
|
||||
if (!policy.enabled) {
|
||||
const detail = connectedAt !== undefined
|
||||
? 'registered tools will fail until an HMR reload or Host restart'
|
||||
: 'no tools were registered; reload the plugin or restart the Host to connect'
|
||||
ctx.logger.error(`${label}: connection lost and reconnect is disabled — ${detail}`)
|
||||
const message = lostEstablishedConnection
|
||||
? 'connection lost and reconnect is disabled — registered tools will fail until an HMR reload or Host restart'
|
||||
: 'connection failed and reconnect is disabled — no tools were registered; reload the plugin or restart the Host to connect'
|
||||
ctx.logger.error(`${label}: ${message}`)
|
||||
return
|
||||
}
|
||||
// A connection that stayed up past the stability window (= maxDelayMs, the
|
||||
@@ -213,7 +214,8 @@ export function startConnection(ctx: Context, config: Config, policy: ResolvedRe
|
||||
return
|
||||
}
|
||||
const delayMs = Math.min(policy.maxDelayMs, policy.initialDelayMs * 2 ** (failedAttempts - 1))
|
||||
ctx.logger.warn(`${label}: connection lost; reconnecting in ${delayMs}ms (attempt ${failedAttempts}/${policy.maxAttempts})`)
|
||||
const action = lostEstablishedConnection ? 'connection lost; reconnecting' : 'connection failed; retrying'
|
||||
ctx.logger.warn(`${label}: ${action} in ${delayMs}ms (attempt ${failedAttempts}/${policy.maxAttempts})`)
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = undefined
|
||||
settling = connectGeneration(false)
|
||||
|
||||
@@ -191,6 +191,7 @@ describe('reconnect supervisor', () => {
|
||||
// Initial connect + exactly maxAttempts reconnect attempts.
|
||||
expect(mockConnect).toHaveBeenCalledTimes(3)
|
||||
expect(warns.some(line => line.includes('connection attempt failed: Error: server gone'))).toBe(true)
|
||||
expect(warns.some(line => line.includes('connection failed; retrying in 4ms (attempt 2/2)'))).toBe(true)
|
||||
await sleep(30)
|
||||
expect(mockConnect).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
@@ -222,6 +223,7 @@ describe('reconnect supervisor', () => {
|
||||
})
|
||||
|
||||
it('does not start a replacement until a failed generation reports that it closed', async () => {
|
||||
const { warns } = captureLogs(ctx)
|
||||
mockConnect.mockRejectedValueOnce(new Error('initialize failed'))
|
||||
// Model the SDK's fire-and-forget close after initialize fails: the
|
||||
// harness's second close call returns, but the child has not exited yet.
|
||||
@@ -235,6 +237,7 @@ describe('reconnect supervisor', () => {
|
||||
instances[0]!.onclose?.()
|
||||
await applying
|
||||
await vi.waitFor(() => { expect(instances).toHaveLength(2) })
|
||||
expect(warns.some(line => line.includes('connection failed; retrying in 2ms (attempt 1/2)'))).toBe(true)
|
||||
})
|
||||
|
||||
it('stops reconnecting when a failed generation never reports that it closed', async () => {
|
||||
@@ -329,7 +332,7 @@ describe('reconnect supervisor', () => {
|
||||
expect(mockConnect).toHaveBeenCalledTimes(1)
|
||||
// Pre-reconnect contract: the generation stays registered until disposal.
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
|
||||
expect(errors.some(line => line.includes('reconnect is disabled'))).toBe(true)
|
||||
expect(errors.some(line => line.includes('connection lost and reconnect is disabled'))).toBe(true)
|
||||
})
|
||||
it('reconnect disabled after a failed initial connect reports no registered tools', async () => {
|
||||
const { errors } = captureLogs(ctx)
|
||||
@@ -337,6 +340,7 @@ describe('reconnect supervisor', () => {
|
||||
await apply(ctx, stdioConfig({ enabled: false }))
|
||||
await sleep(30)
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined()
|
||||
expect(errors.some(line => line.includes('connection failed and reconnect is disabled'))).toBe(true)
|
||||
expect(errors.some(line => line.includes('no tools were registered'))).toBe(true)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user