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
|
|
@ -24,3 +24,12 @@ export const Reconnecting: Story = {
|
|||
export const Ended: Story = {
|
||||
args: { status: "ended" },
|
||||
};
|
||||
|
||||
/**
|
||||
* The server answered `subscriptions/listen` with a bare JSON-RPC result — the
|
||||
* graceful-closure marker — without ever acknowledging it (#2097). Distinct from
|
||||
* `Ended` because the Inspector does not retry it.
|
||||
*/
|
||||
export const NeverAcknowledged: Story = {
|
||||
args: { status: "never-acknowledged" },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
|
|||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import { SubscriptionStreamBadge } from "./SubscriptionStreamBadge";
|
||||
import { subscriptionStreamPresentation } from "./subscriptionStreamUtils";
|
||||
import { NEVER_ACKNOWLEDGED_SUBSCRIPTION_MESSAGE } from "@inspector/core/mcp/subscriptionAck.js";
|
||||
|
||||
describe("subscriptionStreamPresentation", () => {
|
||||
it("maps each status to a color and label", () => {
|
||||
|
|
@ -21,6 +22,18 @@ describe("subscriptionStreamPresentation", () => {
|
|||
color: "gray",
|
||||
label: "Stream ended",
|
||||
});
|
||||
expect(subscriptionStreamPresentation("never-acknowledged")).toMatchObject({
|
||||
color: "orange",
|
||||
label: "Not acknowledged",
|
||||
});
|
||||
});
|
||||
|
||||
// The never-acknowledged tooltip must carry the *reason*, not just the generic
|
||||
// stream explanation — that silence is what #2097 is about.
|
||||
it("says why a never-acknowledged stream closed", () => {
|
||||
expect(
|
||||
subscriptionStreamPresentation("never-acknowledged").tooltip,
|
||||
).toContain(NEVER_ACKNOWLEDGED_SUBSCRIPTION_MESSAGE);
|
||||
});
|
||||
|
||||
it("explains the listen stream in every tooltip", () => {
|
||||
|
|
@ -29,6 +42,7 @@ describe("subscriptionStreamPresentation", () => {
|
|||
"acknowledged",
|
||||
"reconnecting",
|
||||
"ended",
|
||||
"never-acknowledged",
|
||||
] as const) {
|
||||
expect(subscriptionStreamPresentation(status).tooltip).toContain(
|
||||
"subscriptions/listen stream",
|
||||
|
|
@ -52,4 +66,9 @@ describe("SubscriptionStreamBadge", () => {
|
|||
renderWithMantine(<SubscriptionStreamBadge status="ended" />);
|
||||
expect(screen.getByText("Stream ended")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a labelled never-acknowledged badge", () => {
|
||||
renderWithMantine(<SubscriptionStreamBadge status="never-acknowledged" />);
|
||||
expect(screen.getByText("Not acknowledged")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { ResourceSubscriptionStreamStatus } from "../../../../../../core/mcp/types.js";
|
||||
import { NEVER_ACKNOWLEDGED_SUBSCRIPTION_MESSAGE } from "../../../../../../core/mcp/subscriptionAck.js";
|
||||
|
||||
export interface StreamPresentation {
|
||||
/** Mantine palette color name conveying the status. */
|
||||
|
|
@ -39,6 +40,11 @@ const PRESENTATION: Record<
|
|||
label: "Stream ended",
|
||||
tooltip: `${STREAM_INTRO} The stream is closed and won't reconnect on its own — either the server ended it (for example, on shutdown) or reconnection was abandoned after repeated failures. Re-subscribe to try again.`,
|
||||
},
|
||||
"never-acknowledged": {
|
||||
color: "orange",
|
||||
label: "Not acknowledged",
|
||||
tooltip: `${STREAM_INTRO} ${NEVER_ACKNOWLEDGED_SUBSCRIPTION_MESSAGE} Re-subscribe to try again.`,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in a new issue