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:
parent
6bc3251962
commit
8535ae0c2f
3 changed files with 612 additions and 10 deletions
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
|
|
|
|||
75
core/mcp/subscriptionAck.ts
Normal file
75
core/mcp/subscriptionAck.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client";
|
||||
|
||||
/**
|
||||
* Recognizing the "server closed the subscription without ever acknowledging
|
||||
* it" failure of a modern-era `subscriptions/listen` (#2097).
|
||||
*
|
||||
* A `subscriptions/listen` request is long-lived: the first message on the
|
||||
* stream MUST be `notifications/subscriptions/acknowledged`, and the JSON-RPC
|
||||
* `result` for the listen id is reserved as the *graceful-closure* marker
|
||||
* (2026-07-28 spec, Subscriptions → Graceful Closure). A server that answers the
|
||||
* listen request with a bare `result` and never acknowledges is therefore saying
|
||||
* "acknowledged and closed in the same breath": the SDK settles the subscription
|
||||
* with cause `"graceful"` and rejects the pending `listen()` promise.
|
||||
*
|
||||
* That is a *deterministic* condition — the same server answers the same way
|
||||
* every time — so it must not be retried, and it is not the same event as a
|
||||
* graceful shutdown of an established stream. Without the distinction the
|
||||
* Inspector re-listed eight times over roughly a minute and settled on a bare
|
||||
* "Stream ended" badge that said nothing about why (#2063).
|
||||
*/
|
||||
|
||||
/**
|
||||
* The distinguishing fragment of the SDK's rejection message for this case.
|
||||
*
|
||||
* The SDK does not give the never-acknowledged close a code of its own — it is
|
||||
* an `SdkError(ConnectionClosed)`, the same code a stream closed for any other
|
||||
* reason before acknowledgement carries — so the message is the only thing that
|
||||
* separates them. Matching it is deliberately narrow rather than clever: the
|
||||
* alternative is treating *every* pre-ack `ConnectionClosed` as deterministic
|
||||
* and refusing to retry a genuinely transient drop.
|
||||
*
|
||||
* The live check on this string is the integration test in
|
||||
* `inspectorClient-subscriptions-era.test.ts`, which drives a real server that
|
||||
* answers `subscriptions/listen` with a bare result; an SDK that rephrases the
|
||||
* message fails there rather than silently reverting to the eight-retry
|
||||
* behavior.
|
||||
*/
|
||||
const NEVER_ACKNOWLEDGED_SDK_MESSAGE =
|
||||
"closed the subscription gracefully before acknowledging";
|
||||
|
||||
/**
|
||||
* The explanation shown to the user, in the UI and in the client log. Lives in
|
||||
* `core/` so every client says the same thing about the same wire event.
|
||||
*/
|
||||
export const NEVER_ACKNOWLEDGED_SUBSCRIPTION_MESSAGE =
|
||||
"The server closed the subscription without acknowledging it. A subscriptions/listen request must first be answered with a notifications/subscriptions/acknowledged notification; a JSON-RPC result for the listen id means graceful closure. Not retrying — the server would answer the same way again.";
|
||||
|
||||
/**
|
||||
* True when a rejected `listen()` is the never-acknowledged close above, rather
|
||||
* than a transport failure, a timeout, or a pre-ack error response — each of
|
||||
* which may well succeed on a retry.
|
||||
*/
|
||||
export function isNeverAcknowledgedSubscriptionClose(
|
||||
error: unknown,
|
||||
): error is SdkError {
|
||||
return (
|
||||
SdkError.isInstance(error) &&
|
||||
error.code === SdkErrorCode.ConnectionClosed &&
|
||||
error.message.includes(NEVER_ACKNOWLEDGED_SDK_MESSAGE)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The sentence to report for a failed subscribe/unsubscribe. For the
|
||||
* never-acknowledged close that is the explanation above rather than the SDK's
|
||||
* own wording, which names the wire event without saying what the Inspector did
|
||||
* about it (nothing further) — the omission #2097 is about. Every other failure
|
||||
* keeps its own message, which is the diagnostic.
|
||||
*/
|
||||
export function subscriptionFailureMessage(error: unknown): string {
|
||||
if (isNeverAcknowledgedSubscriptionClose(error)) {
|
||||
return NEVER_ACKNOWLEDGED_SUBSCRIPTION_MESSAGE;
|
||||
}
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
|
@ -490,12 +490,19 @@ export interface InspectorResourceSubscription {
|
|||
* - `"ended"` — the server tore the stream down deliberately (`closed` resolved
|
||||
* `"graceful"`, e.g. on shutdown) or reconnection was abandoned; no automatic
|
||||
* re-listen.
|
||||
* - `"never-acknowledged"` — the server answered the `listen()` request itself
|
||||
* with a JSON-RPC `result` (the spec's graceful-closure marker) without ever
|
||||
* sending `notifications/subscriptions/acknowledged`, so the stream was closed
|
||||
* in the same breath it was opened (#2097). Distinct from `"ended"` because
|
||||
* the condition is deterministic rather than a shutdown of an established
|
||||
* stream: the Inspector does not retry it, and the UI says why.
|
||||
*/
|
||||
export type ResourceSubscriptionStreamStatus =
|
||||
| "connecting"
|
||||
| "acknowledged"
|
||||
| "reconnecting"
|
||||
| "ended";
|
||||
| "ended"
|
||||
| "never-acknowledged";
|
||||
|
||||
/**
|
||||
* State of the modern-era resource-subscription listen stream (#1630).
|
||||
|
|
|
|||
Loading…
Reference in a new issue