Skip to content

Commit cf5e52f

Browse files
committed
test: use in-process threads for test servers to enable coverage tracking
Refactor basic_server, event_server, and json_response_server fixtures to use threading.Thread + uvicorn.Server instead of multiprocessing.Process. Since coverage tools cannot track code running in child processes, this change runs test servers in background threads within the same process, allowing coverage to instrument the server handler functions, create_app(), _create_server(), and fixture code. This removes 8 'pragma: no cover' markers from functions that are now covered. The remaining markers are on context_aware_server (PR 2), defensive returns in tests, and one unreachable raise. Fixes #1678 (partial)
1 parent 7ba4fb8 commit cf5e52f

File tree

1 file changed

+51
-30
lines changed

1 file changed

+51
-30
lines changed

tests/shared/test_streamable_http.py

Lines changed: 51 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import json
99
import multiprocessing
10+
import threading
1011
import socket
1112
import time
1213
import traceback
@@ -108,7 +109,7 @@ async def store_event(self, stream_id: StreamId, message: types.JSONRPCMessage |
108109
self._events.append((stream_id, event_id, message))
109110
return event_id
110111

111-
async def replay_events_after( # pragma: no cover
112+
async def replay_events_after(
112113
self,
113114
last_event_id: EventId,
114115
send_callback: EventCallback,
@@ -144,11 +145,11 @@ class ServerState:
144145

145146

146147
@asynccontextmanager
147-
async def _server_lifespan(_server: Server[ServerState]) -> AsyncIterator[ServerState]: # pragma: no cover
148+
async def _server_lifespan(_server: Server[ServerState]) -> AsyncIterator[ServerState]:
148149
yield ServerState()
149150

150151

151-
async def _handle_read_resource( # pragma: no cover
152+
async def _handle_read_resource(
152153
ctx: ServerRequestContext[ServerState], params: ReadResourceRequestParams
153154
) -> ReadResourceResult:
154155
uri = str(params.uri)
@@ -163,7 +164,7 @@ async def _handle_read_resource( # pragma: no cover
163164
return ReadResourceResult(contents=[TextResourceContents(uri=uri, text=text, mime_type="text/plain")])
164165

165166

166-
async def _handle_list_tools( # pragma: no cover
167+
async def _handle_list_tools(
167168
ctx: ServerRequestContext[ServerState], params: PaginatedRequestParams | None
168169
) -> ListToolsResult:
169170
return ListToolsResult(
@@ -228,7 +229,7 @@ async def _handle_list_tools( # pragma: no cover
228229
)
229230

230231

231-
async def _handle_call_tool( # pragma: no cover
232+
async def _handle_call_tool(
232233
ctx: ServerRequestContext[ServerState], params: CallToolRequestParams
233234
) -> CallToolResult:
234235
name = params.name
@@ -382,7 +383,7 @@ async def _handle_call_tool( # pragma: no cover
382383
return CallToolResult(content=[TextContent(type="text", text=f"Called {name}")])
383384

384385

385-
def _create_server() -> Server[ServerState]: # pragma: no cover
386+
def _create_server() -> Server[ServerState]:
386387
return Server(
387388
SERVER_NAME,
388389
lifespan=_server_lifespan,
@@ -396,7 +397,7 @@ def create_app(
396397
is_json_response_enabled: bool = False,
397398
event_store: EventStore | None = None,
398399
retry_interval: int | None = None,
399-
) -> Starlette: # pragma: no cover
400+
) -> Starlette:
400401
"""Create a Starlette application for testing using the session manager.
401402
402403
Args:
@@ -437,7 +438,7 @@ def run_server(
437438
event_store: EventStore | None = None,
438439
retry_interval: int | None = None,
439440
) -> None: # pragma: no cover
440-
"""Run the test server.
441+
"""Run the test server in a subprocess (used only by context_aware_server).
441442
442443
Args:
443444
port: Port to listen on.
@@ -468,6 +469,33 @@ def run_server(
468469
traceback.print_exc()
469470

470471

472+
def _start_server_thread(
473+
port: int,
474+
is_json_response_enabled: bool = False,
475+
event_store: EventStore | None = None,
476+
retry_interval: int | None = None,
477+
) -> tuple[threading.Thread, uvicorn.Server]:
478+
"""Start a test server in a background thread (in-process for coverage).
479+
480+
Returns:
481+
A tuple of (thread, uvicorn_server) for cleanup.
482+
"""
483+
app = create_app(is_json_response_enabled, event_store, retry_interval)
484+
config = uvicorn.Config(
485+
app=app,
486+
host="127.0.0.1",
487+
port=port,
488+
log_level="info",
489+
limit_concurrency=10,
490+
timeout_keep_alive=5,
491+
access_log=False,
492+
)
493+
server = uvicorn.Server(config=config)
494+
thread = threading.Thread(target=server.run, daemon=True)
495+
thread.start()
496+
return thread, server
497+
498+
471499
# Test fixtures - using same approach as SSE tests
472500
@pytest.fixture
473501
def basic_server_port() -> int:
@@ -487,18 +515,17 @@ def json_server_port() -> int:
487515

488516
@pytest.fixture
489517
def basic_server(basic_server_port: int) -> Generator[None, None, None]:
490-
"""Start a basic server."""
491-
proc = multiprocessing.Process(target=run_server, kwargs={"port": basic_server_port}, daemon=True)
492-
proc.start()
518+
"""Start a basic server in a background thread (in-process for coverage)."""
519+
thread, server = _start_server_thread(port=basic_server_port)
493520

494521
# Wait for server to be running
495522
wait_for_server(basic_server_port)
496523

497524
yield
498525

499526
# Clean up
500-
proc.kill()
501-
proc.join(timeout=2)
527+
server.should_exit = True
528+
thread.join(timeout=5)
502529

503530

504531
@pytest.fixture
@@ -519,42 +546,36 @@ def event_server_port() -> int:
519546
def event_server(
520547
event_server_port: int, event_store: SimpleEventStore
521548
) -> Generator[tuple[SimpleEventStore, str], None, None]:
522-
"""Start a server with event store and retry_interval enabled."""
523-
proc = multiprocessing.Process(
524-
target=run_server,
525-
kwargs={"port": event_server_port, "event_store": event_store, "retry_interval": 500},
526-
daemon=True,
549+
"""Start a server with event store and retry_interval enabled (in-process for coverage)."""
550+
thread, server = _start_server_thread(
551+
port=event_server_port, event_store=event_store, retry_interval=500
527552
)
528-
proc.start()
529553

530554
# Wait for server to be running
531555
wait_for_server(event_server_port)
532556

533557
yield event_store, f"http://127.0.0.1:{event_server_port}"
534558

535559
# Clean up
536-
proc.kill()
537-
proc.join(timeout=2)
560+
server.should_exit = True
561+
thread.join(timeout=5)
538562

539563

540564
@pytest.fixture
541565
def json_response_server(json_server_port: int) -> Generator[None, None, None]:
542-
"""Start a server with JSON response enabled."""
543-
proc = multiprocessing.Process(
544-
target=run_server,
545-
kwargs={"port": json_server_port, "is_json_response_enabled": True},
546-
daemon=True,
566+
"""Start a server with JSON response enabled (in-process for coverage)."""
567+
thread, server = _start_server_thread(
568+
port=json_server_port, is_json_response_enabled=True
547569
)
548-
proc.start()
549570

550571
# Wait for server to be running
551572
wait_for_server(json_server_port)
552573

553574
yield
554575

555576
# Clean up
556-
proc.kill()
557-
proc.join(timeout=2)
577+
server.should_exit = True
578+
thread.join(timeout=5)
558579

559580

560581
@pytest.fixture
@@ -1044,7 +1065,7 @@ def test_get_validation(basic_server: None, basic_server_url: str):
10441065

10451066
# Client-specific fixtures
10461067
@pytest.fixture
1047-
async def http_client(basic_server: None, basic_server_url: str): # pragma: no cover
1068+
async def http_client(basic_server: None, basic_server_url: str):
10481069
"""Create test client matching the SSE test pattern."""
10491070
async with httpx.AsyncClient(base_url=basic_server_url) as client:
10501071
yield client

0 commit comments

Comments
 (0)