fix(mcp): drain parked tasks before closing loop - #60104
Conversation
Competing with #60032 for the same MCP shutdown race ( |
|
Thanks for the focused loop-owner cleanup. The reported race remains on current main: parked waiter cleanup calls Suggested changes
Automated hermes-sweeper review. |
19691d5 to
29e9733
Compare
|
Implemented the requested end-to-end regression through The new test registers a real parked Verification:
The branch was also rebased onto current |
|
I'm closing my #66143 as a duplicate of this one — you got here first and this branch is further along. Handing over the one thing from mine that might be worth folding in. The drain can silently no-op when the join times outThe drain lands after loop.call_soon_threadsafe(loop.stop)
if thread is not None:
thread.join(timeout=5)
_drain_pending_mcp_loop_tasks(loop) # <-- hereIn the normal path that's fine: the loop has stopped, the thread has exited, and But when the join times out, the loop is still running, so Repro, using your To be fair on scope: a loop wedged that badly can't be fully drained by anyone — my version times out against it too. The difference is that this fails silently, at Two ways to close it:
Either is fine by me; (1) is what I'd pick. Unrelated, for contextThe ownership bug behind these parked tasks is separate from the traceback and still open. This PR reaps those tasks correctly at exit, which is the right fix for the traceback. The accumulation itself needs a behaviour change with a design fork I'd want maintainer input on first, so #60197 should stay open regardless of this landing. |
29e9733 to
8060bf9
Compare
|
Thanks @shady2k — I folded in the loop-owned drain direction from #66143 and cherry-picked the relevant commit so your authorship is preserved. The shutdown sequence now schedules a single loop-owned coroutine that:
I also tightened the outer-timeout path beyond the first revision. It no longer cancels the drain future and separately queues Verification after the final change:
The separate ownership/accumulation behavior remains outside this PR and stays tracked in #60197. |
d779cae to
71d6f38
Compare
_stop_mcp_loop() stopped and closed the background loop without reaping
the tasks still on it. A task left suspended is resumed later by the GC,
whose finalizer drives its cleanup against the now-closed loop:
Exception ignored in: <coroutine object MCPServerTask.run ...>
File "tools/mcp_tool.py", line 2947, in run
parked = await self._wait_for_reconnect_or_shutdown(
File "tools/mcp_tool.py", line 2161, in _wait_for_reconnect_or_shutdown
t.cancel()
RuntimeError: Event loop is closed
shutdown_mcp_servers() only reaps servers held in _servers, so a server
that parked after exhausting its initial-connect budget — never inserted
there, because start() raises _error before the caller registers it — has
no owner to signal it and stays suspended until the loop is gone.
Drain the loop the way asyncio.run() does: cancel the remaining tasks and
gather them while the loop is still open, so each runs its own finally.
Cancel alone is not enough — Task.cancel() only schedules the throw.
This resolves the reported traceback, but not the ownership bug that
strands the task in the first place; that needs a follow-up. Deliberately
not using "Fixes" so NousResearch#60197 stays open for it.
Addresses NousResearch#60197
Addresses NousResearch#66113
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
71d6f38 to
9c2083e
Compare
|
Rebased this PR onto current |
|
Merged via #74139 with your commits cherry-picked and authorship preserved (rebase merge) — the loop-owned drain landed exactly as you wrote it, including the drain-then-stop sequencing and the blocked-loop regression. shady2k's drain commit from #66143 also survived with authorship intact. Thanks for driving this through the review rounds! |
Summary
Fixes the MCP shutdown race that can print a noisy traceback like:
This is the same failure mode discussed in #60032, but instead of only catching
RuntimeErrorat individualTask.cancel()call sites, this drains pending MCP-loop tasks before the shared MCP event loop is stopped and closed.What changed
finallycleanup gets a cancellation cycle.loop.stop()in one loop-owned sequence, so an outer wait timeout cannot queuestop()ahead of a drain that is still waiting for the loop to resume.shutdown_mcp_servers().Why this is better than #60032's narrow catch
#60032 suppresses the symptom by catching
RuntimeErroraround specific child-task cancellation sites. This PR fixes the lifecycle issue one layer higher: the MCP loop owner now gives pending parked tasks a final cancellation cycle before stopping and closing the loop, so their cleanup does not get deferred to coroutine GC after the loop is already closed.The defensive exception handler for unrelated transport finalizer noise remains unchanged.
Relationship to #72054
#72054 and this PR are complementary:
MCPServerTaskin_connect_server()whenserver.start()fails, preventing the known orphan at its source.The two branches merge without code conflicts. Their combined result was tested against current
main; the full MCP test set passed.The broader parked-task ownership/revival issue remains tracked in #60197 and is intentionally outside this PR's scope.
Tests
A local full-suite run reached the end of the 45k-test matrix but could not produce a clean aggregate result because this workstation's
/tmpquota and unrelated existing flaky tests interfered. The affected MCP suites above pass; the refreshed GitHub CI run is the clean-environment full-suite check.