mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-24 06:24:18 +02:00
Fix server lifespan overlap teardown (#3415)
* Fix server lifespan overlap teardown
🤖 Generated with GPT-5.2-Codex
* Clear lifespan state when non-owner session is last to exit
This commit is contained in:
parent
9f8347dbaf
commit
6637bcebe1
3 changed files with 81 additions and 22 deletions
|
|
@ -136,30 +136,49 @@ class LifespanMixin:
|
|||
|
||||
@asynccontextmanager
|
||||
async def _lifespan_manager(self: FastMCP) -> AsyncIterator[None]:
|
||||
if self._lifespan_result_set:
|
||||
yield
|
||||
async with self._lifespan_lock:
|
||||
if self._lifespan_result_set:
|
||||
self._lifespan_ref_count += 1
|
||||
should_enter_lifespan = False
|
||||
else:
|
||||
self._lifespan_ref_count = 1
|
||||
should_enter_lifespan = True
|
||||
|
||||
if not should_enter_lifespan:
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
async with self._lifespan_lock:
|
||||
self._lifespan_ref_count -= 1
|
||||
if self._lifespan_ref_count == 0:
|
||||
self._lifespan_result_set = False
|
||||
self._lifespan_result = None
|
||||
return
|
||||
|
||||
async with (
|
||||
self._lifespan(self) as user_lifespan_result,
|
||||
self._docket_lifespan(),
|
||||
):
|
||||
self._lifespan_result = user_lifespan_result
|
||||
self._lifespan_result_set = True
|
||||
try:
|
||||
async with (
|
||||
self._lifespan(self) as user_lifespan_result,
|
||||
self._docket_lifespan(),
|
||||
):
|
||||
self._lifespan_result = user_lifespan_result
|
||||
self._lifespan_result_set = True
|
||||
|
||||
async with AsyncExitStack[bool | None]() as stack:
|
||||
# Start lifespans for all providers
|
||||
for provider in self.providers:
|
||||
await stack.enter_async_context(provider.lifespan())
|
||||
async with AsyncExitStack[bool | None]() as stack:
|
||||
# Start lifespans for all providers
|
||||
for provider in self.providers:
|
||||
await stack.enter_async_context(provider.lifespan())
|
||||
|
||||
self._started.set()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._started.clear()
|
||||
|
||||
self._lifespan_result_set = False
|
||||
self._lifespan_result = None
|
||||
self._started.set()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._started.clear()
|
||||
finally:
|
||||
async with self._lifespan_lock:
|
||||
self._lifespan_ref_count -= 1
|
||||
if self._lifespan_ref_count == 0:
|
||||
self._lifespan_result_set = False
|
||||
self._lifespan_result = None
|
||||
|
||||
def _setup_task_protocol_handlers(self: FastMCP) -> None:
|
||||
"""Register SEP-1686 task protocol handlers with SDK.
|
||||
|
|
|
|||
|
|
@ -301,6 +301,8 @@ class FastMCP(
|
|||
self._lifespan = cast(LifespanCallable[LifespanResultT], default_lifespan)
|
||||
self._lifespan_result: LifespanResultT | None = None
|
||||
self._lifespan_result_set: bool = False
|
||||
self._lifespan_ref_count: int = 0
|
||||
self._lifespan_lock: asyncio.Lock = asyncio.Lock()
|
||||
self._started: asyncio.Event = asyncio.Event()
|
||||
|
||||
# Generate random ID if no name provided
|
||||
|
|
|
|||
|
|
@ -54,6 +54,44 @@ class TestServerLifespan:
|
|||
# when the client session closes
|
||||
assert lifespan_events == ["enter", "exit"]
|
||||
|
||||
async def test_server_lifespan_overlapping_sessions(self):
|
||||
"""Test that overlapping sessions keep lifespan active until all sessions close."""
|
||||
lifespan_events: list[str] = []
|
||||
|
||||
resource_state = "missing"
|
||||
|
||||
@asynccontextmanager
|
||||
async def server_lifespan(mcp: FastMCP) -> AsyncIterator[dict[str, Any]]:
|
||||
nonlocal resource_state
|
||||
lifespan_events.append("enter")
|
||||
resource_state = "open"
|
||||
try:
|
||||
yield {"initialized": True}
|
||||
finally:
|
||||
resource_state = "closed"
|
||||
lifespan_events.append("exit")
|
||||
|
||||
mcp = FastMCP("TestServer", lifespan=server_lifespan)
|
||||
|
||||
@mcp.tool
|
||||
def get_resource_state() -> str:
|
||||
return resource_state
|
||||
|
||||
async with Client(mcp) as client1:
|
||||
result1 = await client1.call_tool("get_resource_state", {})
|
||||
assert result1.data == "open"
|
||||
|
||||
async with Client(mcp) as client2:
|
||||
result2 = await client2.call_tool("get_resource_state", {})
|
||||
assert result2.data == "open"
|
||||
|
||||
# client2 exited while client1 is still active; lifespan should remain open
|
||||
result3 = await client1.call_tool("get_resource_state", {})
|
||||
assert result3.data == "open"
|
||||
assert lifespan_events == ["enter"]
|
||||
|
||||
assert lifespan_events == ["enter", "exit"]
|
||||
|
||||
async def test_server_lifespan_context_available(self):
|
||||
"""Test that server_lifespan context is available to tools."""
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue