Conversation
When an MCP operation exceeds its deadline, _run_isolated_operation abandoned the worker (kill(terminate_thread=False)). A worker whose event loop is still cycling then spins at 100% CPU holding the GIL for the lifetime of the process, which starves every other Python thread and makes further update_tools polls likelier to time out in turn. Observed on Agent Zero latest: one leaked "MCPClient-<server>-update_tools" thread at 11h55m of CPU, measured at 100.8% of a core over a 10s sample, py-spy showing it active+gil in nest_asyncio run_forever/_run_once. Across ~12h, 60 abandonments over four servers left exactly one such thread alive, so most workers do exit -- but a wedged one never does. Stopping the loop ends the spin and lets the thread exit. It deliberately does not drain tasks or join the thread: both are unbounded on a wedged loop, which is why the timeout path could not terminate the worker in the first place. This keeps the change self-contained in mcp_handler and avoids overlapping the defer.py teardown work in agent0ai#1794. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
When an MCP operation exceeds its deadline,
MCPClientBase._run_isolated_operationabandons the worker:A worker whose event loop is still cycling then spins at 100% CPU holding the GIL for the lifetime of the process. That starves every other Python thread, which makes subsequent
update_toolspolls likelier to blow their own deadline in turn.Evidence
Observed on
agent0ai/agent-zero:latest:MCPClient-<server>-update_tools-<uuid>thread at 11h55m of CPU since container start/proc/<pid>/task/<tid>/statsamplepy-spy dumpshowed itactive+gil:docker restartreclaimed the core: 100.17% → 0.51%Why not simply
terminate_thread=TrueThat was my first instinct and it is wrong on current
ready.DeferredTask.kill(terminate_thread=True)waits oncleanup_future.result()andthread.join(), both unbounded, and the drain runs on the very loop that is not progressing. Verified: against pristineready,kill(terminate_thread=True)on a wedged worker never returns (killed at a 20s cap). Abandoning was a deliberate trade — "so Agent Zero can continue" — not an oversight.So this PR does not touch that path.
Fix
Stop the loop, and wait on nothing:
loop.stop()is what ends the spin. No drain, no join, so the caller cannot block — the property that made abandoning necessary is preserved.Relationship to #1794
#1794 bounds the
defer.pyteardown (DRAIN_TIMEOUT,cleanup_future.result(timeout=...),thread.join(timeout=...)). It does not touchmcp_handler.py, so the MCP timeout path still passesterminate_thread=Falseand this leak survives it.Deliberately kept self-contained so there is no overlap or merge-order dependency: this is safe to merge before or after #1794, in either order. Once #1794 lands,
_stop_worker_loopcan be folded into a plainkill(terminate_thread=True)and I'm happy to send that follow-up.Tradeoff
Stopping without draining emits
Task was destroyed but it is pending!for the abandoned operation. That is strictly better than a thread spinning at 100% CPU forever, and #1794's bounded drain would remove the warning.Validation
tests/test_mcp_worker_termination.py, driving a genuinely spinning worker (while True: await asyncio.sleep(0)— the state a wedged worker is actually left in, as opposed to a blocked loop, which no callback can reach).pytest tests/in full here —helpers.mcp_handlerimports the whole model stack (litellm) and I did not have those dependencies installed. The new test should run in CI where they are present; please flag if it does not.Based on
ready, matching the branch recent merged PRs use.🤖 Generated with Claude Code