fix: say why a never-acknowledged subscriptions/listen ended, instead of retrying it eight times - #2176
Conversation
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>
There was a problem hiding this comment.
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.
…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>
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: 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.
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.
|
Closes #2097
A modern-era
subscriptions/listenis a long-lived request: the first message on its stream must benotifications/subscriptions/acknowledged, and the JSON-RPCresultfor the listen id is reserved as the graceful-closure marker. A server that answers the listen request with a bareresultis therefore saying "acknowledged and closed in the same breath" — the SDK settles the subscriptiongracefuland rejects the pendinglisten().The Inspector treated that rejection as an ordinary drop: eight
subscriptions/listenrequests with increasing ids over roughly a minute, the badge flickeringReconnecting…, 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.tsowns the predicate. The SDK gives this close no code of its own — it is the sameSdkError(ConnectionClosed)that any pre-ack close carries — so the predicate reads the message, deliberately: the alternative is treating every pre-ackConnectionClosedas 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".endedcovers 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.jsonanswers everysubscriptions/listenwith a bare result — noresultTypediscriminator, 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 — Subscriberesource_1, then Subscriberesource_2.Before — the badge reads
RECONNECTING…, five-plussubscriptions/listenretries stack up in the Protocol tab, and nothing anywhere says why:After — one further
subscriptions/listen, an orangeNOT ACKNOWLEDGEDbadge, and the reason in the panel:Tests
inspectorClient-subscriptions-era.test.ts) — the subscribe fails with the explanation after exactly one listen; the status and itsactivebadge; 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.
Error), and the failure-message mapping.NeverAcknowledgedstory.npm run local:gatepasses.