Skip to content

Commit 12c3900

Browse files
Merge pull request #62 from MarcSkovMadsen/copilot/sub-pr-61
Fix race condition in singleton client initialization
2 parents aa3c848 + bb5f988 commit 12c3900

2 files changed

Lines changed: 92 additions & 2 deletions

File tree

src/holoviz_mcp/client.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
>>> result = await call_tool("docs_search", {"query": "Button"})
1515
"""
1616

17+
import asyncio
1718
from typing import Any
1819

1920
from fastmcp import Client
@@ -25,6 +26,7 @@
2526
__all__ = ["call_tool"]
2627

2728
_CLIENT: Client | None = None
29+
_CLIENT_LOCK = asyncio.Lock()
2830

2931

3032
async def _setup_composed_server() -> None:
@@ -55,6 +57,9 @@ async def call_tool(tool_name: str, parameters: dict[str, Any]) -> CallToolResul
5557
server initialization. The first call will initialize the server and
5658
create a client; subsequent calls reuse the same client.
5759
60+
The client initialization is protected by an asyncio.Lock to prevent
61+
race conditions when multiple tasks call this function concurrently.
62+
5863
Parameters
5964
----------
6065
tool_name : str
@@ -81,8 +86,9 @@ async def call_tool(tool_name: str, parameters: dict[str, Any]) -> CallToolResul
8186
>>> result = await call_tool("docs_get_best_practices", {"project": "panel"})
8287
"""
8388
global _CLIENT
84-
if _CLIENT is None:
85-
_CLIENT = await _create_client()
89+
async with _CLIENT_LOCK:
90+
if _CLIENT is None:
91+
_CLIENT = await _create_client()
8692

8793
async with _CLIENT:
8894
return await _CLIENT.call_tool(tool_name, parameters)

tests/test_client.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""
2+
Integration tests for the client module.
3+
4+
These tests verify the functionality of the client module, including
5+
the singleton client pattern and its thread safety under concurrent access.
6+
"""
7+
8+
import asyncio
9+
10+
import pytest
11+
12+
from holoviz_mcp.client import call_tool
13+
14+
15+
class TestClientSingleton:
16+
"""Tests for the singleton client pattern."""
17+
18+
@pytest.mark.asyncio
19+
async def test_concurrent_client_initialization(self):
20+
"""Test that concurrent calls to call_tool don't create multiple clients.
21+
22+
This test verifies that the singleton client pattern handles race
23+
conditions properly when multiple async tasks call call_tool
24+
concurrently before the client is initialized. The asyncio.Lock
25+
ensures only one task initializes the client.
26+
"""
27+
# Reset the client state for testing
28+
# Note: This directly manipulates module state for testing purposes
29+
import holoviz_mcp.client as client_module
30+
31+
client_module._CLIENT = None
32+
33+
# Create multiple concurrent tasks that all call call_tool
34+
tasks = [
35+
call_tool("panel_list_components", {}),
36+
call_tool("panel_list_components", {}),
37+
call_tool("panel_list_components", {}),
38+
call_tool("panel_list_components", {}),
39+
call_tool("panel_list_components", {}),
40+
]
41+
42+
# Run all tasks concurrently
43+
results = await asyncio.gather(*tasks)
44+
45+
# All tasks should complete successfully
46+
assert len(results) == 5
47+
for result in results:
48+
assert result is not None
49+
# Results should contain component data
50+
assert isinstance(result.data, list)
51+
52+
@pytest.mark.asyncio
53+
async def test_client_reuse(self):
54+
"""Test that the client is reused across multiple calls."""
55+
# First call
56+
result1 = await call_tool("panel_list_components", {})
57+
assert result1 is not None
58+
59+
# Second call should reuse the same client
60+
result2 = await call_tool("panel_list_components", {})
61+
assert result2 is not None
62+
63+
# Both should return valid data
64+
assert isinstance(result1.data, list)
65+
assert isinstance(result2.data, list)
66+
67+
@pytest.mark.asyncio
68+
async def test_concurrent_tool_calls(self):
69+
"""Test multiple concurrent tool calls using the singleton client."""
70+
# Create tasks that call different tools concurrently
71+
tasks = [
72+
call_tool("panel_list_components", {}),
73+
call_tool("panel_list_packages", {}),
74+
call_tool("panel_list_components", {}),
75+
]
76+
77+
# Run all tasks concurrently
78+
results = await asyncio.gather(*tasks)
79+
80+
# All tasks should complete successfully
81+
assert len(results) == 3
82+
for result in results:
83+
assert result is not None
84+
assert isinstance(result.data, list)

0 commit comments

Comments
 (0)