Skip to content

fix(mcp): correct _stdio_children_dead logic and prevent coroutine leak (#96016, #96030) - #96044

Closed
loulanyue wants to merge 1 commit into
NousResearch:mainfrom
loulanyue:fix/96016-stdio-children-dead-logic
Closed

loulanyue wants to merge 1 commit into
NousResearch:mainfrom
loulanyue:fix/96016-stdio-children-dead-logic

Conversation

@loulanyue

Copy link
Copy Markdown
Contributor

Summary

Fixes #96016, #96019, #96030

  • In tools/mcp_tool.py (_stdio_children_dead):
    • Fix logic inversion: previously returned True when psutil.pid_exists(pid) was True (indicating the process is alive), which caused live stdio MCP subprocesses to be prematurely flagged as dead and killed with TimeoutError.
    • Now correctly returns False if any tracked child PID is alive, and True only when all child processes have exited.
  • In tools/mcp_tool.py (_make_tool_handler):
    • Fix unawaited coroutine leak in _watch_ok check by avoiding calling _watch_children() directly during liveness inspection.
  • In tests/tools/test_mcp_stdio_children_dead.py:
    • Add unit tests verifying _stdio_children_dead behavior on empty, alive, and dead PIDs.

@alt-glitch alt-glitch added type/bug Something isn't working P1 High — major feature broken, no workaround comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint tool/mcp MCP client and OAuth labels Aug 27, 2026
@KeyArgo

KeyArgo commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Corroborating #96030 — independent confirmation of the coroutine-leak premise.

I independently root-caused #96030 (reporter ilgo) to the same line: _make_tool_handler's stdio-watch fast-fail guard at tools/mcp_tool.py (~6173) evaluated inspect.isawaitable(_watch_children()), which CALLS _watch_children() and discards the returned coroutine unawaited. On every successful MCP tool call (stdio child-watcher present) that leaks one RuntimeWarning: coroutine 'MCPServerTask._watch_stdio_children' was never awaited per call — confirmed on upstream main.

Your change to probe the function type (inspect.iscoroutinefunction(_watch_children) or callable(...)) instead of invoking it resolves the leak, and the accompanying _stdio_children_dead fix addresses the sibling unreachable-return False in the same race (#96016). Your PR is broader than my local draft (which only touched the guard line), so I'm not pushing a competing fix — yours is the right one.

One suggestion for review: callable() is true for any bound method, so the or callable(_watch_children) arm can never gate anything on a real server (the child-watcher is always a bound coroutine method there). It only matters for MagicMock/stub sessions, where it correctly re-enables the race. That's fine behaviorally, just noting the arm is effectively a mock-compat allowance rather than a liveness check — might read clearer as a comment. Not blocking.

@kshitijk4poor

Copy link
Copy Markdown
Contributor

Closing — both halves of this PR are now handled, with your coroutine-leak fix landing via salvage:

Thanks for the contribution — the leak half would have been easy to miss under the pile of polarity duplicates.

teknium1 pushed a commit that referenced this pull request Sep 2, 2026
…k probe

The fast-fail gate probed the stdio child watcher by CALLING it —
inspect.isawaitable(_watch_children()) — creating a fresh coroutine on
every stdio MCP tool call that was never awaited (RuntimeWarning spam +
gc churn). Inspect the function instead of invoking it.

Salvaged (unique hunk only) from PR #96044; the bundled
_stdio_children_dead polarity fix was already on main via #94339.
teknium1 pushed a commit that referenced this pull request Sep 2, 2026
…function

Follow-up to the salvaged #96044 hunk: drop the 'or callable(...)' arm —
callable(MagicMock) is True, which would have flipped stubbed sessions
into the fast-fail race the surrounding comment explicitly routes to the
plain-await path. inspect.iscoroutinefunction alone reproduces the old
isawaitable(call) split exactly (real async def / AsyncMock -> race,
MagicMock -> plain await) without creating the leaked coroutine.
melon-xf added a commit to melon-xf/hermes-agent that referenced this pull request Sep 3, 2026
…k probe

The fast-fail gate probed the stdio child watcher by CALLING it —
inspect.isawaitable(_watch_children()) — creating a fresh coroutine on
every stdio MCP tool call that was never awaited (RuntimeWarning spam +
gc churn). Inspect the function instead of invoking it.

Salvaged (unique hunk only) from PR NousResearch#96044; the bundled
_stdio_children_dead polarity fix was already on main via NousResearch#94339.
melon-xf added a commit to melon-xf/hermes-agent that referenced this pull request Sep 3, 2026
…function

Follow-up to the salvaged NousResearch#96044 hunk: drop the 'or callable(...)' arm —
callable(MagicMock) is True, which would have flipped stubbed sessions
into the fast-fail race the surrounding comment explicitly routes to the
plain-await path. inspect.iscoroutinefunction alone reproduces the old
isawaitable(call) split exactly (real async def / AsyncMock -> race,
MagicMock -> plain await) without creating the leaked coroutine.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P1 High — major feature broken, no workaround tool/mcp MCP client and OAuth type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MCP stdio fast-fail inverted: _stdio_children_dead() reports a LIVE child as dead, breaking every call to healthy stdio servers

4 participants