Compare commits
4 Commits
main
...
copilot/mo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1c5dfc49b | ||
|
|
2507720cc7 | ||
|
|
7037021496 | ||
|
|
7c751837ef |
214
CHANGES_CDP_CONCURRENCY.md
Normal file
214
CHANGES_CDP_CONCURRENCY.md
Normal file
@@ -0,0 +1,214 @@
|
||||
# CDP Browser Concurrency Fixes and Improvements
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the changes made to fix concurrency issues with CDP (Chrome DevTools Protocol) browsers when using `arun_many` and improve overall browser management.
|
||||
|
||||
## Problems Addressed
|
||||
|
||||
1. **Race Conditions in Page Creation**: When using managed CDP browsers with concurrent `arun_many` calls, the code attempted to reuse existing pages from `context.pages`, leading to race conditions and "Target page/context closed" errors.
|
||||
|
||||
2. **Proxy Configuration Issues**: Proxy credentials were incorrectly embedded in the `--proxy-server` URL, which doesn't work properly with CDP browsers.
|
||||
|
||||
3. **Insufficient Startup Checks**: Browser process startup checks were minimal and didn't catch early failures effectively.
|
||||
|
||||
4. **Unclear Logging**: Logging messages lacked structure and context, making debugging difficult.
|
||||
|
||||
5. **Duplicate Browser Arguments**: Browser launch arguments could contain duplicates despite deduplication attempts.
|
||||
|
||||
## Solutions Implemented
|
||||
|
||||
### 1. Always Create New Pages in Managed Browser Mode
|
||||
|
||||
**File**: `crawl4ai/browser_manager.py` (lines 1106-1113)
|
||||
|
||||
**Change**: Modified `get_page()` method to always create new pages instead of attempting to reuse existing ones for managed browsers without `storage_state`.
|
||||
|
||||
**Before**:
|
||||
```python
|
||||
context = self.default_context
|
||||
pages = context.pages
|
||||
page = next((p for p in pages if p.url == crawlerRunConfig.url), None)
|
||||
if not page:
|
||||
if pages:
|
||||
page = pages[0]
|
||||
else:
|
||||
# Create new page only if none exist
|
||||
async with self._page_lock:
|
||||
page = await context.new_page()
|
||||
```
|
||||
|
||||
**After**:
|
||||
```python
|
||||
context = self.default_context
|
||||
# Always create new pages instead of reusing existing ones
|
||||
# This prevents race conditions in concurrent scenarios (arun_many with CDP)
|
||||
# Serialize page creation to avoid 'Target page/context closed' errors
|
||||
async with self._page_lock:
|
||||
page = await context.new_page()
|
||||
await self._apply_stealth_to_page(page)
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- Eliminates race conditions when multiple tasks call `arun_many` concurrently
|
||||
- Each request gets a fresh, independent page
|
||||
- Page lock serializes creation to prevent TOCTOU (Time-of-check to time-of-use) issues
|
||||
|
||||
### 2. Fixed Proxy Flag Formatting
|
||||
|
||||
**File**: `crawl4ai/browser_manager.py` (lines 103-109)
|
||||
|
||||
**Change**: Removed credentials from proxy URL as they should be handled via separate authentication mechanisms in CDP.
|
||||
|
||||
**Before**:
|
||||
```python
|
||||
elif config.proxy_config:
|
||||
creds = ""
|
||||
if config.proxy_config.username and config.proxy_config.password:
|
||||
creds = f"{config.proxy_config.username}:{config.proxy_config.password}@"
|
||||
flags.append(f"--proxy-server={creds}{config.proxy_config.server}")
|
||||
```
|
||||
|
||||
**After**:
|
||||
```python
|
||||
elif config.proxy_config:
|
||||
# Note: For CDP/managed browsers, proxy credentials should be handled
|
||||
# via authentication, not in the URL. Only pass the server address.
|
||||
flags.append(f"--proxy-server={config.proxy_config.server}")
|
||||
```
|
||||
|
||||
### 3. Enhanced Startup Checks
|
||||
|
||||
**File**: `crawl4ai/browser_manager.py` (lines 298-336)
|
||||
|
||||
**Changes**:
|
||||
- Multiple check intervals (0.1s, 0.2s, 0.3s) to catch early failures
|
||||
- Capture and log stdout/stderr on failure (limited to 200 chars)
|
||||
- Raise `RuntimeError` with detailed diagnostics on startup failure
|
||||
- Log process PID on successful startup in verbose mode
|
||||
|
||||
**Benefits**:
|
||||
- Catches browser crashes during startup
|
||||
- Provides detailed diagnostic information for debugging
|
||||
- Fails fast with clear error messages
|
||||
|
||||
### 4. Improved Logging
|
||||
|
||||
**File**: `crawl4ai/browser_manager.py` (lines 218-291)
|
||||
|
||||
**Changes**:
|
||||
- Structured logging with proper parameter substitution
|
||||
- Log browser type, port, and headless status at launch
|
||||
- Format and log full command with proper shell escaping
|
||||
- Better error messages with context
|
||||
- Consistent use of logger with null checks
|
||||
|
||||
**Example**:
|
||||
```python
|
||||
if self.logger and self.browser_config.verbose:
|
||||
self.logger.debug(
|
||||
"Launching browser: {browser_type} | Port: {port} | Headless: {headless}",
|
||||
tag="BROWSER",
|
||||
params={
|
||||
"browser_type": self.browser_type,
|
||||
"port": self.debugging_port,
|
||||
"headless": self.headless
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### 5. Deduplicate Browser Launch Arguments
|
||||
|
||||
**File**: `crawl4ai/browser_manager.py` (lines 424-425)
|
||||
|
||||
**Change**: Added explicit deduplication after merging all flags.
|
||||
|
||||
```python
|
||||
# merge common launch flags
|
||||
flags.extend(self.build_browser_flags(self.browser_config))
|
||||
# Deduplicate flags - use dict.fromkeys to preserve order while removing duplicates
|
||||
flags = list(dict.fromkeys(flags))
|
||||
```
|
||||
|
||||
### 6. Import Refactoring
|
||||
|
||||
**Files**: `crawl4ai/browser_manager.py`, `crawl4ai/browser_profiler.py`, `tests/browser/test_cdp_concurrency.py`
|
||||
|
||||
**Changes**: Organized all imports according to PEP 8:
|
||||
1. Standard library imports (alphabetized)
|
||||
2. Third-party imports (alphabetized)
|
||||
3. Local imports (alphabetized)
|
||||
|
||||
**Benefits**:
|
||||
- Improved code readability
|
||||
- Easier to spot missing or unused imports
|
||||
- Consistent style across the codebase
|
||||
|
||||
## Testing
|
||||
|
||||
### New Test Suite
|
||||
|
||||
**File**: `tests/browser/test_cdp_concurrency.py`
|
||||
|
||||
Comprehensive test suite with 8 tests covering:
|
||||
|
||||
1. **Basic Concurrent arun_many**: Validates multiple URLs can be crawled concurrently
|
||||
2. **Sequential arun_many Calls**: Ensures multiple sequential batches work correctly
|
||||
3. **Stress Test**: Multiple concurrent `arun_many` calls to test page lock effectiveness
|
||||
4. **Page Isolation**: Verifies pages are truly independent
|
||||
5. **Different Configurations**: Tests with varying viewport sizes and configs
|
||||
6. **Error Handling**: Ensures errors in one request don't affect others
|
||||
7. **Large Batches**: Scalability test with 10+ URLs
|
||||
8. **Smoke Test Script**: Standalone script for quick validation
|
||||
|
||||
### Running Tests
|
||||
|
||||
**With pytest** (if available):
|
||||
```bash
|
||||
cd /path/to/crawl4ai
|
||||
pytest tests/browser/test_cdp_concurrency.py -v
|
||||
```
|
||||
|
||||
**Standalone smoke test**:
|
||||
```bash
|
||||
cd /path/to/crawl4ai
|
||||
python3 tests/browser/smoke_test_cdp.py
|
||||
```
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### For Users
|
||||
|
||||
No breaking changes. Existing code will continue to work, but with better reliability in concurrent scenarios.
|
||||
|
||||
### For Contributors
|
||||
|
||||
When working with managed browsers:
|
||||
1. Always use the page lock when creating pages in shared contexts
|
||||
2. Prefer creating new pages over reusing existing ones for concurrent operations
|
||||
3. Use structured logging with parameter substitution
|
||||
4. Follow PEP 8 import organization
|
||||
|
||||
## Performance Impact
|
||||
|
||||
- **Positive**: Eliminates race conditions and crashes in concurrent scenarios
|
||||
- **Neutral**: Page creation overhead is negligible compared to page navigation
|
||||
- **Consideration**: More pages may be created, but they are properly closed after use
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
All changes are backward compatible. Session-based page reuse still works as before when `session_id` is provided.
|
||||
|
||||
## Related Issues
|
||||
|
||||
- Fixes race conditions in concurrent `arun_many` calls with CDP browsers
|
||||
- Addresses "Target page/context closed" errors
|
||||
- Improves browser startup reliability
|
||||
|
||||
## Future Improvements
|
||||
|
||||
Consider:
|
||||
1. Configurable page pooling with proper lifecycle management
|
||||
2. More granular locks for different contexts
|
||||
3. Metrics for page creation/reuse patterns
|
||||
4. Connection pooling for CDP connections
|
||||
@@ -1,21 +1,26 @@
|
||||
# Standard library imports
|
||||
import asyncio
|
||||
import time
|
||||
from typing import List, Optional
|
||||
import hashlib
|
||||
import os
|
||||
import sys
|
||||
import shlex
|
||||
import shutil
|
||||
import tempfile
|
||||
import psutil
|
||||
import signal
|
||||
import subprocess
|
||||
import shlex
|
||||
from playwright.async_api import BrowserContext
|
||||
import hashlib
|
||||
from .js_snippet import load_js_script
|
||||
from .config import DOWNLOAD_PAGE_TIMEOUT
|
||||
from .async_configs import BrowserConfig, CrawlerRunConfig
|
||||
from .utils import get_chromium_path
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import warnings
|
||||
from typing import List, Optional
|
||||
|
||||
# Third-party imports
|
||||
import psutil
|
||||
from playwright.async_api import BrowserContext
|
||||
|
||||
# Local imports
|
||||
from .async_configs import BrowserConfig, CrawlerRunConfig
|
||||
from .config import DOWNLOAD_PAGE_TIMEOUT
|
||||
from .js_snippet import load_js_script
|
||||
from .utils import get_chromium_path
|
||||
|
||||
|
||||
BROWSER_DISABLE_OPTIONS = [
|
||||
@@ -104,10 +109,9 @@ class ManagedBrowser:
|
||||
if config.proxy:
|
||||
flags.append(f"--proxy-server={config.proxy}")
|
||||
elif config.proxy_config:
|
||||
creds = ""
|
||||
if config.proxy_config.username and config.proxy_config.password:
|
||||
creds = f"{config.proxy_config.username}:{config.proxy_config.password}@"
|
||||
flags.append(f"--proxy-server={creds}{config.proxy_config.server}")
|
||||
# Note: For CDP/managed browsers, proxy credentials should be handled
|
||||
# via authentication, not in the URL. Only pass the server address.
|
||||
flags.append(f"--proxy-server={config.proxy_config.server}")
|
||||
# dedupe
|
||||
return list(dict.fromkeys(flags))
|
||||
|
||||
@@ -219,11 +223,27 @@ class ManagedBrowser:
|
||||
os.remove(fp)
|
||||
except Exception as _e:
|
||||
# non-fatal — we'll try to start anyway, but log what happened
|
||||
self.logger.warning(f"pre-launch cleanup failed: {_e}", tag="BROWSER")
|
||||
|
||||
if self.logger:
|
||||
self.logger.warning(
|
||||
"Pre-launch cleanup failed: {error} | Will attempt to start browser anyway",
|
||||
tag="BROWSER",
|
||||
params={"error": str(_e)}
|
||||
)
|
||||
|
||||
# Start browser process
|
||||
try:
|
||||
# Log browser launch intent
|
||||
if self.logger and self.browser_config.verbose:
|
||||
self.logger.debug(
|
||||
"Launching browser: {browser_type} | Port: {port} | Headless: {headless}",
|
||||
tag="BROWSER",
|
||||
params={
|
||||
"browser_type": self.browser_type,
|
||||
"port": self.debugging_port,
|
||||
"headless": self.headless
|
||||
}
|
||||
)
|
||||
|
||||
# Use DETACHED_PROCESS flag on Windows to fully detach the process
|
||||
# On Unix, we'll use preexec_fn=os.setpgrp to start the process in a new process group
|
||||
if sys.platform == "win32":
|
||||
@@ -241,19 +261,36 @@ class ManagedBrowser:
|
||||
preexec_fn=os.setpgrp # Start in a new process group
|
||||
)
|
||||
|
||||
# If verbose is True print args used to run the process
|
||||
# Log full command if verbose logging is enabled
|
||||
if self.logger and self.browser_config.verbose:
|
||||
# Format args for better readability - escape and join
|
||||
formatted_args = ' '.join(shlex.quote(str(arg)) for arg in args)
|
||||
self.logger.debug(
|
||||
f"Starting browser with args: {' '.join(args)}",
|
||||
tag="BROWSER"
|
||||
"Browser launch command: {command}",
|
||||
tag="BROWSER",
|
||||
params={"command": formatted_args}
|
||||
)
|
||||
|
||||
# We'll monitor for a short time to make sure it starts properly, but won't keep monitoring
|
||||
await asyncio.sleep(0.5) # Give browser time to start
|
||||
# Perform startup health checks
|
||||
await asyncio.sleep(0.5) # Initial delay for process startup
|
||||
await self._initial_startup_check()
|
||||
await asyncio.sleep(2) # Give browser time to start
|
||||
return f"http://{self.host}:{self.debugging_port}"
|
||||
await asyncio.sleep(2) # Additional time for browser initialization
|
||||
|
||||
cdp_url = f"http://{self.host}:{self.debugging_port}"
|
||||
if self.logger:
|
||||
self.logger.info(
|
||||
"Browser started successfully | CDP URL: {cdp_url}",
|
||||
tag="BROWSER",
|
||||
params={"cdp_url": cdp_url}
|
||||
)
|
||||
return cdp_url
|
||||
except Exception as e:
|
||||
if self.logger:
|
||||
self.logger.error(
|
||||
"Failed to start browser: {error}",
|
||||
tag="BROWSER",
|
||||
params={"error": str(e)}
|
||||
)
|
||||
await self.cleanup()
|
||||
raise Exception(f"Failed to start browser: {e}")
|
||||
|
||||
@@ -266,24 +303,42 @@ class ManagedBrowser:
|
||||
return
|
||||
|
||||
# Check that process started without immediate termination
|
||||
await asyncio.sleep(0.5)
|
||||
# Perform multiple checks with increasing delays to catch early failures
|
||||
check_intervals = [0.1, 0.2, 0.3] # Total 0.6s
|
||||
|
||||
for delay in check_intervals:
|
||||
await asyncio.sleep(delay)
|
||||
if self.browser_process.poll() is not None:
|
||||
# Process already terminated
|
||||
# Process already terminated - capture output for debugging
|
||||
stdout, stderr = b"", b""
|
||||
try:
|
||||
stdout, stderr = self.browser_process.communicate(timeout=0.5)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
|
||||
error_msg = "Browser process terminated during startup"
|
||||
if stderr:
|
||||
error_msg += f" | STDERR: {stderr.decode()[:200]}" # Limit output length
|
||||
if stdout:
|
||||
error_msg += f" | STDOUT: {stdout.decode()[:200]}"
|
||||
|
||||
self.logger.error(
|
||||
message="Browser process terminated during startup | Code: {code} | STDOUT: {stdout} | STDERR: {stderr}",
|
||||
tag="ERROR",
|
||||
message="{error_msg} | Exit code: {code}",
|
||||
tag="BROWSER",
|
||||
params={
|
||||
"error_msg": error_msg,
|
||||
"code": self.browser_process.returncode,
|
||||
"stdout": stdout.decode() if stdout else "",
|
||||
"stderr": stderr.decode() if stderr else "",
|
||||
},
|
||||
)
|
||||
raise RuntimeError(f"Browser failed to start: {error_msg}")
|
||||
|
||||
# Process is still running after checks - log success
|
||||
if self.logger and self.browser_config.verbose:
|
||||
self.logger.debug(
|
||||
"Browser process startup check passed | PID: {pid}",
|
||||
tag="BROWSER",
|
||||
params={"pid": self.browser_process.pid}
|
||||
)
|
||||
|
||||
async def _monitor_browser_process(self):
|
||||
"""
|
||||
@@ -371,6 +426,8 @@ class ManagedBrowser:
|
||||
flags.append("--headless=new")
|
||||
# merge common launch flags
|
||||
flags.extend(self.build_browser_flags(self.browser_config))
|
||||
# Deduplicate flags - use dict.fromkeys to preserve order while removing duplicates
|
||||
flags = list(dict.fromkeys(flags))
|
||||
elif self.browser_type == "firefox":
|
||||
flags = [
|
||||
"--remote-debugging-port",
|
||||
@@ -1048,19 +1105,10 @@ class BrowserManager:
|
||||
await self._apply_stealth_to_page(page)
|
||||
else:
|
||||
context = self.default_context
|
||||
pages = context.pages
|
||||
page = next((p for p in pages if p.url == crawlerRunConfig.url), None)
|
||||
if not page:
|
||||
if pages:
|
||||
page = pages[0]
|
||||
else:
|
||||
# Double-check under lock to avoid TOCTOU and ensure only
|
||||
# one task calls new_page when pages=[] concurrently
|
||||
# Always create new pages instead of reusing existing ones
|
||||
# This prevents race conditions in concurrent scenarios (arun_many with CDP)
|
||||
# Serialize page creation to avoid 'Target page/context closed' errors
|
||||
async with self._page_lock:
|
||||
pages = context.pages
|
||||
if pages:
|
||||
page = pages[0]
|
||||
else:
|
||||
page = await context.new_page()
|
||||
await self._apply_stealth_to_page(page)
|
||||
else:
|
||||
|
||||
@@ -5,22 +5,26 @@ This module provides a dedicated class for managing browser profiles
|
||||
that can be used for identity-based crawling with Crawl4AI.
|
||||
"""
|
||||
|
||||
import os
|
||||
# Standard library imports
|
||||
import asyncio
|
||||
import signal
|
||||
import sys
|
||||
import datetime
|
||||
import uuid
|
||||
import shutil
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import List, Dict, Optional, Any
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
# Third-party imports
|
||||
from rich.console import Console
|
||||
|
||||
# Local imports
|
||||
from .async_configs import BrowserConfig
|
||||
from .browser_manager import ManagedBrowser
|
||||
from .async_logger import AsyncLogger, AsyncLoggerBase, LogColor
|
||||
from .browser_manager import ManagedBrowser
|
||||
from .utils import get_home_folder
|
||||
|
||||
|
||||
|
||||
165
tests/browser/smoke_test_cdp.py
Executable file
165
tests/browser/smoke_test_cdp.py
Executable file
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Simple smoke test for CDP concurrency fixes.
|
||||
This can be run without pytest to quickly validate the changes.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add the project root to Python path
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../..')))
|
||||
|
||||
from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig, CacheMode
|
||||
|
||||
|
||||
async def test_basic_cdp():
|
||||
"""Basic test that CDP browser works"""
|
||||
print("Test 1: Basic CDP browser test...")
|
||||
|
||||
browser_config = BrowserConfig(
|
||||
use_managed_browser=True,
|
||||
headless=True,
|
||||
verbose=False
|
||||
)
|
||||
|
||||
try:
|
||||
async with AsyncWebCrawler(config=browser_config) as crawler:
|
||||
result = await crawler.arun(
|
||||
url="https://example.com",
|
||||
config=CrawlerRunConfig(cache_mode=CacheMode.BYPASS)
|
||||
)
|
||||
assert result.success, f"Failed: {result.error_message}"
|
||||
assert len(result.html) > 0, "Empty HTML"
|
||||
print(" ✓ Basic CDP test passed")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f" ✗ Basic CDP test failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def test_arun_many_cdp():
|
||||
"""Test arun_many with CDP browser - the key concurrency fix"""
|
||||
print("\nTest 2: arun_many with CDP browser...")
|
||||
|
||||
browser_config = BrowserConfig(
|
||||
use_managed_browser=True,
|
||||
headless=True,
|
||||
verbose=False
|
||||
)
|
||||
|
||||
urls = [
|
||||
"https://example.com",
|
||||
"https://httpbin.org/html",
|
||||
"https://www.example.org",
|
||||
]
|
||||
|
||||
try:
|
||||
async with AsyncWebCrawler(config=browser_config) as crawler:
|
||||
results = await crawler.arun_many(
|
||||
urls=urls,
|
||||
config=CrawlerRunConfig(cache_mode=CacheMode.BYPASS)
|
||||
)
|
||||
|
||||
assert len(results) == len(urls), f"Expected {len(urls)} results, got {len(results)}"
|
||||
|
||||
success_count = sum(1 for r in results if r.success)
|
||||
print(f" ✓ Crawled {success_count}/{len(urls)} URLs successfully")
|
||||
|
||||
if success_count >= len(urls) * 0.8: # Allow 20% failure for network issues
|
||||
print(" ✓ arun_many CDP test passed")
|
||||
return True
|
||||
else:
|
||||
print(f" ✗ Too many failures: {len(urls) - success_count}/{len(urls)}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f" ✗ arun_many CDP test failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
|
||||
async def test_concurrent_arun_many():
|
||||
"""Test concurrent arun_many calls - stress test for page lock"""
|
||||
print("\nTest 3: Concurrent arun_many calls...")
|
||||
|
||||
browser_config = BrowserConfig(
|
||||
use_managed_browser=True,
|
||||
headless=True,
|
||||
verbose=False
|
||||
)
|
||||
|
||||
try:
|
||||
async with AsyncWebCrawler(config=browser_config) as crawler:
|
||||
# Run two arun_many calls concurrently
|
||||
task1 = crawler.arun_many(
|
||||
urls=["https://example.com", "https://httpbin.org/html"],
|
||||
config=CrawlerRunConfig(cache_mode=CacheMode.BYPASS)
|
||||
)
|
||||
|
||||
task2 = crawler.arun_many(
|
||||
urls=["https://www.example.org", "https://example.com"],
|
||||
config=CrawlerRunConfig(cache_mode=CacheMode.BYPASS)
|
||||
)
|
||||
|
||||
results1, results2 = await asyncio.gather(task1, task2, return_exceptions=True)
|
||||
|
||||
# Check for exceptions
|
||||
if isinstance(results1, Exception):
|
||||
print(f" ✗ Task 1 raised exception: {results1}")
|
||||
return False
|
||||
if isinstance(results2, Exception):
|
||||
print(f" ✗ Task 2 raised exception: {results2}")
|
||||
return False
|
||||
|
||||
total_success = sum(1 for r in results1 if r.success) + sum(1 for r in results2 if r.success)
|
||||
total_requests = len(results1) + len(results2)
|
||||
|
||||
print(f" ✓ {total_success}/{total_requests} concurrent requests succeeded")
|
||||
|
||||
if total_success >= total_requests * 0.7: # Allow 30% failure for concurrent stress
|
||||
print(" ✓ Concurrent arun_many test passed")
|
||||
return True
|
||||
else:
|
||||
print(f" ✗ Too many concurrent failures")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f" ✗ Concurrent test failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
|
||||
async def main():
|
||||
"""Run all smoke tests"""
|
||||
print("=" * 60)
|
||||
print("CDP Concurrency Smoke Tests")
|
||||
print("=" * 60)
|
||||
|
||||
results = []
|
||||
|
||||
# Run tests sequentially
|
||||
results.append(await test_basic_cdp())
|
||||
results.append(await test_arun_many_cdp())
|
||||
results.append(await test_concurrent_arun_many())
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
passed = sum(results)
|
||||
total = len(results)
|
||||
|
||||
if passed == total:
|
||||
print(f"✓ All {total} smoke tests passed!")
|
||||
print("=" * 60)
|
||||
return 0
|
||||
else:
|
||||
print(f"✗ {total - passed}/{total} smoke tests failed")
|
||||
print("=" * 60)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit_code = asyncio.run(main())
|
||||
sys.exit(exit_code)
|
||||
282
tests/browser/test_cdp_concurrency.py
Normal file
282
tests/browser/test_cdp_concurrency.py
Normal file
@@ -0,0 +1,282 @@
|
||||
"""
|
||||
Test CDP browser concurrency with arun_many.
|
||||
|
||||
This test suite validates that the fixes for concurrent page creation
|
||||
in managed browsers (CDP mode) work correctly, particularly:
|
||||
1. Always creating new pages instead of reusing
|
||||
2. Page lock serialization prevents race conditions
|
||||
3. Multiple concurrent arun_many calls work correctly
|
||||
"""
|
||||
|
||||
# Standard library imports
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Third-party imports
|
||||
import pytest
|
||||
|
||||
# Add the project root to Python path
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../..')))
|
||||
|
||||
# Local imports
|
||||
from crawl4ai import AsyncWebCrawler, BrowserConfig, CacheMode, CrawlerRunConfig
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cdp_concurrent_arun_many_basic():
|
||||
"""
|
||||
Test basic concurrent arun_many with CDP browser.
|
||||
This tests the fix for always creating new pages.
|
||||
"""
|
||||
browser_config = BrowserConfig(
|
||||
use_managed_browser=True,
|
||||
headless=True,
|
||||
verbose=False
|
||||
)
|
||||
|
||||
urls = [
|
||||
"https://example.com",
|
||||
"https://www.python.org",
|
||||
"https://httpbin.org/html",
|
||||
]
|
||||
|
||||
config = CrawlerRunConfig(cache_mode=CacheMode.BYPASS)
|
||||
|
||||
async with AsyncWebCrawler(config=browser_config) as crawler:
|
||||
# Run arun_many - should create new pages for each URL
|
||||
results = await crawler.arun_many(urls=urls, config=config)
|
||||
|
||||
# Verify all URLs were crawled successfully
|
||||
assert len(results) == len(urls), f"Expected {len(urls)} results, got {len(results)}"
|
||||
|
||||
for i, result in enumerate(results):
|
||||
assert result is not None, f"Result {i} is None"
|
||||
assert result.success, f"Result {i} failed: {result.error_message}"
|
||||
assert result.status_code == 200, f"Result {i} has status {result.status_code}"
|
||||
assert len(result.html) > 0, f"Result {i} has empty HTML"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cdp_multiple_sequential_arun_many():
|
||||
"""
|
||||
Test multiple sequential arun_many calls with CDP browser.
|
||||
Each call should work correctly without interference.
|
||||
"""
|
||||
browser_config = BrowserConfig(
|
||||
use_managed_browser=True,
|
||||
headless=True,
|
||||
verbose=False
|
||||
)
|
||||
|
||||
urls_batch1 = [
|
||||
"https://example.com",
|
||||
"https://httpbin.org/html",
|
||||
]
|
||||
|
||||
urls_batch2 = [
|
||||
"https://www.python.org",
|
||||
"https://example.org",
|
||||
]
|
||||
|
||||
config = CrawlerRunConfig(cache_mode=CacheMode.BYPASS)
|
||||
|
||||
async with AsyncWebCrawler(config=browser_config) as crawler:
|
||||
# First batch
|
||||
results1 = await crawler.arun_many(urls=urls_batch1, config=config)
|
||||
assert len(results1) == len(urls_batch1)
|
||||
for result in results1:
|
||||
assert result.success, f"First batch failed: {result.error_message}"
|
||||
|
||||
# Second batch - should work without issues
|
||||
results2 = await crawler.arun_many(urls=urls_batch2, config=config)
|
||||
assert len(results2) == len(urls_batch2)
|
||||
for result in results2:
|
||||
assert result.success, f"Second batch failed: {result.error_message}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cdp_concurrent_arun_many_stress():
|
||||
"""
|
||||
Stress test: Multiple concurrent arun_many calls with CDP browser.
|
||||
This is the key test for the concurrency fix - ensures page lock works.
|
||||
"""
|
||||
browser_config = BrowserConfig(
|
||||
use_managed_browser=True,
|
||||
headless=True,
|
||||
verbose=False
|
||||
)
|
||||
|
||||
# Create multiple batches of URLs
|
||||
num_batches = 3
|
||||
urls_per_batch = 3
|
||||
|
||||
batches = [
|
||||
[f"https://httpbin.org/delay/{i}?batch={batch}"
|
||||
for i in range(urls_per_batch)]
|
||||
for batch in range(num_batches)
|
||||
]
|
||||
|
||||
config = CrawlerRunConfig(cache_mode=CacheMode.BYPASS)
|
||||
|
||||
async with AsyncWebCrawler(config=browser_config) as crawler:
|
||||
# Run multiple arun_many calls concurrently
|
||||
tasks = [
|
||||
crawler.arun_many(urls=batch, config=config)
|
||||
for batch in batches
|
||||
]
|
||||
|
||||
# Execute all batches in parallel
|
||||
all_results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# Verify no exceptions occurred
|
||||
for i, results in enumerate(all_results):
|
||||
assert not isinstance(results, Exception), f"Batch {i} raised exception: {results}"
|
||||
assert len(results) == urls_per_batch, f"Batch {i}: expected {urls_per_batch} results, got {len(results)}"
|
||||
|
||||
# Verify each result
|
||||
for j, result in enumerate(results):
|
||||
assert result is not None, f"Batch {i}, result {j} is None"
|
||||
# Some may fail due to network/timing, but should not crash
|
||||
if result.success:
|
||||
assert len(result.html) > 0, f"Batch {i}, result {j} has empty HTML"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cdp_page_isolation():
|
||||
"""
|
||||
Test that pages are properly isolated - changes to one don't affect another.
|
||||
This validates that we're creating truly independent pages.
|
||||
"""
|
||||
browser_config = BrowserConfig(
|
||||
use_managed_browser=True,
|
||||
headless=True,
|
||||
verbose=False
|
||||
)
|
||||
|
||||
url = "https://example.com"
|
||||
|
||||
# Use different JS codes to verify isolation
|
||||
config1 = CrawlerRunConfig(
|
||||
cache_mode=CacheMode.BYPASS,
|
||||
js_code="document.body.setAttribute('data-test', 'page1');"
|
||||
)
|
||||
|
||||
config2 = CrawlerRunConfig(
|
||||
cache_mode=CacheMode.BYPASS,
|
||||
js_code="document.body.setAttribute('data-test', 'page2');"
|
||||
)
|
||||
|
||||
async with AsyncWebCrawler(config=browser_config) as crawler:
|
||||
# Run both configs concurrently
|
||||
results = await crawler.arun_many(
|
||||
urls=[url, url],
|
||||
configs=[config1, config2]
|
||||
)
|
||||
|
||||
assert len(results) == 2
|
||||
assert results[0].success and results[1].success
|
||||
|
||||
# Both should succeed with their own modifications
|
||||
# (We can't directly check the data-test attribute, but success indicates isolation)
|
||||
assert 'Example Domain' in results[0].html
|
||||
assert 'Example Domain' in results[1].html
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cdp_with_different_viewport_sizes():
|
||||
"""
|
||||
Test concurrent crawling with different viewport configurations.
|
||||
Ensures context/page creation handles different configs correctly.
|
||||
"""
|
||||
browser_config = BrowserConfig(
|
||||
use_managed_browser=True,
|
||||
headless=True,
|
||||
verbose=False
|
||||
)
|
||||
|
||||
url = "https://example.com"
|
||||
|
||||
# Different viewport sizes (though in CDP mode these may be limited)
|
||||
configs = [
|
||||
CrawlerRunConfig(cache_mode=CacheMode.BYPASS),
|
||||
CrawlerRunConfig(cache_mode=CacheMode.BYPASS),
|
||||
CrawlerRunConfig(cache_mode=CacheMode.BYPASS),
|
||||
]
|
||||
|
||||
async with AsyncWebCrawler(config=browser_config) as crawler:
|
||||
results = await crawler.arun_many(
|
||||
urls=[url] * len(configs),
|
||||
configs=configs
|
||||
)
|
||||
|
||||
assert len(results) == len(configs)
|
||||
for i, result in enumerate(results):
|
||||
assert result.success, f"Config {i} failed: {result.error_message}"
|
||||
assert len(result.html) > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cdp_error_handling_concurrent():
|
||||
"""
|
||||
Test that errors in one concurrent request don't affect others.
|
||||
This ensures proper isolation and error handling.
|
||||
"""
|
||||
browser_config = BrowserConfig(
|
||||
use_managed_browser=True,
|
||||
headless=True,
|
||||
verbose=False
|
||||
)
|
||||
|
||||
urls = [
|
||||
"https://example.com", # Valid
|
||||
"https://this-domain-definitely-does-not-exist-12345.com", # Invalid
|
||||
"https://httpbin.org/html", # Valid
|
||||
]
|
||||
|
||||
config = CrawlerRunConfig(cache_mode=CacheMode.BYPASS)
|
||||
|
||||
async with AsyncWebCrawler(config=browser_config) as crawler:
|
||||
results = await crawler.arun_many(urls=urls, config=config)
|
||||
|
||||
assert len(results) == len(urls)
|
||||
|
||||
# First and third should succeed
|
||||
assert results[0].success, "First URL should succeed"
|
||||
assert results[2].success, "Third URL should succeed"
|
||||
|
||||
# Second may fail (invalid domain)
|
||||
# But its failure shouldn't affect the others
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cdp_large_batch():
|
||||
"""
|
||||
Test handling a larger batch of URLs to ensure scalability.
|
||||
"""
|
||||
browser_config = BrowserConfig(
|
||||
use_managed_browser=True,
|
||||
headless=True,
|
||||
verbose=False
|
||||
)
|
||||
|
||||
# Create 10 URLs
|
||||
num_urls = 10
|
||||
urls = [f"https://httpbin.org/delay/0?id={i}" for i in range(num_urls)]
|
||||
|
||||
config = CrawlerRunConfig(cache_mode=CacheMode.BYPASS)
|
||||
|
||||
async with AsyncWebCrawler(config=browser_config) as crawler:
|
||||
results = await crawler.arun_many(urls=urls, config=config)
|
||||
|
||||
assert len(results) == num_urls
|
||||
|
||||
# Count successes
|
||||
successes = sum(1 for r in results if r.success)
|
||||
# Allow some failures due to network issues, but most should succeed
|
||||
assert successes >= num_urls * 0.8, f"Only {successes}/{num_urls} succeeded"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Run tests with pytest
|
||||
pytest.main([__file__, "-v", "-s"])
|
||||
Reference in New Issue
Block a user