Skip to content

Commit 18b0d20

Browse files
Fix race condition in singleton client initialization using asyncio.Lock
Co-authored-by: MarcSkovMadsen <42288570+MarcSkovMadsen@users.noreply.github.com>
1 parent c3de44b commit 18b0d20

2 files changed

Lines changed: 90 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: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
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 is thread-safe
23+
and handles race conditions properly when multiple tasks call call_tool
24+
concurrently before the client is initialized.
25+
"""
26+
# Reset the client state by importing fresh
27+
import holoviz_mcp.client as client_module
28+
29+
client_module._CLIENT = None
30+
31+
# Create multiple concurrent tasks that all call call_tool
32+
tasks = [
33+
call_tool("panel_list_components", {}),
34+
call_tool("panel_list_components", {}),
35+
call_tool("panel_list_components", {}),
36+
call_tool("panel_list_components", {}),
37+
call_tool("panel_list_components", {}),
38+
]
39+
40+
# Run all tasks concurrently
41+
results = await asyncio.gather(*tasks)
42+
43+
# All tasks should complete successfully
44+
assert len(results) == 5
45+
for result in results:
46+
assert result is not None
47+
# Results should contain component data
48+
assert isinstance(result.data, list)
49+
50+
@pytest.mark.asyncio
51+
async def test_client_reuse(self):
52+
"""Test that the client is reused across multiple calls."""
53+
# First call
54+
result1 = await call_tool("panel_list_components", {})
55+
assert result1 is not None
56+
57+
# Second call should reuse the same client
58+
result2 = await call_tool("panel_list_components", {})
59+
assert result2 is not None
60+
61+
# Both should return valid data
62+
assert isinstance(result1.data, list)
63+
assert isinstance(result2.data, list)
64+
65+
@pytest.mark.asyncio
66+
async def test_concurrent_tool_calls(self):
67+
"""Test multiple concurrent tool calls using the singleton client."""
68+
# Create tasks that call different tools concurrently
69+
tasks = [
70+
call_tool("panel_list_components", {}),
71+
call_tool("panel_list_packages", {}),
72+
call_tool("panel_list_components", {}),
73+
]
74+
75+
# Run all tasks concurrently
76+
results = await asyncio.gather(*tasks)
77+
78+
# All tasks should complete successfully
79+
assert len(results) == 3
80+
for result in results:
81+
assert result is not None
82+
assert isinstance(result.data, list)

0 commit comments

Comments
 (0)