Skip to content

Commit 266c129

Browse files
authored
Test lifespan fires once per process over HTTP (#4480)
* Add regression test: HTTP lifespan fires once per process across sessions * Drop redundant enter-count assertion at teardown (CodeQL) * Assert session-manager lifespan entry directly, not user-lifespan count
1 parent 836ceac commit 266c129

1 file changed

Lines changed: 55 additions & 12 deletions

File tree

tests/server/http/test_lifespan_once_per_process.py

Lines changed: 55 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,46 @@
33
Before FastMCP handed its lifespan to the SDK lowlevel Server (PR #4446), the
44
SDK v1 lifespan was effectively session-scoped and FastMCP worked around it by
55
driving its own ``_lifespan_manager`` beside the session manager. The SDK v2
6-
``StreamableHTTPSessionManager`` now enters ``app.lifespan(app)`` exactly once
7-
for the manager's lifetime, so the user lifespan must fire once per process and
8-
persist across multiple HTTP client sessions -- not once per session.
6+
``StreamableHTTPSessionManager`` now enters ``app.lifespan(app)`` -- FastMCP's
7+
``_lifespan_proxy`` -- exactly once for the manager's lifetime and reuses the
8+
yielded state for every session. The user lifespan must therefore fire once per
9+
process and persist across HTTP client sessions, not once per session.
10+
11+
The invariant that actually matters is "the session manager drives the lifespan
12+
exactly once, regardless of how many client sessions connect." A plain
13+
user-lifespan enter/exit counter cannot guard it: ``_lifespan_manager`` is
14+
ref-counted, and ``run_http_async`` opens an *outer* ``_lifespan_manager``
15+
around uvicorn. That outer entry holds the ref count at >= 1 for the whole
16+
server lifetime, so even if the session manager regressed to re-entering
17+
``_lifespan_proxy`` once per session, the user lifespan would still be entered
18+
exactly once (the proxy's nested ``_lifespan_manager`` entries would all reuse
19+
the outer result). The user counter would stay ``1`` while the behavior it
20+
claims to guard was broken.
21+
22+
So this test spies on the session-manager entry point directly -- it counts how
23+
many times the SDK enters ``server._mcp_server.lifespan`` (the ``_lifespan_proxy``
24+
wrapper) -- and asserts that count is exactly one across sequential and
25+
overlapping sessions. A regression that moves ``app.lifespan(app)`` into the
26+
per-session code path makes this count grow with the session count and fails
27+
loudly. The user enter/exit counter is kept as a secondary check on the
28+
"entered once, exited once at shutdown" shape.
929
"""
1030

1131
from collections.abc import AsyncIterator
1232
from contextlib import asynccontextmanager
1333
from typing import Any
1434

35+
from mcp.server.lowlevel.server import Server as LowLevelServer
36+
1537
from fastmcp import Client, FastMCP
1638
from fastmcp.client.transports import StreamableHttpTransport
1739
from fastmcp.utilities.tests import run_server_async
1840

1941

2042
async def test_http_user_lifespan_fires_once_across_sessions():
21-
"""The user lifespan must be entered exactly once for the server process,
22-
even when several independent HTTP client sessions connect and disconnect.
43+
"""The session manager must drive the user lifespan exactly once for the
44+
server process, even when several independent HTTP client sessions connect
45+
and disconnect.
2346
"""
2447
enter_count = 0
2548
exit_count = 0
@@ -39,16 +62,34 @@ async def counting_lifespan(mcp: FastMCP) -> AsyncIterator[dict[str, Any]]:
3962
def ping() -> str:
4063
return "pong"
4164

65+
# Spy on the session manager's single entry point into FastMCP's lifespan.
66+
# `StreamableHTTPSessionManager.run()` calls `self.app.lifespan(self.app)`
67+
# exactly once and reuses the yielded state per session; `self.app` is
68+
# `server._mcp_server`, so `server._mcp_server.lifespan` is the
69+
# `_lifespan_proxy` wrapper. Counting entries here asserts the invariant
70+
# directly, independent of `_lifespan_manager`'s ref-count masking.
71+
proxy_enter_count = 0
72+
original_lifespan = server._mcp_server.lifespan
73+
74+
@asynccontextmanager
75+
async def counting_proxy(app: LowLevelServer[Any]) -> AsyncIterator[Any]:
76+
nonlocal proxy_enter_count
77+
proxy_enter_count += 1
78+
async with original_lifespan(app) as state:
79+
yield state
80+
81+
server._mcp_server.lifespan = counting_proxy
82+
4283
async with run_server_async(server, transport="http") as mcp_url:
4384
# `run_server_async` yields a URL that already includes the `/mcp` path.
4485
# Three separate, sequential client sessions against the same process.
4586
for _ in range(3):
4687
async with Client(StreamableHttpTransport(mcp_url)) as client:
4788
result = await client.call_tool("ping", {})
4889
assert result.data == "pong"
49-
# The lifespan must not have exited when a session closed -- it is
50-
# owned by the session manager for the whole process lifetime.
51-
assert enter_count == 1
90+
# The session manager must not re-drive the lifespan when a session
91+
# closes -- it owns a single entry for the whole process lifetime.
92+
assert proxy_enter_count == 1
5293
assert exit_count == 0
5394

5495
# Overlapping sessions must also observe a single, still-open lifespan.
@@ -58,10 +99,12 @@ def ping() -> str:
5899
):
59100
assert (await c1.call_tool("ping", {})).data == "pong"
60101
assert (await c2.call_tool("ping", {})).data == "pong"
61-
assert enter_count == 1
102+
assert proxy_enter_count == 1
62103
assert exit_count == 0
63104

64-
# After the server process task is torn down, the lifespan has exited once.
65-
# (A spurious re-entry during teardown would re-exit, so this also guards
66-
# that the lifespan was entered exactly once.)
105+
# The session manager entered the lifespan exactly once across every
106+
# session, and the user lifespan was entered once and exited once at
107+
# process shutdown.
108+
assert proxy_enter_count == 1
109+
assert enter_count == 1
67110
assert exit_count == 1

0 commit comments

Comments
 (0)