fix(mcp): tear down the in-flight owner when get_session is cancelled mid-eviction (#5008)

* fix(mcp): keep session owner teardown safe across cancellation paths

* fix(mcp): gate pooled-session publication on the commit inside the owner task

The owner resolved `ready` as soon as initialize() finished, but the
session only became pool property when the creator promoted it into
_entries in Phase 4. A concurrent get_session() could join the in-flight
creation and receive that session from Phase 2b while the creator was
still parked in the Phase-2 eviction teardown; cancelling the creator
then ran the Phase-2 unwind, which unconditionally shut the owner down —
closing the session underneath the joiner (#5008 review).

Move the commit into the owner task: initialize() success now pops the
in-flight record, registers the session in _entries, and resolves ready
with the session in one atomic critical section, so 'ready resolved with
a result' is exactly 'session registered and pool-owned'. Joiners can
therefore only ever receive a committed session, and both creator unwind
paths (Phase 2 and Phase 3) skip teardown when the creation already
committed, leaving the pooled session to LRU eviction / close_*. When the
record was removed before the commit (close_* or creator unwind), the
owner aborts and ready carries the same cancellation the old Phase-4
not-still-ours path raised, so joiners fail with the creation's outcome
instead of hanging or holding an unmanaged session.

test_cancelled_creator_does_not_close_session_held_by_joiner reproduces
the review's scenario deterministically (MAX_SESSIONS=1, hung LRU victim,
gated initialize, second caller receives the session, creator cancelled):
red on the previous commit, green now.
test_joiner_follows_creation_outcome_when_creator_is_cancelled pins the
joiner outcome-gating semantics as a drift guard.
This commit is contained in:
hataa 2026-08-30 11:36:26 +08:00 committed by GitHub
parent 0dd233afc4
commit 567a06783c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 831 additions and 99 deletions

View File

@ -29,6 +29,13 @@ To make this impossible, every pooled session is owned by a dedicated
session back to the caller, and then *waits* on a close event. All shutdown
paths only ever **signal** that event; the owner task performs ``__aexit__``
itself, guaranteeing enter and exit always happen in the same task.
The owner task is also the only writer that promotes a creation into the pool:
once ``initialize()`` succeeds it registers the session in ``_entries`` and
resolves the creation's future in one atomic critical section, so callers can
only ever receive a session the pool already owns (and will retire via LRU
eviction or the close_* paths) — never one whose lifetime is still tied to a
single caller that might get cancelled.
"""
from __future__ import annotations
@ -132,6 +139,10 @@ class MCPSessionPool:
# In-flight creations, keyed by (server, scope). Lets concurrent callers
# on the same loop share a single creation instead of each spawning a
# duplicate session. Value: (loop, ready_future, owner_task, close_event).
# The owner task promotes the record into ``_entries`` and resolves
# ``ready`` with the session in one atomic critical section (see
# ``_run_session``), so ``ready`` resolving with a *result* always means
# the session is registered — never merely handed to one caller.
self._inflight: dict[
tuple[str, str],
tuple[
@ -144,6 +155,12 @@ class MCPSessionPool:
# threading.Lock is not bound to any event loop, so it is safe to
# acquire from both async paths and sync/worker-thread paths.
self._lock = threading.Lock()
# Strong references to detached teardown reaper tasks. The event loop
# only keeps weak references to tasks, so an unheld reaper — and with
# it the owner it is awaiting, mid-__aexit__ — could be
# garbage-collected before teardown completes; the done callback keeps
# the set from growing without bound.
self._teardown_tasks: set[asyncio.Task[Any]] = set()
# ------------------------------------------------------------------
# Session owner task
@ -151,16 +168,19 @@ class MCPSessionPool:
async def _run_session(
self,
key: tuple[str, str],
connection: dict[str, Any],
ready: asyncio.Future[ClientSession],
close_evt: asyncio.Event,
) -> None:
"""Own a single MCP session for its entire lifetime.
Enters the session context manager, initializes it, publishes the live
session via ``ready``, then blocks until ``close_evt`` is set. The
context manager is *always* exited from this task, satisfying anyio's
cancel-scope same-task requirement.
Enters the session context manager, initializes it, and *commits* the
session: promotes the in-flight record into ``_entries`` and resolves
``ready`` with the live session in one atomic critical section, then
blocks until ``close_evt`` is set. The context manager is *always*
exited from this task, satisfying anyio's cancel-scope same-task
requirement.
"""
from langchain_mcp_adapters.sessions import create_session
@ -179,8 +199,29 @@ class MCPSessionPool:
# leaking the session/subprocess.
try:
await session.initialize()
if not ready.done():
ready.set_result(session)
# Commit point. ``ready`` resolves with a *result* only inside the
# critical section that also registers the session in ``_entries``:
# a resolved-with-result future therefore means the session is
# pool-owned (visible to LRU eviction and the close_* paths), never
# the private property of one caller. Joiners waiting on ``ready``
# can only ever receive a committed session, and an unwind path can
# check the outcome race-free. If the record was already removed
# (the creator unwound or a close_* ran), the creation is aborted:
# ``ready`` carries the cancellation instead, so no caller can end
# up holding an unmanaged session.
loop = asyncio.get_running_loop()
task = asyncio.current_task()
with self._lock:
still_ours = self._inflight.get(key) == (loop, ready, task, close_evt)
if still_ours:
self._inflight.pop(key)
self._entries[key] = (session, loop, task, close_evt)
if not ready.done():
ready.set_result(session)
if still_ours:
logger.info("Created persistent MCP session for %s/%s", key[0], key[1])
elif not ready.done():
ready.set_exception(asyncio.CancelledError("MCP session pool was closed while the session was being created"))
await close_evt.wait()
except BaseException as e:
if not ready.done():
@ -217,10 +258,13 @@ class MCPSessionPool:
# Phase 1: inspect/mutate the registry under the thread lock (no awaits).
# Decide one of three outcomes atomically: return an existing session,
# join an in-flight creation, or become the creator for this key.
# Each item: (loop, owner_task, close_event, cancel). ``cancel`` is True
# for in-flight creations, whose owner may be blocked inside
# ``initialize()`` where close_evt cannot wake it — it must be cancelled.
evicted: list[tuple[asyncio.AbstractEventLoop, asyncio.Task[Any], asyncio.Event, bool]] = []
# Each item: (loop, owner_task, close_event, cancel, ready_future).
# ``cancel`` is True for in-flight creations, whose owner may be blocked
# inside ``initialize()`` where close_evt cannot wake it — it must be
# cancelled (guarded: never when the owner already failed and is
# unwinding in __aexit__). ``ready`` is the creation's future, used only
# for that guard.
evicted: list[tuple[asyncio.AbstractEventLoop, asyncio.Task[Any], asyncio.Event, bool, asyncio.Future[ClientSession] | None]] = []
join: asyncio.Future[ClientSession] | None = None
ready: asyncio.Future[ClientSession] | None = None
close_evt: asyncio.Event | None = None
@ -233,7 +277,7 @@ class MCPSessionPool:
return session
# Session belongs to a different/closed event loop – evict it.
self._entries.pop(key)
evicted.append((loop, ent_task, ent_close, False))
evicted.append((loop, ent_task, ent_close, False, None))
inflight = self._inflight.get(key)
if inflight is not None and inflight[0] is current_loop and not inflight[0].is_closed():
@ -248,86 +292,118 @@ class MCPSessionPool:
# wake it), it must be cancelled. We then create a fresh
# session here.
self._inflight.pop(key)
evicted.append((inflight[0], inflight[2], inflight[3], True))
evicted.append((inflight[0], inflight[2], inflight[3], True, inflight[1]))
# Become the creator: publish an in-flight record before any
# await so concurrent callers join us instead of racing.
ready = current_loop.create_future()
close_evt = asyncio.Event()
task = current_loop.create_task(self._run_session(connection, ready, close_evt))
task = current_loop.create_task(self._run_session(key, connection, ready, close_evt))
self._inflight[key] = (current_loop, ready, task, close_evt)
# Evict LRU entries when at capacity.
while len(self._entries) >= self.MAX_SESSIONS:
oldest_key, (_, loop, ent_task, ent_close) = next(iter(self._entries.items()))
self._entries.pop(oldest_key)
evicted.append((loop, ent_task, ent_close, False))
evicted.append((loop, ent_task, ent_close, False, None))
# Phase 2: shut down evicted sessions/creations. Same-loop owners are
# awaited so they finish deterministically; foreign-loop owners are
# routed to their own loop. In every case the owner task — never this
# one — runs __aexit__. In-flight owners are cancelled (cancel=True) so a
# blocking initialize() cannot leave them hung.
for loop, ent_task, ent_close, cancel in evicted:
if loop is current_loop and not loop.is_closed():
await self._shutdown(ent_close, ent_task, cancel)
elif cancel:
await self._shutdown_entry(loop, ent_task, ent_close, cancel=True)
else:
self._signal_close(loop, ent_close)
# Phase 2: shut down evicted sessions/creations. Signal EVERY removed
# owner first — the signal loop contains no awaits, so it completes
# atomically and a cancellation during the teardown awaits below can
# never strand an owner that was already removed from the registries.
# Then await teardowns (same-loop deterministically; foreign-loop
# in-flight creations routed to their loop). In every case the owner
# task — never this one — runs __aexit__.
for loop, ent_task, ent_close, cancel, ent_ready in evicted:
self._signal_close(loop, ent_close)
if cancel:
self._cancel_owner(loop, ent_task, ent_ready)
try:
for loop, ent_task, ent_close, cancel, ent_ready in evicted:
if loop is current_loop and not loop.is_closed():
await self._shutdown(ent_close, ent_task, cancel=False, ready=ent_ready)
elif cancel:
await self._shutdown_entry(loop, ent_task, ent_close, cancel=False, ready=ent_ready)
# else: foreign-loop registered entry — already signalled above;
# its teardown completes on its own loop.
except BaseException:
# We may already be the creator for ``key``: the in-flight record
# and owner task were published under the lock *before* these
# awaits. A cancellation here (routine — both production call
# sites wrap get_session in asyncio.wait_for(session_init_timeout))
# would otherwise orphan that owner: it finishes initialize(),
# commits, and blocks on close_evt forever — invisible to LRU
# eviction, which only scans _entries. Unwind exactly like the
# Phase-3 path — unless the owner already committed while we were
# parked here: then the session is registered in _entries and may
# already be in a joiner's hands, so tearing it down would close it
# underneath them. A committed session is pool property; LRU
# eviction and the close_* paths own it from here on. Every evicted
# owner was already signalled in the atomic loop above, so skipping
# the remaining teardown *awaits* (not signals) on unwind is safe:
# each victim still tears down in its own task.
if task is not None and not self._creation_committed(ready):
assert ready is not None and close_evt is not None
try:
await self._shutdown(close_evt, task, cancel=True, ready=ready)
except BaseException:
logger.debug("Owner teardown interrupted during eviction unwind", exc_info=True)
with self._lock:
if self._inflight.get(key) == (current_loop, ready, task, close_evt):
self._inflight.pop(key)
raise
# Phase 2b: a concurrent creation for this key is already in progress on
# this loop — share its result rather than create a duplicate session.
# The creator's owner task resolves ``ready`` with a result only at the
# commit critical section that also registers the session, so a value
# returned here is always pool-owned; if the creation is aborted (the
# creator unwound or a close_* ran), ``ready`` carries the exception and
# this joiner fails with it instead of holding an unmanaged session.
if join is not None:
return await asyncio.shield(join)
assert ready is not None and close_evt is not None and task is not None
# Phase 3: wait for our owner task to publish the initialized session.
# Phase 3: wait for our owner task to commit the initialized session.
# A successful result means the session is already registered in
# _entries — the commit critical section did both — so from that moment
# the pool, not this call, owns its lifetime.
try:
session = await asyncio.shield(ready)
except BaseException:
# Two distinct cases reach here:
# Three distinct cases reach here:
#
# 1. The owner task failed (e.g. connect/initialize error) and
# reported it via ready.set_exception(). It is *already* in its
# finally block running cm.__aexit__ in its own task, so we must
# NOT cancel it — doing so would interrupt that cleanup. We only
# wait for it to finish unwinding.
# 2. This call itself was cancelled (CancelledError). Because of the
# shield, `ready` is still pending and the owner task is alive and
# blocked. We signal close and cancel it so it exits the cancel
# scope in its own task, then wait for it to finish.
#
# The session is never registered yet, so nobody else can close it;
# waiting here guarantees we never leak a session or owner task.
owner_already_failed = ready.done() and not ready.cancelled() and ready.exception() is not None
if not owner_already_failed:
close_evt.set()
task.cancel()
try:
await asyncio.shield(task)
except BaseException:
logger.debug("Owner task ended during get_session unwind", exc_info=True)
with self._lock:
if self._inflight.get(key) == (current_loop, ready, task, close_evt):
self._inflight.pop(key)
# 2. This call itself was cancelled (CancelledError) while the
# creation was still in flight. Because of the shield, `ready`
# is still pending and the owner task is alive. We signal close
# and cancel it so it exits the cancel scope in its own task,
# then wait for it to finish.
# 3. This call was cancelled at the very moment the owner
# committed: `ready` resolved with a result and the session is
# registered in _entries. Joiners may already hold it, so we
# must NOT tear it down — just propagate the cancellation and
# leave the pooled session to LRU eviction / close_*.
if not self._creation_committed(ready):
owner_already_failed = ready.done() and not ready.cancelled() and ready.exception() is not None
if not owner_already_failed:
close_evt.set()
task.cancel()
try:
await asyncio.shield(task)
except BaseException:
logger.debug("Owner task ended during get_session unwind", exc_info=True)
with self._lock:
if self._inflight.get(key) == (current_loop, ready, task, close_evt):
self._inflight.pop(key)
raise
# Phase 4: promote the in-flight creation to a registered entry — but
# only if our in-flight record is still the live one. A concurrent
# close_* / close_all may have removed it while we were initializing; in
# that case we must NOT resurrect the session into _entries. Instead we
# own the teardown: signal our owner task and wait for it to run
# __aexit__ in its own task, then surface the cancellation.
with self._lock:
still_ours = self._inflight.get(key) == (current_loop, ready, task, close_evt)
if still_ours:
self._inflight.pop(key)
self._entries[key] = (session, current_loop, task, close_evt)
if not still_ours:
await self._shutdown(close_evt, task)
raise asyncio.CancelledError("MCP session pool was closed while the session was being created")
logger.info("Created persistent MCP session for %s/%s", server_name, scope_key)
# Phase 4: the commit inside the owner task already promoted the
# creation into a registered entry; nothing left to decide here.
return session
# ------------------------------------------------------------------
@ -349,25 +425,123 @@ class MCPSessionPool:
# Loop was closed between the is_closed() check and now.
pass
@staticmethod
def _owner_unwinding_after_failure(ready: asyncio.Future[ClientSession] | None) -> bool:
"""True when the owner already failed and is running ``__aexit__``.
Cancelling such an owner would interrupt that in-task cleanup — the
same-task exit anyio requires — so callers must skip the cancel.
"""
return ready is not None and ready.done() and not ready.cancelled() and ready.exception() is not None
@staticmethod
def _creation_committed(ready: asyncio.Future[ClientSession] | None) -> bool:
"""True once the creation's session is registered in ``_entries``.
``_run_session`` resolves ``ready`` with a *result* only inside the
commit critical section that also moves the record into ``_entries``,
so this is exactly the state in which the session is pool-owned —
visible to LRU eviction and the close_* paths, and possibly already
handed to a joiner. Unwind paths must not tear such a session down:
closing it here would yank a live session from a joiner's hands
(#5008 review).
"""
return ready is not None and ready.done() and not ready.cancelled() and ready.exception() is None
@classmethod
def _cancel_owner(cls, loop: asyncio.AbstractEventLoop, task: asyncio.Task[Any], ready: asyncio.Future[ClientSession] | None = None) -> None:
"""Thread-safe guarded cancel of an owner task on its owning loop.
The failure-state recheck runs INSIDE the callback that executes on the
owning loop, immediately before ``task.cancel()``: evaluating it on the
caller's loop first and queueing the cancel after opens a
time-of-check/time-of-use window in which the owner can fail, publish
the exception to ``ready``, and enter ``__aexit__`` — the already-queued
cancel would then interrupt that in-task cleanup. On the owning loop
the check and the cancel are atomic with respect to owner-task
progress (the owner cannot advance between two non-awaiting
statements), so an owner already unwinding after failure is never
cancelled.
"""
def _guarded_cancel() -> None:
if cls._owner_unwinding_after_failure(ready):
return
task.cancel()
if loop.is_closed():
return
try:
loop.call_soon_threadsafe(_guarded_cancel)
except RuntimeError:
# Loop was closed between the is_closed() check and now.
pass
def _track_owner_teardown(self, task: asyncio.Task[Any]) -> None:
"""Keep an owner's teardown observable after its awaiter was cancelled.
The awaiter unwinds, but the owner still has to finish ``__aexit__`` in
its own task; the reaper awaits that completion so exceptions are
retrieved and the teardown stays observable instead of dangling. The
reaper is strongly retained in ``_teardown_tasks`` until it finishes —
the loop only keeps weak task references, so an unheld reaper (and
transitively the owner mid-``__aexit__``) could be garbage-collected
before the exit completes (#5008 review).
"""
async def _reap() -> None:
try:
await task
except BaseException:
logger.debug("Owner task ended after caller cancellation", exc_info=True)
try:
reaper = asyncio.get_running_loop().create_task(_reap(), name=f"mcp-session-owner-reap:{task.get_name()}")
except RuntimeError:
return
self._teardown_tasks.add(reaper)
reaper.add_done_callback(self._teardown_tasks.discard)
async def _shutdown(
self,
close_evt: asyncio.Event,
task: asyncio.Task[Any],
cancel: bool = False,
ready: asyncio.Future[ClientSession] | None = None,
) -> None:
"""Signal an owner task and wait for it to finish (runs on its loop).
``cancel=True`` is used for in-flight creations: the owner task may be
blocked inside ``initialize()`` where ``close_evt`` cannot wake it, so it
must be cancelled. Its ``finally`` block still runs ``__aexit__`` in its
own task, satisfying anyio's same-task cancel-scope requirement.
must be cancelled — unless it already failed and is unwinding in its
``finally`` block (``ready`` carries an exception), where a cancel would
interrupt the in-task ``__aexit__``. The exit always runs in the owner
task itself, satisfying anyio's same-task cancel-scope requirement.
The await is shielded: a cancellation of *this* awaiting task
propagates immediately without cancelling the owner, whose teardown
keeps running under a tracked reaper task. The victim's own
cancellation or exception is swallowed and logged as before.
"""
close_evt.set()
if cancel:
if cancel and not self._owner_unwinding_after_failure(ready):
task.cancel()
caller = asyncio.current_task()
caller_cancels = caller.cancelling() if caller is not None else 0
try:
await task
except (Exception, asyncio.CancelledError):
await asyncio.shield(task)
except asyncio.CancelledError:
# ``shield`` surfaces the victim's cancellation (we cancelled it
# above, or someone else did). But if the count rose, the
# cancellation belongs to US — the awaiting task was cancelled
# while waiting (e.g. a get_session parked in eviction teardown
# under asyncio.wait_for). Propagate it while keeping the owner's
# teardown tracked so __aexit__ still completes.
if caller is not None and caller.cancelling() > caller_cancels:
self._track_owner_teardown(task)
raise
logger.debug("Owner task cancelled during shutdown")
except Exception:
logger.debug("Owner task ended during shutdown", exc_info=True)
async def _shutdown_entry(
@ -376,15 +550,16 @@ class MCPSessionPool:
task: asyncio.Task[Any],
close_evt: asyncio.Event,
cancel: bool = False,
ready: asyncio.Future[ClientSession] | None = None,
) -> None:
"""Shut down one entry, routing the close to its owning loop."""
if loop.is_closed():
return
current_loop = asyncio.get_running_loop()
if loop is current_loop:
await self._shutdown(close_evt, task, cancel)
await self._shutdown(close_evt, task, cancel, ready=ready)
elif loop.is_running():
future = asyncio.run_coroutine_threadsafe(self._shutdown(close_evt, task, cancel), loop)
future = asyncio.run_coroutine_threadsafe(self._shutdown(close_evt, task, cancel, ready=ready), loop)
try:
await asyncio.wrap_future(future)
except Exception:
@ -402,10 +577,31 @@ class MCPSessionPool:
logger.warning("Owning loop for MCP session is idle; signalling close best-effort. Session may leak until the loop runs again.")
self._signal_close(loop, close_evt)
if cancel:
try:
loop.call_soon_threadsafe(task.cancel)
except RuntimeError:
pass
self._cancel_owner(loop, task, ready)
async def _close_owners(
self,
entries: list[tuple[ClientSession, asyncio.AbstractEventLoop, asyncio.Task[Any], asyncio.Event]],
inflight: list[tuple[asyncio.AbstractEventLoop, asyncio.Future[ClientSession], asyncio.Task[Any], asyncio.Event]],
) -> None:
"""Shut down already-removed owners: signal all first, then await.
Signalling every removed owner BEFORE awaiting any teardown guarantees
a cancellation of the close call can never strand an owner that is no
longer reachable through the registries: each has its close event (and,
for in-flight creations, its guarded cancel) in hand and tears down in
its own task regardless. The awaiting phase adds best-effort
determinism on top; skipping the remaining awaits on unwind is safe.
"""
for _session, loop, ent_task, ent_close in entries:
self._signal_close(loop, ent_close)
for loop, ent_ready, ent_task, ent_close in inflight:
self._signal_close(loop, ent_close)
self._cancel_owner(loop, ent_task, ent_ready)
for _session, loop, ent_task, ent_close in entries:
await self._shutdown_entry(loop, ent_task, ent_close)
for loop, ent_ready, ent_task, ent_close in inflight:
await self._shutdown_entry(loop, ent_task, ent_close, ready=ent_ready)
async def close_scope(self, scope_key: str) -> None:
"""Close all sessions for a given scope (e.g. thread_id)."""
@ -414,10 +610,7 @@ class MCPSessionPool:
entries = [(self._entries.pop(k)) for k in keys]
inflight_keys = [k for k in self._inflight if k[1] == scope_key]
inflight = [self._inflight.pop(k) for k in inflight_keys]
for _session, loop, task, close_evt in entries:
await self._shutdown_entry(loop, task, close_evt)
for loop, _ready, task, close_evt in inflight:
await self._shutdown_entry(loop, task, close_evt, cancel=True)
await self._close_owners(entries, inflight)
async def close_session(self, server_name: str, scope_key: str) -> None:
"""Close one exact server/scope session so a retry reconnects cleanly."""
@ -425,12 +618,7 @@ class MCPSessionPool:
with self._lock:
entry = self._entries.pop(key, None)
inflight = self._inflight.pop(key, None)
if entry is not None:
_session, loop, task, close_evt = entry
await self._shutdown_entry(loop, task, close_evt)
if inflight is not None:
loop, _ready, task, close_evt = inflight
await self._shutdown_entry(loop, task, close_evt, cancel=True)
await self._close_owners([entry] if entry is not None else [], [inflight] if inflight is not None else [])
async def close_session_if_current(
self,
@ -456,10 +644,7 @@ class MCPSessionPool:
entries = [(self._entries.pop(k)) for k in keys]
inflight_keys = [k for k in self._inflight if k[0] == server_name]
inflight = [self._inflight.pop(k) for k in inflight_keys]
for _session, loop, task, close_evt in entries:
await self._shutdown_entry(loop, task, close_evt)
for loop, _ready, task, close_evt in inflight:
await self._shutdown_entry(loop, task, close_evt, cancel=True)
await self._close_owners(entries, inflight)
async def close_all(self) -> None:
"""Close every managed session."""
@ -468,10 +653,7 @@ class MCPSessionPool:
self._entries.clear()
inflight = list(self._inflight.values())
self._inflight.clear()
for _session, loop, task, close_evt in entries:
await self._shutdown_entry(loop, task, close_evt)
for loop, _ready, task, close_evt in inflight:
await self._shutdown_entry(loop, task, close_evt, cancel=True)
await self._close_owners(entries, inflight)
def close_all_sync(self) -> None:
"""Close all sessions on their owning event loops (synchronous).
@ -499,14 +681,17 @@ class MCPSessionPool:
self._inflight.clear()
# Entries are initialized (gentle close_evt path). In-flight creations
# may be blocked mid-init, so they are cancelled to unblock teardown.
owners = [(loop, task, close_evt, False) for _s, loop, task, close_evt in entries]
owners += [(loop, task, close_evt, True) for loop, _r, task, close_evt in inflight]
# may be blocked mid-init, so they are cancelled to unblock teardown —
# but every guard below re-checks on the owning loop (or atomically on
# this thread for the current-loop branch), so an owner that has since
# failed and is unwinding in __aexit__ is never cancelled.
owners = [(loop, task, close_evt, False, None) for _s, loop, task, close_evt in entries]
owners += [(loop, task, ent_close, True, ent_ready) for loop, ent_ready, task, ent_close in inflight]
try:
current_running_loop = asyncio.get_running_loop()
except RuntimeError:
current_running_loop = None
for loop, task, close_evt, cancel in owners:
for loop, task, close_evt, cancel, ent_ready in owners:
if loop.is_closed():
continue
try:
@ -515,16 +700,18 @@ class MCPSessionPool:
# waiting on run_coroutine_threadsafe(...).result() would
# deadlock until timeout. Signal the owner task directly and
# let it finish once this synchronous call returns control to
# the running loop.
# the running loop. Same-thread, no yield between the guard
# and the cancel, so the check is atomic here.
close_evt.set()
if cancel:
if cancel and not self._owner_unwinding_after_failure(ent_ready):
task.cancel()
elif loop.is_running():
# Schedule the shutdown on the owning loop from this thread.
future = asyncio.run_coroutine_threadsafe(self._shutdown(close_evt, task, cancel), loop)
# Schedule the shutdown on the owning loop from this thread;
# _shutdown applies the failure guard on that loop.
future = asyncio.run_coroutine_threadsafe(self._shutdown(close_evt, task, cancel, ready=ent_ready), loop)
future.result(timeout=self.SESSION_CLOSE_TIMEOUT)
else:
loop.run_until_complete(self._shutdown(close_evt, task, cancel))
loop.run_until_complete(self._shutdown(close_evt, task, cancel, ready=ent_ready))
except Exception:
logger.debug("Error closing MCP session during sync close", exc_info=True)

View File

@ -1,10 +1,12 @@
"""Tests for the MCP persistent-session pool."""
import asyncio
import gc
import logging
import stat
import sys
import threading
import weakref
from unittest.mock import AsyncMock, MagicMock, patch
import anyio
@ -1721,6 +1723,549 @@ async def test_get_session_cancelled_while_initializing_does_not_leak():
assert not leaked, "owner task must not be left pending after cancellation"
@pytest.mark.asyncio
async def test_get_session_cancelled_during_eviction_teardown_does_not_leak():
"""Cancelling get_session while it awaits an evicted session's teardown
must not orphan the just-created owner task.
The in-flight record and owner task are published before the Phase-2
eviction awaits, so a caller cancelled there — routine, since both
production call sites wrap get_session in asyncio.wait_for(
session_init_timeout) — would otherwise leak the owner: it finishes
initialize(), publishes ready, and blocks on close_evt forever, invisible
to LRU eviction, which only scans _entries.
"""
pool = MCPSessionPool()
pool.MAX_SESSIONS = 1
# A pre-registered LRU victim whose teardown hangs (wedged server): the
# creator parks in Phase 2 awaiting it, so the cancel below is guaranteed
# to land between publishing the in-flight record and Phase 3.
victim_hang = asyncio.Event()
async def victim_owner() -> None:
await victim_hang.wait()
loop = asyncio.get_running_loop()
victim_task = asyncio.create_task(victim_owner())
pool._entries[("victim", "scope")] = (MagicMock(), loop, victim_task, asyncio.Event())
gate = asyncio.Event()
cms: list[_BlockingInitCm] = []
call: asyncio.Task | None = None
def make_cm(*args, **kwargs):
cm = _BlockingInitCm(gate)
cms.append(cm)
return cm
try:
with patch("langchain_mcp_adapters.sessions.create_session", side_effect=make_cm):
call = asyncio.create_task(pool.get_session("srv", "scope-2", {"transport": "stdio", "command": "x", "args": []}))
# The creator publishes its in-flight record before awaiting the
# hung victim, so this is deterministic.
for _ in range(100):
if ("srv", "scope-2") in pool._inflight:
break
await asyncio.sleep(0.01)
assert ("srv", "scope-2") in pool._inflight
await asyncio.sleep(0.02)
call.cancel()
# On the bug, the caller's cancellation is swallowed by the
# eviction teardown and the task never finishes; the shield keeps
# the timeout from cancelling it so the hang is observable.
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(asyncio.shield(call), timeout=5.0)
# The orphaned owner must have been torn down: its __aexit__ ran even
# though initialize() was never released.
for _ in range(50):
if cms and cms[0].closed:
break
await asyncio.sleep(0.01)
assert cms, "owner task must have been created"
assert cms[0].closed, "owner must run __aexit__ after Phase-2 cancellation"
assert ("srv", "scope-2") not in pool._inflight
current = asyncio.current_task()
leaked = [t for t in asyncio.all_tasks() if t is not current and not t.done() and "_run_session" in str(t.get_coro())]
assert not leaked, "owner task must not be left pending after Phase-2 cancellation"
finally:
if call is not None and not call.done():
call.cancel()
victim_hang.set()
victim_task.cancel()
try:
await victim_task
except BaseException:
pass
@pytest.mark.asyncio
async def test_cancelled_creator_does_not_close_session_held_by_joiner():
"""Cancelling the creator must not close a session a joiner already holds.
Reproduces the review race deterministically: MAX_SESSIONS=1, the creator
parks in Phase 2 awaiting a hung LRU victim while its owner finishes
initialize(); a second caller for the same key then receives the session.
Cancelling the creator afterwards must leave that session open and
registered — once the creation committed, the session is pool property
(LRU eviction / close_* own it), never the cancelled caller's (#5008
review).
"""
pool = MCPSessionPool()
pool.MAX_SESSIONS = 1
# A pre-registered LRU victim whose teardown hangs: the creator parks in
# Phase 2 awaiting it, so the cancel below lands while the creator is
# between publishing the in-flight record and awaiting ready.
victim_hang = asyncio.Event()
async def victim_owner() -> None:
await victim_hang.wait()
loop = asyncio.get_running_loop()
victim_task = asyncio.create_task(victim_owner())
pool._entries[("victim", "scope")] = (MagicMock(), loop, victim_task, asyncio.Event())
gate = asyncio.Event()
cms: list[_BlockingInitCm] = []
creator: asyncio.Task | None = None
def make_cm(*args, **kwargs):
cm = _BlockingInitCm(gate)
cms.append(cm)
return cm
try:
with patch("langchain_mcp_adapters.sessions.create_session", side_effect=make_cm):
conn = {"transport": "stdio", "command": "x", "args": []}
creator = asyncio.create_task(pool.get_session("srv", "scope-2", conn))
# The creator publishes its in-flight record before awaiting the
# hung victim, so this is deterministic.
for _ in range(100):
if ("srv", "scope-2") in pool._inflight:
break
await asyncio.sleep(0.01)
assert ("srv", "scope-2") in pool._inflight
# The owner finishes initialize() and commits while its creator is
# still parked on the victim's teardown. The second caller then
# receives the initialized session (Phase 2b before this fix, the
# _entries fast path after) — either way it now holds it.
gate.set()
joiner = asyncio.create_task(pool.get_session("srv", "scope-2", conn))
session = await asyncio.wait_for(asyncio.shield(joiner), timeout=5.0)
assert cms, "owner task must have been created"
# On the bug, this unwind unconditionally shut the owner down even
# though a joiner held the session: __aexit__ ran underneath it.
creator.cancel()
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(asyncio.shield(creator), timeout=5.0)
await asyncio.sleep(0.02)
assert not cms[0].closed, "cancelling the creator must not close a session already handed to a caller"
assert ("srv", "scope-2") in pool._entries, "committed session must stay registered after creator cancellation"
assert pool._entries[("srv", "scope-2")][0] is session, "the joiner's session must be the registered one"
assert ("srv", "scope-2") not in pool._inflight
# Cleanup: close the pool so the parked owner finishes deterministically.
await pool.close_all()
for _ in range(100):
if cms[0].closed:
break
await asyncio.sleep(0.01)
assert cms[0].closed, "owner must still tear down through the pool close paths"
current = asyncio.current_task()
leaked = [t for t in asyncio.all_tasks() if t is not current and not t.done() and "_run_session" in str(t.get_coro())]
assert not leaked, "owner task must not be left pending after cleanup"
finally:
if creator is not None and not creator.done():
creator.cancel()
victim_hang.set()
victim_task.cancel()
try:
await victim_task
except BaseException:
pass
@pytest.mark.asyncio
async def test_joiner_follows_creation_outcome_when_creator_is_cancelled():
"""A joiner must follow the creation's outcome, never hold an orphan.
If the creator is cancelled while the shared owner is still initializing,
the creation is aborted before it commits: the joiner fails with the same
cancellation instead of receiving (or hanging on) a session whose teardown
is already underway. Drift guard for the outcome-gated join semantics.
"""
pool = MCPSessionPool()
gate = asyncio.Event()
cms: list[_BlockingInitCm] = []
def make_cm(*a, **kw):
cm = _BlockingInitCm(gate)
cms.append(cm)
return cm
with patch("langchain_mcp_adapters.sessions.create_session", side_effect=make_cm):
conn = {"transport": "stdio", "command": "x", "args": []}
creator = asyncio.create_task(pool.get_session("s", "same", conn))
for _ in range(100):
if ("s", "same") in pool._inflight:
break
await asyncio.sleep(0.01)
assert ("s", "same") in pool._inflight
# Second caller joins the in-flight creation instead of duplicating it.
joiner = asyncio.create_task(pool.get_session("s", "same", conn))
await asyncio.sleep(0.01)
# The creator is cancelled mid-init: the creation aborts, and the
# joiner must observe that outcome rather than succeed or hang.
creator.cancel()
with pytest.raises(asyncio.CancelledError):
await creator
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(asyncio.shield(joiner), timeout=5.0)
assert len(cms) == 1, "the joiner must not have created a duplicate session"
for _ in range(100):
if cms[0].closed:
break
await asyncio.sleep(0.01)
assert cms[0].closed, "aborted creation must still run its __aexit__"
assert len(pool._entries) == 0
assert len(pool._inflight) == 0
current = asyncio.current_task()
leaked = [t for t in asyncio.all_tasks() if t is not current and not t.done() and "_run_session" in str(t.get_coro())]
assert not leaked, "owner task must not be left pending after the creation aborted"
@pytest.mark.asyncio
async def test_eviction_teardown_completes_normally_then_session_is_returned():
"""Non-cancelled path: eviction teardown runs to completion and the caller
proceeds to Phase 3 unchanged — guards the Phase-2 try/except against
happy-path drift."""
pool = MCPSessionPool()
pool.MAX_SESSIONS = 1
class CmFactory:
def __init__(self):
self.closed = False
async def __aenter__(self):
return AsyncMock()
async def __aexit__(self, *args):
self.closed = True
return False
cms: list[CmFactory] = []
def make_cm(*a, **kw):
cm = CmFactory()
cms.append(cm)
return cm
with patch("langchain_mcp_adapters.sessions.create_session", side_effect=make_cm):
first = await pool.get_session("s", "t1", {"transport": "stdio", "command": "x", "args": []})
# Pool is full (1): this call evicts t1 through Phase 2 and then
# returns the fresh session through Phase 3/4.
second = await pool.get_session("s", "t2", {"transport": "stdio", "command": "x", "args": []})
assert first is not second
assert cms[0].closed, "evicted owner must complete teardown before the caller proceeds"
assert ("s", "t2") in pool._entries
assert ("s", "t1") not in pool._entries
@pytest.mark.asyncio
async def test_close_scope_does_not_cancel_owner_already_unwinding_in_aexit():
"""A failed in-flight owner is already running __aexit__ in its own task;
the close paths must not cancel it — the cancel would interrupt that
in-task cleanup and the exit would never finish."""
pool = MCPSessionPool()
cms: list[_InitFailCm] = []
def make_cm(*args, **kwargs):
cm = _InitFailCm()
cms.append(cm)
return cm
with patch("langchain_mcp_adapters.sessions.create_session", side_effect=make_cm):
call = asyncio.create_task(pool.get_session("s", "t1", {"transport": "stdio", "command": "x", "args": []}))
# Wait until the owner failed initialize() and entered its slow __aexit__.
for _ in range(100):
if cms and cms[0].exit_started:
break
await asyncio.sleep(0.01)
assert cms and cms[0].exit_started, "owner must reach its slow __aexit__ first"
await pool.close_scope("t1")
with pytest.raises(RuntimeError):
await call
assert cms[0].closed is True, "close_scope must not cancel an owner already unwinding in __aexit__"
@pytest.mark.asyncio
async def test_cancelling_close_scope_does_not_strand_other_removed_owners():
"""Cancelling close_scope mid-teardown must not strand the other owners it
already removed from the registry: every removed owner gets its close
signal before any teardown is awaited."""
pool = MCPSessionPool()
loop = asyncio.get_running_loop()
first_hang = asyncio.Event() # first owner's slow teardown stand-in
first_close = asyncio.Event()
second_close = asyncio.Event()
second_done = asyncio.Event()
async def first_owner() -> None:
await first_close.wait()
await first_hang.wait()
async def second_owner() -> None:
await second_close.wait()
second_done.set()
first_task = asyncio.create_task(first_owner())
second_task = asyncio.create_task(second_owner())
pool._entries[("s1", "scope")] = (MagicMock(), loop, first_task, first_close)
pool._entries[("s2", "scope")] = (MagicMock(), loop, second_task, second_close)
closer = asyncio.create_task(pool.close_scope("scope"))
# Let the closer reach its teardown await (both signals are already out).
for _ in range(100):
if first_close.is_set() and second_close.is_set():
break
await asyncio.sleep(0.01)
await asyncio.sleep(0.02)
closer.cancel()
with pytest.raises(asyncio.CancelledError):
await closer
assert second_close.is_set(), "every removed owner must be signalled before teardown awaits"
for _ in range(100):
if second_done.is_set():
break
await asyncio.sleep(0.01)
assert second_done.is_set(), "the second owner must finish its own teardown"
first_hang.set()
first_task.cancel()
try:
await first_task
except BaseException:
pass
@pytest.mark.asyncio
async def test_owner_mid_aexit_survives_gc_after_closer_cancellation():
"""The reaper spawned for a cancelled closer must be strongly retained.
The event loop holds only weak references to tasks, so an unheld reaper —
and transitively the owner it awaits, mid-``__aexit__`` — can be
garbage-collected before the teardown completes once the registry entry
is gone (#5008 review). The closer scenario runs in its own dropped task
so no harness frame (including the caught CancelledError's traceback)
keeps the teardown alive."""
pool = MCPSessionPool()
class GcProneCm:
def __init__(self):
self.entered = False
self.exit_started = False
self.closed = False
async def __aenter__(self):
self.entered = True
session = MagicMock()
session.initialize = self._initialize
return session
async def _initialize(self):
return None
async def __aexit__(self, *args):
self.exit_started = True
# An orphaned future: nothing external references it, so the
# awaiting owner forms a collectable cycle once no strong root
# holds the reaper.
await asyncio.get_running_loop().create_future()
self.closed = True
holder: dict[str, GcProneCm] = {}
def make_cm(*args, **kwargs):
cm = GcProneCm()
holder["cm"] = cm
return cm
async def scenario() -> None:
closer = asyncio.create_task(pool.close_scope("t1"))
for _ in range(200):
cm = cm_weak()
if cm is not None and cm.exit_started:
break
await asyncio.sleep(0.01)
closer.cancel()
try:
await closer
except asyncio.CancelledError:
pass
with patch("langchain_mcp_adapters.sessions.create_session", side_effect=make_cm):
await pool.get_session("s", "t1", {"transport": "stdio", "command": "x", "args": []})
cm_weak = weakref.ref(holder["cm"])
holder.clear()
scen = asyncio.create_task(scenario())
await scen
del scen
# Drop every remaining strong root and force collection: the registry
# entry is gone (close_scope popped it), the scenario task and its
# frames are gone, and the only thing that may keep the teardown alive
# is the retained reaper.
gc.collect()
assert cm_weak() is not None, "owner mid-__aexit__ must survive GC while teardown is pending"
# Cleanup: cancel the surviving owner/reaper tasks.
for t in [t for t in asyncio.all_tasks() if t is not asyncio.current_task() and ("_run_session" in str(t.get_coro()) or "_reap" in str(t.get_coro()))]:
t.cancel()
await asyncio.sleep(0)
@pytest.mark.asyncio
async def test_queued_cancel_rechecks_failure_on_owning_loop():
"""A cancellation queued from another loop must recheck the owner's failure
state ON the owning loop, immediately before cancelling.
Reproduces the time-of-check/time-of-use window: the guard snapshot is
taken while the owner has not failed yet, the queued cancellation is
delivered (delayed here deterministically) only after the owner failed and
parked inside ``__aexit__`` — delivering it must be a no-op, not an
interruption of that cleanup (#5008 review)."""
pool = MCPSessionPool()
loop2 = asyncio.new_event_loop()
thread2 = threading.Thread(target=loop2.run_forever, daemon=True)
thread2.start()
class _DelayedLoop:
"""Wraps the foreign loop; while holding, callbacks queue locally.
``is_running()`` reports False so the pool's await phase takes its
harmless idle-loop branch instead of needing a real loop handle.
"""
def __init__(self, wrapped: asyncio.AbstractEventLoop) -> None:
self._wrapped = wrapped
self.hold = False
self._held: list[tuple] = []
def is_closed(self) -> bool:
return self._wrapped.is_closed()
def is_running(self) -> bool:
return False
def call_soon_threadsafe(self, callback, *args) -> None:
if self.hold:
self._held.append((callback, args))
else:
self._wrapped.call_soon_threadsafe(callback, *args)
def flush(self) -> None:
held, self._held = self._held, []
for callback, args in held:
self._wrapped.call_soon_threadsafe(callback, *args)
class _GatedAexitCm:
def __init__(self):
self.exit_started = False
self.closed = False
async def __aexit__(self, *args):
self.exit_started = True
await gate2.wait()
self.closed = True
cm = _GatedAexitCm()
holder: dict[str, object] = {}
def _spawn() -> None:
ready2 = loop2.create_future()
release2 = asyncio.Event()
close_evt2 = asyncio.Event()
async def owner() -> None:
try:
await release2.wait()
raise RuntimeError("init boom")
except BaseException as exc:
if not ready2.done():
ready2.set_exception(exc)
finally:
await cm.__aexit__(None, None, None)
task2 = asyncio.ensure_future(owner())
holder.update(ready=ready2, task=task2, close_evt=close_evt2, release=release2, gate=asyncio.Event())
gate2 = None
loop2.call_soon_threadsafe(_spawn)
for _ in range(200):
if "task" in holder:
break
await asyncio.sleep(0.005)
assert "task" in holder, "foreign owner must be spawned"
gate2 = holder["gate"]
proxy = _DelayedLoop(loop2)
pool._inflight[("s", "t1")] = (proxy, holder["ready"], holder["task"], holder["close_evt"])
# Snapshot moment: the owner has NOT failed yet. close_scope's signal phase
# queues the (guarded) cancellation into the holding proxy; its await phase
# takes the idle branch and returns.
proxy.hold = True
await pool.close_scope("t1")
assert pool._inflight == {}
# The owner now fails on its own loop and parks inside the gated __aexit__.
loop2.call_soon_threadsafe(holder["release"].set)
for _ in range(400):
if cm.exit_started:
break
await asyncio.sleep(0.005)
assert cm.exit_started, "owner must enter its gated __aexit__ before the cancel is delivered"
# Deliver the held cancellation — this is the stale-snapshot catch-up the
# on-owning-loop recheck must neutralize.
proxy.hold = False
proxy.flush()
# Let the exit finish: the delivered cancel must have been skipped.
loop2.call_soon_threadsafe(gate2.set)
for _ in range(400):
if cm.closed:
break
await asyncio.sleep(0.005)
assert cm.closed, "queued cancel must not interrupt an owner already unwinding in __aexit__"
loop2.call_soon_threadsafe(loop2.stop)
thread2.join(timeout=2)
loop2.close()
class _InitFailCm:
"""Fake session CM whose ``initialize`` fails, with a slow ``__aexit__``.