Skip to content

Commit 356c555

Browse files
authored
Revert "fix(toolbox-core): Prevent ToolboxClient from closing externally ma…"
This reverts commit 7520adf.
1 parent 2638f66 commit 356c555

File tree

5 files changed

+42
-72
lines changed

5 files changed

+42
-72
lines changed

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

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

1515

16-
from asyncio import get_running_loop
1716
from types import MappingProxyType
1817
from typing import Any, Awaitable, Callable, Mapping, Optional, Union
1918

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

3634
__base_url: str
3735
__session: ClientSession
38-
__manage_session: bool
3936

4037
def __init__(
4138
self,
@@ -59,9 +56,7 @@ def __init__(
5956
self.__base_url = url
6057

6158
# If no aiohttp.ClientSession is provided, make our own
62-
self.__manage_session = False
6359
if session is None:
64-
self.__manage_session = True
6560
session = ClientSession()
6661
self.__session = session
6762

@@ -141,32 +136,16 @@ async def __aexit__(self, exc_type, exc_val, exc_tb):
141136
"""
142137
await self.close()
143138

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-
160139
async def close(self):
161140
"""
162141
Asynchronously closes the underlying client session. Doing so will cause
163142
any tools created by this Client to cease to function.
164143
165144
If the session was provided externally during initialization, the caller
166-
is responsible for its lifecycle.
145+
is responsible for its lifecycle, but calling close here will still
146+
attempt to close it.
167147
"""
168-
if self.__manage_session and not self.__session.closed:
169-
await self.__session.close()
148+
await self.__session.close()
170149

171150
async def load_tool(
172151
self,

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

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

1515

16-
from asyncio import AbstractEventLoop, new_event_loop, run_coroutine_threadsafe
16+
import asyncio
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[AbstractEventLoop] = None
32+
__loop: Optional[asyncio.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 = new_event_loop()
52+
loop = asyncio.new_event_loop()
5353
thread = Thread(target=loop.run_forever, daemon=True)
5454
thread.start()
5555
self.__class__.__thread = thread
@@ -58,20 +58,21 @@ def __init__(
5858
async def create_client():
5959
return ToolboxClient(url, client_headers=client_headers)
6060

61-
self.__async_client = run_coroutine_threadsafe(
61+
self.__async_client = asyncio.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.
6973
"""
7074
coro = self.__async_client.close()
71-
run_coroutine_threadsafe(coro, self.__loop).result(timeout=5)
72-
73-
def __del__(self):
74-
self.close()
75+
asyncio.run_coroutine_threadsafe(coro, self.__loop).result()
7576

7677
def load_tool(
7778
self,
@@ -107,7 +108,7 @@ def load_tool(
107108
if not self.__loop or not self.__thread:
108109
raise ValueError("Background loop or thread cannot be None.")
109110

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

113114
def load_toolset(
@@ -150,7 +151,7 @@ def load_toolset(
150151
if not self.__loop or not self.__thread:
151152
raise ValueError("Background loop or thread cannot be None.")
152153

153-
async_tools = run_coroutine_threadsafe(coro, self.__loop).result()
154+
async_tools = asyncio.run_coroutine_threadsafe(coro, self.__loop).result()
154155
return [
155156
ToolboxSyncTool(async_tool, self.__loop, self.__thread)
156157
for async_tool in async_tools

packages/toolbox-core/tests/conftest.py

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

2827
import google
2928
import pytest_asyncio
3029
from google.auth import compute_engine
3130
from google.cloud import secretmanager, storage
3231

33-
from toolbox_core import ToolboxSyncClient
34-
3532

3633
#### Define Utility Functions
3734
def get_env_var(key: str) -> str:
@@ -95,38 +92,6 @@ def get_auth_token(client_id: str) -> str:
9592

9693

9794
#### 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-
13095
@pytest_asyncio.fixture(scope="session")
13196
def project_id() -> str:
13297
return get_env_var("GOOGLE_CLOUD_PROJECT")

packages/toolbox-core/tests/test_sync_client.py

Lines changed: 26 additions & 2 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()
50+
original_thread.join(timeout=5)
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()
66+
test_thread.join(timeout=5)
6767

6868
# Explicitly set to None to ensure a clean state for the next fixture use/test.
6969
ToolboxSyncClient._ToolboxSyncClient__loop = None
@@ -288,6 +288,30 @@ 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+
291315
@pytest.mark.usefixtures("sync_client_environment")
292316
def test_sync_client_context_manager(self, aioresponses, tool_schema_minimal):
293317
"""

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

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

1919
from toolbox_core.sync_client import ToolboxSyncClient as ToolboxCoreSyncClient
20+
from toolbox_core.sync_tool import ToolboxSyncTool
2021

2122
from .tools import ToolboxTool
2223

0 commit comments

Comments
 (0)