Skip to content

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
IABTechLab:mainfrom
garvitkaushik-123:fix/execution-activation-cancel-scope
Open

fix: hold the MCP session open in one background task, don't split enter/exit#66
garvitkaushik-123 wants to merge 1 commit into
IABTechLab:mainfrom
garvitkaushik-123:fix/execution-activation-cancel-scope

Conversation

@garvitkaushik-123

Copy link
Copy Markdown
Contributor

Summary

Part 2 of #60.

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 RuntimeError: 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.

Root-cause isolation

Before touching anything, confirmed this precisely:

  • A bare streamablehttp_client(url).__aenter__() / manual __aexit__() later, against an unreachable host, reproduces the crash on its own — 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.

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 closing streamablehttp_client and ClientSession within one unbroken async with, in that one task, 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 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

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 method OpenDirect21Client never 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:

  • connection failure degrades cleanly with no raise (the regression itself)
  • _call_tool falls back to REST when MCP never connected
  • 5 repeated connect/disconnect cycles
  • disconnect() without a prior connect() is a no-op
  • 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 --check clean.

Closes #60 (part 2, alongside #63 for part 1).

…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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Stale '@listen() CrewAI version mismatch' test stub hides zero coverage on discovery_inquiry_flow (execution_activation_flow has a real, different bug)

1 participant