Repository navigation
Conversation
The per-session broker topic was subscribed only by the instance that created the session, and unsubscribed when its last stream closed. A GET attaching later, or on another instance, never subscribed again, so messages were neither delivered nor recorded in history. With Redis the subscription also outlived expired sessions, and RedisMessageBroker unsubscribe never sent UNSUBSCRIBE, so channels leaked in Redis too. - mcpSendToSession now records the message in history and then publishes it with its event id. History no longer depends on any subscription and stays exactly-once with several instances. - An instance subscribes to a session topic exactly while it holds one of the session's streams. Changes per session are serialized, so a quick close-and-reconnect can't drop the new subscription. - A GET subscribes before replaying Last-Event-ID; live events that arrive during the replay are queued and deduplicated against it. - DELETE publishes a termination on the session topic, so the instance holding the stream ends it. - The stale-session sweep runs for every store and closes streams whose session is gone. Memory store cleanup() still runs as before. - RedisMessageBroker waits for SUBSCRIBE and releases the Redis channel on unsubscribe. Fixes #327 Fixes #328 Fixes #329 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LmhsFjk8W96X2PvmShmDm9
When a legacy session was DELETEd or expired, its resource subscriptions went away with it but the unsubscribe handler never ran. A handler that starts a watcher on subscribe leaked it. Each instance now keeps a per-URI holder count of the sessions it ran the subscribe handler for. When a session stops holding a URI without an explicit resources/unsubscribe, the handler runs once no other session on that instance holds the URI. That covers DELETE, a session termination published by another instance, and, through the sweep, expiry (including Redis key TTL expiry) and unsubscribes or DELETEs handled by another instance. An explicit resources/unsubscribe still calls the handler directly, since its result is the response. On session end there is no request to answer, so the unsubscribe handler context now types request and reply as optional (ResourceUnsubscribeContext). Fixes #337 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LmhsFjk8W96X2PvmShmDm9
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
On the legacy Streamable HTTP + SSE transport, the per-session topic
mcp/session/<id>/messagehad an inconsistent lifecycle:createSessionsubscribed, and the last stream closing unsubscribed. A later GET never subscribed again, so after the first stream closed, messages were neither delivered nor stored in history, whilemcpSendToSessionstill returnedtrue.initializenever subscribed, so it never received per-session messages. A DELETE on A did not close B's stream.initialize, and the stale-session sweep ran only forMemorySessionStore, so expired sessions leaked a broker subscription and alocalStreamsentry. On top of that,RedisMessageBroker.unsubscribeused mqemitter's baseremoveAllListeners, which never sendsUNSUBSCRIBE. Even explicit unsubscribes leaked the channel in Redis.Design
The obvious fix is "subscribe wherever a stream attaches and keep it for the session's lifetime". That doesn't work with several instances on its own. History was written by the subscriber when it had no local stream, so with A (creator) and B (stream holder) both subscribed, every message would be stored twice. So this PR moves history writes to the sender.
src/session-topic.ts).mcpSendToSession(and so elicitation and task status updates) writes the message to history, then publishes{ eventId, message }on the session topic. History is exactly-once and no longer depends on any subscription. If the store fails,mcpSendToSessionnow returnsfalseinstead oftrue.Last-Event-ID. Changes for one session are serialized (SessionTopicSubscriptions), so a quick close-and-reconnect can't have a lateUNSUBSCRIBEdrop the new subscription.RedisMessageBroker.subscribenow waits for the SUBSCRIBE ack.initialize, Redis backend leaks a broker subscription for every expired legacy session #329's leak can't happen in the first place.MemorySessionStore.cleanup()still runs as before.RedisMessageBroker.unsubscribetracks its listeners and removes them withremoveListener, so the Redis channel is actually released.src/resource-subscription-holders.ts). An instance records the sessions it ran the subscribe handler for, since any watcher lives there. When a session stops holding a URI without an explicitresources/unsubscribe, the unsubscribe handler runs once no other session on that instance holds the URI. That covers DELETE, a termination from another instance, and, through the sweep, expiry (including Redis TTL expiry) and DELETE/unsubscribe handled by another instance. An explicitresources/unsubscribestill calls the handler directly, because its return value is the response. In those calls there is no request, so the unsubscribe handler's context is nowResourceUnsubscribeContext, withrequest/replyoptional. That is a type-level change for TypeScript users who readcontext.requestin an unsubscribe handler.Raw messages published straight to the session topic (for example by the internal, unexported
TokenRefreshService) still work: an instance that holds a stream records and delivers them. If no stream is open, they are not stored.The commits are split: #327/#328/#329 first, then #337. I kept them in one PR because #337 relies on the end-of-session path (DELETE plus the all-store sweep) that the first commit introduces.
Possible conflict with #325
#325 (atomic SSE event ids) changes
SessionStore.addMessageandsendSSEToStreamsinsrc/routes/mcp.ts. Here, every history write goes through one helper,appendToHistoryinsrc/session-topic.ts, used both bysendSSEToStreamsand by the sender path. Once #325 lands, that helper becomesreturn sessionStore.addMessage(sessionId, message). Expect a textual conflict insendSSEToStreamsand in the removed subscriber-side history write increateSession.Tests
test/legacy-session-subscriptions.test.ts(new, real listening servers + undici, memory and Redis):Last-Event-ID, and live delivery resumesPUBSUB NUMSUB)SessionTopicSubscriptionsRedisMessageBroker.unsubscribereleases the channeltest/resource-subscription-lifecycle.test.ts(new):test/broadcast-error-handling.test.ts: the two "session store fails" cases now expectmcpSendToSessionto returnfalse, and still check that nothing is unhandled and the error is logged.npm run lint,npm run typecheck, andnpm run test(tsd + 784 tests, withREDIS_DB=3) all pass.Fixes #327
Fixes #328
Fixes #329
Fixes #337
🤖 Generated with Claude Code
https://claude.ai/code/session_01LmhsFjk8W96X2PvmShmDm9