Repository navigation
fix: serialize progress callbacks and drain them before the request completes - #3753
Jihaochang1 wants to merge 1 commit into
Conversation
|
This PR has been closed automatically. This repo only keeps pull requests open when they come from a maintainer, or from a contributor a maintainer has assigned to the linked issue, and you aren't currently assigned to #3747. If a maintainer assigns you to #3747, this PR reopens on its own and there's nothing more you need to do here. Assignment is a maintainer call based on capacity; comments that only ask to be assigned don't factor in. What does help is engaging on the issue itself by confirming the repro, explaining why it matters for your use case, or describing the approach you'd take. You're welcome to keep pushing commits here (just avoid force-pushing, since GitHub can't reopen a rewritten branch), but that on its own won't get the PR reviewed or the issue assigned, and realistically most auto-closed PRs stay closed. There's no need to open a new PR either way. CONTRIBUTING.md has the full reasoning, but in short:
Maintainers: reopen, remove |
…ompletes Each `notifications/progress` used to spawn its own task with nothing waiting for it, so callbacks for the same request could run concurrently, complete in any order, and still be running after `send_raw_request()` handed back the result (modelcontextprotocol#3747). A callback that awaits anything (updating a UI, writing a log) also delayed the callbacks behind it arbitrarily. Each notification's task now awaits its predecessor's done-event before invoking the callback, so the callbacks for a request run one at a time in the order the notifications arrived, and `send_raw_request` drains the tail of the chain before the request completes - on the success and error-response paths alike. The callback stays shielded and exception-contained, the read loop never blocks on it, and the drain is bounded because the pending entry is removed first (progress arriving after the response is still dropped, as before). The same dispatcher serves client `call_tool(progress_callback=...)` and server `send_request(progress_callback=...)`, so both directions are fixed. Fixes modelcontextprotocol#3747
AI disclosure: prepared with AI assistance under my direction; I have read the change and verified the reproduction and test results myself.
Fixes #3747.
Problem
Over any transport that goes through
JSONRPCDispatcher(stdio, SSE, streamable HTTP, in-memory streams), eachnotifications/progressspawned its own task for the request'sprogress_callback, and nothing waited for it. As soon as the callback awaited anything:/n/n- callbacks for the same request ran concurrently and could finish in any ordercall_tool()/send_request()could return before earlier progress callbacks had runThe reporter's example printed
['call_tool returned', 'progress 3.0', 'progress 2.0', 'progress 1.0']on a real transport, while the in-processDirectDispatcher(which awaits the callback inline) printed the expected order.tests/interaction/lowlevel/test_progress.pydid not catch it because its callback never awaits.Fix
Each notification's task now awaits its predecessor's done-event before invoking the callback, so the callbacks for a request run one at a time, in the order the read loop saw the notifications.
send_raw_requestdrains the tail of the chain before the request completes.Design notes:/n/n- The read loop never blocks on a callback: notification handling still just spawns, so a slow callback cannot stall other messages on the connection (awaiting the callback inline in the read loop, the other option the issue mentions, would do exactly that).
_shielded_progressboundary; the done-event is set in afinally, so the chain and the drain release even when the dispatcher is cancelled.call_tool(progress_callback=...)and serversend_request(progress_callback=...), so both directions are covered.Verification
test_progress_callbacks_are_serialized_and_finish_before_the_resultuses an awaiting callback with reversed sleep durations; it fails on 5 of 6 matrix cells before the change (the modern in-memory cell usesDirectDispatcher, which was already correct) and passes on all 6 after.tests/shared/suite (incl. dispatcher unit tests): 1025 passed.tests/interaction/,tests/client/,tests/server/: 3127 passed, 10 skipped, 1 xfailed.ruff check,ruff format --check, andpyrightare clean on the changed files.