fix(mcp): recover closed stdio sessions after keepalive failures - #65111
fix(mcp): recover closed stdio sessions after keepalive failures#65111TurgutKural wants to merge 5 commits into
Conversation
db4bdc5 to
e7966e6
Compare
teknium1
left a comment
There was a problem hiding this comment.
Thanks for carrying the closed-stream recovery follow-up. The underlying serialization premise remains on current main: keepalive RPCs are outside the shared lock (tools/mcp_tool.py:2004-2026), while normal calls hold it (tools/mcp_tool.py:4100-4108).
Problems
tools/mcp_tool.py:3507: the newanyio/"Resource"predicate matches any AnyIO exception withResourcein its name, not only the closed-stream failures named in the PR. A match enters the reconnect-and-retry path (tools/mcp_tool.py:3540-3588), so this can turn an unrelated error into transport churn.
Suggested changes
- Limit this branch to the explicit closed-stream exception names/types and add a negative test for a non-target AnyIO-style resource exception.
- Preserve #62811's active-RPC skip when salvaging serialization; its focused regression establishes that an in-flight RPC is the liveness signal and a periodic probe should not queue behind it.
This is an automated hermes-sweeper review.
247bc18 to
f82e29c
Compare
|
Rebased onto current Changes in this rebase:
|
e2141bc to
03278cc
Compare
Deep upstream-already-fixed analysis — verdict: PARTIALLY_FIXED (gap still real, PR is needed)I read current What upstream ALREADY has (the reconnect plumbing is in place)
The partial fix that's already upstreamCommit The gap — why the PR is still needed (empirically verified)The classifier When a stdio subprocess dies, the SDK stdio I executed the actual upstream function against these real exceptions: So on Why the PR is correct and should stay openPR #65111 makes I executed the actual PR-head function against the same real exceptions: It keeps ConclusionALREADY_FIXED_DIFFERENTLY does not apply: the reconnect mechanism exists, but its classifier gate cannot match the real closed-stdio exception types because they carry empty messages. The PR closes a genuine, reproducible gap. Recommended: keep open / merge — it is not redundant with Evidence table:
|
03278cc to
bb66c18
Compare
Thanks for the follow-up — the core classifier fix is sound and I verified the failure path on current Two things before merge:
Minor: the closed-event-loop guard returns from a |
(cherry picked from commit e59c9325f4a4ed23a489ee2f621ac6eb8b365e9b)
…ch#65111) At process exit the event loop can be closed before a parked MCPServerTask._wait_for_reconnect_or_shutdown finally-block runs. Calling t.cancel() on the dead loop schedules via call_soon -> _check_closed and raises RuntimeError: Event loop is closed, which asyncio prints as an 'Exception ignored' traceback. Guard the cancellation cleanup: if the running loop is already closed, return 'shutdown' and skip touching the dead loop. Add a regression test.
…types (NousResearch#65111) The review flagged that the anyio 'Resource' substring predicate matched every AnyIO error whose type name contains 'Resource', not just the closed/broken/end-of-stream failures. That funneled unrelated errors into the reconnect-and-retry path and churned the transport. Restrict the branch to the explicit named types (ClosedResourceError, BrokenResourceError, EndOfStream) plus the stdlib BrokenPipeError/EOFError, and add a negative regression test so an unrelated AnyIO 'Resource' error (no capacity, etc.) does not trigger recovery.
…p' too The previous guard called asyncio.get_event_loop() directly; when the loop is already torn down (no current loop in the thread) that call itself raises "RuntimeError: There is no current event loop", which asyncio printed as another 'Exception ignored' traceback on session close. Wrap the lookup in try/except RuntimeError and bail to 'shutdown' when no loop is available. Add a regression test for the no-current-loop case (NousResearch#65111).
bb66c18 to
2c1915d
Compare
Close as absorbed upstreamThis PR's functionality has been independently implemented on upstream/main through these commits:
What upstream has that the PR also proposed:
No remaining gap — closing. |
Summary
Follow-up to #62811 and replacement for #64989.
#62811 confirms the same MCP failure class: keepalive and an active RPC can interfere on the single JSON-RPC stream, causing a wedged/closed transport, false reconnects, and failed tool calls. This PR keeps the
_rpc_lockserialization from #64989 and adds the missing transport-recovery path for the resulting closed-stream exceptions.Changes
_rpc_lockserialization for keepaliveping/list_toolsprobes.anyio.ClosedResourceError,BrokenResourceError,EndOfStream,BrokenPipeError,EOFError) as reconnectable transport failures.The existing reconnect path remains bounded: one reconnect/retry at the tool-call layer, the existing reconnect budget, circuit breaker, and parked self-probe behavior remain unchanged.
Validation
py_compilepassed.git diff --checkpassed.Refs: #62811, closes #64989