Skip to content

fix(streamable-http-server): release the session map lock before waiting on a session - #1323

Merged
DaleSeo merged 3 commits into
modelcontextprotocol:mainfrom
TheSeydiCharyyev:fix/session-map-lock
Oct 11, 2026
Merged

DaleSeo merged 3 commits into
modelcontextprotocol:mainfrom
TheSeydiCharyyev:fix/session-map-lock

Conversation

@TheSeydiCharyyev

@TheSeydiCharyyev TheSeydiCharyyev commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

While one client's initialize is running, no other client can connect. Once a new client tries, requests on all other sessions wait too. With a server whose initialize takes 3 s, a second client's initialize took 2.8 s and a ping on an existing session took 2.6 s. With this change, both take about 3 ms.

Motivation and Context

LocalSessionManager keeps all sessions in one RwLock<HashMap<..>>. Five methods take the read guard and keep it while they wait on the session worker:

  • initialize_session waits for the initialize response, so it keeps the guard during the whole ServerHandler::initialize.
  • create_stream, create_standalone_stream and resume wait for the worker to reply.
  • accept_message waits when the session's event channel is full.

create_session needs the write lock, so a new client waits for that guard. tokio's RwLock is fair: when a writer is waiting, new readers wait behind it. The tower service calls has_session for every POST and GET that has a session id, so requests on other sessions wait as well. The wait lasts as long as the slow initialize runs.

Fix

A private helper, session_handle, clones the LocalSessionHandle out of the map and drops the guard before the caller waits on the worker. The handle is only an id and a channel sender, so the clone is cheap. The five methods use the helper. close_session already drops the lock before it awaits; now the other methods work the same way.

How Has This Been Tested?

  • New tests/test_streamable_http_session_isolation.rs. One session waits on its worker: nothing answers on its transport, the same setup as test_streamable_http_init_timeout.rs. Then create_session and has_session for another session must finish within 1 s. There is one case for each of the five methods. The test runs with the tokio clock paused: it waits until the worker has passed initialize on, and each next step starts only after all spawned tasks are blocked, so the order does not depend on timing. On main all five cases fail with has_session blocked: Err(Elapsed(())) (30 of 30 runs). With this change they pass (100 of 100 runs).
  • A second test, close_session_does_not_wait_for_pending_initialize: while initialize is pending, close_session returns at once and removes the session; initialize keeps waiting and still gets the handler's response after that. On main it fails with close_session blocked: Err(Elapsed(())).
  • I reverted the change in one method at a time. Only the case for that method fails. Reverting initialize_session fails all five cases, because every case starts with a pending initialize.
  • Over real HTTP (a local test, not in this PR): StreamableHttpService with a ServerHandler whose initialize sleeps 3 s, then a second client's initialize and a ping on an existing session. The numbers are at the top.
  • cargo test -p rmcp with the CI feature set without local, for the 19 streamable HTTP server test files: all 154 tests pass. Also the nightly rustfmt check and both clippy commands from CI.

Tested on Windows 11.

Breaking Changes

None. The public API does not change: the helper is private.

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

Additional context

One small change in behavior: close_session no longer waits for calls that hold the read guard. During initialization the worker does not read session events, so a pending initialize_session still waits for the handler's response, as before, and the worker handles Close only after that. Before this change close_session blocked until initialize finished; now it returns at once and removes the session from the map, while initialize keeps waiting. The second test above covers this. After initialization the worker handles events in order, so events queued before Close are still handled.

@TheSeydiCharyyev
TheSeydiCharyyev requested a review from a team as a code owner October 5, 2026 02:51
@github-actions github-actions Bot added T-test Testing related changes T-core Core library changes T-transport Transport layer changes labels Oct 5, 2026
@DaleSeo

DaleSeo commented Oct 5, 2026

Copy link
Copy Markdown
Member

The MSRV failure isn't from your change. It was fixed on main in #1316. Could you rebase onto the latest main and push? A re-run alone won't pick up the fix.

…ing on a session

LocalSessionManager kept the read guard on its session map while it
waited on a session worker. initialize_session keeps it for the whole
ServerHandler::initialize, so one slow initialize blocks create_session
for every new client. tokio's RwLock is fair, so has_session calls for
other sessions then wait behind that writer, and the whole server waits
until the slow initialize returns.

Clone the session handle out of the map and drop the guard before
waiting, like close_session already does.
@TheSeydiCharyyev

Copy link
Copy Markdown
Contributor Author

Rebased onto the latest main (79437f2) and pushed. The workflows need approval to run again.

Comment thread crates/rmcp/tests/test_streamable_http_session_isolation.rs Outdated
Comment thread crates/rmcp/src/transport/streamable_http_server/session/local.rs
Comment thread crates/rmcp/src/transport/streamable_http_server/session/local.rs Outdated
…d clone the id lazily

The test now runs with the tokio clock paused, so each step starts only
after all spawned tasks are blocked, instead of after a fixed sleep. It
also waits until the worker has passed initialize on before it adds the
second call.

session_handle now builds the SessionNotFound error only when the
session is missing.
DaleSeo
DaleSeo previously approved these changes Oct 11, 2026

@DaleSeo DaleSeo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. I left a couple of comments, but they’re not blocking.

Comment thread crates/rmcp/tests/test_streamable_http_session_isolation.rs
Comment thread crates/rmcp/tests/test_streamable_http_session_isolation.rs Outdated
…itialize

Add a test that close_session returns at once while initialize is still
pending and removes the session, and that the pending initialize still
gets the handler's response afterwards.

The accept_message case now reads the channel capacity from the
manager's session config instead of the default constant.
@DaleSeo
DaleSeo merged commit 808124b into modelcontextprotocol:main Oct 11, 2026
24 checks passed
@TheSeydiCharyyev
TheSeydiCharyyev deleted the fix/session-map-lock branch October 12, 2026 03:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

T-core Core library changes T-test Testing related changes T-transport Transport layer changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants