From 74c2c3c27a8fff80bfb07d646d4c7407530192c5 Mon Sep 17 00:00:00 2001 From: Filip Michalsky Date: Thu, 17 Jul 2025 18:54:36 -0400 Subject: [PATCH 01/13] move async playwright start to a separate thread --- stagehand/main.py | 41 ++++++++++- tests/unit/test_client_initialization.py | 89 ++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 1 deletion(-) diff --git a/stagehand/main.py b/stagehand/main.py index 0de682e0..35db2cc9 100644 --- a/stagehand/main.py +++ b/stagehand/main.py @@ -391,7 +391,9 @@ async def init(self): self.logger.debug("Initializing Stagehand...") self.logger.debug(f"Environment: {self.env}") - self._playwright = await async_playwright().start() + # Always initialize playwright in a thread to avoid event loop conflicts + # This ensures compatibility with strict event loop environments like Langgraph + self._playwright = await self._init_playwright_in_thread() if self.env == "BROWSERBASE": # Create session if we don't have one @@ -450,6 +452,43 @@ async def init(self): self._initialized = True + async def _init_playwright_in_thread(self): + """ + Initialize playwright in a separate thread for compatibility with strict event loops. + + This method runs the playwright initialization in a separate thread to avoid + blocking operations that can conflict with strict event loop environments + like Langgraph when running without --allow-blocking. + """ + def _start_playwright(): + """Helper function to start playwright in a separate thread.""" + import asyncio + + # Create a new event loop for this thread + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + try: + # Start playwright in this thread's event loop + return loop.run_until_complete(async_playwright().start()) + finally: + # Clean up the loop + loop.close() + + self.logger.debug("Starting playwright in separate thread...") + + try: + # Run the playwright initialization in a thread + playwright_instance = await asyncio.to_thread(_start_playwright) + self.logger.debug("Playwright initialized successfully in thread") + return playwright_instance + except Exception as e: + self.logger.error(f"Failed to initialize playwright in thread: {e}") + raise RuntimeError( + "Failed to initialize Playwright in thread. This may indicate a " + "deeper compatibility issue with your environment." + ) from e + def agent(self, **kwargs) -> Agent: """ Create an agent instance configured with the provided options. diff --git a/tests/unit/test_client_initialization.py b/tests/unit/test_client_initialization.py index cd748ac4..3041136f 100644 --- a/tests/unit/test_client_initialization.py +++ b/tests/unit/test_client_initialization.py @@ -203,3 +203,92 @@ async def mock_create_session(): # Call _create_session and expect error with pytest.raises(RuntimeError, match="Invalid response format"): await client._create_session() + + @pytest.mark.asyncio + @mock.patch("stagehand.main.async_playwright") + async def test_init_playwright_in_thread(self, mock_async_playwright): + """Test that playwright initialization works properly in a separate thread.""" + # Create a mock playwright instance + mock_playwright_instance = mock.AsyncMock() + mock_playwright_instance.stop = mock.AsyncMock() + mock_playwright_instance.chromium = mock.MagicMock() + + # Mock the async_playwright().start() to return our mock instance + mock_async_playwright_start = mock.AsyncMock(return_value=mock_playwright_instance) + mock_async_playwright.return_value.start = mock_async_playwright_start + + # Create a Stagehand client with LOCAL env + config = StagehandConfig(env="LOCAL") + client = Stagehand(config=config) + + # Test the threaded playwright initialization + result = await client._init_playwright_in_thread() + + # Verify that the playwright instance was returned + assert result is mock_playwright_instance + + # Verify that async_playwright().start() was called + mock_async_playwright_start.assert_called_once() + + # Verify the result has the expected attributes + assert hasattr(result, 'chromium') + assert hasattr(result, 'stop') + + @pytest.mark.asyncio + @mock.patch("stagehand.main.async_playwright") + async def test_init_playwright_in_thread_handles_exceptions(self, mock_async_playwright): + """Test that threaded playwright initialization properly handles exceptions.""" + # Mock async_playwright().start() to raise an exception + mock_async_playwright.return_value.start.side_effect = Exception("Test exception") + + # Create a Stagehand client with LOCAL env + config = StagehandConfig(env="LOCAL") + client = Stagehand(config=config) + + # Test that the method raises a RuntimeError with our exception message + with pytest.raises(RuntimeError, match="Failed to initialize Playwright in thread"): + await client._init_playwright_in_thread() + + @pytest.mark.asyncio + @mock.patch("stagehand.main.cleanup_browser_resources") + @mock.patch("stagehand.main.connect_local_browser") + @mock.patch.object(Stagehand, "_init_playwright_in_thread") + async def test_init_uses_threaded_playwright( + self, mock_init_playwright, mock_connect_local, mock_cleanup + ): + """Test that the main init() method uses threaded playwright initialization.""" + # Set up mocks + mock_playwright_instance = mock.AsyncMock() + mock_init_playwright.return_value = mock_playwright_instance + + # Mock the browser connection to avoid complex setup + mock_browser = mock.AsyncMock() + mock_context = mock.AsyncMock() + mock_stagehand_context = mock.MagicMock() + mock_page = mock.MagicMock() + mock_page._page = mock.AsyncMock() + + mock_connect_local.return_value = ( + mock_browser, + mock_context, + mock_stagehand_context, + mock_page, + None # temp_user_data_dir + ) + + # Create a Stagehand client with LOCAL env + config = StagehandConfig(env="LOCAL") + client = Stagehand(config=config) + + # Initialize the client + await client.init() + + # Verify that threaded playwright initialization was called + mock_init_playwright.assert_called_once() + + # Verify that the client is properly initialized + assert client._initialized is True + assert client._playwright is mock_playwright_instance + + # Clean up + await client.close() From 6938d3791ed60cca8e6b60bca2fa5b4e2e2764da Mon Sep 17 00:00:00 2001 From: Filip Michalsky Date: Thu, 17 Jul 2025 18:59:15 -0400 Subject: [PATCH 02/13] add changeset --- .changeset/wealthy-lion-of-honeydew.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/wealthy-lion-of-honeydew.md diff --git a/.changeset/wealthy-lion-of-honeydew.md b/.changeset/wealthy-lion-of-honeydew.md new file mode 100644 index 00000000..e8b0c36c --- /dev/null +++ b/.changeset/wealthy-lion-of-honeydew.md @@ -0,0 +1,5 @@ +--- +"stagehand": patch +--- + +moved async playwright start to a thread to prevent blocking behaviour From 246ba1ec6bd4e6f28b0bb0c0599f778bf61cc78b Mon Sep 17 00:00:00 2001 From: Filip Michalsky Date: Thu, 17 Jul 2025 19:06:26 -0400 Subject: [PATCH 03/13] event loop is blocked - fixing --- stagehand/main.py | 31 ++++++++++++------------ tests/unit/test_client_initialization.py | 29 ++++++++++++---------- 2 files changed, 32 insertions(+), 28 deletions(-) diff --git a/stagehand/main.py b/stagehand/main.py index 35db2cc9..9f56f92a 100644 --- a/stagehand/main.py +++ b/stagehand/main.py @@ -454,38 +454,39 @@ async def init(self): async def _init_playwright_in_thread(self): """ - Initialize playwright in a separate thread for compatibility with strict event loops. + Initialize playwright using asyncio.to_thread to avoid blocking the main event loop. - This method runs the playwright initialization in a separate thread to avoid - blocking operations that can conflict with strict event loop environments - like Langgraph when running without --allow-blocking. + This method runs the potentially blocking async_playwright().start() in a thread + to avoid conflicts with strict event loop environments like Langgraph. """ - def _start_playwright(): - """Helper function to start playwright in a separate thread.""" + self.logger.debug("Starting playwright initialization in background thread...") + + def _start_playwright_blocking(): + """Start playwright in a blocking manner - will be run in a thread.""" import asyncio + from playwright.async_api import async_playwright # Create a new event loop for this thread loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: - # Start playwright in this thread's event loop + # Run the async playwright start in this loop return loop.run_until_complete(async_playwright().start()) finally: - # Clean up the loop loop.close() - self.logger.debug("Starting playwright in separate thread...") - try: - # Run the playwright initialization in a thread - playwright_instance = await asyncio.to_thread(_start_playwright) - self.logger.debug("Playwright initialized successfully in thread") + # Use asyncio.to_thread to run the blocking initialization + playwright_instance = await asyncio.to_thread(_start_playwright_blocking) + + self.logger.debug("Playwright initialized successfully in background thread") return playwright_instance + except Exception as e: - self.logger.error(f"Failed to initialize playwright in thread: {e}") + self.logger.error(f"Failed to initialize playwright in background thread: {e}") raise RuntimeError( - "Failed to initialize Playwright in thread. This may indicate a " + "Failed to initialize Playwright in background thread. This may indicate a " "deeper compatibility issue with your environment." ) from e diff --git a/tests/unit/test_client_initialization.py b/tests/unit/test_client_initialization.py index 3041136f..e2c9f1e5 100644 --- a/tests/unit/test_client_initialization.py +++ b/tests/unit/test_client_initialization.py @@ -205,17 +205,18 @@ async def mock_create_session(): await client._create_session() @pytest.mark.asyncio - @mock.patch("stagehand.main.async_playwright") - async def test_init_playwright_in_thread(self, mock_async_playwright): - """Test that playwright initialization works properly in a separate thread.""" + @mock.patch("asyncio.to_thread") + async def test_init_playwright_in_thread(self, mock_to_thread): + """Test that playwright initialization works properly using asyncio.to_thread.""" # Create a mock playwright instance mock_playwright_instance = mock.AsyncMock() mock_playwright_instance.stop = mock.AsyncMock() mock_playwright_instance.chromium = mock.MagicMock() + mock_playwright_instance.firefox = mock.MagicMock() + mock_playwright_instance.webkit = mock.MagicMock() - # Mock the async_playwright().start() to return our mock instance - mock_async_playwright_start = mock.AsyncMock(return_value=mock_playwright_instance) - mock_async_playwright.return_value.start = mock_async_playwright_start + # Mock asyncio.to_thread to return our mock instance + mock_to_thread.return_value = mock_playwright_instance # Create a Stagehand client with LOCAL env config = StagehandConfig(env="LOCAL") @@ -227,26 +228,28 @@ async def test_init_playwright_in_thread(self, mock_async_playwright): # Verify that the playwright instance was returned assert result is mock_playwright_instance - # Verify that async_playwright().start() was called - mock_async_playwright_start.assert_called_once() + # Verify that asyncio.to_thread was called + mock_to_thread.assert_called_once() # Verify the result has the expected attributes assert hasattr(result, 'chromium') + assert hasattr(result, 'firefox') + assert hasattr(result, 'webkit') assert hasattr(result, 'stop') @pytest.mark.asyncio - @mock.patch("stagehand.main.async_playwright") - async def test_init_playwright_in_thread_handles_exceptions(self, mock_async_playwright): + @mock.patch("asyncio.to_thread") + async def test_init_playwright_in_thread_handles_exceptions(self, mock_to_thread): """Test that threaded playwright initialization properly handles exceptions.""" - # Mock async_playwright().start() to raise an exception - mock_async_playwright.return_value.start.side_effect = Exception("Test exception") + # Mock asyncio.to_thread to raise an exception + mock_to_thread.side_effect = Exception("Test exception") # Create a Stagehand client with LOCAL env config = StagehandConfig(env="LOCAL") client = Stagehand(config=config) # Test that the method raises a RuntimeError with our exception message - with pytest.raises(RuntimeError, match="Failed to initialize Playwright in thread"): + with pytest.raises(RuntimeError, match="Failed to initialize Playwright in background thread"): await client._init_playwright_in_thread() @pytest.mark.asyncio From 932e74510a785bf8c3b0e7ea8ce0b3f9f96b5c14 Mon Sep 17 00:00:00 2001 From: Filip Michalsky Date: Thu, 17 Jul 2025 19:11:22 -0400 Subject: [PATCH 04/13] revert --- stagehand/main.py | 52 ++++++++++---------- tests/unit/test_client_initialization.py | 60 +++++++++++++++--------- 2 files changed, 63 insertions(+), 49 deletions(-) diff --git a/stagehand/main.py b/stagehand/main.py index 9f56f92a..bbab4e75 100644 --- a/stagehand/main.py +++ b/stagehand/main.py @@ -391,9 +391,9 @@ async def init(self): self.logger.debug("Initializing Stagehand...") self.logger.debug(f"Environment: {self.env}") - # Always initialize playwright in a thread to avoid event loop conflicts + # Always initialize playwright with timeout to avoid hanging # This ensures compatibility with strict event loop environments like Langgraph - self._playwright = await self._init_playwright_in_thread() + self._playwright = await self._init_playwright_with_timeout() if self.env == "BROWSERBASE": # Create session if we don't have one @@ -452,42 +452,38 @@ async def init(self): self._initialized = True - async def _init_playwright_in_thread(self): + async def _init_playwright_with_timeout(self): """ - Initialize playwright using asyncio.to_thread to avoid blocking the main event loop. + Initialize playwright with a timeout to avoid hanging in strict event loop environments. - This method runs the potentially blocking async_playwright().start() in a thread - to avoid conflicts with strict event loop environments like Langgraph. + This method adds a timeout to the regular async_playwright().start() to prevent + hanging in environments like Langgraph that restrict blocking operations. """ - self.logger.debug("Starting playwright initialization in background thread...") - - def _start_playwright_blocking(): - """Start playwright in a blocking manner - will be run in a thread.""" - import asyncio - from playwright.async_api import async_playwright - - # Create a new event loop for this thread - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - try: - # Run the async playwright start in this loop - return loop.run_until_complete(async_playwright().start()) - finally: - loop.close() + self.logger.debug("Starting playwright initialization with timeout...") try: - # Use asyncio.to_thread to run the blocking initialization - playwright_instance = await asyncio.to_thread(_start_playwright_blocking) + # Use asyncio.wait_for to add a timeout to prevent hanging + # If the environment doesn't allow blocking operations, this will fail fast + playwright_instance = await asyncio.wait_for( + async_playwright().start(), + timeout=30.0 # 30 second timeout + ) - self.logger.debug("Playwright initialized successfully in background thread") + self.logger.debug("Playwright initialized successfully") return playwright_instance + except asyncio.TimeoutError: + self.logger.error("Playwright initialization timed out") + raise RuntimeError( + "Playwright initialization timed out after 30 seconds. This may indicate " + "your environment has strict event loop restrictions. If using Langgraph, " + "consider using the --allow-blocking flag." + ) except Exception as e: - self.logger.error(f"Failed to initialize playwright in background thread: {e}") + self.logger.error(f"Failed to initialize playwright: {e}") raise RuntimeError( - "Failed to initialize Playwright in background thread. This may indicate a " - "deeper compatibility issue with your environment." + "Failed to initialize Playwright. This may indicate your environment " + "has restrictions on subprocess creation or event loop operations." ) from e def agent(self, **kwargs) -> Agent: diff --git a/tests/unit/test_client_initialization.py b/tests/unit/test_client_initialization.py index e2c9f1e5..3f646b1c 100644 --- a/tests/unit/test_client_initialization.py +++ b/tests/unit/test_client_initialization.py @@ -205,9 +205,9 @@ async def mock_create_session(): await client._create_session() @pytest.mark.asyncio - @mock.patch("asyncio.to_thread") - async def test_init_playwright_in_thread(self, mock_to_thread): - """Test that playwright initialization works properly using asyncio.to_thread.""" + @mock.patch("stagehand.main.async_playwright") + async def test_init_playwright_with_timeout(self, mock_async_playwright): + """Test that playwright initialization works properly with timeout.""" # Create a mock playwright instance mock_playwright_instance = mock.AsyncMock() mock_playwright_instance.stop = mock.AsyncMock() @@ -215,22 +215,22 @@ async def test_init_playwright_in_thread(self, mock_to_thread): mock_playwright_instance.firefox = mock.MagicMock() mock_playwright_instance.webkit = mock.MagicMock() - # Mock asyncio.to_thread to return our mock instance - mock_to_thread.return_value = mock_playwright_instance + # Mock async_playwright().start() to return our mock instance as an awaitable + async def mock_start(): + return mock_playwright_instance + + mock_async_playwright.return_value.start = mock_start # Create a Stagehand client with LOCAL env config = StagehandConfig(env="LOCAL") client = Stagehand(config=config) - # Test the threaded playwright initialization - result = await client._init_playwright_in_thread() + # Test the playwright initialization with timeout + result = await client._init_playwright_with_timeout() # Verify that the playwright instance was returned assert result is mock_playwright_instance - # Verify that asyncio.to_thread was called - mock_to_thread.assert_called_once() - # Verify the result has the expected attributes assert hasattr(result, 'chromium') assert hasattr(result, 'firefox') @@ -238,28 +238,46 @@ async def test_init_playwright_in_thread(self, mock_to_thread): assert hasattr(result, 'stop') @pytest.mark.asyncio - @mock.patch("asyncio.to_thread") - async def test_init_playwright_in_thread_handles_exceptions(self, mock_to_thread): - """Test that threaded playwright initialization properly handles exceptions.""" - # Mock asyncio.to_thread to raise an exception - mock_to_thread.side_effect = Exception("Test exception") + @mock.patch("stagehand.main.async_playwright") + async def test_init_playwright_with_timeout_handles_exceptions(self, mock_async_playwright): + """Test that playwright initialization properly handles exceptions.""" + # Mock async_playwright().start() to raise an exception as an awaitable + async def mock_start(): + raise Exception("Test exception") + + mock_async_playwright.return_value.start = mock_start # Create a Stagehand client with LOCAL env config = StagehandConfig(env="LOCAL") client = Stagehand(config=config) # Test that the method raises a RuntimeError with our exception message - with pytest.raises(RuntimeError, match="Failed to initialize Playwright in background thread"): - await client._init_playwright_in_thread() + with pytest.raises(RuntimeError, match="Failed to initialize Playwright"): + await client._init_playwright_with_timeout() + + @pytest.mark.asyncio + @mock.patch("stagehand.main.asyncio.wait_for") + async def test_init_playwright_with_timeout_handles_timeout(self, mock_wait_for): + """Test that playwright initialization properly handles timeouts.""" + # Mock asyncio.wait_for to raise a TimeoutError + mock_wait_for.side_effect = asyncio.TimeoutError() + + # Create a Stagehand client with LOCAL env + config = StagehandConfig(env="LOCAL") + client = Stagehand(config=config) + + # Test that the method raises a RuntimeError with timeout message + with pytest.raises(RuntimeError, match="Playwright initialization timed out"): + await client._init_playwright_with_timeout() @pytest.mark.asyncio @mock.patch("stagehand.main.cleanup_browser_resources") @mock.patch("stagehand.main.connect_local_browser") - @mock.patch.object(Stagehand, "_init_playwright_in_thread") - async def test_init_uses_threaded_playwright( + @mock.patch.object(Stagehand, "_init_playwright_with_timeout") + async def test_init_uses_playwright_with_timeout( self, mock_init_playwright, mock_connect_local, mock_cleanup ): - """Test that the main init() method uses threaded playwright initialization.""" + """Test that the main init() method uses playwright initialization with timeout.""" # Set up mocks mock_playwright_instance = mock.AsyncMock() mock_init_playwright.return_value = mock_playwright_instance @@ -286,7 +304,7 @@ async def test_init_uses_threaded_playwright( # Initialize the client await client.init() - # Verify that threaded playwright initialization was called + # Verify that playwright initialization with timeout was called mock_init_playwright.assert_called_once() # Verify that the client is properly initialized From 08af605a9e545f98ac921bdce35c64481a69fa3f Mon Sep 17 00:00:00 2001 From: Filip Michalsky Date: Thu, 17 Jul 2025 20:21:07 -0400 Subject: [PATCH 05/13] update comment --- stagehand/main.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/stagehand/main.py b/stagehand/main.py index bbab4e75..1ca8a943 100644 --- a/stagehand/main.py +++ b/stagehand/main.py @@ -392,7 +392,7 @@ async def init(self): self.logger.debug(f"Environment: {self.env}") # Always initialize playwright with timeout to avoid hanging - # This ensures compatibility with strict event loop environments like Langgraph + # This ensures compatibility with strict event loop environments self._playwright = await self._init_playwright_with_timeout() if self.env == "BROWSERBASE": @@ -457,7 +457,7 @@ async def _init_playwright_with_timeout(self): Initialize playwright with a timeout to avoid hanging in strict event loop environments. This method adds a timeout to the regular async_playwright().start() to prevent - hanging in environments like Langgraph that restrict blocking operations. + hanging in environments that restrict blocking operations. """ self.logger.debug("Starting playwright initialization with timeout...") @@ -476,8 +476,7 @@ async def _init_playwright_with_timeout(self): self.logger.error("Playwright initialization timed out") raise RuntimeError( "Playwright initialization timed out after 30 seconds. This may indicate " - "your environment has strict event loop restrictions. If using Langgraph, " - "consider using the --allow-blocking flag." + "your environment has strict event loop restrictions." ) except Exception as e: self.logger.error(f"Failed to initialize playwright: {e}") From 4596922adeab8adbdb730e9e1a9060ac79566fa9 Mon Sep 17 00:00:00 2001 From: Filip Michalsky Date: Thu, 17 Jul 2025 20:22:40 -0400 Subject: [PATCH 06/13] linter --- stagehand/main.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/stagehand/main.py b/stagehand/main.py index 1ca8a943..74f5142f 100644 --- a/stagehand/main.py +++ b/stagehand/main.py @@ -455,23 +455,22 @@ async def init(self): async def _init_playwright_with_timeout(self): """ Initialize playwright with a timeout to avoid hanging in strict event loop environments. - + This method adds a timeout to the regular async_playwright().start() to prevent hanging in environments that restrict blocking operations. """ self.logger.debug("Starting playwright initialization with timeout...") - + try: # Use asyncio.wait_for to add a timeout to prevent hanging # If the environment doesn't allow blocking operations, this will fail fast playwright_instance = await asyncio.wait_for( - async_playwright().start(), - timeout=30.0 # 30 second timeout + async_playwright().start(), timeout=30.0 # 30 second timeout ) - + self.logger.debug("Playwright initialized successfully") return playwright_instance - + except asyncio.TimeoutError: self.logger.error("Playwright initialization timed out") raise RuntimeError( From d1400ff966d373f5bcf8d15ece27a896eca6785a Mon Sep 17 00:00:00 2001 From: Filip Michalsky Date: Thu, 17 Jul 2025 20:25:31 -0400 Subject: [PATCH 07/13] fix linter --- stagehand/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stagehand/main.py b/stagehand/main.py index 74f5142f..fb4cb5b0 100644 --- a/stagehand/main.py +++ b/stagehand/main.py @@ -476,7 +476,7 @@ async def _init_playwright_with_timeout(self): raise RuntimeError( "Playwright initialization timed out after 30 seconds. This may indicate " "your environment has strict event loop restrictions." - ) + ) from None except Exception as e: self.logger.error(f"Failed to initialize playwright: {e}") raise RuntimeError( From 5b6819397a300b42eeb5b007b7c0a578b373971a Mon Sep 17 00:00:00 2001 From: Filip Michalsky Date: Thu, 17 Jul 2025 21:14:16 -0400 Subject: [PATCH 08/13] update --- stagehand/main.py | 43 +++++++++---------------------------------- 1 file changed, 9 insertions(+), 34 deletions(-) diff --git a/stagehand/main.py b/stagehand/main.py index fb4cb5b0..2517fbfc 100644 --- a/stagehand/main.py +++ b/stagehand/main.py @@ -391,9 +391,10 @@ async def init(self): self.logger.debug("Initializing Stagehand...") self.logger.debug(f"Environment: {self.env}") - # Always initialize playwright with timeout to avoid hanging - # This ensures compatibility with strict event loop environments - self._playwright = await self._init_playwright_with_timeout() + # Initialize Playwright with timeout + self._playwright = await asyncio.wait_for( + async_playwright().start(), timeout=30.0 # 30 second timeout + ) if self.env == "BROWSERBASE": # Create session if we don't have one @@ -452,37 +453,11 @@ async def init(self): self._initialized = True - async def _init_playwright_with_timeout(self): - """ - Initialize playwright with a timeout to avoid hanging in strict event loop environments. - - This method adds a timeout to the regular async_playwright().start() to prevent - hanging in environments that restrict blocking operations. - """ - self.logger.debug("Starting playwright initialization with timeout...") - - try: - # Use asyncio.wait_for to add a timeout to prevent hanging - # If the environment doesn't allow blocking operations, this will fail fast - playwright_instance = await asyncio.wait_for( - async_playwright().start(), timeout=30.0 # 30 second timeout - ) - - self.logger.debug("Playwright initialized successfully") - return playwright_instance - - except asyncio.TimeoutError: - self.logger.error("Playwright initialization timed out") - raise RuntimeError( - "Playwright initialization timed out after 30 seconds. This may indicate " - "your environment has strict event loop restrictions." - ) from None - except Exception as e: - self.logger.error(f"Failed to initialize playwright: {e}") - raise RuntimeError( - "Failed to initialize Playwright. This may indicate your environment " - "has restrictions on subprocess creation or event loop operations." - ) from e + async def _connect_local_browser_threaded(self, playwright, launch_options, stagehand, logger): + """Connect to local browser using the threaded Playwright instance.""" + return await self._playwright_runner.run_coroutine_async( + connect_local_browser(playwright, launch_options, stagehand, logger) + ) def agent(self, **kwargs) -> Agent: """ From 61db6ea869f21c5f2b8be5bf70a51dc993a4311f Mon Sep 17 00:00:00 2001 From: Filip Michalsky Date: Thu, 17 Jul 2025 21:16:19 -0400 Subject: [PATCH 09/13] formatting --- stagehand/main.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/stagehand/main.py b/stagehand/main.py index 2517fbfc..3ae6c55e 100644 --- a/stagehand/main.py +++ b/stagehand/main.py @@ -453,7 +453,9 @@ async def init(self): self._initialized = True - async def _connect_local_browser_threaded(self, playwright, launch_options, stagehand, logger): + async def _connect_local_browser_threaded( + self, playwright, launch_options, stagehand, logger + ): """Connect to local browser using the threaded Playwright instance.""" return await self._playwright_runner.run_coroutine_async( connect_local_browser(playwright, launch_options, stagehand, logger) From 8fe72cda760ac5af8a17baed3c72ee8a998f6572 Mon Sep 17 00:00:00 2001 From: Filip Michalsky Date: Thu, 17 Jul 2025 21:17:04 -0400 Subject: [PATCH 10/13] add changeset --- .changeset/wakeful-pogona-of-stamina.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/wakeful-pogona-of-stamina.md diff --git a/.changeset/wakeful-pogona-of-stamina.md b/.changeset/wakeful-pogona-of-stamina.md new file mode 100644 index 00000000..a5eef99d --- /dev/null +++ b/.changeset/wakeful-pogona-of-stamina.md @@ -0,0 +1,5 @@ +--- +"stagehand": patch +--- + +simple event loop timeout for strict event loops for async playwright (which has blocking start) From 0b22387e13a20cf988b8238e60fa530a1766e192 Mon Sep 17 00:00:00 2001 From: Filip Michalsky Date: Thu, 17 Jul 2025 21:18:18 -0400 Subject: [PATCH 11/13] update --- stagehand/main.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/stagehand/main.py b/stagehand/main.py index 3ae6c55e..f6918c15 100644 --- a/stagehand/main.py +++ b/stagehand/main.py @@ -453,14 +453,6 @@ async def init(self): self._initialized = True - async def _connect_local_browser_threaded( - self, playwright, launch_options, stagehand, logger - ): - """Connect to local browser using the threaded Playwright instance.""" - return await self._playwright_runner.run_coroutine_async( - connect_local_browser(playwright, launch_options, stagehand, logger) - ) - def agent(self, **kwargs) -> Agent: """ Create an agent instance configured with the provided options. From c40c0bb0ae9976399d463856a05891a4e5392a77 Mon Sep 17 00:00:00 2001 From: Filip Michalsky Date: Thu, 17 Jul 2025 21:20:16 -0400 Subject: [PATCH 12/13] update test --- tests/unit/test_client_initialization.py | 135 +++++------------------ 1 file changed, 25 insertions(+), 110 deletions(-) diff --git a/tests/unit/test_client_initialization.py b/tests/unit/test_client_initialization.py index 3f646b1c..237ada9b 100644 --- a/tests/unit/test_client_initialization.py +++ b/tests/unit/test_client_initialization.py @@ -136,6 +136,31 @@ def test_init_as_context_manager(self): # Verify close is called in __aexit__ assert client.close is not None + @pytest.mark.asyncio + async def test_init_playwright_timeout(self): + """Test that init() raises TimeoutError when playwright takes too long to start.""" + config = StagehandConfig(env="LOCAL") + client = Stagehand(config=config) + + # Mock async_playwright to simulate a hanging start() method + mock_playwright_instance = mock.AsyncMock() + mock_start = mock.AsyncMock() + + # Make start() hang indefinitely + async def hanging_start(): + await asyncio.sleep(100) # Sleep longer than the 30s timeout + + mock_start.side_effect = hanging_start + mock_playwright_instance.start = mock_start + + with mock.patch("stagehand.main.async_playwright", return_value=mock_playwright_instance): + # The init() method should raise TimeoutError due to the 30-second timeout + with pytest.raises(asyncio.TimeoutError): + await client.init() + + # Ensure the client is not marked as initialized + assert client._initialized is False + @pytest.mark.asyncio async def test_create_session(self): """Test session creation.""" @@ -203,113 +228,3 @@ async def mock_create_session(): # Call _create_session and expect error with pytest.raises(RuntimeError, match="Invalid response format"): await client._create_session() - - @pytest.mark.asyncio - @mock.patch("stagehand.main.async_playwright") - async def test_init_playwright_with_timeout(self, mock_async_playwright): - """Test that playwright initialization works properly with timeout.""" - # Create a mock playwright instance - mock_playwright_instance = mock.AsyncMock() - mock_playwright_instance.stop = mock.AsyncMock() - mock_playwright_instance.chromium = mock.MagicMock() - mock_playwright_instance.firefox = mock.MagicMock() - mock_playwright_instance.webkit = mock.MagicMock() - - # Mock async_playwright().start() to return our mock instance as an awaitable - async def mock_start(): - return mock_playwright_instance - - mock_async_playwright.return_value.start = mock_start - - # Create a Stagehand client with LOCAL env - config = StagehandConfig(env="LOCAL") - client = Stagehand(config=config) - - # Test the playwright initialization with timeout - result = await client._init_playwright_with_timeout() - - # Verify that the playwright instance was returned - assert result is mock_playwright_instance - - # Verify the result has the expected attributes - assert hasattr(result, 'chromium') - assert hasattr(result, 'firefox') - assert hasattr(result, 'webkit') - assert hasattr(result, 'stop') - - @pytest.mark.asyncio - @mock.patch("stagehand.main.async_playwright") - async def test_init_playwright_with_timeout_handles_exceptions(self, mock_async_playwright): - """Test that playwright initialization properly handles exceptions.""" - # Mock async_playwright().start() to raise an exception as an awaitable - async def mock_start(): - raise Exception("Test exception") - - mock_async_playwright.return_value.start = mock_start - - # Create a Stagehand client with LOCAL env - config = StagehandConfig(env="LOCAL") - client = Stagehand(config=config) - - # Test that the method raises a RuntimeError with our exception message - with pytest.raises(RuntimeError, match="Failed to initialize Playwright"): - await client._init_playwright_with_timeout() - - @pytest.mark.asyncio - @mock.patch("stagehand.main.asyncio.wait_for") - async def test_init_playwright_with_timeout_handles_timeout(self, mock_wait_for): - """Test that playwright initialization properly handles timeouts.""" - # Mock asyncio.wait_for to raise a TimeoutError - mock_wait_for.side_effect = asyncio.TimeoutError() - - # Create a Stagehand client with LOCAL env - config = StagehandConfig(env="LOCAL") - client = Stagehand(config=config) - - # Test that the method raises a RuntimeError with timeout message - with pytest.raises(RuntimeError, match="Playwright initialization timed out"): - await client._init_playwright_with_timeout() - - @pytest.mark.asyncio - @mock.patch("stagehand.main.cleanup_browser_resources") - @mock.patch("stagehand.main.connect_local_browser") - @mock.patch.object(Stagehand, "_init_playwright_with_timeout") - async def test_init_uses_playwright_with_timeout( - self, mock_init_playwright, mock_connect_local, mock_cleanup - ): - """Test that the main init() method uses playwright initialization with timeout.""" - # Set up mocks - mock_playwright_instance = mock.AsyncMock() - mock_init_playwright.return_value = mock_playwright_instance - - # Mock the browser connection to avoid complex setup - mock_browser = mock.AsyncMock() - mock_context = mock.AsyncMock() - mock_stagehand_context = mock.MagicMock() - mock_page = mock.MagicMock() - mock_page._page = mock.AsyncMock() - - mock_connect_local.return_value = ( - mock_browser, - mock_context, - mock_stagehand_context, - mock_page, - None # temp_user_data_dir - ) - - # Create a Stagehand client with LOCAL env - config = StagehandConfig(env="LOCAL") - client = Stagehand(config=config) - - # Initialize the client - await client.init() - - # Verify that playwright initialization with timeout was called - mock_init_playwright.assert_called_once() - - # Verify that the client is properly initialized - assert client._initialized is True - assert client._playwright is mock_playwright_instance - - # Clean up - await client.close() From 0f42439160c2b79515fe5405d933e5590c2941d9 Mon Sep 17 00:00:00 2001 From: Filip Michalsky <31483888+filip-michalsky@users.noreply.github.com> Date: Thu, 17 Jul 2025 21:20:40 -0400 Subject: [PATCH 13/13] Delete .changeset/wealthy-lion-of-honeydew.md --- .changeset/wealthy-lion-of-honeydew.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/wealthy-lion-of-honeydew.md diff --git a/.changeset/wealthy-lion-of-honeydew.md b/.changeset/wealthy-lion-of-honeydew.md deleted file mode 100644 index e8b0c36c..00000000 --- a/.changeset/wealthy-lion-of-honeydew.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"stagehand": patch ---- - -moved async playwright start to a thread to prevent blocking behaviour