Skip to content

Commit 7520adf

Browse files
authored
fix(toolbox-core): Prevent ToolboxClient from closing externally managed client sessions (#260)
* fix(toolbox-core): Prevent `ToolboxClient` from closing externally managed client sessions This change ensures that the `ToolboxClient` only closes client sessions that it creates and manages internally. If a client session is provided externally (i.e., injected into the `ToolboxClient`), the responsibility for managing its lifecycle, including closing it, remains with the entity that provided it. This prevents unintended closure of externally managed sessions. * docs: Fix function docstring * docs: Fix sync client close function docstring * docs: Improve client class comment * fix(toolbox-core): Attempt to close underlying managed session during gc * fix(toolbox-core): Fix accessing async client's session from sync client * chore: Remove unnecessary timeout in sync client tests * fix(toolbox-core): Fix deadlock in sync client tests * chore: Delint * chore(toolbox-core): Fix deadlock issue in e2e tests * chore(toolbox-core): Remove unused import
1 parent 536ec0a commit 7520adf

File tree

5 files changed

+72
-42
lines changed

5 files changed

+72
-42
lines changed

packages/toolbox-core/src/toolbox_core/client.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
# limitations under the License.
1414

1515

16+
from asyncio import get_running_loop
1617
from types import MappingProxyType
1718
from typing import Any, Awaitable, Callable, Mapping, Optional, Union
1819

@@ -28,11 +29,13 @@ class ToolboxClient:
2829
An asynchronous client for interacting with a Toolbox service.
2930
3031
Provides methods to discover and load tools defined by a remote Toolbox
31-
service endpoint. It manages an underlying `aiohttp.ClientSession`.
32+
service endpoint. It manages an underlying `aiohttp.ClientSession`, if one
33+
is not provided.
3234
"""
3335

3436
__base_url: str
3537
__session: ClientSession
38+
__manage_session: bool
3639

3740
def __init__(
3841
self,
@@ -56,7 +59,9 @@ def __init__(
5659
self.__base_url = url
5760

5861
# If no aiohttp.ClientSession is provided, make our own
62+
self.__manage_session = False
5963
if session is None:
64+
self.__manage_session = True
6065
session = ClientSession()
6166
self.__session = session
6267

@@ -136,16 +141,32 @@ async def __aexit__(self, exc_type, exc_val, exc_tb):
136141
"""
137142
await self.close()
138143

144+
def __del__(self):
145+
# This method is a "best-effort" safety net.
146+
# It should NOT be relied upon for guaranteed resource cleanup.
147+
# Explicitly using "async with" or calling "await client.close()" is the correct way.
148+
if self.__manage_session:
149+
try:
150+
loop = get_running_loop()
151+
except RuntimeError:
152+
loop = None
153+
154+
if loop and loop.is_running():
155+
# If a loop is running, try to schedule the close operation.
156+
# This is "fire-and-forget"; there's no guarantee it will complete
157+
# before the loop or interpreter shuts down.
158+
loop.create_task(self.__session.close())
159+
139160
async def close(self):
140161
"""
141162
Asynchronously closes the underlying client session. Doing so will cause
142163
any tools created by this Client to cease to function.
143164
144165
If the session was provided externally during initialization, the caller
145-
is responsible for its lifecycle, but calling close here will still
146-
attempt to close it.
166+
is responsible for its lifecycle.
147167
"""
148-
await self.__session.close()
168+
if self.__manage_session and not self.__session.closed:
169+
await self.__session.close()
149170

150171
async def load_tool(
151172
self,

packages/toolbox-core/src/toolbox_core/sync_client.py

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
# limitations under the License.
1414

1515

16-
import asyncio
16+
from asyncio import AbstractEventLoop, new_event_loop, run_coroutine_threadsafe
1717
from threading import Thread
1818
from typing import Any, Awaitable, Callable, Mapping, Optional, Union
1919

@@ -29,7 +29,7 @@ class ToolboxSyncClient:
2929
service endpoint.
3030
"""
3131

32-
__loop: Optional[asyncio.AbstractEventLoop] = None
32+
__loop: Optional[AbstractEventLoop] = None
3333
__thread: Optional[Thread] = None
3434

3535
def __init__(
@@ -49,7 +49,7 @@ def __init__(
4949
# Running a loop in a background thread allows us to support async
5050
# methods from non-async environments.
5151
if self.__class__.__loop is None:
52-
loop = asyncio.new_event_loop()
52+
loop = new_event_loop()
5353
thread = Thread(target=loop.run_forever, daemon=True)
5454
thread.start()
5555
self.__class__.__thread = thread
@@ -58,21 +58,20 @@ def __init__(
5858
async def create_client():
5959
return ToolboxClient(url, client_headers=client_headers)
6060

61-
self.__async_client = asyncio.run_coroutine_threadsafe(
61+
self.__async_client = run_coroutine_threadsafe(
6262
create_client(), self.__class__.__loop
6363
).result()
6464

6565
def close(self):
6666
"""
6767
Synchronously closes the underlying client session. Doing so will cause
6868
any tools created by this Client to cease to function.
69-
70-
If the session was provided externally during initialization, the caller
71-
is responsible for its lifecycle, but calling close here will still
72-
attempt to close it.
7369
"""
7470
coro = self.__async_client.close()
75-
asyncio.run_coroutine_threadsafe(coro, self.__loop).result()
71+
run_coroutine_threadsafe(coro, self.__loop).result(timeout=5)
72+
73+
def __del__(self):
74+
self.close()
7675

7776
def load_tool(
7877
self,
@@ -108,7 +107,7 @@ def load_tool(
108107
if not self.__loop or not self.__thread:
109108
raise ValueError("Background loop or thread cannot be None.")
110109

111-
async_tool = asyncio.run_coroutine_threadsafe(coro, self.__loop).result()
110+
async_tool = run_coroutine_threadsafe(coro, self.__loop).result()
112111
return ToolboxSyncTool(async_tool, self.__loop, self.__thread)
113112

114113
def load_toolset(
@@ -151,7 +150,7 @@ def load_toolset(
151150
if not self.__loop or not self.__thread:
152151
raise ValueError("Background loop or thread cannot be None.")
153152

154-
async_tools = asyncio.run_coroutine_threadsafe(coro, self.__loop).result()
153+
async_tools = run_coroutine_threadsafe(coro, self.__loop).result()
155154
return [
156155
ToolboxSyncTool(async_tool, self.__loop, self.__thread)
157156
for async_tool in async_tools

packages/toolbox-core/tests/conftest.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,16 @@
2222
import subprocess
2323
import tempfile
2424
import time
25+
from asyncio import run_coroutine_threadsafe
2526
from typing import Generator
2627

2728
import google
2829
import pytest_asyncio
2930
from google.auth import compute_engine
3031
from google.cloud import secretmanager, storage
3132

33+
from toolbox_core import ToolboxSyncClient
34+
3235

3336
#### Define Utility Functions
3437
def get_env_var(key: str) -> str:
@@ -92,6 +95,38 @@ def get_auth_token(client_id: str) -> str:
9295

9396

9497
#### Define Fixtures
98+
@pytest_asyncio.fixture(autouse=True)
99+
def patch_sync_client_for_deadlock(monkeypatch):
100+
"""
101+
Automatically replaces the blocking `ToolboxSyncClient.close()` method
102+
with a non-blocking version for the entire test run.
103+
104+
The original `ToolboxSyncClient.close()` is a blocking method because it
105+
calls `.result()`. In the pytest environment, this blocking call creates a
106+
deadlock during the test teardown phase when it conflicts with other fixtures
107+
(like `sync_client_environment` or `toolbox_server`) that are also managing
108+
background processes and threads.
109+
110+
By replacing `close` with this safe, non-blocking version, we prevent the
111+
deadlock and allow the test suite's fixtures to tear down cleanly.
112+
This change is only active during the test run.
113+
"""
114+
115+
def non_blocking_close(self):
116+
"""A replacement for close() that doesn't block."""
117+
if hasattr(self.__class__, "_ToolboxSyncClient__loop") and hasattr(
118+
self, "_ToolboxSyncClient__async_client"
119+
):
120+
loop = self.__class__._ToolboxSyncClient__loop
121+
async_client = self._ToolboxSyncClient__async_client
122+
123+
if loop and loop.is_running():
124+
coro = async_client.close()
125+
run_coroutine_threadsafe(coro, loop)
126+
127+
monkeypatch.setattr(ToolboxSyncClient, "close", non_blocking_close)
128+
129+
95130
@pytest_asyncio.fixture(scope="session")
96131
def project_id() -> str:
97132
return get_env_var("GOOGLE_CLOUD_PROJECT")

packages/toolbox-core/tests/test_sync_client.py

Lines changed: 2 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ def sync_client_environment():
4747
if original_loop and original_loop.is_running():
4848
original_loop.call_soon_threadsafe(original_loop.stop)
4949
if original_thread and original_thread.is_alive():
50-
original_thread.join(timeout=5)
50+
original_thread.join()
5151
ToolboxSyncClient._ToolboxSyncClient__loop = None
5252
ToolboxSyncClient._ToolboxSyncClient__thread = None
5353

@@ -63,7 +63,7 @@ def sync_client_environment():
6363
if test_loop and test_loop.is_running():
6464
test_loop.call_soon_threadsafe(test_loop.stop)
6565
if test_thread and test_thread.is_alive():
66-
test_thread.join(timeout=5)
66+
test_thread.join()
6767

6868
# Explicitly set to None to ensure a clean state for the next fixture use/test.
6969
ToolboxSyncClient._ToolboxSyncClient__loop = None
@@ -288,30 +288,6 @@ def test_sync_client_creation_in_isolated_env(self, sync_client):
288288
sync_client._ToolboxSyncClient__async_client, ToolboxClient
289289
), "Async client should be ToolboxClient instance"
290290

291-
@pytest.mark.usefixtures("sync_client_environment")
292-
def test_sync_client_close_method(self):
293-
"""
294-
Tests the close() method of ToolboxSyncClient when manually created.
295-
The sync_client_environment ensures loop/thread cleanup.
296-
"""
297-
mock_async_client_instance = AsyncMock(spec=ToolboxClient)
298-
# AsyncMock methods are already AsyncMocks
299-
# mock_async_client_instance.close = AsyncMock(return_value=None)
300-
301-
with patch(
302-
"toolbox_core.sync_client.ToolboxClient",
303-
return_value=mock_async_client_instance,
304-
) as MockedAsyncClientConst:
305-
client = ToolboxSyncClient(TEST_BASE_URL)
306-
# The sync client passes its internal loop to the async client.
307-
MockedAsyncClientConst.assert_called_once_with(
308-
TEST_BASE_URL, client_headers=None
309-
)
310-
311-
client.close() # This call closes the async_client's session.
312-
mock_async_client_instance.close.assert_awaited_once()
313-
# The sync_client_environment fixture handles stopping the loop/thread.
314-
315291
@pytest.mark.usefixtures("sync_client_environment")
316292
def test_sync_client_context_manager(self, aioresponses, tool_schema_minimal):
317293
"""

packages/toolbox-langchain/src/toolbox_langchain/client.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
from warnings import warn
1818

1919
from toolbox_core.sync_client import ToolboxSyncClient as ToolboxCoreSyncClient
20-
from toolbox_core.sync_tool import ToolboxSyncTool
2120

2221
from .tools import ToolboxTool
2322

0 commit comments

Comments
 (0)