Skip to content

fix: say why a never-acknowledged subscriptions/listen ended, instead of retrying it eight times - #2176

Merged
cliffhall merged 3 commits into
v2/mainfrom
v2/fix/2097-never-acknowledged-subscription
Aug 28, 2026
Merged

fix: say why a never-acknowledged subscriptions/listen ended, instead of retrying it eight times#2176
cliffhall merged 3 commits into
v2/mainfrom
v2/fix/2097-never-acknowledged-subscription

Conversation

@cliffhall

Copy link
Copy Markdown
Member

Closes #2097

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 therefore saying "acknowledged and closed in the same breath" — the SDK settles the subscription graceful and rejects the pending listen().

The Inspector treated that rejection as an ordinary drop: eight subscriptions/listen requests with increasing ids over roughly a minute, the badge flickering Reconnecting…, and a final bare Stream ended that said nothing about why. That is the report in #2063, where it read as the Inspector "accepting" an invalid response. The Inspector was not wrong to accept the message — it is a well-formed graceful-closure response. The defect was the silence.

What changed

Distinguish the case. core/mcp/subscriptionAck.ts owns the predicate. The SDK gives this close no code of its own — it is the same SdkError(ConnectionClosed) that any pre-ack close carries — so the predicate reads the message, deliberately: the alternative is treating every pre-ack ConnectionClosed as deterministic and refusing to retry a genuinely transient drop.

Don't retry it. The condition is deterministic, so the stream ends on the first occurrence instead of spending the whole eight-attempt backoff run on a server that will answer the same way each time. Both failure routes are covered — the one a subscribe/unsubscribe reaches, and the one a reconnect reaches when a stream that had been acknowledged drops and the server has since started refusing.

Say what happened. A "never-acknowledged" stream status of its own, not "ended". ended covers the two expected closes (a server tearing an established stream down, reconnection abandoned after repeated failures), and reading a deterministic conformance failure as either of them is the silence itself. It surfaces three ways: the failed subscribe throws with the explanation rather than the SDK's wording, the Subscriptions header badge reads an orange Not acknowledged, and the panel carries the sentence as a notice.

A server to reproduce it against. test-servers/configs/subscriptions-never-acknowledged-http.json answers every subscriptions/listen with a bare result — no resultType discriminator, matching the reporter's payload rather than the spec's example. A conformant server cannot produce this shape, which is the point: there was no way to reach the path from one.

Proof

Same server (subscriptions-never-acknowledged-http.json), same two clicks — Subscribe resource_1, then Subscribe resource_2.

Before — the badge reads RECONNECTING…, five-plus subscriptions/listen retries stack up in the Protocol tab, and nothing anywhere says why:

before: RECONNECTING badge and a stack of subscriptions/listen retries

After — one further subscriptions/listen, an orange NOT ACKNOWLEDGED badge, and the reason in the panel:

after: NOT ACKNOWLEDGED badge, one listen, and the explanation in the panel

Tests

  • Integration, against the real never-acknowledging server (inspectorClient-subscriptions-era.test.ts) — the subscribe fails with the explanation after exactly one listen; the status and its active badge; a reconnect run that ends at the first such answer instead of counting toward the cap; and the flag resetting so a re-subscribe genuinely retries.
    These deliberately drive a real server rather than a stubbed rejection: the predicate reads the SDK's message, so an SDK that rephrases it fails here instead of silently reverting to the retry loop.
  • Unit — the predicate against its three near-misses (same code/different message, same message/different code, a plain Error), and the failure-message mapping.
  • Web — the badge presentation and the panel notice, plus a NeverAcknowledged story.

npm run local:gate passes.

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>
@cliffhall cliffhall added the v2 Issues and PRs for v2 label Aug 28, 2026
@cliffhall
cliffhall requested a balanced review from Copilot August 28, 2026 01:00

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds explicit handling for modern subscription streams closed before acknowledgement, preventing futile retries and surfacing the cause to users.

Changes:

  • Detects never-acknowledged streams and stops reconnection.
  • Adds web status, notice, documentation, and a reproduction server.
  • Adds unit and integration coverage for core behavior.

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
test-servers/src/test-server-http.ts Adds the failure injector.
test-servers/src/load-config.ts Extends test-server configuration.
test-servers/src/composable-test-server.ts Documents the new server option.
test-servers/configs/subscriptions-never-acknowledged-http.json Adds a reproduction fixture.
README.md Documents the fixture and workflow.
core/mcp/types.ts Adds the new stream status.
core/mcp/subscriptionAck.ts Detects and explains the SDK rejection.
core/mcp/inspectorClient.ts Stops retries and updates stream state.
clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts Tests client behavior against a real server.
clients/web/src/test/core/mcp/subscriptionAck.test.ts Tests detection and messaging.
clients/web/src/components/groups/ResourceControls/ResourceControls.tsx Displays the explanatory notice.
clients/web/src/components/groups/ResourceControls/ResourceControls.test.tsx Tests notice rendering.
clients/web/src/components/elements/SubscriptionStreamBadge/subscriptionStreamUtils.ts Defines badge presentation.
clients/web/src/components/elements/SubscriptionStreamBadge/SubscriptionStreamBadge.test.tsx Tests badge behavior.
clients/web/src/components/elements/SubscriptionStreamBadge/SubscriptionStreamBadge.stories.tsx Adds a status story.
AGENTS.md Records the new core module and behavior.
Suppressed comments (1)

README.md:475

  • “The first listen” is inaccurate for this fixture because the connect-time list-change-only listen is also acknowledged without consuming the allowance. Qualify this as the first resource-subscription listen to keep the explanation consistent with the injector.
The first listen is acknowledged **so the badge is reachable at all**: it is gated on a live subscription, which a server refusing from the outset never lets you hold. That variant — refuse every listen, the literal shape in the report — is what the integration tests drive; it is the same code path, minus the badge. And `never-acknowledged` is a status of its own rather than **Stream ended** on purpose: `ended` covers the two *expected* closes (a server tearing an established stream down, and reconnection abandoned after repeated failures), and reading a deterministic conformance failure as either of them is the silence the issue is about.

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment thread README.md Outdated
Comment thread test-servers/src/composable-test-server.ts Outdated
Comment thread test-servers/src/test-server-http.ts
…ion counting

Copilot review on #2176. All three findings were the same gap seen from
three angles: `"after-first"` counts *resource-subscription* listens, not
listens, and both the README and the option's own doc comment said
"the first listen".

That distinction is load-bearing rather than incidental — the Inspector
opens a listen at connect time whenever a list-change opt-in is live
(#1920), so counting listens would spend the allowance there and refuse
the user's very first Subscribe, leaving the documented repro describing
a trace nobody sees. It is also what a reader of the option would
otherwise get wrong.

- README: say "the first listen that subscribes to a resource" in both
  places, and name the connect-time listen as the reason.
- `ServerConfig.modern.neverAcknowledgeSubscriptions`: document the
  list-change exemption and contrast it with `true`.
- Integration test for the mode itself, with every list-change opt-in on
  so the connect-time listen really is opened: it must be acknowledged
  without consuming the allowance, the first resource subscription
  acknowledged, the next refused with the stream left `active`.

Closes #2097

Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall

Copy link
Copy Markdown
Member Author

Copilot review round 1 — all four addressed (9136ab6)

Mirrored here because inline replies go hidden once the fix is pushed and the threads go outdated.

All four findings (3 inline + 1 suppressed) were the same gap seen from different angles: "after-first" counts resource-subscription listens, not listens, and both the README and the option's own doc comment said "the first listen".

That distinction is load-bearing rather than incidental. The Inspector opens a listen at connect time whenever a list-change opt-in is live (#1920), so counting listens would spend the allowance there and refuse the user's very first Subscribe — which is precisely the trace a reader following the README would get, and not the one it described.

Finding Done
README.md:463 — say which listen is acknowledged Now "the first subscriptions/listen that subscribes to a resource", with the connect-time listen named as the reason for counting that way
README.md:475 (suppressed) — same wording, second spot Same fix applied
composable-test-server.ts:713 — public option doc doesn't match the middleware Documents the list-change exemption, why it exists, and contrasts it with true (refuses every listen unconditionally — the literal shape in the report)
test-server-http.ts:244"after-first" unexercised by tests New integration test, below

On the third: this was the most useful of the four — the exemption was the one behavior the manual repro depended on and nothing pinned it. acknowledges the first resource subscription, then refuses (after-first) connects with every list-change opt-in on so the connect-time listen really is opened, then asserts in order: that listen goes out and is acknowledged (allowance unspent), the first subscribeToResource resolves with status acknowledged, and the second rejects with the explanation while the stream stays active with the first URI still subscribed — the state the badge is gated on. Remove the exemption and it fails at the first subscribe.

npm run local:gate passes.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 16 out of 16 changed files in this pull request and generated no new comments.

@cliffhall
cliffhall merged commit 4d69dbf into v2/main Aug 28, 2026
4 checks passed
@cliffhall
cliffhall deleted the v2/fix/2097-never-acknowledged-subscription branch August 28, 2026 02:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2 Issues and PRs for v2

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Modern subscriptions: say "the server closed the subscription without acknowledging it" instead of silently retrying eight times

2 participants