Skip to content

Commit aca6b0d

Browse files
test: add integration test for idle session cleanup (issue #1283)
Add tests/issues/test_1283_idle_session_cleanup.py demonstrating the memory leak from issue #1283 where idle sessions are never cleaned up. The tests verify: 1. Sessions are terminated and removed after idle timeout expires 2. Activity (requests) resets the idle timer and prevents premature reaping 3. Multiple sessions are reaped independently Github-Issue: #1283
1 parent bac2789 commit aca6b0d

File tree

1 file changed

+159
-0
lines changed

1 file changed

+159
-0
lines changed
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
"""Test for issue #1283 - Memory leak from idle sessions never being cleaned up.
2+
3+
Without an idle timeout mechanism, sessions created via StreamableHTTPSessionManager
4+
persist indefinitely in ``_server_instances`` even after the client disconnects.
5+
Over time this leaks memory.
6+
7+
The ``session_idle_timeout`` parameter on ``StreamableHTTPSessionManager`` allows
8+
the manager to automatically terminate and remove sessions that have been idle for
9+
longer than the configured duration.
10+
11+
These tests verify:
12+
1. Sessions are terminated after idle timeout expires.
13+
2. Activity (requests) resets the idle timer and prevents premature reaping.
14+
3. After activity stops, the session is eventually cleaned up.
15+
"""
16+
17+
import anyio
18+
import pytest
19+
from starlette.types import Message
20+
21+
from mcp.server.lowlevel import Server
22+
from mcp.server.streamable_http import MCP_SESSION_ID_HEADER
23+
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
24+
25+
26+
def _make_scope() -> dict:
27+
return {
28+
"type": "http",
29+
"method": "POST",
30+
"path": "/mcp",
31+
"headers": [(b"content-type", b"application/json")],
32+
}
33+
34+
35+
async def _mock_receive() -> Message: # pragma: no cover
36+
return {"type": "http.request", "body": b"", "more_body": False}
37+
38+
39+
def _make_send(sent: list[Message]):
40+
async def mock_send(message: Message) -> None:
41+
sent.append(message)
42+
43+
return mock_send
44+
45+
46+
def _extract_session_id(sent_messages: list[Message]) -> str:
47+
for msg in sent_messages:
48+
if msg["type"] == "http.response.start":
49+
for name, value in msg.get("headers", []):
50+
if name.decode().lower() == MCP_SESSION_ID_HEADER.lower():
51+
return value.decode()
52+
raise AssertionError("Session ID not found in response headers")
53+
54+
55+
def _make_blocking_run(stop_event: anyio.Event):
56+
"""Create a mock app.run that blocks until stop_event is set."""
57+
58+
async def blocking_run(*args, **kwargs):
59+
await stop_event.wait()
60+
61+
return blocking_run
62+
63+
64+
@pytest.mark.anyio
65+
async def test_idle_session_is_reaped():
66+
"""Session should be removed from _server_instances after idle timeout."""
67+
app = Server("test-idle-reap")
68+
stop = anyio.Event()
69+
app.run = _make_blocking_run(stop) # type: ignore[assignment]
70+
71+
manager = StreamableHTTPSessionManager(
72+
app=app,
73+
session_idle_timeout=0.15,
74+
)
75+
76+
async with manager.run():
77+
sent: list[Message] = []
78+
await manager.handle_request(_make_scope(), _mock_receive, _make_send(sent))
79+
session_id = _extract_session_id(sent)
80+
81+
assert session_id in manager._server_instances
82+
83+
# Wait long enough for the reaper to fire (scan_interval = timeout/2 = 0.075s)
84+
await anyio.sleep(0.4)
85+
86+
assert session_id not in manager._server_instances
87+
assert session_id not in manager._last_activity
88+
89+
# Let the server task finish cleanly
90+
stop.set()
91+
92+
93+
@pytest.mark.anyio
94+
async def test_activity_resets_idle_timer():
95+
"""Requests during the timeout window should prevent the session from being reaped."""
96+
app = Server("test-idle-reset")
97+
stop = anyio.Event()
98+
app.run = _make_blocking_run(stop) # type: ignore[assignment]
99+
100+
manager = StreamableHTTPSessionManager(
101+
app=app,
102+
session_idle_timeout=0.3,
103+
)
104+
105+
async with manager.run():
106+
sent: list[Message] = []
107+
await manager.handle_request(_make_scope(), _mock_receive, _make_send(sent))
108+
session_id = _extract_session_id(sent)
109+
110+
# Simulate ongoing activity by updating the activity timestamp periodically
111+
for _ in range(4):
112+
await anyio.sleep(0.1)
113+
manager._last_activity[session_id] = anyio.current_time()
114+
115+
# Session should still be alive because we kept it active
116+
assert session_id in manager._server_instances
117+
118+
# Now stop activity and let the timeout expire
119+
await anyio.sleep(0.6)
120+
121+
assert session_id not in manager._server_instances
122+
123+
stop.set()
124+
125+
126+
@pytest.mark.anyio
127+
async def test_multiple_sessions_reaped_independently():
128+
"""Each session tracks its own idle timeout independently."""
129+
app = Server("test-multi-idle")
130+
stop = anyio.Event()
131+
app.run = _make_blocking_run(stop) # type: ignore[assignment]
132+
133+
manager = StreamableHTTPSessionManager(
134+
app=app,
135+
session_idle_timeout=0.15,
136+
)
137+
138+
async with manager.run():
139+
# Create session 1
140+
sent1: list[Message] = []
141+
await manager.handle_request(_make_scope(), _mock_receive, _make_send(sent1))
142+
session_id_1 = _extract_session_id(sent1)
143+
144+
# Wait a bit, then create session 2
145+
await anyio.sleep(0.05)
146+
sent2: list[Message] = []
147+
await manager.handle_request(_make_scope(), _mock_receive, _make_send(sent2))
148+
session_id_2 = _extract_session_id(sent2)
149+
150+
assert session_id_1 in manager._server_instances
151+
assert session_id_2 in manager._server_instances
152+
153+
# After enough time, both should be reaped
154+
await anyio.sleep(0.4)
155+
156+
assert session_id_1 not in manager._server_instances
157+
assert session_id_2 not in manager._server_instances
158+
159+
stop.set()

0 commit comments

Comments
 (0)