Skip to content

Perform SSE stream writes outside the session mutex#449

Merged
koic merged 1 commit into
modelcontextprotocol:mainfrom
koic:avoid_blocking_sse_writes_under_session_mutex
Jul 11, 2026
Merged

Perform SSE stream writes outside the session mutex#449
koic merged 1 commit into
modelcontextprotocol:mainfrom
koic:avoid_blocking_sse_writes_under_session_mutex

Conversation

@koic

@koic koic commented Jul 11, 2026

Copy link
Copy Markdown
Member

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.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

## 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.
@koic koic force-pushed the avoid_blocking_sse_writes_under_session_mutex branch from ad540ca to 5b935eb Compare July 11, 2026 08:18
@koic koic merged commit e81196e into modelcontextprotocol:main Jul 11, 2026
11 checks passed
@koic koic deleted the avoid_blocking_sse_writes_under_session_mutex branch July 11, 2026 16:43
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants