Skip to content

Commit 74c2c3c

Browse files
move async playwright start to a separate thread
1 parent 830fed5 commit 74c2c3c

File tree

2 files changed

+129
-1
lines changed

2 files changed

+129
-1
lines changed

stagehand/main.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,9 @@ async def init(self):
391391
self.logger.debug("Initializing Stagehand...")
392392
self.logger.debug(f"Environment: {self.env}")
393393

394-
self._playwright = await async_playwright().start()
394+
# Always initialize playwright in a thread to avoid event loop conflicts
395+
# This ensures compatibility with strict event loop environments like Langgraph
396+
self._playwright = await self._init_playwright_in_thread()
395397

396398
if self.env == "BROWSERBASE":
397399
# Create session if we don't have one
@@ -450,6 +452,43 @@ async def init(self):
450452

451453
self._initialized = True
452454

455+
async def _init_playwright_in_thread(self):
456+
"""
457+
Initialize playwright in a separate thread for compatibility with strict event loops.
458+
459+
This method runs the playwright initialization in a separate thread to avoid
460+
blocking operations that can conflict with strict event loop environments
461+
like Langgraph when running without --allow-blocking.
462+
"""
463+
def _start_playwright():
464+
"""Helper function to start playwright in a separate thread."""
465+
import asyncio
466+
467+
# Create a new event loop for this thread
468+
loop = asyncio.new_event_loop()
469+
asyncio.set_event_loop(loop)
470+
471+
try:
472+
# Start playwright in this thread's event loop
473+
return loop.run_until_complete(async_playwright().start())
474+
finally:
475+
# Clean up the loop
476+
loop.close()
477+
478+
self.logger.debug("Starting playwright in separate thread...")
479+
480+
try:
481+
# Run the playwright initialization in a thread
482+
playwright_instance = await asyncio.to_thread(_start_playwright)
483+
self.logger.debug("Playwright initialized successfully in thread")
484+
return playwright_instance
485+
except Exception as e:
486+
self.logger.error(f"Failed to initialize playwright in thread: {e}")
487+
raise RuntimeError(
488+
"Failed to initialize Playwright in thread. This may indicate a "
489+
"deeper compatibility issue with your environment."
490+
) from e
491+
453492
def agent(self, **kwargs) -> Agent:
454493
"""
455494
Create an agent instance configured with the provided options.

tests/unit/test_client_initialization.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,3 +203,92 @@ async def mock_create_session():
203203
# Call _create_session and expect error
204204
with pytest.raises(RuntimeError, match="Invalid response format"):
205205
await client._create_session()
206+
207+
@pytest.mark.asyncio
208+
@mock.patch("stagehand.main.async_playwright")
209+
async def test_init_playwright_in_thread(self, mock_async_playwright):
210+
"""Test that playwright initialization works properly in a separate thread."""
211+
# Create a mock playwright instance
212+
mock_playwright_instance = mock.AsyncMock()
213+
mock_playwright_instance.stop = mock.AsyncMock()
214+
mock_playwright_instance.chromium = mock.MagicMock()
215+
216+
# Mock the async_playwright().start() to return our mock instance
217+
mock_async_playwright_start = mock.AsyncMock(return_value=mock_playwright_instance)
218+
mock_async_playwright.return_value.start = mock_async_playwright_start
219+
220+
# Create a Stagehand client with LOCAL env
221+
config = StagehandConfig(env="LOCAL")
222+
client = Stagehand(config=config)
223+
224+
# Test the threaded playwright initialization
225+
result = await client._init_playwright_in_thread()
226+
227+
# Verify that the playwright instance was returned
228+
assert result is mock_playwright_instance
229+
230+
# Verify that async_playwright().start() was called
231+
mock_async_playwright_start.assert_called_once()
232+
233+
# Verify the result has the expected attributes
234+
assert hasattr(result, 'chromium')
235+
assert hasattr(result, 'stop')
236+
237+
@pytest.mark.asyncio
238+
@mock.patch("stagehand.main.async_playwright")
239+
async def test_init_playwright_in_thread_handles_exceptions(self, mock_async_playwright):
240+
"""Test that threaded playwright initialization properly handles exceptions."""
241+
# Mock async_playwright().start() to raise an exception
242+
mock_async_playwright.return_value.start.side_effect = Exception("Test exception")
243+
244+
# Create a Stagehand client with LOCAL env
245+
config = StagehandConfig(env="LOCAL")
246+
client = Stagehand(config=config)
247+
248+
# Test that the method raises a RuntimeError with our exception message
249+
with pytest.raises(RuntimeError, match="Failed to initialize Playwright in thread"):
250+
await client._init_playwright_in_thread()
251+
252+
@pytest.mark.asyncio
253+
@mock.patch("stagehand.main.cleanup_browser_resources")
254+
@mock.patch("stagehand.main.connect_local_browser")
255+
@mock.patch.object(Stagehand, "_init_playwright_in_thread")
256+
async def test_init_uses_threaded_playwright(
257+
self, mock_init_playwright, mock_connect_local, mock_cleanup
258+
):
259+
"""Test that the main init() method uses threaded playwright initialization."""
260+
# Set up mocks
261+
mock_playwright_instance = mock.AsyncMock()
262+
mock_init_playwright.return_value = mock_playwright_instance
263+
264+
# Mock the browser connection to avoid complex setup
265+
mock_browser = mock.AsyncMock()
266+
mock_context = mock.AsyncMock()
267+
mock_stagehand_context = mock.MagicMock()
268+
mock_page = mock.MagicMock()
269+
mock_page._page = mock.AsyncMock()
270+
271+
mock_connect_local.return_value = (
272+
mock_browser,
273+
mock_context,
274+
mock_stagehand_context,
275+
mock_page,
276+
None # temp_user_data_dir
277+
)
278+
279+
# Create a Stagehand client with LOCAL env
280+
config = StagehandConfig(env="LOCAL")
281+
client = Stagehand(config=config)
282+
283+
# Initialize the client
284+
await client.init()
285+
286+
# Verify that threaded playwright initialization was called
287+
mock_init_playwright.assert_called_once()
288+
289+
# Verify that the client is properly initialized
290+
assert client._initialized is True
291+
assert client._playwright is mock_playwright_instance
292+
293+
# Clean up
294+
await client.close()

0 commit comments

Comments
 (0)