Skip to content

Cover dispatcher-thread commit inside McpClient::initializeProtocol#217

Merged
gophergogo merged 1 commit into
mainfrom
feature/mcp-client-initialize-dispatcher-test
Apr 20, 2026
Merged

Cover dispatcher-thread commit inside McpClient::initializeProtocol#217
gophergogo merged 1 commit into
mainfrom
feature/mcp-client-initialize-dispatcher-test

Conversation

@gophergogo
Copy link
Copy Markdown
Collaborator

Summary

End-to-end integration test for the contract that worker-parsed initialize results are committed to McpClient state on the main dispatcher, not on the worker thread.

  • Stands up a real McpServer on a loopback ephemeral port and drives a real McpClient through connect → initializeProtocol → ping.
  • Asserts the initialize future resolves in bounded time and that protocolVersion comes back populated (observable proof that the dispatcher-thread commit post ran before the future was released).
  • Asserts a follow-up ping round-trips, proving the protocol state machine actually advanced into INITIALIZED.

Finer-grained capability / serverInfo field asserts are intentionally skipped: the current client parser only recognizes flat dotted-key metadata while the server emits nested JSON objects. That deserialization gap is out of scope for this test.

Known pre-existing teardown bug (flagged, not fixed here)

McpServer::shutdown() flips server_running_ = false on the caller thread before posting any cleanup, and the shutdown post itself never drains active_connections_. Connections therefore outlive the dispatcher and get destroyed on the test thread inside ~McpServer, whose ConnectionImpl::~ConnectionImpl() → closeSocket(LocalClose) forwards through the lifecycle adapter into McpServer::onConnectionLifecycleEvent, which asserts it is on the dispatcher thread (mcp_server.cc:804).

That fix belongs in the server-lifecycle test's scope (tracked under the next PR). Here the teardown deliberately release()s the server to keep this test focused on the client-side routing contract. The test process is about to exit; the heap is reclaimed by the OS; the dispatcher thread has already joined so nothing else touches the object.

Test plan

  • cmake --build . --target test_mcp_client_initialize_routing
  • ./tests/test_mcp_client_initialize_routing — passes
  • Run 5× in a row to check stability — all pass, ~650ms each

The worker thread that blocks on the initialize response parses it,
then posts the actual mutation of client state --
server_capabilities_, initialized_, and the protocol state machine's
INITIALIZED transition -- back to the main dispatcher, and only
resolves the externally-visible std::future<InitializeResult> from
inside that post. Writing those fields off the dispatcher would be
a data race with dispatcher readers.

Drive the full path end-to-end against a real McpServer: pick a
free loopback port, stand the server up on a background thread,
connect a real McpClient at http://127.0.0.1:<port>/rpc, run
initializeProtocol().get(), and fire a follow-up ping on the same
connection.

Observable asserts:
  - connect() succeeds against a listening server
  - the initialize future resolves (no deadlock / lost post)
  - protocolVersion is populated, proving the dispatcher-thread
    commit post executed before the future was released
  - a subsequent ping round-trips, proving the protocol state
    machine actually advanced into INITIALIZED

Finer-grained capability / serverInfo asserts are deliberately
omitted: the current client parser only recognizes flat dotted-key
metadata while the server emits nested JSON objects. Bridging that
deserialization gap is out of scope here -- this test covers the
dispatcher-routing contract, not the response-parsing schema.

Teardown leaks the McpServer on purpose. McpServer::shutdown()
flips server_running_ = false on the caller thread before posting
cleanup, and the shutdown post itself never drains
active_connections_, so connections outlive the dispatcher and get
destroyed on the test thread inside ~McpServer, which trips the
dispatcher-thread assert inside onConnectionLifecycleEvent. That
bug belongs in the server-lifecycle test's scope; here we just
need the client-side contract verified and a clean exit, and the
process is about to die anyway.
@gophergogo gophergogo merged commit 2fbb883 into main Apr 20, 2026
1 check passed
gophergogo pushed a commit that referenced this pull request Apr 20, 2026
…217)

The worker thread that blocks on the initialize response parses it,
then posts the actual mutation of client state --
server_capabilities_, initialized_, and the protocol state machine's
INITIALIZED transition -- back to the main dispatcher, and only
resolves the externally-visible std::future<InitializeResult> from
inside that post. Writing those fields off the dispatcher would be
a data race with dispatcher readers.

Drive the full path end-to-end against a real McpServer: pick a
free loopback port, stand the server up on a background thread,
connect a real McpClient at http://127.0.0.1:<port>/rpc, run
initializeProtocol().get(), and fire a follow-up ping on the same
connection.

Observable asserts:
  - connect() succeeds against a listening server
  - the initialize future resolves (no deadlock / lost post)
  - protocolVersion is populated, proving the dispatcher-thread
    commit post executed before the future was released
  - a subsequent ping round-trips, proving the protocol state
    machine actually advanced into INITIALIZED

Finer-grained capability / serverInfo asserts are deliberately
omitted: the current client parser only recognizes flat dotted-key
metadata while the server emits nested JSON objects. Bridging that
deserialization gap is out of scope here -- this test covers the
dispatcher-routing contract, not the response-parsing schema.

Teardown leaks the McpServer on purpose. McpServer::shutdown()
flips server_running_ = false on the caller thread before posting
cleanup, and the shutdown post itself never drains
active_connections_, so connections outlive the dispatcher and get
destroyed on the test thread inside ~McpServer, which trips the
dispatcher-thread assert inside onConnectionLifecycleEvent. That
bug belongs in the server-lifecycle test's scope; here we just
need the client-side contract verified and a clean exit, and the
process is about to die anyway.
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