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
3 changed files with 612 additions and 10 deletions

View file

@ -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" },
};

View file

@ -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();
});
});

View file

@ -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.`,
},
};
/**