improve BrowserSession auto-restart on disconnection logic

This commit is contained in:
Nick Sweeting
2025-05-27 17:09:02 -07:00
parent 0c758f8894
commit 02ec9c8c6a

View File

@@ -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 <user_data_dir>/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 <user_data_dir>/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"""