fix: Close inner SDK auth generator to prevent OAuth reconnect deadlock - #38198
fix: Close inner SDK auth generator to prevent OAuth reconnect deadlock#38198igorhvr wants to merge 1 commit into
Conversation
This fixes NousResearch#38193 Add a `finally: await inner.aclose()` block in HermesMCPOAuthProvider.async_auth_flow to deterministically close the inner MCP SDK auth-flow generator when the Hermes wrapper is closed. Without this, keepalive transport failures on OAuth-backed MCP servers (e.g. Databricks) orphan the inner SDK generator suspended inside `async with self.context.lock`. When Python's async-generator finalizer later runs from a different task, anyio's task-ownership check on lock release raises RuntimeError("The current task is not holding this lock") and leaves the lock permanently held. Cached provider reuse then makes reconnect hang until the gateway process is restarted. - Add regression test proving the lock stays held after aclose() without the fix - Add finally block to close the inner SDK generator and release the anyio.Lock - Keep existing bidirectional .asend() forwarding logic unchanged - All 73 existing MCP OAuth tests continue to pass Co-Authored-By: Tamandua <tamandua@tetradactyla.org>
|
Found one minor issue worth fixing.
# Current (line 166):
raise AssertionError(
# Should be:
raise AssertionError(The core fix in |
Hi @liuhao1024 ! Thanks for reviewing. However, both lines look the same to me here... I also checked and could not find the version with the missing s in the patch (see below). Can you please help me see what am I missing? igorhvr:hermes-agent/ $ grep AsertionError /tmp/oauth-mcp-server-deadlock-after-keepalive-reconnect.patch [16:31:04]
|
tonydwb
left a comment
There was a problem hiding this comment.
Code Review Summary
Verdict: Approved
Changes
Closes an OAuth reconnect deadlock in HermesMCPOAuthProvider.async_auth_flow by adding finally: await inner.aclose() to the function body so the inner SDK auth generator is deterministically closed from the owning asyncio task — releasing the anyio.Lock before the wrapper returns.
Background
Without this fix:
- HTTPX closes the Hermes wrapper generator when the auth flow ends
- The inner SDK generator remains suspended inside
async with self.context.lock - Python's async generator finalizer tries to release the lock from a different task
- anyio raises
RuntimeError("The current task is not holding this lock") - Lock is permanently held → all subsequent reconnects deadlock until process restart
Testing
Regression test test_mcp_oauth_generator_cleanup.py directly validates the deadlock scenario — it creates a real provider, drives the inner generator to its yield point inside the lock, closes the wrapper, and asserts the lock is released without cross-task errors.
Code Quality
- The fix is minimal and surgical (4 lines).
- The detailed inline comment documenting the deadlock mechanism is excellent for future maintainers.
Reviewed by Hermes Agent
|
I hit what looks like the same failure path today with the official Swiggy Instamart remote OAuth MCP server ( Observed before this fix in the gateway logs: I tested this PR locally in an isolated worktree against the same configured OAuth MCP server and cached credentials. It fixes the Swiggy case without any server-specific workaround. Validation run from this PR branch: I also ran a read-only native tool call via So this appears to cover a real OAuth remote-MCP gateway failure beyond the original Databricks report. |
|
Cross-link: #63495 is a transparent current- |
Related: #63495 is a transparent current- |
teknium1
left a comment
There was a problem hiding this comment.
Thanks for isolating the OAuth bridge teardown failure. The production premise still holds on current main: tools/mcp_oauth_manager.py:416-424 creates and drives the SDK generator, while the method exits through the normal StopAsyncIteration branch at lines 425-429 with no finally to close that inner generator. The proposed finally: await inner.aclose() therefore binds the inner flow to the outer HTTPX-owned flow without changing the bidirectional forwarding contract.
Problems
tests/tools/test_mcp_oauth_generator_cleanup.py:121-125depends ongc.collect()and event-loop timing rather than HTTPX's actual timeout/teardown path; it also does not prove provider reuse after teardown.- The temporary exception handler is restored to
Nonerather than the prior handler (tests/tools/test_mcp_oauth_generator_cleanup.py:111-147), and its assertion only readscontext["message"], notcontext["exception"](:133-139).
Suggested changes
- Preserve the fix commit, but use the deterministic HTTPX timeout plus second-request regression in related current-main salvage PR #63495 (
tests/tools/test_mcp_oauth_bidirectional.py). It asserts both lock release and successful reuse.
This is an automated hermes-sweeper review.
| try: | ||
| # Close the Hermes wrapper. Without the fix, this only closes the | ||
| # outer wrapper; the inner SDK generator stays suspended with the | ||
| # lock held. |
There was a problem hiding this comment.
This relies on GC/finalizer scheduling rather than the HTTPX teardown that triggers the reported production path. Please replace it with a MockTransport timeout through httpx.AsyncClient, then assert the lock is released and a second request using the same provider succeeds; related #63495 has that deterministic shape.
| captured_exceptions.append({ | ||
| "message": context.get("message", ""), | ||
| "exception": context.get("exception"), | ||
| }) |
There was a problem hiding this comment.
Save loop.get_exception_handler() before replacing it and restore that saved handler in finally; restoring None can discard a handler installed by the test runner or enclosing test infrastructure.
| "the inner SDK auth generator should have been properly closed, " | ||
| "releasing the anyio.Lock it acquired" | ||
| ) | ||
|
|
There was a problem hiding this comment.
Also inspect exc_info["exception"]: asyncio normally puts the actual RuntimeError there while message is often only Task exception was never retrieved, so the current check can miss the error it intends to detect.
This fixes #38193
Add a
finally: await inner.aclose()block inHermesMCPOAuthProvider.async_auth_flow to deterministically close the inner MCP SDK auth-flow generator when the Hermes wrapper is closed.
Without this, keepalive transport failures on OAuth-backed MCP servers (e.g. Databricks) orphan the inner SDK generator suspended inside
async with self.context.lock. When Python's async-generator finalizer later runs from a different task, anyio's task-ownership check on lock release raises RuntimeError("The current task is not holding this lock") and leaves the lock permanently held. Cached provider reuse then makes reconnect hang until the gateway process is restarted.What does this PR do?
Related Issue
Fixes #
Type of Change
Changes Made
How to Test
Checklist
Code
fix(scope):,feat(scope):, etc.)pytest tests/ -qand all tests passDocumentation & Housekeeping
docs/, docstrings) — or N/Acli-config.yaml.exampleif I added/changed config keys — or N/ACONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — or N/AFor New Skills
hermes --toolsets skills -q "Use the X skill to do Y"Screenshots / Logs