fix(tools): skip MCP keepalive during in-flight calls + fail orphaned calls on reconnect - #48069
fix(tools): skip MCP keepalive during in-flight calls + fail orphaned calls on reconnect#48069arminanton wants to merge 3 commits into
Conversation
… calls on reconnect An MCP stdio session is a single JSON-RPC stream. The idle keepalive (list_tools/send_ping) could fire while a call_tool was in flight, wedging the stream so the call timed out -> false reconnect -> the SDK does not always fail the pending call when its streams close, so its run_coroutine_threadsafe future never resolves and the agent thread polls to the full tool_timeout (hours). The tree already serializes RPCs via _rpc_lock (mcp_tool.py:1193) but the keepalive loop does not participate: its list_tools (:1398) / send_ping (:1403) run outside the lock, nothing skips the cycle when a call is active, and a reconnect/shutdown never cancels a pending call. Fix: track in-flight call tasks; skip the keepalive when a call is active and otherwise run the probe under _rpc_lock; _fail_inflight_calls() cancels pending calls on reconnect/shutdown; _call() converts a deliberate teardown-cancel into a retryable RuntimeError so the agent self-heals on the rebuilt session. Builds on the existing _rpc_lock groundwork. Related to NousResearch#30268 (partial mitigation of the post-sleep keepalive storm); complementary to NousResearch#30694 and NousResearch#26493.
…light-race # Conflicts: # tools/mcp_tool.py
PR CAMPAIGN — INDEPENDENT CHERRY-PICK RE-APPLICATION VERDICT (correction, 2026-06-21)This SUPERSEDES the §2 "36/37 clean" claim from the earlier git-apply proof. An independent TRUE ISOLATED VERDICT vs v0.17.0: 35 CLEAN / 2 CONFLICT (of 37)CONFLICT #1 — #50056 (sqlite-driver): tests/hermes_cli/test_kanban_db.py CONFLICT #2 — #48069 (mcp-keepalive-inflight-race): tools/mcp_tool.py [SUBSTANTIVE — was hidden by git-apply] HONEST IMPLICATION"Re-appliable onto v0.17" is TRUE but NOT "trivial for all": 35 PRs apply with zero edits, STILL OUTSIDE AGENT CONTROLCI: all 37 fork PRs = "no checks reported" (NousResearch holds fork-PR workflow runs pending Reproduce: |
Forward-compat: pulling this PR onto v0.17.0 (2bd1977)This PR targets A ready-to-pull branch with the keep-both resolution committed is published: Note for re-appliers: this PR's branch head ( |
After a reconnect cycle, _reconnecting stayed True, so the next legitimate in-flight call could be falsely converted to a 'reconnected, retry' error. Clear the deliberate-teardown flag when entering a healthy wait state (session established + ready), matching the _reconnecting=True set on teardown. Picks up a refinement that post-dated the original branch.
…econciler) - independent_coverage_proof.sh: per-file content-containment coverage (different method from reconcile_campaign.sh). Caught + fixed 4 lines the reconciler missed (google_user_agent utf-8 drift -> aligned to NousResearch#50033). Now 0 uncovered, both methods agree. - independent_cumulative_verify.sh: cumulative apply onto fresh v0.17.0 + reproduce-src- from-v0.16.0 proofs. - INDEPENDENT-VERIFICATION-ROUND2.md: the 3 web_server failures reproduced on PRISTINE v0.17.0 (upstream, not ours); real-cherry-pick composition clean except the 2 documented drifts (NousResearch#48069, NousResearch#50056).
…file-residual analysis - external_cherrypick_all_prs.sh: real git cherry-pick of all 40 feature PR REMOTE heads onto fresh v0.17.0 = 37 CLEAN + 2 documented drifts (NousResearch#48069, NousResearch#50056); NousResearch#48101 conflict is a transient (clean in isolation). - INDEPENDENT-VERIFICATION-ROUND3.md: confirms NousResearch#50033 remote head carries the utf-8 fix; explains all 94 whole-file diffs (upstream drift / PR-adds-new-file / private-deferred); per-file content-containment vs remote heads = 0 uncovered.
…usResearch#4) Full stack on v0.17.0: build OK (0 compile errors), test slice 672 passed / 0 failed vs src baseline 604 passed / 0 failed (delta = PR-added new tests; both 100% pass, no regressions). Cherry-pick 37 clean + 3 documented-drift-resolved. Operator apply-time conflict notes added to NousResearch#48069/NousResearch#50056/NousResearch#48101 descriptions.
…-resolved on v0.17.0 Regenerated from the CURRENT NousResearch#48069 head (4a1fbe9, includes the getattr(server, '_inflight_tasks', None) hardening). Net diff applies cleanly onto v0.17.0; the complementary-additive __slots__/__init__ overlaps with v0.17.0's _pending_call_context are kept-both. tests/tools/test_mcp_{structured_content,tool}.py: 205 passed, 0 failed. Supersedes the earlier stale forward-compat branch that predated the getattr fix.
…ailure evidence - diff_equivalence_proof.sh + .out: union(41 PR diffs)+NousResearch#50111 reconstructs every src-added line = 0 residual (13493 added lines, 139 files, 14 audited multi-PR overlaps, 0 collide). - PER-PR-STATE-TABLE.txt: all 41 OPEN (8 review/33 draft), 0 merged/closed, rebase/build/test. - pristine-v017-web_server-FAILURES.log: the 6 web_server fails reproduced on pristine v0.17.0, zero PRs (proves NousResearch#50066/NousResearch#50086 upstream). - PR-body notes added: NousResearch#50078 stack-declaration, NousResearch#50031 live-cred, NousResearch#50066/NousResearch#50086 upstream, NousResearch#48069/NousResearch#50056 apply-time (verified).
…0-clean resolved forms as PR-resident pullable patches (git apply --check exit 0 onto fresh v0.17.0)
… v0.17.0-changed files, all COMPLEMENTARY (clean 3-way), NousResearch#48069 deep-dive proves ours adds in-flight guard v0.17.0 lacks; (2) CORRECTED build-test — 91 full-suite fails are pre-existing pollution (pristine v0.17.0 fails 79 too, every file passes isolated), 0 campaign regressions
…rch#50626), refresh PINNED-SHAS - BUILD-TEST-VERIFICATION.txt: NousResearch#50064 (18 passed, fixed a real dropped-@patch collection-error), NousResearch#49644 (10 passed), NousResearch#48069 (10 passed) — all on correct base. - REPRODUCE.sh: fix coverage union to use git-diff (not gh --files which caps at 100 and produced false 'unmapped'). Re-verified 0 real orphans across 42 PRs. - PINNED-SHAS.txt: regenerated from live GitHub (42 PRs, 8 ready / 34 draft), reconciling 11 drifted heads. - 3 previously-orphaned files (subdirectory_hints + xai label) re-homed into new draft PR NousResearch#50626.
|
Thanks for documenting the in-flight keepalive race and the broader orphan-call failure mode. I reproduced the same stream wedge on current The current #48069 head is 2,923 commits behind and The direct regression was RED→GREEN; 265 targeted MCP tests, including both lock orderings, ruff, the Windows-footgun scan, and a fresh-process real completion canary pass. |
|
Thanks for documenting and isolating the MCP stream race. The premise remains present on current main: the lifecycle loop invokes Problems
Suggested changes
This is an automated hermes-sweeper review. |
What does this PR do?
Stops the MCP idle keepalive from racing an in-flight tool call, and fails orphaned calls cleanly when the session is torn down. Together these close a failure mode where a single MCP tool call could hang the agent for up to
tool_timeout(hours).The bug
An MCP stdio session is a single JSON-RPC stream. The idle keepalive (
list_tools/send_ping) runs on a timer independent of tool dispatch, so it can fire while acall_toolis in flight. The concurrent request wedges the stream, the in-flight call times out, that timeout triggers a reconnect, and the MCP SDK does not always fail the pendingcall_toolwhen its streams close — so itsrun_coroutine_threadsafefuture never resolves and the calling agent thread polls to the fulltool_timeout(up to hours). The user sees the agent "hang" on a tool that the server may have actually answered.Relationship to existing code (what's already here vs. the remaining gap)
The current tree already serializes client-initiated RPCs via
self._rpc_lock(tools/mcp_tool.py:1193, with the rationale comment at:1187-1192describing exactly this "list_tools while a normal tool call is in flight" wedge). That lock is applied around thecall_tooldispatch path.But the keepalive loop itself does not participate in that protection. On current
main:list_tools()(tools/mcp_tool.py:1398) andsend_ping()(:1403) are not taken under_rpc_lock, so the keepalive can still issue a concurrent request on the shared stream while acall_toolruns;call_tool, so it can be orphaned (the hang above).This PR closes that remaining gap by extending the existing
_rpc_lockdiscipline to the keepalive and adding orphan-cancellation on teardown. It builds on the_rpc_lockgroundwork rather than duplicating it.The fix
_inflight_tasksset and a_reconnectingflag onMCPServerTask.self._rpc_lock.locked() or self._inflight_tasks) — a server actively serving a call is provably alive — and otherwise runs thelist_tools/send_pingprobe under_rpc_lock, so it can never overlap acall_tool._fail_inflight_calls()cancels pending call tasks on reconnect/shutdown instead of orphaning them._call()registers itself in_inflight_tasksand, when a deliberate teardown cancels it (_reconnecting), converts theCancelledErrorinto a clean, retryableRuntimeError("reconnected during the call; retry the tool") so the agent re-runs the tool on the freshly rebuilt session (self-healing) instead of hanging.Related Issue
Related to #30268 ("All connected MCP servers fail keepalive simultaneously after Mac sleep/wake or network blip"). This PR doesn't fully fix the sleep/wake storm, but it partially mitigates it: skipping the keepalive while calls are active reduces spurious reconnect churn, and cancelling orphaned calls on reconnect means a post-wake reconnect no longer leaves a call hanging to
tool_timeout.Complementary to (not overlapping) the other open MCP-keepalive PRs:
Type of Change
Changes Made
tools/mcp_tool.py:MCPServerTask.__slots__/__init__: add_inflight_tasks(set) and_reconnecting(bool).list_tools/send_pingprobe under_rpc_lock._fail_inflight_calls(reason)called on both theshutdownandreconnectexits.call_tool_call()coroutine: register/discard the task in_inflight_tasksand convert a deliberate-teardownCancelledErrorinto a retryableRuntimeError.tests/tools/test_mcp_keepalive_inflight_race.py: 5 new tests (empty initial state;_fail_inflight_callsno-op when idle; cancels pending + flags teardown; add/discard bookkeeping; the_reconnectingflag distinguishing a deliberate teardown).How to Test
list_tools/send_pingcan wedge the stream → the call times out → reconnect → the call is orphaned and the agent hangs totool_timeout. After: the keepalive is skipped while the call is active; if a reconnect does occur, the call is cancelled and surfaces a clean "retry the tool" error.Checklist
Code
fix(tools):)_rpc_lockatmcp_tool.py:1193)pytest tests/ -qand all tests pass (5 new + 238 existing MCP tests across cancel/reconnect/stability/capability/tool suites, no regression)Documentation & Housekeeping
_fail_inflight_callsdocstring explaining the orphan-hang mechanism) — or N/Acli-config.yaml.exampleif I added/changed config keys — N/A (no config keys)CONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — N/AWhen cherry-picking/rebasing this PR onto v0.17.0 (
2bd1977d8), expect one conflict intools/mcp_tool.py. This is EXPECTED and documented: origin/main commit40722058erefactored our inline keepalive intoself._keepalive_probe(), so the same region is touched on both sides.Resolution (keep-both): retain v0.17.0's
_keepalive_probe()helper structure AND this PR's_inflight_tasksskip-guard + orphan-fail-on-reconnect logic. The load-bearing guard (if self._rpc_lock.locked() or self._inflight_tasks: continue) sits just above the conflict and merges clean. A ready-to-pull pre-resolved branch is published atforward-compat/48069-on-v0.17.0on the fork (0 conflict markers, subsystem tests green). Full recipe:#50111:verification/+ the drifted-PR reference.