4cd2d4af2b
Test Browser Use CLI Install / uv pip install (ubuntu-latest) (push) Failing after 1s
Test Browser Use CLI Install / uvx browser-use from local wheel (push) Failing after 1s
Test Browser Use CLI Install / uvx browser-use[cli] from PyPI (push) Failing after 1s
package / pip-install-on-macos-latest-py-3.11 (push) Has been skipped
package / pip-install-on-macos-latest-py-3.13 (push) Has been skipped
package / pip-install-on-ubuntu-latest-py-3.11 (push) Has been skipped
package / pip-install-on-windows-latest-py-3.13 (push) Has been skipped
cloud_evals / trigger_cloud_eval_image_build (push) Failing after 1s
docker / build_publish_image (push) Failing after 1s
Test Browser Use CLI Install / browser-use skill sync (push) Failing after 1s
lint / code-style (push) Failing after 0s
lint / type-checker (push) Failing after 1s
package / pip-build (push) Failing after 1s
lint / syntax-errors (push) Failing after 3s
package / pip-install-on-ubuntu-latest-py-3.13 (push) Has been skipped
package / pip-install-on-windows-latest-py-3.11 (push) Has been skipped
test / ${{ matrix.test_filename }} (push) Has been skipped
test / evaluate-tasks (push) Has been skipped
test / setup-chromium (push) Failing after 2s
test / find_tests (push) Failing after 2s
Test Browser Use CLI Install / uv pip install (windows-latest) (push) Has been cancelled
Test Browser Use CLI Install / uv pip install (macos-latest) (push) Has been cancelled
507 lines
18 KiB
Python
507 lines
18 KiB
Python
"""Local browser watchdog for managing browser subprocess lifecycle."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
import shutil
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Any, ClassVar
|
|
|
|
import psutil
|
|
from bubus import BaseEvent
|
|
from pydantic import PrivateAttr
|
|
|
|
from browser_use.browser.events import (
|
|
BrowserKillEvent,
|
|
BrowserLaunchEvent,
|
|
BrowserLaunchResult,
|
|
BrowserStopEvent,
|
|
)
|
|
from browser_use.browser.watchdog_base import BaseWatchdog
|
|
from browser_use.observability import observe_debug
|
|
|
|
if TYPE_CHECKING:
|
|
from browser_use.browser.profile import BrowserChannel
|
|
|
|
|
|
class LocalBrowserWatchdog(BaseWatchdog):
|
|
"""Manages local browser subprocess lifecycle."""
|
|
|
|
# Events this watchdog listens to
|
|
LISTENS_TO: ClassVar[list[type[BaseEvent[Any]]]] = [
|
|
BrowserLaunchEvent,
|
|
BrowserKillEvent,
|
|
BrowserStopEvent,
|
|
]
|
|
|
|
# Events this watchdog emits
|
|
EMITS: ClassVar[list[type[BaseEvent[Any]]]] = []
|
|
|
|
# Private state for subprocess management
|
|
_subprocess: psutil.Process | None = PrivateAttr(default=None)
|
|
_owns_browser_resources: bool = PrivateAttr(default=True)
|
|
_temp_dirs_to_cleanup: list[Path] = PrivateAttr(default_factory=list)
|
|
_original_user_data_dir: str | None = PrivateAttr(default=None)
|
|
|
|
@observe_debug(ignore_input=True, ignore_output=True, name='browser_launch_event')
|
|
async def on_BrowserLaunchEvent(self, event: BrowserLaunchEvent) -> BrowserLaunchResult:
|
|
"""Launch a local browser process."""
|
|
|
|
try:
|
|
self.logger.debug('[LocalBrowserWatchdog] Received BrowserLaunchEvent, launching local browser...')
|
|
|
|
# self.logger.debug('[LocalBrowserWatchdog] Calling _launch_browser...')
|
|
process, cdp_url = await self._launch_browser()
|
|
self._subprocess = process
|
|
# self.logger.debug(f'[LocalBrowserWatchdog] _launch_browser returned: process={process}, cdp_url={cdp_url}')
|
|
|
|
return BrowserLaunchResult(cdp_url=cdp_url)
|
|
except Exception as e:
|
|
self.logger.error(f'[LocalBrowserWatchdog] Exception in on_BrowserLaunchEvent: {e}', exc_info=True)
|
|
raise
|
|
|
|
async def on_BrowserKillEvent(self, event: BrowserKillEvent) -> None:
|
|
"""Kill the local browser subprocess."""
|
|
self.logger.debug('[LocalBrowserWatchdog] Killing local browser process')
|
|
|
|
if self._subprocess:
|
|
await self._cleanup_process(self._subprocess)
|
|
self._subprocess = None
|
|
|
|
# Clean up temp directories if any were created
|
|
for temp_dir in self._temp_dirs_to_cleanup:
|
|
self._cleanup_temp_dir(temp_dir)
|
|
self._temp_dirs_to_cleanup.clear()
|
|
|
|
# Restore original user_data_dir if it was modified
|
|
if self._original_user_data_dir is not None:
|
|
self.browser_session.browser_profile.user_data_dir = self._original_user_data_dir
|
|
self._original_user_data_dir = None
|
|
|
|
self.logger.debug('[LocalBrowserWatchdog] Browser cleanup completed')
|
|
|
|
async def on_BrowserStopEvent(self, event: BrowserStopEvent) -> None:
|
|
"""Listen for BrowserStopEvent and dispatch BrowserKillEvent without awaiting it."""
|
|
if self.browser_session.is_local and self._subprocess:
|
|
self.logger.debug('[LocalBrowserWatchdog] BrowserStopEvent received, dispatching BrowserKillEvent')
|
|
# Dispatch BrowserKillEvent without awaiting so it gets processed after all BrowserStopEvent handlers
|
|
self.event_bus.dispatch(BrowserKillEvent())
|
|
|
|
@observe_debug(ignore_input=True, ignore_output=True, name='launch_browser_process')
|
|
async def _launch_browser(self, max_retries: int = 3) -> tuple[psutil.Process, str]:
|
|
"""Launch browser process and return (process, cdp_url).
|
|
|
|
Handles launch errors by falling back to temporary directories if needed.
|
|
|
|
Returns:
|
|
Tuple of (psutil.Process, cdp_url)
|
|
"""
|
|
# Keep track of original user_data_dir to restore if needed
|
|
profile = self.browser_session.browser_profile
|
|
self._original_user_data_dir = str(profile.user_data_dir) if profile.user_data_dir else None
|
|
self._temp_dirs_to_cleanup = []
|
|
|
|
for attempt in range(max_retries):
|
|
try:
|
|
# Get launch args from profile
|
|
launch_args = profile.get_args()
|
|
|
|
# Add debugging port
|
|
debug_port = self._find_free_port()
|
|
launch_args.extend(
|
|
[
|
|
f'--remote-debugging-port={debug_port}',
|
|
]
|
|
)
|
|
assert '--user-data-dir' in str(launch_args), (
|
|
'User data dir must be set somewhere in launch args to a non-default path, otherwise Chrome will not let us attach via CDP'
|
|
)
|
|
|
|
# Get browser executable
|
|
# Priority: custom executable > fallback paths > playwright subprocess
|
|
if profile.executable_path:
|
|
browser_path = profile.executable_path
|
|
self.logger.debug(f'[LocalBrowserWatchdog] 📦 Using custom local browser executable_path= {browser_path}')
|
|
else:
|
|
# self.logger.debug('[LocalBrowserWatchdog] 🔍 Looking for local browser binary path...')
|
|
# Try fallback paths first (Playwright's Chromium preferred by default)
|
|
browser_path = self._find_installed_browser_path(channel=profile.channel)
|
|
if not browser_path:
|
|
self.logger.error(
|
|
'[LocalBrowserWatchdog] ⚠️ No local browser binary found, installing browser using playwright subprocess...'
|
|
)
|
|
browser_path = await self._install_browser_with_playwright()
|
|
|
|
self.logger.debug(f'[LocalBrowserWatchdog] 📦 Found local browser installed at executable_path= {browser_path}')
|
|
if not browser_path:
|
|
raise RuntimeError('No local Chrome/Chromium install found, and failed to install with playwright')
|
|
|
|
# Launch browser subprocess directly
|
|
self.logger.debug(f'[LocalBrowserWatchdog] 🚀 Launching browser subprocess with {len(launch_args)} args...')
|
|
self.logger.debug(
|
|
f'[LocalBrowserWatchdog] 📂 user_data_dir={profile.user_data_dir}, profile_directory={profile.profile_directory}'
|
|
)
|
|
subprocess = await asyncio.create_subprocess_exec(
|
|
browser_path,
|
|
*launch_args,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
self.logger.debug(
|
|
f'[LocalBrowserWatchdog] 🎭 Browser running with browser_pid= {subprocess.pid} 🔗 listening on CDP port :{debug_port}'
|
|
)
|
|
|
|
# Convert to psutil.Process
|
|
process = psutil.Process(subprocess.pid)
|
|
|
|
# Wait for CDP to be ready and get the URL
|
|
cdp_url = await self._wait_for_cdp_url(debug_port)
|
|
|
|
# Success! Clean up only the temp dirs we created but didn't use
|
|
currently_used_dir = str(profile.user_data_dir)
|
|
unused_temp_dirs = [tmp_dir for tmp_dir in self._temp_dirs_to_cleanup if str(tmp_dir) != currently_used_dir]
|
|
|
|
for tmp_dir in unused_temp_dirs:
|
|
try:
|
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
except Exception:
|
|
pass
|
|
|
|
# Keep only the in-use directory for cleanup during browser kill
|
|
if currently_used_dir and 'browseruse-tmp-' in currently_used_dir:
|
|
self._temp_dirs_to_cleanup = [Path(currently_used_dir)]
|
|
else:
|
|
self._temp_dirs_to_cleanup = []
|
|
|
|
return process, cdp_url
|
|
|
|
except Exception as e:
|
|
error_str = str(e).lower()
|
|
|
|
# Check if this is a user_data_dir related error
|
|
if any(err in error_str for err in ['singletonlock', 'user data directory', 'cannot create', 'already in use']):
|
|
self.logger.warning(f'Browser launch failed (attempt {attempt + 1}/{max_retries}): {e}')
|
|
|
|
if attempt < max_retries - 1:
|
|
# Create a temporary directory for next attempt
|
|
tmp_dir = Path(tempfile.mkdtemp(prefix='browseruse-tmp-'))
|
|
self._temp_dirs_to_cleanup.append(tmp_dir)
|
|
|
|
# Update profile to use temp directory
|
|
profile.user_data_dir = str(tmp_dir)
|
|
self.logger.debug(f'Retrying with temporary user_data_dir: {tmp_dir}')
|
|
|
|
# Small delay before retry
|
|
await asyncio.sleep(0.5)
|
|
continue
|
|
|
|
# Not a recoverable error or last attempt failed
|
|
# Restore original user_data_dir before raising
|
|
if self._original_user_data_dir is not None:
|
|
profile.user_data_dir = self._original_user_data_dir
|
|
|
|
# Clean up any temp dirs we created
|
|
for tmp_dir in self._temp_dirs_to_cleanup:
|
|
try:
|
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
except Exception:
|
|
pass
|
|
|
|
raise
|
|
|
|
# Should not reach here, but just in case
|
|
if self._original_user_data_dir is not None:
|
|
profile.user_data_dir = self._original_user_data_dir
|
|
raise RuntimeError(f'Failed to launch browser after {max_retries} attempts')
|
|
|
|
@staticmethod
|
|
def _find_installed_browser_path(channel: BrowserChannel | None = None) -> str | None:
|
|
"""Try to find browser executable from common fallback locations.
|
|
|
|
If a channel is specified, paths for that browser are searched first.
|
|
Falls back to all known browser paths if the channel-specific search fails.
|
|
|
|
Prioritizes:
|
|
1. Channel-specific paths (if channel is set to a non-default value)
|
|
2. Playwright bundled Chromium (when no channel or default channel specified)
|
|
3. System Chrome stable
|
|
4. Other system native browsers (Chromium -> Chrome Canary/Dev -> Brave -> Edge)
|
|
5. Playwright headless-shell fallback
|
|
|
|
Returns:
|
|
Path to browser executable or None if not found
|
|
"""
|
|
import glob
|
|
import platform
|
|
from pathlib import Path
|
|
|
|
from browser_use.browser.profile import BROWSERUSE_DEFAULT_CHANNEL, BrowserChannel
|
|
|
|
system = platform.system()
|
|
|
|
# Get playwright browsers path from environment variable if set
|
|
playwright_path = os.environ.get('PLAYWRIGHT_BROWSERS_PATH')
|
|
|
|
# Build tagged pattern lists per OS: (browser_group, path)
|
|
# browser_group is used to match against the requested channel
|
|
if system == 'Darwin': # macOS
|
|
if not playwright_path:
|
|
playwright_path = '~/Library/Caches/ms-playwright'
|
|
all_patterns = [
|
|
('chrome', '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'),
|
|
('chromium', f'{playwright_path}/chromium-*/chrome-mac*/Chromium.app/Contents/MacOS/Chromium'),
|
|
('chromium', '/Applications/Chromium.app/Contents/MacOS/Chromium'),
|
|
('chrome-canary', '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary'),
|
|
('brave', '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser'),
|
|
('msedge', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'),
|
|
('chromium', f'{playwright_path}/chromium_headless_shell-*/chrome-mac/Chromium.app/Contents/MacOS/Chromium'),
|
|
]
|
|
elif system == 'Linux':
|
|
if not playwright_path:
|
|
playwright_path = '~/.cache/ms-playwright'
|
|
all_patterns = [
|
|
('chrome', '/usr/bin/google-chrome-stable'),
|
|
('chrome', '/usr/bin/google-chrome'),
|
|
('chrome', '/usr/local/bin/google-chrome'),
|
|
('chromium', f'{playwright_path}/chromium-*/chrome-linux*/chrome'),
|
|
('chromium', '/usr/bin/chromium'),
|
|
('chromium', '/usr/bin/chromium-browser'),
|
|
('chromium', '/usr/local/bin/chromium'),
|
|
('chromium', '/snap/bin/chromium'),
|
|
('chrome-beta', '/usr/bin/google-chrome-beta'),
|
|
('chrome-dev', '/usr/bin/google-chrome-dev'),
|
|
('brave', '/usr/bin/brave-browser'),
|
|
('msedge', '/usr/bin/microsoft-edge-stable'),
|
|
('msedge', '/usr/bin/microsoft-edge'),
|
|
('chromium', f'{playwright_path}/chromium_headless_shell-*/chrome-linux*/chrome'),
|
|
]
|
|
elif system == 'Windows':
|
|
if not playwright_path:
|
|
playwright_path = r'%LOCALAPPDATA%\ms-playwright'
|
|
all_patterns = [
|
|
('chrome', r'C:\Program Files\Google\Chrome\Application\chrome.exe'),
|
|
('chrome', r'C:\Program Files (x86)\Google\Chrome\Application\chrome.exe'),
|
|
('chrome', r'%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe'),
|
|
('chrome', r'%PROGRAMFILES%\Google\Chrome\Application\chrome.exe'),
|
|
('chrome', r'%PROGRAMFILES(X86)%\Google\Chrome\Application\chrome.exe'),
|
|
('chromium', f'{playwright_path}\\chromium-*\\chrome-win\\chrome.exe'),
|
|
('chromium', r'C:\Program Files\Chromium\Application\chrome.exe'),
|
|
('chromium', r'C:\Program Files (x86)\Chromium\Application\chrome.exe'),
|
|
('chromium', r'%LOCALAPPDATA%\Chromium\Application\chrome.exe'),
|
|
('brave', r'C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe'),
|
|
('brave', r'C:\Program Files (x86)\BraveSoftware\Brave-Browser\Application\brave.exe'),
|
|
('msedge', r'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe'),
|
|
('msedge', r'C:\Program Files\Microsoft\Edge\Application\msedge.exe'),
|
|
('msedge', r'%LOCALAPPDATA%\Microsoft\Edge\Application\msedge.exe'),
|
|
('chromium', f'{playwright_path}\\chromium_headless_shell-*\\chrome-win\\chrome.exe'),
|
|
]
|
|
else:
|
|
all_patterns = []
|
|
|
|
# Map channel enum values to browser group tags
|
|
_channel_to_group: dict[BrowserChannel, str] = {
|
|
BrowserChannel.CHROME: 'chrome',
|
|
BrowserChannel.CHROME_BETA: 'chrome-beta',
|
|
BrowserChannel.CHROME_DEV: 'chrome-dev',
|
|
BrowserChannel.CHROME_CANARY: 'chrome-canary',
|
|
BrowserChannel.CHROMIUM: 'chromium',
|
|
BrowserChannel.MSEDGE: 'msedge',
|
|
BrowserChannel.MSEDGE_BETA: 'msedge',
|
|
BrowserChannel.MSEDGE_DEV: 'msedge',
|
|
BrowserChannel.MSEDGE_CANARY: 'msedge',
|
|
}
|
|
|
|
# Prioritize the target browser group, then fall back to the rest.
|
|
if channel and channel != BROWSERUSE_DEFAULT_CHANNEL and channel in _channel_to_group:
|
|
target_group = _channel_to_group[channel]
|
|
else:
|
|
target_group = _channel_to_group[BROWSERUSE_DEFAULT_CHANNEL]
|
|
prioritized = [p for g, p in all_patterns if g == target_group]
|
|
rest = [p for g, p in all_patterns if g != target_group]
|
|
patterns = prioritized + rest
|
|
|
|
for pattern in patterns:
|
|
# Expand user home directory
|
|
expanded_pattern = Path(pattern).expanduser()
|
|
|
|
# Handle Windows environment variables
|
|
if system == 'Windows':
|
|
pattern_str = str(expanded_pattern)
|
|
for env_var in ['%LOCALAPPDATA%', '%PROGRAMFILES%', '%PROGRAMFILES(X86)%']:
|
|
if env_var in pattern_str:
|
|
env_key = env_var.strip('%').replace('(X86)', ' (x86)')
|
|
env_value = os.environ.get(env_key, '')
|
|
if env_value:
|
|
pattern_str = pattern_str.replace(env_var, env_value)
|
|
expanded_pattern = Path(pattern_str)
|
|
|
|
# Convert to string for glob
|
|
pattern_str = str(expanded_pattern)
|
|
|
|
# Check if pattern contains wildcards
|
|
if '*' in pattern_str:
|
|
# Use glob to expand the pattern
|
|
matches = glob.glob(pattern_str)
|
|
if matches:
|
|
# Sort matches and take the last one (alphanumerically highest version)
|
|
matches.sort()
|
|
browser_path = matches[-1]
|
|
if Path(browser_path).exists() and Path(browser_path).is_file():
|
|
return browser_path
|
|
else:
|
|
# Direct path check
|
|
if expanded_pattern.exists() and expanded_pattern.is_file():
|
|
return str(expanded_pattern)
|
|
|
|
return None
|
|
|
|
async def _install_browser_with_playwright(self) -> str:
|
|
"""Get browser executable path from playwright in a subprocess to avoid thread issues."""
|
|
import platform
|
|
|
|
# Build command - only use --with-deps on Linux (it fails on Windows/macOS)
|
|
cmd = ['uvx', 'playwright', 'install', 'chromium']
|
|
if platform.system() == 'Linux':
|
|
cmd.append('--with-deps')
|
|
|
|
# Run in subprocess with timeout
|
|
process = await asyncio.create_subprocess_exec(
|
|
*cmd,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
|
|
try:
|
|
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=60.0)
|
|
self.logger.debug(f'[LocalBrowserWatchdog] 📦 Playwright install output: {stdout}')
|
|
browser_path = self._find_installed_browser_path()
|
|
if browser_path:
|
|
return browser_path
|
|
self.logger.error(f'[LocalBrowserWatchdog] ❌ Playwright local browser installation error: \n{stdout}\n{stderr}')
|
|
raise RuntimeError('No local browser path found after: uvx playwright install chromium')
|
|
except TimeoutError:
|
|
# Kill the subprocess if it times out
|
|
process.kill()
|
|
await process.wait()
|
|
raise RuntimeError('Timeout getting browser path from playwright')
|
|
except Exception as e:
|
|
# Make sure subprocess is terminated
|
|
if process.returncode is None:
|
|
process.kill()
|
|
await process.wait()
|
|
raise RuntimeError(f'Error getting browser path: {e}')
|
|
|
|
@staticmethod
|
|
def _find_free_port() -> int:
|
|
"""Find a free port for the debugging interface."""
|
|
import socket
|
|
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
s.bind(('127.0.0.1', 0))
|
|
s.listen(1)
|
|
port = s.getsockname()[1]
|
|
return port
|
|
|
|
@staticmethod
|
|
async def _wait_for_cdp_url(port: int, timeout: float = 30) -> str:
|
|
"""Wait for the browser to start and return the CDP URL."""
|
|
import aiohttp
|
|
|
|
start_time = asyncio.get_event_loop().time()
|
|
|
|
while asyncio.get_event_loop().time() - start_time < timeout:
|
|
try:
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.get(f'http://127.0.0.1:{port}/json/version') as resp:
|
|
if resp.status == 200:
|
|
# Chrome is ready
|
|
return f'http://127.0.0.1:{port}/'
|
|
else:
|
|
# Chrome is starting up and returning 502/500 errors
|
|
await asyncio.sleep(0.1)
|
|
except Exception:
|
|
# Connection error - Chrome might not be ready yet
|
|
await asyncio.sleep(0.1)
|
|
|
|
raise TimeoutError(f'Browser did not start within {timeout} seconds')
|
|
|
|
@staticmethod
|
|
async def _cleanup_process(process: psutil.Process) -> None:
|
|
"""Clean up browser process.
|
|
|
|
Args:
|
|
process: psutil.Process to terminate
|
|
"""
|
|
if not process:
|
|
return
|
|
|
|
try:
|
|
# Try graceful shutdown first
|
|
process.terminate()
|
|
|
|
# Use async wait instead of blocking wait
|
|
for _ in range(50): # Wait up to 5 seconds (50 * 0.1)
|
|
if not process.is_running():
|
|
return
|
|
await asyncio.sleep(0.1)
|
|
|
|
# If still running after 5 seconds, force kill
|
|
if process.is_running():
|
|
process.kill()
|
|
# Give it a moment to die
|
|
await asyncio.sleep(0.1)
|
|
|
|
except psutil.NoSuchProcess:
|
|
# Process already gone
|
|
pass
|
|
except Exception:
|
|
# Ignore any other errors during cleanup
|
|
pass
|
|
|
|
def _cleanup_temp_dir(self, temp_dir: Path | str) -> None:
|
|
"""Clean up temporary directory.
|
|
|
|
Args:
|
|
temp_dir: Path to temporary directory to remove
|
|
"""
|
|
if not temp_dir:
|
|
return
|
|
|
|
try:
|
|
temp_path = Path(temp_dir)
|
|
# Only remove if it's actually a temp directory we created
|
|
if 'browseruse-tmp-' in str(temp_path):
|
|
shutil.rmtree(temp_path, ignore_errors=True)
|
|
except Exception as e:
|
|
self.logger.debug(f'Failed to cleanup temp dir {temp_dir}: {e}')
|
|
|
|
@property
|
|
def browser_pid(self) -> int | None:
|
|
"""Get the browser process ID."""
|
|
if self._subprocess:
|
|
return self._subprocess.pid
|
|
return None
|
|
|
|
@staticmethod
|
|
async def get_browser_pid_via_cdp(browser) -> int | None:
|
|
"""Get the browser process ID via CDP SystemInfo.getProcessInfo.
|
|
|
|
Args:
|
|
browser: Playwright Browser instance
|
|
|
|
Returns:
|
|
Process ID or None if failed
|
|
"""
|
|
try:
|
|
cdp_session = await browser.new_browser_cdp_session()
|
|
result = await cdp_session.send('SystemInfo.getProcessInfo')
|
|
process_info = result.get('processInfo', {})
|
|
pid = process_info.get('id')
|
|
await cdp_session.detach()
|
|
return pid
|
|
except Exception:
|
|
# If we can't get PID via CDP, it's not critical
|
|
return None
|