Perform SSE stream writes outside the session mutex#449
Merged
koic merged 1 commit intoJul 11, 2026
Merged
Conversation
## Motivation and Context `StreamableHTTPTransport` guards its `@sessions` and `@pending_responses` maps with one `@mutex`. The send paths wrote to an SSE stream while holding that lock: the targeted and broadcast branches of `send_notification`, the server-to-client `send_request`, and `send_keepalive_ping` all called `send_to_stream`/`send_ping_to_stream` (a `stream.write` plus `stream.flush`) inside `@mutex.synchronize`. A stream write is blocking I/O with no timeout: if a client reads its SSE stream slowly, the server-side `write` waits until the socket buffer drains. Performing that write under `@mutex` serializes every other session on the same lock (head-of-line blocking): new `initialize` inserts, session validation, `handle_response`, the reaper, and delivery to other sessions all wait behind one slow reader. The broadcast path holds the lock across every session, so one slow stream also delays delivery to the sessions after it. The cleanup paths already release the lock before touching a stream: they collect `streams_to_close` and call `close` outside `synchronize`. This change applies the same discipline to the writes. - Each send path resolves its target stream under `@mutex` (and, for `send_request`, registers the pending response there), then releases the lock before writing. - A write error still drops the broken stream. That teardown is centralized in a new `drop_broken_stream`, which mutates `@sessions` under `@mutex` and closes the affected streams outside it. A request-scoped stream is dropped on its own; a GET SSE failure removes the whole session, matching the previous behavior. - `send_notification` splits into `deliver_targeted_notification` and `deliver_broadcast_notification`; the return values (true/false for a session, the delivered count for a broadcast) are unchanged. ## How Has This Been Tested? New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` install a probe stream that records, via `mutex.try_lock`, whether `@mutex` is held during each write, and assert it is not held for the targeted notification, broadcast, `send_request`, and keepalive paths. Each new test fails against the previous code and passes now. The existing send and cleanup tests still pass. ## Breaking Changes None. Delivery semantics and return values are unchanged; the lock is simply no longer held while writing to a stream.
ad540ca to
5b935eb
Compare
atesgoral
approved these changes
Jul 11, 2026
9 tasks
koic
added a commit
that referenced
this pull request
Jul 11, 2026
## Motivation and Context `bundle exec rake` fails on main with four errors in `streamable_http_transport_test.rb`: ``` NoMethodError: undefined method '[]=' for nil streamable_http_transport_test.rb:5119 (install_mutex_probe_stream) ``` This is a semantic conflict between two merged PRs. #451 made the server reject `initialize` requests that lack the required `protocolVersion`, `capabilities`, and `clientInfo` params with -32602. #449, merged after but developed before it, added the "writes outside the mutex" tests, whose `initialize_test_session` helper sends an `initialize` request with no params. That request is now rejected, no session is created, so `@sessions[session_id]` is nil and installing the probe stream raises `NoMethodError`. The fix adds `params: initialize_params` to the helper's request body, matching the pattern used by every other `initialize` request in the file via `InitializeParamsTestHelper`. ## How Has This Been Tested? `bundle exec rake` now passes: 1318 runs, 0 failures, 0 errors, and RuboCop reports no offenses. The four previously failing tests are the regression coverage. ## Breaking Changes None. Test-only change.
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.
Motivation and Context
StreamableHTTPTransportguards its@sessionsand@pending_responsesmaps with one@mutex. The send paths wrote to an SSE stream while holding that lock: the targeted and broadcast branches ofsend_notification, the server-to-clientsend_request, andsend_keepalive_pingall calledsend_to_stream/send_ping_to_stream(astream.writeplusstream.flush) inside@mutex.synchronize.A stream write is blocking I/O with no timeout: if a client reads its SSE stream slowly, the server-side
writewaits until the socket buffer drains. Performing that write under@mutexserializes every other session on the same lock (head-of-line blocking): newinitializeinserts, session validation,handle_response, the reaper, and delivery to other sessions all wait behind one slow reader. The broadcast path holds the lock across every session, so one slow stream also delays delivery to the sessions after it.The cleanup paths already release the lock before touching a stream: they collect
streams_to_closeand callcloseoutsidesynchronize. This change applies the same discipline to the writes.@mutex(and, forsend_request, registers the pending response there), then releases the lock before writing.drop_broken_stream, which mutates@sessionsunder@mutexand closes the affected streams outside it. A request-scoped stream is dropped on its own; a GET SSE failure removes the whole session, matching the previous behavior.send_notificationsplits intodeliver_targeted_notificationanddeliver_broadcast_notification; the return values (true/false for a session, the delivered count for a broadcast) are unchanged.How Has This Been Tested?
New tests in
test/mcp/server/transports/streamable_http_transport_test.rbinstall a probe stream that records, viamutex.try_lock, whether@mutexis held during each write, and assert it is not held for the targeted notification, broadcast,send_request, and keepalive paths. Each new test fails against the previous code and passes now. The existing send and cleanup tests still pass.Breaking Changes
None. Delivery semantics and return values are unchanged; the lock is simply no longer held while writing to a stream.
Types of changes
Checklist