fix: hold the MCP session open in one background task, don't split enter/exit - #66
Open
garvitkaushik-123 wants to merge 1 commit into
Open
Conversation
…ter/exit (issue IABTechLab#60 part 2) OpenDirect21Client.connect()/disconnect() used to split streamablehttp_client's and ClientSession's __aenter__/__aexit__ across two separate method calls. That's fine as long as the connection succeeds -- but the moment the attempt itself failed, __aenter__ never returned, so __aexit__ was never called by anything. The transport was abandoned to Python's async-generator GC finalizer, which runs in whatever task the garbage collector happens to be executing in at finalization time -- not necessarily the task that opened the connection. anyio requires a cancel scope to be entered and exited by the same task, so that mismatch crashed with "Attempted to exit cancel scope in a different task than it was entered in", and the crash propagated all the way up through kickoff_async(), taking down the whole ExecutionActivationFlow. Confirmed the root cause in isolation before touching anything: a bare `streamablehttp_client(url).__aenter__()` / manual `__aexit__()` later against an unreachable host reproduces the crash on its own, with nothing else from this codebase involved. A plain, unbroken nested `async with streamablehttp_client(...) as (r, w, _): async with ClientSession(r, w) as session: ...` against the same unreachable host fails cleanly with an ordinary ExceptionGroup -- no crash. The fix has to make connect()/disconnect() behave like that nested block, not like two independent calls. connect() now starts a background task that owns the MCP session's entire lifetime -- opening AND closing streamablehttp_client and ClientSession within one unbroken `async with`, in that one task, from start to finish. The task blocks on an asyncio.Event in between; disconnect() sets the event and awaits the task to let it unwind naturally, in the same task it opened in. This mirrors the pattern deals_api_mcp_client.py already uses for exactly this reason, cited in its own docstring: "Satisfies anyio's cancel-scope invariant by running the full streamablehttp_client lifecycle inside a single background asyncio Task." Verified: the original ExecutionActivationFlow repro from IABTechLab#60 no longer raises anything -- kickoff_async() now completes and the connection failure is recorded as a plain state warning, exactly the graceful-degrade behavior create_execution_order's own (now reachable) try/except was always meant to provide. Verified both execution_type paths (deal_id and io_order). Verified 5 repeated connect/disconnect cycles against an unreachable host in a row. Found along the way, not fixed here (separate bug, out of scope for this crash): with the crash no longer masking it, the resulting warning reads "'OpenDirect21Client' object has no attribute 'create_execution_order'" -- UnifiedClient.create_execution_order() calls a method OpenDirect21Client never actually defines. Worth its own issue. Tests: new tests/unit/test_opendirect21_client.py -- connection failure degrades cleanly with no raise (the regression), REST fallback via _call_tool when MCP never connected, 5 repeated cycles, disconnect without a prior connect is a no-op, and a mocked successful connection still sets up the session/tools and disconnects both context managers correctly (the happy path the rewrite must not break). Full suite: 1484 passed, 28 skipped (pre-existing, unrelated), no regressions. ruff check / format clean. Closes IABTechLab#60 (part 2, alongside IABTechLab#63 for part 1).
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.
Summary
Part 2 of #60.
OpenDirect21Client.connect()/disconnect()used to splitstreamablehttp_client's andClientSession's__aenter__/__aexit__across two separate method calls. That's fine as long as the connection succeeds — but the moment the attempt itself failed,__aenter__never returned, so__aexit__was never called by anything. The transport was abandoned to Python's async-generator GC finalizer, which runs in whatever task the garbage collector happens to be executing in at finalization time — not necessarily the task that opened the connection.anyiorequires a cancel scope to be entered and exited by the same task, so that mismatch crashed withRuntimeError: Attempted to exit cancel scope in a different task than it was entered in, and the crash propagated all the way up throughkickoff_async(), taking down the wholeExecutionActivationFlow.Root-cause isolation
Before touching anything, confirmed this precisely:
streamablehttp_client(url).__aenter__()/ manual__aexit__()later, against an unreachable host, reproduces the crash on its own — nothing else from this codebase involved.async with streamablehttp_client(...) as (r, w, _): async with ClientSession(r, w) as session: ...against the same unreachable host fails cleanly with an ordinaryExceptionGroup— no crash.So the fix has to make
connect()/disconnect()behave like that nested block, not like two independent calls split across method boundaries.Fix
connect()now starts a background task that owns the MCP session's entire lifetime — opening and closingstreamablehttp_clientandClientSessionwithin one unbrokenasync with, in that one task, start to finish. The task blocks on anasyncio.Eventin between;disconnect()sets the event and awaits the task to let it unwind naturally, in the same task it opened in.This mirrors a pattern already proven safe elsewhere in this codebase —
deals_api_mcp_client.py's own docstring says exactly why: "Satisfies anyio's cancel-scope invariant by running the full streamablehttp_client lifecycle inside a single background asyncio Task."Verification
ExecutionActivationFlowrepro from Stale '@listen() CrewAI version mismatch' test stub hides zero coverage on discovery_inquiry_flow (execution_activation_flow has a real, different bug) #60 no longer raises anything —kickoff_async()completes, and the connection failure is recorded as a plain state warning, exactly the graceful-degrade behaviorcreate_execution_order's own (now actually reachable)try/exceptwas always meant to provide.execution_typepaths (deal_idandio_order).Found along the way, not fixed here
With the crash no longer masking it, the resulting warning now reads:
"'OpenDirect21Client' object has no attribute 'create_execution_order'".UnifiedClient.create_execution_order()calls a methodOpenDirect21Clientnever actually defines. Separate bug, out of scope for this crash fix — worth its own issue if useful, happy to file it.Test plan
New
tests/unit/test_opendirect21_client.py:_call_toolfalls back to REST when MCP never connecteddisconnect()without a priorconnect()is a no-opFull suite: 1484 passed, 28 skipped (pre-existing, unrelated), no regressions.
ruff check/format --checkclean.Closes #60 (part 2, alongside #63 for part 1).