fix(core): say why a never-acknowledged subscriptions/listen ended

A modern-era `subscriptions/listen` is a long-lived request: the first
message on its stream must be `notifications/subscriptions/acknowledged`,
and the JSON-RPC `result` for the listen id is reserved as the
graceful-closure marker. A server that answers the listen request with a
bare `result` is saying "acknowledged and closed in the same breath" — the
SDK settles the subscription `graceful` and rejects the pending `listen()`.

The Inspector read that rejection as an ordinary drop: eight re-lists over
roughly a minute, the badge flickering `Reconnecting…`, and a final bare
`Stream ended` that said nothing about why. Accepting the message was
never wrong — it is a well-formed graceful-closure response. The silence
was.

- `core/mcp/subscriptionAck.ts` owns the predicate. The SDK gives this
  close no code of its own — it is the same `SdkError(ConnectionClosed)`
  any pre-ack close carries — so the predicate reads the message, since
  the alternative is refusing to retry a genuinely transient drop.
- The condition is deterministic, so the stream ends on the first
  occurrence instead of spending the whole backoff run. Both routes are
  covered: the one a subscribe/unsubscribe reaches, and the one a
  reconnect reaches when an acknowledged stream drops and the server has
  since started refusing.
- A `"never-acknowledged"` status of its own, not `"ended"` — `ended`
  means an expected close. It surfaces as the thrown error's message, an
  orange "Not acknowledged" badge, and a notice in the panel.
- `subscriptions-never-acknowledged-http.json` reproduces it. It
  acknowledges the first listen that subscribes to a resource and refuses
  every one after, which is what makes the badge reachable by hand.

Closes #2097

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-27 20:49:51 -04:00
commit 8535ae0c2f

View file

@ -46,6 +46,11 @@ import {
resolveModernLogLevel,
} from "./types.js";
import { cleanRoots } from "./serverList.js";
import {
isNeverAcknowledgedSubscriptionClose,
subscriptionFailureMessage,
NEVER_ACKNOWLEDGED_SUBSCRIPTION_MESSAGE,
} from "./subscriptionAck.js";
// Fallback client identity, used ONLY when a caller doesn't pass
// `clientIdentity`. Real clients supply their own: the Node clients (CLI, TUI)
// read the single-source version from the root package.json via
@ -608,6 +613,13 @@ export class InspectorClient extends InspectorClientEventTarget {
// pending re-listen timer.
private modernReconnectAttempts = 0;
private modernReconnectTimer: ReturnType<typeof setTimeout> | undefined;
// Set when the last `listen()` was rejected because the server answered it
// with a bare graceful-closure result instead of acknowledging (#2097). That
// condition is deterministic, so the reconnect machinery must not treat it as
// a drop to retry; the flag is what carries the distinction from the rejection
// site to the two failure handlers. Cleared by any user-initiated refresh (a
// subscribe/unsubscribe is a fresh attempt, and the server may have changed).
private modernNeverAcknowledged = false;
// Task ids the user explicitly cancelled. A cancel makes the in-flight
// `callToolStream` reject with a generic -32603 error, which the stream's
// error path would otherwise report as a *failed* task — flashing "failed"
@ -1786,6 +1798,7 @@ export class InspectorClient extends InspectorClientEventTarget {
this.modernListenGeneration++;
this.clearModernReconnectTimer();
this.modernReconnectAttempts = 0;
this.modernNeverAcknowledged = false;
this.modernSubscription = null;
// Announced only once both have moved: a listener that ran between them
// would see an empty set with an `active` stream — the combination this
@ -5968,6 +5981,7 @@ export class InspectorClient extends InspectorClientEventTarget {
if (!fromReconnect) {
this.clearModernReconnectTimer();
this.modernReconnectAttempts = 0;
this.modernNeverAcknowledged = false;
}
const generation = ++this.modernListenGeneration;
@ -5984,10 +5998,31 @@ export class InspectorClient extends InspectorClientEventTarget {
return;
}
const subscription = await this.client.listen(
this.buildSubscriptionFilter(),
this.getRequestOptions(),
);
let subscription: McpSubscription;
try {
subscription = await this.client.listen(
this.buildSubscriptionFilter(),
this.getRequestOptions(),
);
} catch (error) {
// Record the one rejection that must not be retried, so the failure
// handlers can tell it from a drop (#2097). Recorded rather than acted on
// here because *which* state to write depends on whether this caller still
// owns the stream, which only they know — see
// `reconcileModernStreamStateAfterFailedRefresh`.
//
// Gated on the same generation test the callers use: a newer refresh has
// already cleared the flag for its own attempt, and a stale caller writing
// to it afterwards would make that attempt's *unrelated* failure look
// deterministic and end a stream that deserved a retry.
if (
generation === this.modernListenGeneration &&
isNeverAcknowledgedSubscriptionClose(error)
) {
this.modernNeverAcknowledged = true;
}
throw error;
}
// A newer refresh superseded us while awaiting the ack — discard this one.
if (generation !== this.modernListenGeneration) {
@ -6101,9 +6136,39 @@ export class InspectorClient extends InspectorClientEventTarget {
this.setModernStreamState(INACTIVE_SUBSCRIPTION_STREAM_STATE);
return;
}
// The one failure a retry cannot fix (#2097) — end the stream here rather
// than spending the whole backoff run on a server that will answer the same
// way every time.
if (this.modernNeverAcknowledged) {
this.endModernStreamNeverAcknowledged();
return;
}
this.scheduleModernReconnect();
}
/**
* Settle the stream on the never-acknowledged close (#2097): a status of its
* own so the UI can say what happened, no reconnect, and a log line carrying
* the same sentence the UI shows.
*
* The state is *not* `"ended"` — that badge covers the two expected closes (a
* server shutting an established stream down, and reconnection abandoned after
* repeated failures), and reading this case as either of them is exactly the
* silence #2063 reported.
*/
private endModernStreamNeverAcknowledged(): void {
this.clearModernReconnectTimer();
this.logger.warn(
{ message: NEVER_ACKNOWLEDGED_SUBSCRIPTION_MESSAGE },
"subscriptions/listen closed without an acknowledgement",
);
this.setModernStreamState({
active: this.modernStreamActive(),
status: "never-acknowledged",
honoredUris: [],
});
}
/**
* Schedule a reconnect re-listen after the current backoff delay (#1630).
* `modernReconnectAttempts` reflects the number of *consecutive failed*
@ -6140,6 +6205,18 @@ export class InspectorClient extends InspectorClientEventTarget {
* stream ended (re-subscribing resets the run and tries again).
*/
private onModernReconnectFailed(): void {
// A reconnect that lost to the never-acknowledged close (#2097) ends the run
// immediately: the remaining attempts would each buy the same answer. This
// is reachable when a stream that *had* been acknowledged dropped and the
// server has since started refusing to acknowledge.
if (
this.modernNeverAcknowledged &&
!isTerminalStatus(this.status) &&
this.wantsModernStream()
) {
this.endModernStreamNeverAcknowledged();
return;
}
this.modernReconnectAttempts += 1;
if (
this.modernReconnectAttempts > MODERN_RECONNECT_MAX_ATTEMPTS ||
@ -6233,7 +6310,7 @@ export class InspectorClient extends InspectorClientEventTarget {
this.dispatchSubscriptionsChange();
} catch (error) {
throw new Error(
`Failed to subscribe to resource: ${error instanceof Error ? error.message : String(error)}`,
`Failed to subscribe to resource: ${subscriptionFailureMessage(error)}`,
{ cause: error },
);
}
@ -6294,7 +6371,7 @@ export class InspectorClient extends InspectorClientEventTarget {
}
} catch (error) {
throw new Error(
`Failed to unsubscribe from resource: ${error instanceof Error ? error.message : String(error)}`,
`Failed to unsubscribe from resource: ${subscriptionFailureMessage(error)}`,
{ cause: error },
);
}