diff --git a/README.md b/README.md index aff5444e..f8d77d56 100644 --- a/README.md +++ b/README.md @@ -295,10 +295,10 @@ from stagehand import AsyncStagehand async def main() -> None: - client = AsyncStagehand() - session = await client.sessions.start(model_name="anthropic/claude-sonnet-4-6") - response = await session.act(input="click the first link on the page") - print(response.data) + async with AsyncStagehand() as client: + session = await client.sessions.start(model_name="anthropic/claude-sonnet-4-6") + response = await session.act(input="click the first link on the page") + print(response.data) asyncio.run(main()) @@ -677,7 +677,9 @@ client.with_options(http_client=DefaultHttpxClient(...)) ### Managing HTTP resources -By default the library closes underlying HTTP connections whenever the client is [garbage collected](https://docs.python.org/3/reference/datamodel.html#object.__del__). You can manually close the client using the `.close()` method if desired, or with a context manager that closes when exiting. +The synchronous client makes a best effort to close underlying HTTP connections when it is [garbage collected](https://docs.python.org/3/reference/datamodel.html#object.__del__). You can close it deterministically with `.close()` or a context manager. + +Async clients cannot reliably close connections during garbage collection when no event loop is running. Always use `async with AsyncStagehand(...)` or call `await client.close()`. ```py from stagehand import Stagehand diff --git a/src/stagehand/_base_client.py b/src/stagehand/_base_client.py index 9af0033b..77d23432 100644 --- a/src/stagehand/_base_client.py +++ b/src/stagehand/_base_client.py @@ -1419,7 +1419,23 @@ def __del__(self) -> None: try: # TODO(someday): support non asyncio runtimes here - asyncio.get_running_loop().create_task(self.aclose()) + loop = asyncio.get_running_loop() + except RuntimeError: + try: + warnings.warn( + "Unclosed async HTTP client; use `async with AsyncStagehand(...)` or `await client.close()`", + ResourceWarning, + stacklevel=2, + source=self, + ) + except Exception: + pass + return + except Exception: + return + + try: + loop.create_task(self.aclose()) except Exception: pass diff --git a/tests/test_async_client_cleanup.py b/tests/test_async_client_cleanup.py new file mode 100644 index 00000000..7d5fb85b --- /dev/null +++ b/tests/test_async_client_cleanup.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import asyncio +import warnings + +import pytest + +from stagehand._base_client import AsyncHttpxClientWrapper + + +def test_unclosed_async_http_client_warns_without_running_loop() -> None: + client = AsyncHttpxClientWrapper() + + with pytest.warns(ResourceWarning, match="Unclosed async HTTP client"): + client.__del__() + + asyncio.run(client.aclose()) + + +def test_closed_async_http_client_does_not_warn() -> None: + client = AsyncHttpxClientWrapper() + asyncio.run(client.aclose()) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + client.__del__() + + assert caught == []