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:
Jeremiah Lowin 2026-03-07 12:09:50 -05:00 committed by GitHub
commit 6637bcebe1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 81 additions and 22 deletions

View file

@ -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.

View file

@ -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

View file

@ -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."""