Repository navigation
[Server] Poll only the client request a stream's own fiber sent - #556
chr-hertel merged 3 commits into
Conversation
|
Thanks @mglaman - looks good at first glance, will have another look tomorrow 👍 |
There was a problem hiding this comment.
🟢 Approval recommended
The focused change preserves transport extension hooks and includes regression coverage for initial and subsequent request yields.
0 open findings
What changed in this PR
Fixes cross-stream response delivery in the PHP SDK by limiting each transport’s polling to the client request its own fiber awaits.
Changes:
- Tracks awaited request IDs per transport, including subsequent fiber yields.
- Adds regression coverage for concurrent streams sharing a session.
- Documents the fix and updated return value.
| File | Description |
|---|---|
| tests/Unit/Server/ProtocolTest.php | Tests request isolation across streams and subsequent yields. |
| tests/Unit/Fixtures/PollingLoopTransport.php | Exposes polling callbacks for regression tests. |
| src/Server/Protocol.php | Tracks and filters pending requests per transport. |
| CHANGELOG.md | Documents the polling fix and return-value change. |
🧠 Review effort: Balanced
Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.
|
Applied the |
d724c0e to
4e58fff
Compare
Pending server-to-client requests live in one session-wide list. Each SSE loop walked the whole list, so with two tool calls waiting on one session, either stream could consume the other's elicitation answer and resume its own fiber with it. Protocol now records, per connected transport, the request its fiber is suspended on, and the pending-requests provider returns only that one. 🤖 Assisted with AI
🤖 Assisted with AI
4e58fff to
19a24e1
Compare
Cover a notification yield clearing the awaited request, and rename awaitResponse() to trackAwaitedRequest().
|
Opened #561 as follow up for the timeout & late response cleanup 👍 |
Closes #544
Pending server-to-client requests live in one session-wide list,
_mcp.pending_requests. The SSE loop inStreamableHttpTransport::createStreamedResponse()(and the stdio loop) walked that whole list. With two tool calls waiting inClientGateway::elicit()on one session, the first stream to poll could take the other's answer and resume its own fiber with it.Protocolnow records which client request each connected transport's fiber is suspended on, and the pending-requests provider it hands that transport returns only that one:handleRequest()records the request from the fiber's first suspend beforeattachFiberToSession().handleFiberYield()returns the ID of the request it sent, so a request yielded during a later resume replaces the recorded one. A notification yield clears it.WeakMapkeyed by transport. A fiber waits on one client request at a time, so each transport holds one ID.I kept the tracking in
Protocolso neitherTransportInterfacenorBaseTransport's protected hooks change. Drupal'smcp_serveroverrideshandleFiberYield()andcheckForResponse()in aStreamableHttpTransportsubclass to lock the session per write, and those overrides keep working.Not in this PR
@jarrettdustinqq's review raises two lifecycle gaps that exist on
maintoday:_mcp.pending_requests. After this change, other streams no longer see it, so it can't resume the wrong fiber.Both are session cleanup rather than cross-stream delivery, and the whole-session read-modify-write race is #275. I'd rather handle them separately.
Tests
ProtocolTeststarts two tool calls on one session, each suspended on a client request, through aPollingLoopTransportfixture that exposes what the transport's polling loop sees:testConcurrentStreamsPollOnlyTheirOwnPendingRequest: after the client answers the second stream's request, each stream still sees only its own.testRequestYieldedOnResumeIsPolledOnlyByItsOwnStream: a request yielded through the transport's fiber-yield handler is pending for that stream only.Both fail on
main(each stream sees[1000, 1001]).Checked locally:
php-cs-fixeris clean,phpstanreports no errors, and the unit (1,691) and integration (82) suites pass.🤖 Generated with Claude Code