From 02ec9c8c6a4bfb52269d6c5eefd01dd2fa702b63 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Tue, 27 May 2025 17:09:02 -0700 Subject: [PATCH] improve BrowserSession auto-restart on disconnection logic --- browser_use/browser/session.py | 157 ++++++++++++++++++++++++++------- 1 file changed, 124 insertions(+), 33 deletions(-) diff --git a/browser_use/browser/session.py b/browser_use/browser/session.py index a6f142ce5..a8bf7babe 100644 --- a/browser_use/browser/session.py +++ b/browser_use/browser/session.py @@ -73,22 +73,36 @@ def require_initialization(func): @wraps(func) async def wrapper(self, *args, **kwargs): - if not self.initialized: - # raise RuntimeError('BrowserSession(...).start() must be called first to launch or connect to the browser') - await self.start() # just start it automatically if not already started + try: + if not self.initialized: + # raise RuntimeError('BrowserSession(...).start() must be called first to launch or connect to the browser') + await self.start() # just start it automatically if not already started - if not self.agent_current_page or self.agent_current_page.is_closed(): - self.agent_current_page = self.browser_context.pages[0] if self.browser_context.pages else None + if not self.agent_current_page or self.agent_current_page.is_closed(): + self.agent_current_page = ( + self.browser_context.pages[0] if (self.browser_context and self.browser_context.pages) else None + ) - if not self.agent_current_page or self.agent_current_page.is_closed(): - await self.create_new_tab() + if not self.agent_current_page or self.agent_current_page.is_closed(): + await self.create_new_tab() - assert self.agent_current_page and not self.agent_current_page.is_closed() + assert self.agent_current_page and not self.agent_current_page.is_closed() - if not hasattr(self, '_cached_browser_state_summary'): - raise RuntimeError('BrowserSession(...).start() must be called first to initialize the browser session') + if not hasattr(self, '_cached_browser_state_summary'): + raise RuntimeError('BrowserSession(...).start() must be called first to initialize the browser session') - return await func(self, *args, **kwargs) + return await func(self, *args, **kwargs) + + except Exception as e: + # Check if this is a TargetClosedError or similar connection error + if 'TargetClosedError' in str(type(e)) or 'context or browser has been closed' in str(e): + logger.debug(f'Detected closed browser connection in {func.__name__}, resetting connection state') + self._reset_connection_state() + # Re-raise the error so the caller can handle it appropriately + raise + else: + # Re-raise other exceptions unchanged + raise return wrapper @@ -186,6 +200,7 @@ class BrowserSession(BaseModel): _cached_browser_state_summary: BrowserStateSummary | None = PrivateAttr(default=None) _cached_clickable_element_hashes: CachedClickableElementHashes | None = PrivateAttr(default=None) + _start_lock: asyncio.Lock = PrivateAttr(default_factory=asyncio.Lock) @model_validator(mode='after') def apply_session_overrides_to_profile(self) -> Self: @@ -230,28 +245,39 @@ class BrowserSession(BaseModel): 7. playwright=Playwright object, will use its chromium instance to launch a new browser """ - # apply last-minute runtime-computed options to the the browser_profile, validate profile, set up folders on disk - assert isinstance(self.browser_profile, BrowserProfile) - self.browser_profile.prepare_user_data_dir() # create/unlock the /SingletonLock - self.browser_profile.detect_display_configuration() # adjusts config values, must come before launch/connect + async with self._start_lock: + # if we're already initialized and the connection is still valid, return the existing session state and start from scratch + if self.initialized and self.is_connected(): + return self + self._reset_connection_state() - # launch/connect to the browser: - # setup playwright library client, Browser, and BrowserContext objects - await self.setup_playwright() - await self.setup_browser_via_passed_objects() - await self.setup_browser_via_browser_pid() - await self.setup_browser_via_wss_url() - await self.setup_browser_via_cdp_url() - await self.setup_new_browser_context() # creates a new context in existing browser or launches a new persistent context - assert self.browser_context, f'Failed to connect to or create a new BrowserContext for browser={self.browser}' + self.initialized = True # set this first to ensure two parallel calls to start() don't clash with each other + try: + # apply last-minute runtime-computed options to the the browser_profile, validate profile, set up folders on disk + assert isinstance(self.browser_profile, BrowserProfile) + self.browser_profile.prepare_user_data_dir() # create/unlock the /SingletonLock + self.browser_profile.detect_display_configuration() # adjusts config values, must come before launch/connect - # resize the existing pages and set up foreground tab detection - await self._setup_viewports() - await self._setup_current_page_change_listeners() + # launch/connect to the browser: + # setup playwright library client, Browser, and BrowserContext objects + await self.setup_playwright() + await self.setup_browser_via_passed_objects() + await self.setup_browser_via_browser_pid() + await self.setup_browser_via_wss_url() + await self.setup_browser_via_cdp_url() + await ( + self.setup_new_browser_context() + ) # creates a new context in existing browser or launches a new persistent context + assert self.browser_context, f'Failed to connect to or create a new BrowserContext for browser={self.browser}' - self.initialized = True + # resize the existing pages and set up foreground tab detection + await self._setup_viewports() + await self._setup_current_page_change_listeners() + except Exception: + self.initialized = False + raise - return self + return self async def stop(self) -> None: """Shuts down the BrowserSession, killing the browser process if keep_alive=False""" @@ -317,8 +343,28 @@ class BrowserSession(BaseModel): # 1. check for a passed Page object, if present, it always takes priority, set browser_context = page.context self.browser_context = (self.agent_current_page and self.agent_current_page.context) or self.browser_context or None - # 2. if we have a context now, it always takes precedence, set browser = context.browser, otherwise use the passed browser - self.browser = (self.browser_context and self.browser_context.browser) or self.browser or None + # 2. Check if the current browser connection is valid, if not clear the invalid objects + if self.browser_context: + try: + # Try to access a property that would fail if the context is closed + _ = self.browser_context.pages + # Additional check: verify the browser is still connected + if self.browser_context.browser and not self.browser_context.browser.is_connected(): + self.browser_context = None + except Exception: + # Context is closed, clear it + self.browser_context = None + + # 3. if we have a browser object but it's disconnected, clear it and the context because we cant use either + if self.browser and not self.browser.is_connected(): + if self.browser_context and (self.browser_context.browser is self.browser): + self.browser_context = None + self.browser = None + + # 4. if we have a context now, it always takes precedence, set browser = context.browser, otherwise use the passed browser + browser_from_context = self.browser_context and self.browser_context.browser + if browser_from_context and browser_from_context.is_connected(): + self.browser = browser_from_context if self.browser or self.browser_context: logger.info(f'🌎 Connected to existing user-provided browser_context: {self.browser_context}') @@ -433,8 +479,11 @@ class BrowserSession(BaseModel): **self.browser_profile.kwargs_for_launch_persistent_context().model_dump() ) - self.browser = (self.browser_context and self.browser_context.browser) or self.browser - # ^ this can unfortunately still be None at the end ^ + # Only restore browser from context if it's connected, otherwise keep it None to force new launch + browser_from_context = self.browser_context and self.browser_context.browser + if browser_from_context and browser_from_context.is_connected(): + self.browser = browser_from_context + # ^ self.browser can unfortunately still be None at the end ^ # playwright does not give us a browser object at all when we use launch_persistent_context()! # Detect any new child chrome processes that we might have launched above @@ -719,6 +768,46 @@ class BrowserSession(BaseModel): if self.browser_profile.keep_alive is None: self.browser_profile.keep_alive = keep_alive + def is_connected(self) -> bool: + """ + Check if the browser session has valid, connected browser and context objects. + Returns False if any of the following conditions are met: + - No browser_context exists + - Browser exists but is disconnected + - Browser_context's browser exists but is disconnected + - Browser_context itself is closed/unusable + """ + # Check if browser_context is missing + if not self.browser_context: + return False + + # Check if browser exists but is disconnected + if self.browser and not self.browser.is_connected(): + return False + + # Check if browser_context's browser exists but is disconnected + if self.browser_context.browser and not self.browser_context.browser.is_connected(): + return False + + # Check if the browser_context itself is closed/unusable + try: + # Try to access a property that would fail if the context is closed + _ = self.browser_context.pages + # Additional check: try to access the browser property which might fail if context is closed + if self.browser_context.browser and not self.browser_context.browser.is_connected(): + return False + return True + except Exception: + return False + + def _reset_connection_state(self) -> None: + """Reset the browser connection state when disconnection is detected""" + self.initialized = False + self.browser = None + self.browser_context = None + # Also clear browser_pid since the process may no longer exist + self.browser_pid = None + # --- Tab management --- async def get_current_page(self) -> Page: """Get the current page + ensure it's not None / closed""" @@ -821,6 +910,7 @@ class BrowserSession(BaseModel): selector_map = await self.get_selector_map() return selector_map.get(index) + @require_initialization @time_execution_async('--click_element_node') async def _click_element_node(self, element_node: DOMElementNode) -> str | None: """ @@ -883,6 +973,7 @@ class BrowserSession(BaseModel): except Exception as e: raise Exception(f'Failed to click element: {repr(element_node)}. Error: {str(e)}') + @require_initialization @time_execution_async('--get_tabs_info') async def get_tabs_info(self) -> list[TabInfo]: """Get information about all tabs"""