mcp: retire only the subscriptions a listen registered - #1275
Conversation
|
Hi @cgair, thanks for the diagnostics. So the only bug is the change-registry defer — it deletes all three maps unconditionally, including ones this listen never wrote: Scoping it to what this listen actually registered, mirroring the registration block right above it: |
subscriptionsListen registers a session in the change registries according to what that listen asked for, but its deferred cleanup deletes the session from all three unconditionally, including registries this listen never wrote. On the persistent-session transports -- stdio and in-memory -- a session can hold several listens at once, and there the first one to unwind takes the others' registrations with it. Reachable through the SDK's own client: Connect opens the auto-listen for list-changed notifications, Subscribe opens a second listen for a resource, and Unsubscribe retires that second listen. The auto-listen is still open afterwards but never receives tools/list_changed again. Guard each delete with the condition that guarded the registration.
db42988 to
4a7e4f1
Compare
|
Thanks — the transport point is right and I had not established it before claiming a general impact. I checked it: a stateful On the size of the change I'll follow you. Pushed, in the form you wrote — the three guards mirroring the registration block, nothing else. One correction first, because it is why I did not stop at the teardown the first time. I applied exactly your patch and ran it against the three tests the earlier revision carried: The resource teardown is scoped per URI but not per listen. Two listens on one session subscribed to the same URI overwrite Neither is reachable from this SDK's client — What's left in the PR: your three guards, |
|
Thank you for the contribution! |
Narrowed after review — see the discussion below. The earlier revision changed the
registry keying; this one changes only the teardown.
The problem
subscriptionsListenregisters a session according to what the listen asked for:Its deferred cleanup does not:
It runs whether or not this listen ever wrote those registries. A session can
hold several listens at once, so the first one to unwind takes the others'
registrations with it, and those streams stay open while receiving nothing.
The scope is the persistent-session transports, stdio and in-memory. Over
streamable HTTP the registries cannot collide: a stateful handler refuses
SEP-2575 requests outright with
CodeUnsupportedProtocolVersion, and astateless one builds a temporary session per POST and closes it when the
request ends.
Reachable through this SDK's own client, on stdio:
Client.Connectopens the auto-listen for list-changed notifications.ClientSession.Subscribe(uri)opens a second listen, carrying only aresource subscription.
ClientSession.Unsubscribe(uri)retires that second listen.toolChangeSubscriptions[session], which belongs to theauto-listen.
tools/list_changedagain.How I pinned it down
A diagnostic run over the three states, reading the registries and
ServerSession.listenIDsdirectly:Line 2 rules out the second listen having overwritten the entry:
toolSubisstill 2, listen 3 never wrote that registry. Line 3 rules out the client
cancelling the wrong stream:
listenIDsstill holds listen 2, so theauto-listen is alive on the server, parked on
<-ctx.Done(). What changed isonly that its registration is gone, deleted by listen 3's teardown.
The change
Guard each delete with the condition that guarded the registration, so a listen
retires what it registered and nothing else.
Validation
Two tests, next to the existing
TestSubscriptionsListen_*ones and reusingtheir fixtures. Against
826e653without the change:The second passes before and after on purpose: it asserts that the only listen
on a session does still get retired, so that the guards cannot be tightened
into a registry leak. The eight pre-existing
TestSubscriptionsListen_*testspass in both runs, and all ten pass with the change.
Run on the same commit, before and after:
go test ./...andgo test -race ./...ok with no races,gofmt -l .,go vet ./...andstaticcheck v0.6.1clean. Alsogo test ./...on Go 1.25.0 and 1.26.8 tomatch the CI matrix, and
go generate ./internal/readme ./internal/docsproduces no diff. Environment: Intel Core i5-1038NG7, macOS 14.
Known limitations
Two cases are outside this change, both needing two listens on one session that
overlap:
allowed.ToolsListChangedis true for both, so the registration overwrites and this teardown still
retires the survivor's entry.
resourceSubscriptions[uri][session]overwrites the same way, and the deferred
unsubscribedeletes it.Neither is reachable from this SDK's client —
Subscribededups per URI andthere is no exported way to open a second list-changed listen — so both require
a non-Go client on stdio. I have tests for them and am happy to open a separate
issue if you want them tracked.
AI assistance was used for reading through the code, the diagnostic and
regression tests, and running the verification above. The root cause analysis
and the design decisions are mine, and I can explain every line without it.