Skip to content

fix: dispatch client request handlers concurrently (#2489)#2490

Open
demoray wants to merge 1 commit intomodelcontextprotocol:mainfrom
demoray:bcaswell/fix-concurrent-request-dispatch-2489
Open

fix: dispatch client request handlers concurrently (#2489)#2490
demoray wants to merge 1 commit intomodelcontextprotocol:mainfrom
demoray:bcaswell/fix-concurrent-request-dispatch-2489

Conversation

@demoray
Copy link
Copy Markdown

@demoray demoray commented Apr 21, 2026

Closes #2489.

Problem

BaseSession._receive_loop awaited each incoming request handler inline, so concurrent server→client requests (e.g. N create_message sampling callbacks launched via asyncio.gather) ran strictly one-at-a-time. Peak in-flight handlers = 1 regardless of fan-out.

Fix

Add an opt-in _dispatch_requests_concurrently flag on BaseSession that spawns each request handler in the session's task group via start_soon instead of awaiting it inline.

ClientSession enables the flag. ServerSession stays serial because its _received_request has an InitializeRequest state machine (Initializing → respond → Initialized) that would race if a follow-up request were dispatched concurrently before the state transitioned.

Collateral fixes

Concurrent dispatch widens two latent RequestResponder races:

  • Cancel-before-enter. __enter__ used to replace the cancel scope created in __init__. With concurrent dispatch, a CancelledNotification can arrive before the handler task enters with responder: — the pre-entry cancel() would then target a scope that is about to be thrown away. Fixed by not replacing the scope in __enter__.
  • Double cancel / pre-entry cancel. cancel() is now idempotent (early return when already completed) and no longer requires _entered.

Handler exceptions are translated into a JSON-RPC error response so a raising handler can't wedge the peer (otherwise the exception would propagate from the dispatch task and tear down the session's task group).

Tests

Four regression tests in tests/shared/test_session.py:

  • test_concurrent_server_to_client_requests_run_in_parallel — N=4 fan-out, asserts peak concurrency = 4.
  • test_sampling_callback_exception_returns_error_response — handler that raises still gets a JSON-RPC error reply for its id.
  • test_double_cancel_does_not_send_second_response — idempotent cancel.
  • test_cancel_before_context_entered_marks_scope_cancelled — cancel is safe before __enter__.
  • test_handler_that_responds_then_raises_emits_no_duplicate_error — responded-then-raised handlers don't produce a second error for the same id.

All 1174 tests pass; 100% branch coverage maintained; strict-no-cover, ruff, and pyright clean.

…col#2489)

BaseSession._receive_loop awaited each incoming request handler inline,
serializing server->client requests (e.g. concurrent sampling calls via
asyncio.gather peaked at one in flight).

Add an opt-in '_dispatch_requests_concurrently' flag on BaseSession that
spawns each request handler in the session's task group. ClientSession
enables it; ServerSession stays serial to preserve the initialize
ordering that its state machine relies on.

Also fix two RequestResponder races that concurrent dispatch widens:
- __enter__ no longer replaces the cancel scope, so a cancel() that
  arrives before the handler enters the context targets the same scope
  the handler will later run under.
- cancel() is idempotent and safe to call before entry.

Handler exceptions are translated into a JSON-RPC error response so a
raising handler can't wedge the peer.
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.

Concurrent server-side requests are serialized end-to-end by BaseSession

1 participant