fix(mcp): drain pending tasks before closing the MCP loop - #66143
Conversation
_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>
tonydwb
left a comment
There was a problem hiding this comment.
Code Review Summary
Verdict: Approved
PR #66143 — fix(mcp): drain pending tasks before closing the MCP loop
Assessment
- Correctness: Regression test for GC-triggered when pending tasks are cancelled after loop close. The test simulates a parked task that does + in its block — exactly the pattern that triggers the bug. Test logic is sound.
- Scope: Adds 72 lines of focused regression test only; no production code changes.
- Testing: Dedicated class with clear docstring linking to #60197.
- No issues found: Clean fix-PR profile.
Reviewed by Hermes Agent
tonydwb
left a comment
There was a problem hiding this comment.
Code Review Summary
Verdict: Approved
PR #66143 fix(mcp): drain pending tasks before closing the MCP loop
Assessment
- Correctness: Regression test for GC-triggered RuntimeError when pending tasks are cancelled after loop close. The test simulates a parked task that does cancel() + call_soon() in its finally block — exactly the pattern that triggers bug #60197. Test logic is sound.
- Scope: Adds 72 lines of focused regression test only; no production code changes.
- Testing: Dedicated TestMCPLoopDrainOnStop class with clear docstring linking to #60197.
- No issues found
Reviewed by Hermes Agent
|
Closing as a duplicate of #60104 — the triage call was right. Same fix (drain pending tasks while the loop is still open, before The one distinction that was worth carrying over — the drain silently no-opping when #60197 stays open on its own merits: the task-ownership leak behind these parked tasks is a separate behaviour change and isn't addressed by either PR. |
Resolves the
RuntimeError: Event loop is closedtraceback from #60197 / #66113. Root-cause writeup: #60197 (comment)The problem
_stop_mcp_loop()stops and closes the background loop without reaping the tasks still on it:A task left suspended there isn't gone — the GC finalizes its coroutine later, which resumes it to run cleanup against a loop that no longer exists. For a server parked in
_wait_for_reconnect_or_shutdown, that cleanup ist.cancel()→call_soon()→RuntimeError. Because it happens in a finalizer, Python can only print "Exception ignored in".How a task ends up unowned:
run()deliberately stays alive after exhausting_MAX_INITIAL_CONNECT_RETRIESso a later refresh can revive it, butstart()then raises_errorwithout cancellingself._task, so_discover_and_register_servernever reaches_servers[name] = server.shutdown_mcp_servers()iterates only_servers, so nothing ever sets that task's_shutdown_event.The fix
Drain the loop the way
asyncio.run()does before closing it — cancel the remaining tasks andgatherthem while the loop is still open, so each runs its ownfinally.Cancel-without-gather would not work:
Task.cancel()only schedules the throw.This is deliberately not a
try/exceptaround the raise site. #64960 took that route and the reporter of #66113 observed the error "shifted but never fully disappeared" — silencing the exception leaves the cleanup unrun. This makes the cleanup actually run.Scope — please read before merging
This fixes the traceback, not the leak. Until final shutdown the parked task is still unowned: every rediscovery constructs another one (
:5106and:5224classify servers purely by absence from_servers), and each self-probes every_PARKED_RETRY_INTERVAL. Long-lived gateways still accumulate them; this change just reaps them correctly at the end.The ownership fix is a separate, behaviour-changing patch with a design fork I'd like maintainer input on first — I laid the options out in the issue comment linked above. I used "Addresses" rather than "Fixes" on purpose, so #60197 stays open for that follow-up.
Testing
tests/tools/test_mcp_stability.py::TestMCPLoopDrainOnStop— puts a task on the MCP loop whosefinallycancels a helper (the operation that needs a live loop), then calls_stop_mcp_loop().Verified it's a real regression test:
task.done() = False, cleanup never ran, and Python printsTask was destroyed but it is pending!Full MCP suite (
pytest tests/tools/ -k mcp): 663 passed with this change vs 662 passed onmain— the same 5 pre-existing failures intest_mcp_structured_content.pyappear on both (they pass in isolation; looks like unrelated cross-test pollution).🤖 Generated with Claude Code