Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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
Expand Down
18 changes: 17 additions & 1 deletion src/stagehand/_base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
28 changes: 28 additions & 0 deletions tests/test_async_client_cleanup.py
Original file line number Diff line number Diff line change
@@ -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())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: In test_unclosed_async_http_client_warns_without_running_loop, the asyncio.run(client.aclose()) cleanup runs only if the pytest.warns(...) block passes. If the expect-warning assertion fails (e.g. __del__ stops warning), the block raises and the client is never closed, leaking an unclosed HTTP client into subsequent tests. Wrap the cleanup in a try/finally so the client is always closed deterministically.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/test_async_client_cleanup.py, line 17:

<comment>In `test_unclosed_async_http_client_warns_without_running_loop`, the `asyncio.run(client.aclose())` cleanup runs only if the `pytest.warns(...)` block passes. If the expect-warning assertion fails (e.g. `__del__` stops warning), the block raises and the client is never closed, leaking an unclosed HTTP client into subsequent tests. Wrap the cleanup in a `try/finally` so the client is always closed deterministically.</comment>

<file context>
@@ -0,0 +1,28 @@
+    with pytest.warns(ResourceWarning, match="Unclosed async HTTP client"):
+        client.__del__()
+
+    asyncio.run(client.aclose())
+
+
</file context>



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 == []