Compare commits

..

2 Commits

Author SHA1 Message Date
AHMET YILMAZ
61a18e01dc #1563 fix(browser): ensure new pages are created for managed browser concurrency
- Modify get_page() to always create new pages instead of reusing existing ones
- Add page lock to serialize new page creation in managed browser context
- Improve subprocess argument formatting and cleanup logging
- Delegate profile-related static methods to BrowserProfiler class
- Enhance startup checks for managed browser processes
- Add comprehensive test suite validating concurrency fix for arun_many with CDP browsers
- Fix proxy flag formatting and deduplicate browser launch args
- Refactor imports and code formatting for clarity and consistency
2025-10-29 18:00:10 +08:00
AHMET YILMAZ
977f7156aa fix(browser): ensure new pages are created for managed browser concurrency
- Modify get_page() to always create new pages instead of reusing existing ones
- Add page lock to serialize new page creation in managed browser context
- Improve subprocess argument formatting and cleanup logging
- Delegate profile-related static methods to BrowserProfiler class
- Enhance startup checks for managed browser processes
- Add comprehensive test suite validating concurrency fix for arun_many with CDP browsers
- Fix proxy flag formatting and deduplicate browser launch args
- Refactor imports and code formatting for clarity and consistency
2025-10-29 17:45:41 +08:00
17 changed files with 470 additions and 921 deletions

1
.yoyo/snapshot Submodule

Submodule .yoyo/snapshot added at 5e783b71e7

View File

@@ -1,214 +0,0 @@
# 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

View File

@@ -1383,10 +1383,9 @@ class AsyncPlaywrightCrawlerStrategy(AsyncCrawlerStrategy):
try:
await self.adapter.evaluate(page,
f"""
(async () => {{
(() => {{
try {{
const removeOverlays = {remove_overlays_js};
await removeOverlays();
{remove_overlays_js}
return {{ success: true }};
}} catch (error) {{
return {{

View File

@@ -1,4 +1,3 @@
# Standard library imports
import asyncio
import hashlib
import os
@@ -12,17 +11,14 @@ 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 = [
"--disable-background-networking",
"--disable-background-timer-throttling",
@@ -70,7 +66,7 @@ class ManagedBrowser:
_cleanup(): Terminates the browser process and removes the temporary directory.
create_profile(): Static method to create a user profile by launching a browser for user interaction.
"""
@staticmethod
def build_browser_flags(config: BrowserConfig) -> List[str]:
"""Common CLI flags for launching Chromium"""
@@ -97,21 +93,26 @@ class ManagedBrowser:
if config.light_mode:
flags.extend(BROWSER_DISABLE_OPTIONS)
if config.text_mode:
flags.extend([
"--blink-settings=imagesEnabled=false",
"--disable-remote-fonts",
"--disable-images",
"--disable-javascript",
"--disable-software-rasterizer",
"--disable-dev-shm-usage",
])
flags.extend(
[
"--blink-settings=imagesEnabled=false",
"--disable-remote-fonts",
"--disable-images",
"--disable-javascript",
"--disable-software-rasterizer",
"--disable-dev-shm-usage",
]
)
# proxy support
if config.proxy:
flags.append(f"--proxy-server={config.proxy}")
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}")
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}")
# dedupe
return list(dict.fromkeys(flags))
@@ -131,7 +132,7 @@ class ManagedBrowser:
logger=None,
host: str = "localhost",
debugging_port: int = 9222,
cdp_url: Optional[str] = None,
cdp_url: Optional[str] = None,
browser_config: Optional[BrowserConfig] = None,
):
"""
@@ -167,7 +168,7 @@ class ManagedBrowser:
Starts the browser process or returns CDP endpoint URL.
If cdp_url is provided, returns it directly.
If user_data_dir is not provided for local browser, creates a temporary directory.
Returns:
str: CDP endpoint URL
"""
@@ -183,10 +184,9 @@ class ManagedBrowser:
# Get browser path and args based on OS and browser type
# browser_path = self._get_browser_path()
args = await self._get_browser_args()
if self.browser_config.extra_args:
args.extend(self.browser_config.extra_args)
# ── make sure no old Chromium instance is owning the same port/profile ──
try:
@@ -204,7 +204,9 @@ class ManagedBrowser:
else: # macOS / Linux
# kill any process listening on the same debugging port
pids = (
subprocess.check_output(shlex.split(f"lsof -t -i:{self.debugging_port}"))
subprocess.check_output(
shlex.split(f"lsof -t -i:{self.debugging_port}")
)
.decode()
.strip()
.splitlines()
@@ -223,74 +225,40 @@ class ManagedBrowser:
os.remove(fp)
except Exception as _e:
# non-fatal — we'll try to start anyway, but log what happened
if self.logger:
self.logger.warning(
"Pre-launch cleanup failed: {error} | Will attempt to start browser anyway",
tag="BROWSER",
params={"error": str(_e)}
)
self.logger.warning(f"pre-launch cleanup failed: {_e}", tag="BROWSER")
# 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":
self.browser_process = subprocess.Popen(
args,
stdout=subprocess.PIPE,
args,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
creationflags=subprocess.DETACHED_PROCESS
| subprocess.CREATE_NEW_PROCESS_GROUP,
)
else:
self.browser_process = subprocess.Popen(
args,
stdout=subprocess.PIPE,
args,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
preexec_fn=os.setpgrp # Start in a new process group
preexec_fn=os.setpgrp, # Start in a new process group
)
# Log full command if verbose logging is enabled
# If verbose is True print args used to run the process
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(
"Browser launch command: {command}",
tag="BROWSER",
params={"command": formatted_args}
f"Starting browser with args: {' '.join(args)}", tag="BROWSER"
)
# Perform startup health checks
await asyncio.sleep(0.5) # Initial delay for process startup
# 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
await self._initial_startup_check()
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
await asyncio.sleep(2) # Give browser time to start
return f"http://{self.host}:{self.debugging_port}"
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}")
@@ -301,45 +269,27 @@ class ManagedBrowser:
"""
if not self.browser_process:
return
# Check that process started without immediate termination
# 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 - 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="{error_msg} | Exit code: {code}",
tag="BROWSER",
params={
"error_msg": error_msg,
"code": self.browser_process.returncode,
},
)
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}
await asyncio.sleep(0.5)
if self.browser_process.poll() is not None:
# Process already terminated
stdout, stderr = b"", b""
try:
stdout, stderr = self.browser_process.communicate(timeout=0.5)
except subprocess.TimeoutExpired:
pass
self.logger.error(
message="Browser process terminated during startup | Code: {code} | STDOUT: {stdout} | STDERR: {stderr}",
tag="ERROR",
params={
"code": self.browser_process.returncode,
"stdout": stdout.decode() if stdout else "",
"stderr": stderr.decode() if stderr else "",
},
)
async def _monitor_browser_process(self):
"""
Monitor the browser process for unexpected termination.
@@ -426,8 +376,6 @@ 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",
@@ -464,7 +412,14 @@ class ManagedBrowser:
if sys.platform == "win32":
# On Windows we might need taskkill for detached processes
try:
subprocess.run(["taskkill", "/F", "/PID", str(self.browser_process.pid)])
subprocess.run(
[
"taskkill",
"/F",
"/PID",
str(self.browser_process.pid),
]
)
except Exception:
self.browser_process.kill()
else:
@@ -474,7 +429,7 @@ class ManagedBrowser:
except Exception as e:
self.logger.error(
message="Error terminating browser: {error}",
tag="ERROR",
tag="ERROR",
params={"error": str(e)},
)
@@ -487,75 +442,77 @@ class ManagedBrowser:
tag="ERROR",
params={"error": str(e)},
)
# These methods have been moved to BrowserProfiler class
@staticmethod
async def create_profile(browser_config=None, profile_name=None, logger=None):
"""
This method has been moved to the BrowserProfiler class.
Creates a browser profile by launching a browser for interactive user setup
and waits until the user closes it. The profile is stored in a directory that
can be used later with BrowserConfig.user_data_dir.
Please use BrowserProfiler.create_profile() instead.
Example:
```python
from crawl4ai.browser_profiler import BrowserProfiler
profiler = BrowserProfiler()
profile_path = await profiler.create_profile(profile_name="my-login-profile")
```
"""
from .browser_profiler import BrowserProfiler
# Create a BrowserProfiler instance and delegate to it
profiler = BrowserProfiler(logger=logger)
return await profiler.create_profile(profile_name=profile_name, browser_config=browser_config)
return await profiler.create_profile(
profile_name=profile_name, browser_config=browser_config
)
@staticmethod
def list_profiles():
"""
This method has been moved to the BrowserProfiler class.
Lists all available browser profiles in the Crawl4AI profiles directory.
Please use BrowserProfiler.list_profiles() instead.
Example:
```python
from crawl4ai.browser_profiler import BrowserProfiler
profiler = BrowserProfiler()
profiles = profiler.list_profiles()
```
"""
from .browser_profiler import BrowserProfiler
# Create a BrowserProfiler instance and delegate to it
profiler = BrowserProfiler()
return profiler.list_profiles()
@staticmethod
def delete_profile(profile_name_or_path):
"""
This method has been moved to the BrowserProfiler class.
Delete a browser profile by name or path.
Please use BrowserProfiler.delete_profile() instead.
Example:
```python
from crawl4ai.browser_profiler import BrowserProfiler
profiler = BrowserProfiler()
success = profiler.delete_profile("my-profile")
```
"""
from .browser_profiler import BrowserProfiler
# Create a BrowserProfiler instance and delegate to it
profiler = BrowserProfiler()
return profiler.delete_profile(profile_name_or_path)
@@ -608,9 +565,8 @@ async def clone_runtime_state(
"accuracy": crawlerRunConfig.geolocation.accuracy,
}
)
return dst
return dst
class BrowserManager:
@@ -629,7 +585,7 @@ class BrowserManager:
"""
_playwright_instance = None
@classmethod
async def get_playwright(cls, use_undetected: bool = False):
if use_undetected:
@@ -637,9 +593,11 @@ class BrowserManager:
else:
from playwright.async_api import async_playwright
cls._playwright_instance = await async_playwright().start()
return cls._playwright_instance
return cls._playwright_instance
def __init__(self, browser_config: BrowserConfig, logger=None, use_undetected: bool = False):
def __init__(
self, browser_config: BrowserConfig, logger=None, use_undetected: bool = False
):
"""
Initialize the BrowserManager with a browser configuration.
@@ -665,16 +623,17 @@ class BrowserManager:
# Keep track of contexts by a "config signature," so each unique config reuses a single context
self.contexts_by_config = {}
self._contexts_lock = asyncio.Lock()
# Serialize context.new_page() across concurrent tasks to avoid races
# when using a shared persistent context (context.pages may be empty
# for all racers). Prevents 'Target page/context closed' errors.
self._page_lock = asyncio.Lock()
# Stealth adapter for stealth mode
self._stealth_adapter = None
if self.config.enable_stealth and not self.use_undetected:
from .browser_adapter import StealthAdapter
self._stealth_adapter = StealthAdapter()
# Initialize ManagedBrowser if needed
@@ -703,7 +662,7 @@ class BrowserManager:
"""
if self.playwright is not None:
await self.close()
if self.use_undetected:
from patchright.async_api import async_playwright
else:
@@ -714,7 +673,11 @@ class BrowserManager:
if self.config.cdp_url or self.config.use_managed_browser:
self.config.use_managed_browser = True
cdp_url = await self.managed_browser.start() if not self.config.cdp_url else self.config.cdp_url
cdp_url = (
await self.managed_browser.start()
if not self.config.cdp_url
else self.config.cdp_url
)
self.browser = await self.playwright.chromium.connect_over_cdp(cdp_url)
contexts = self.browser.contexts
if contexts:
@@ -735,7 +698,6 @@ class BrowserManager:
self.default_context = self.browser
def _build_browser_args(self) -> dict:
"""Build browser launch arguments from config."""
args = [
@@ -781,7 +743,7 @@ class BrowserManager:
# Deduplicate args
args = list(dict.fromkeys(args))
browser_args = {"headless": self.config.headless, "args": args}
if self.config.chrome_channel:
@@ -858,9 +820,9 @@ class BrowserManager:
context.set_default_navigation_timeout(DOWNLOAD_PAGE_TIMEOUT)
if self.config.downloads_path:
context._impl_obj._options["accept_downloads"] = True
context._impl_obj._options[
"downloads_path"
] = self.config.downloads_path
context._impl_obj._options["downloads_path"] = (
self.config.downloads_path
)
# Handle user agent and browser hints
if self.config.user_agent:
@@ -891,7 +853,7 @@ class BrowserManager:
or crawlerRunConfig.simulate_user
or crawlerRunConfig.magic
):
await context.add_init_script(load_js_script("navigator_overrider"))
await context.add_init_script(load_js_script("navigator_overrider"))
async def create_browser_context(self, crawlerRunConfig: CrawlerRunConfig = None):
"""
@@ -902,7 +864,7 @@ class BrowserManager:
Context: Browser context object with the specified configurations
"""
# Base settings
user_agent = self.config.headers.get("User-Agent", self.config.user_agent)
user_agent = self.config.headers.get("User-Agent", self.config.user_agent)
viewport_settings = {
"width": self.config.viewport_width,
"height": self.config.viewport_height,
@@ -975,7 +937,7 @@ class BrowserManager:
"device_scale_factor": 1.0,
"java_script_enabled": self.config.java_script_enabled,
}
if crawlerRunConfig:
# Check if there is value for crawlerRunConfig.proxy_config set add that to context
if crawlerRunConfig.proxy_config:
@@ -983,10 +945,12 @@ class BrowserManager:
"server": crawlerRunConfig.proxy_config.server,
}
if crawlerRunConfig.proxy_config.username:
proxy_settings.update({
"username": crawlerRunConfig.proxy_config.username,
"password": crawlerRunConfig.proxy_config.password,
})
proxy_settings.update(
{
"username": crawlerRunConfig.proxy_config.username,
"password": crawlerRunConfig.proxy_config.password,
}
)
context_settings["proxy"] = proxy_settings
if self.config.text_mode:
@@ -1044,12 +1008,12 @@ class BrowserManager:
"cache_mode",
"content_filter",
"semaphore_count",
"url"
"url",
]
# Do NOT exclude locale, timezone_id, or geolocation as these DO affect browser context
# and should cause a new context to be created if they change
for key in ephemeral_keys:
if key in config_dict:
del config_dict[key]
@@ -1070,7 +1034,7 @@ class BrowserManager:
self.logger.warning(
message="Failed to apply stealth to page: {error}",
tag="STEALTH",
params={"error": str(e)}
params={"error": str(e)},
)
async def get_page(self, crawlerRunConfig: CrawlerRunConfig):
@@ -1096,8 +1060,10 @@ class BrowserManager:
if self.config.use_managed_browser:
if self.config.storage_state:
context = await self.create_browser_context(crawlerRunConfig)
ctx = self.default_context # default context, one window only
ctx = await clone_runtime_state(context, ctx, crawlerRunConfig, self.config)
ctx = self.default_context # default context, one window only
ctx = await clone_runtime_state(
context, ctx, crawlerRunConfig, self.config
)
# Avoid concurrent new_page on shared persistent context
# See GH-1198: context.pages can be empty under races
async with self._page_lock:
@@ -1105,12 +1071,28 @@ class BrowserManager:
await self._apply_stealth_to_page(page)
else:
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)
pages = context.pages
page = next((p for p in pages if p.url == crawlerRunConfig.url), None)
if not page:
if pages:
# FIX: Always create a new page for managed browsers to support concurrent crawling
# Previously: page = pages[0]
async with self._page_lock:
page = await context.new_page()
await self._apply_stealth_to_page(page)
else:
# Double-check under lock to avoid TOCTOU and ensure only
# one task calls new_page when pages=[] concurrently
async with self._page_lock:
pages = context.pages
if pages:
# FIX: Always create a new page for managed browsers to support concurrent crawling
# Previously: page = pages[0]
page = await context.new_page()
await self._apply_stealth_to_page(page)
else:
page = await context.new_page()
await self._apply_stealth_to_page(page)
else:
# Otherwise, check if we have an existing context for this config
config_signature = self._make_config_signature(crawlerRunConfig)
@@ -1163,7 +1145,7 @@ class BrowserManager:
"""Close all browser resources and clean up."""
if self.config.cdp_url:
return
if self.config.sleep_on_close:
await asyncio.sleep(0.5)
@@ -1179,7 +1161,7 @@ class BrowserManager:
self.logger.error(
message="Error closing context: {error}",
tag="ERROR",
params={"error": str(e)}
params={"error": str(e)},
)
self.contexts_by_config.clear()

View File

@@ -5,26 +5,22 @@ This module provides a dedicated class for managing browser profiles
that can be used for identity-based crawling with Crawl4AI.
"""
# Standard library imports
import asyncio
import datetime
import json
import os
import shutil
import asyncio
import signal
import subprocess
import sys
import time
import datetime
import uuid
from typing import Any, Dict, List, Optional
# Third-party imports
import shutil
import json
import subprocess
import time
from typing import List, Dict, Optional, Any
from rich.console import Console
# Local imports
from .async_configs import BrowserConfig
from .async_logger import AsyncLogger, AsyncLoggerBase, LogColor
from .browser_manager import ManagedBrowser
from .async_logger import AsyncLogger, AsyncLoggerBase, LogColor
from .utils import get_home_folder

View File

@@ -6,16 +6,15 @@ x-base-config: &base-config
- "11235:11235" # Gunicorn port
env_file:
- .llm.env # API keys (create from .llm.env.example)
# Uncomment to set default environment variables (will overwrite .llm.env)
# environment:
# - OPENAI_API_KEY=${OPENAI_API_KEY:-}
# - DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY:-}
# - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
# - GROQ_API_KEY=${GROQ_API_KEY:-}
# - TOGETHER_API_KEY=${TOGETHER_API_KEY:-}
# - MISTRAL_API_KEY=${MISTRAL_API_KEY:-}
# - GEMINI_API_KEY=${GEMINI_API_KEY:-}
# - LLM_PROVIDER=${LLM_PROVIDER:-} # Optional: Override default provider (e.g., "anthropic/claude-3-opus")
environment:
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY:-}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- GROQ_API_KEY=${GROQ_API_KEY:-}
- TOGETHER_API_KEY=${TOGETHER_API_KEY:-}
- MISTRAL_API_KEY=${MISTRAL_API_KEY:-}
- GEMINI_API_TOKEN=${GEMINI_API_TOKEN:-}
- LLM_PROVIDER=${LLM_PROVIDER:-} # Optional: Override default provider (e.g., "anthropic/claude-3-opus")
volumes:
- /dev/shm:/dev/shm # Chromium performance
deploy:

View File

@@ -18,7 +18,7 @@ A comprehensive web-based tutorial for learning and experimenting with C4A-Scrip
2. **Install Dependencies**
```bash
pip install -r requirements.txt
pip install flask
```
3. **Launch the Server**
@@ -28,7 +28,7 @@ A comprehensive web-based tutorial for learning and experimenting with C4A-Scrip
4. **Open in Browser**
```
http://localhost:8000
http://localhost:8080
```
**🌐 Try Online**: [Live Demo](https://docs.crawl4ai.com/c4a-script/demo)
@@ -325,7 +325,7 @@ Powers the recording functionality:
### Configuration
```python
# server.py configuration
PORT = 8000
PORT = 8080
DEBUG = True
THREADED = True
```
@@ -343,9 +343,9 @@ THREADED = True
**Port Already in Use**
```bash
# Kill existing process
lsof -ti:8000 | xargs kill -9
lsof -ti:8080 | xargs kill -9
# Or use different port
python server.py --port 8001
python server.py --port 8081
```
**Blockly Not Loading**

View File

@@ -216,7 +216,7 @@ def get_examples():
'name': 'Handle Cookie Banner',
'description': 'Accept cookies and close newsletter popup',
'script': '''# Handle cookie banner and newsletter
GO http://127.0.0.1:8000/playground/
GO http://127.0.0.1:8080/playground/
WAIT `body` 2
IF (EXISTS `.cookie-banner`) THEN CLICK `.accept`
IF (EXISTS `.newsletter-popup`) THEN CLICK `.close`'''

View File

@@ -82,42 +82,6 @@ If you installed Crawl4AI (which installs Playwright under the hood), you alread
---
### Creating a Profile Using the Crawl4AI CLI (Easiest)
If you prefer a guided, interactive setup, use the built-in CLI to create and manage persistent browser profiles.
1.Launch the profile manager:
```bash
crwl profiles
```
2.Choose "Create new profile" and enter a profile name. A Chromium window opens so you can log in to sites and configure settings. When finished, return to the terminal and press `q` to save the profile.
3.Profiles are saved under `~/.crawl4ai/profiles/<profile_name>` (for example: `/home/<you>/.crawl4ai/profiles/test_profile_1`) along with a `storage_state.json` for cookies and session data.
4.Optionally, choose "List profiles" in the CLI to view available profiles and their paths.
5.Use the saved path with `BrowserConfig.user_data_dir`:
```python
from crawl4ai import AsyncWebCrawler, BrowserConfig
profile_path = "/home/<you>/.crawl4ai/profiles/test_profile_1"
browser_config = BrowserConfig(
headless=True,
use_managed_browser=True,
user_data_dir=profile_path,
browser_type="chromium",
)
async with AsyncWebCrawler(config=browser_config) as crawler:
result = await crawler.arun(url="https://example.com/private")
```
The CLI also supports listing and deleting profiles, and even testing a crawl directly from the menu.
---
## 3. Using Managed Browsers in Crawl4AI
Once you have a data directory with your session data, pass it to **`BrowserConfig`**:

View File

@@ -18,7 +18,7 @@ A comprehensive web-based tutorial for learning and experimenting with C4A-Scrip
2. **Install Dependencies**
```bash
pip install -r requirements.txt
pip install flask
```
3. **Launch the Server**
@@ -28,7 +28,7 @@ A comprehensive web-based tutorial for learning and experimenting with C4A-Scrip
4. **Open in Browser**
```
http://localhost:8000
http://localhost:8080
```
**🌐 Try Online**: [Live Demo](https://docs.crawl4ai.com/c4a-script/demo)
@@ -325,7 +325,7 @@ Powers the recording functionality:
### Configuration
```python
# server.py configuration
PORT = 8000
PORT = 8080
DEBUG = True
THREADED = True
```
@@ -343,9 +343,9 @@ THREADED = True
**Port Already in Use**
```bash
# Kill existing process
lsof -ti:8000 | xargs kill -9
lsof -ti:8080 | xargs kill -9
# Or use different port
python server.py --port 8001
python server.py --port 8081
```
**Blockly Not Loading**

View File

@@ -216,7 +216,7 @@ def get_examples():
'name': 'Handle Cookie Banner',
'description': 'Accept cookies and close newsletter popup',
'script': '''# Handle cookie banner and newsletter
GO http://127.0.0.1:8000/playground/
GO http://127.0.0.1:8080/playground/
WAIT `body` 2
IF (EXISTS `.cookie-banner`) THEN CLICK `.accept`
IF (EXISTS `.newsletter-popup`) THEN CLICK `.close`'''
@@ -283,7 +283,7 @@ WAIT `.success-message` 5'''
return jsonify(examples)
if __name__ == '__main__':
port = int(os.environ.get('PORT', 8000))
port = int(os.environ.get('PORT', 8080))
print(f"""
╔══════════════════════════════════════════════════════════╗
║ C4A-Script Interactive Tutorial Server ║

View File

@@ -69,12 +69,12 @@ The tutorial includes a Flask-based web interface with:
cd docs/examples/c4a_script/tutorial/
# Install dependencies
pip install -r requirements.txt
pip install flask
# Launch the tutorial server
python server.py
python app.py
# Open http://localhost:8000 in your browser
# Open http://localhost:5000 in your browser
```
## Core Concepts
@@ -111,8 +111,8 @@ CLICK `.submit-btn`
# By attribute
CLICK `button[type="submit"]`
# By accessible attributes
CLICK `button[aria-label="Search"][title="Search"]`
# By text content
CLICK `button:contains("Sign In")`
# Complex selectors
CLICK `.form-container input[name="email"]`

View File

@@ -57,7 +57,7 @@
Crawl4AI is the #1 trending GitHub repository, actively maintained by a vibrant community. It delivers blazing-fast, AI-ready web crawling tailored for large language models, AI agents, and data pipelines. Fully open source, flexible, and built for real-time performance, **Crawl4AI** empowers developers with unmatched speed, precision, and deployment ease.
> Enjoy using Crawl4AI? Consider **[becoming a sponsor](https://github.com/sponsors/unclecode)** to support ongoing development and community growth!
> **Note**: If you're looking for the old documentation, you can access it [here](https://old.docs.crawl4ai.com).
## 🆕 AI Assistant Skill Now Available!

View File

@@ -1,165 +0,0 @@
#!/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)

View File

@@ -1,282 +0,0 @@
"""
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"])

View File

@@ -364,19 +364,5 @@ async def test_network_error_handling():
async with AsyncPlaywrightCrawlerStrategy() as strategy:
await strategy.crawl("https://invalid.example.com", config)
@pytest.mark.asyncio
async def test_remove_overlay_elements(crawler_strategy):
config = CrawlerRunConfig(
remove_overlay_elements=True,
delay_before_return_html=5,
)
response = await crawler_strategy.crawl(
"https://www2.hm.com/en_us/index.html",
config
)
assert response.status_code == 200
assert "Accept all cookies" not in response.html
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@@ -0,0 +1,283 @@
"""
Compact test suite for CDP concurrency fix.
This file consolidates all tests related to the CDP concurrency fix for
AsyncWebCrawler.arun_many() with managed browsers.
The bug was that all concurrent tasks were fighting over one shared tab,
causing failures. This has been fixed by modifying the get_page() method
in browser_manager.py to always create new pages instead of reusing pages[0].
"""
import asyncio
import shutil
import sys
import tempfile
from pathlib import Path
# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
from crawl4ai import AsyncWebCrawler, CacheMode, CrawlerRunConfig
from crawl4ai.async_configs import BrowserConfig
# =============================================================================
# TEST 1: Basic arun_many functionality
# =============================================================================
async def test_basic_arun_many():
"""Test that arun_many works correctly with basic configuration."""
print("=== TEST 1: Basic arun_many functionality ===")
# Configuration to bypass cache for testing
config = CrawlerRunConfig(cache_mode=CacheMode.BYPASS)
# Test URLs - using reliable test URLs
test_urls = [
"https://httpbin.org/html", # Simple HTML page
"https://httpbin.org/json", # Simple JSON response
]
async with AsyncWebCrawler() as crawler:
print(f"Testing concurrent crawling of {len(test_urls)} URLs...")
# This should work correctly
result = await crawler.arun_many(urls=test_urls, config=config)
# Simple verification - if we get here without exception, the basic functionality works
print(f"✓ arun_many completed successfully")
return True
# =============================================================================
# TEST 2: CDP Browser with Managed Configuration
# =============================================================================
async def test_arun_many_with_managed_cdp_browser():
"""Test that arun_many works correctly with managed CDP browsers."""
print("\n=== TEST 2: arun_many with managed CDP browser ===")
# Create a temporary user data directory for the CDP browser
user_data_dir = tempfile.mkdtemp(prefix="crawl4ai-cdp-test-")
try:
# Configure browser to use managed CDP mode
browser_config = BrowserConfig(
use_managed_browser=True,
browser_type="chromium",
headless=True,
user_data_dir=user_data_dir,
verbose=True,
)
# Configuration to bypass cache for testing
crawler_config = CrawlerRunConfig(
cache_mode=CacheMode.BYPASS,
page_timeout=60000,
wait_until="domcontentloaded",
)
# Test URLs - using reliable test URLs
test_urls = [
"https://httpbin.org/html", # Simple HTML page
"https://httpbin.org/json", # Simple JSON response
]
# Create crawler with CDP browser configuration
async with AsyncWebCrawler(config=browser_config) as crawler:
print(f"Testing concurrent crawling of {len(test_urls)} URLs...")
# This should work correctly with our fix
result = await crawler.arun_many(urls=test_urls, config=crawler_config)
print(f"✓ arun_many completed successfully with managed CDP browser")
return True
except Exception as e:
print(f"❌ Test failed with error: {str(e)}")
raise
finally:
# Clean up temporary directory
try:
shutil.rmtree(user_data_dir, ignore_errors=True)
except:
pass
# =============================================================================
# TEST 3: Concurrency Verification
# =============================================================================
async def test_concurrent_crawling():
"""Test concurrent crawling to verify the fix works."""
print("\n=== TEST 3: Concurrent crawling verification ===")
# Configuration to bypass cache for testing
config = CrawlerRunConfig(cache_mode=CacheMode.BYPASS)
# Test URLs - using reliable test URLs
test_urls = [
"https://httpbin.org/html", # Simple HTML page
"https://httpbin.org/json", # Simple JSON response
"https://httpbin.org/uuid", # Simple UUID response
"https://example.com/", # Standard example page
]
async with AsyncWebCrawler() as crawler:
print(f"Testing concurrent crawling of {len(test_urls)} URLs...")
# This should work correctly with our fix
results = await crawler.arun_many(urls=test_urls, config=config)
# Simple verification - if we get here without exception, the fix works
print("✓ arun_many completed successfully with concurrent crawling")
return True
# =============================================================================
# TEST 4: Concurrency Fix Demonstration
# =============================================================================
async def test_concurrency_fix():
"""Demonstrate that the concurrency fix works."""
print("\n=== TEST 4: Concurrency fix demonstration ===")
# Configuration to bypass cache for testing
config = CrawlerRunConfig(cache_mode=CacheMode.BYPASS)
# Test URLs - using reliable test URLs
test_urls = [
"https://httpbin.org/html", # Simple HTML page
"https://httpbin.org/json", # Simple JSON response
"https://httpbin.org/uuid", # Simple UUID response
]
async with AsyncWebCrawler() as crawler:
print(f"Testing concurrent crawling of {len(test_urls)} URLs...")
# This should work correctly with our fix
results = await crawler.arun_many(urls=test_urls, config=config)
# Simple verification - if we get here without exception, the fix works
print("✓ arun_many completed successfully with concurrent crawling")
return True
# =============================================================================
# TEST 5: Before/After Behavior Comparison
# =============================================================================
async def test_before_after_behavior():
"""Test that demonstrates concurrent crawling works correctly after the fix."""
print("\n=== TEST 5: Before/After behavior test ===")
# Configuration to bypass cache for testing
config = CrawlerRunConfig(cache_mode=CacheMode.BYPASS)
# Test URLs - using reliable test URLs that would stress the concurrency system
test_urls = [
"https://httpbin.org/delay/1", # Delayed response to increase chance of contention
"https://httpbin.org/delay/2", # Delayed response to increase chance of contention
"https://httpbin.org/uuid", # Fast response
"https://httpbin.org/json", # Fast response
]
async with AsyncWebCrawler() as crawler:
print(
f"Testing concurrent crawling of {len(test_urls)} URLs (including delayed responses)..."
)
print(
"This test would have failed before the concurrency fix due to page contention."
)
# This should work correctly with our fix
results = await crawler.arun_many(urls=test_urls, config=config)
# Simple verification - if we get here without exception, the fix works
print("✓ arun_many completed successfully with concurrent crawling")
print("✓ No page contention issues detected")
return True
# =============================================================================
# TEST 6: Reference Pattern Test
# =============================================================================
async def test_reference_pattern():
"""Main test function following reference pattern."""
print("\n=== TEST 6: Reference pattern test ===")
# Configure crawler settings
crawler_cfg = CrawlerRunConfig(
cache_mode=CacheMode.BYPASS,
page_timeout=60000,
wait_until="domcontentloaded",
)
# Define URLs to crawl
URLS = [
"https://httpbin.org/html",
"https://httpbin.org/json",
"https://httpbin.org/uuid",
]
# Crawl all URLs using arun_many
async with AsyncWebCrawler() as crawler:
print(f"Testing concurrent crawling of {len(URLS)} URLs...")
results = await crawler.arun_many(urls=URLS, config=crawler_cfg)
# Simple verification - if we get here without exception, the fix works
print("✓ arun_many completed successfully with concurrent crawling")
print("✅ Reference pattern test completed successfully!")
# =============================================================================
# MAIN EXECUTION
# =============================================================================
async def main():
"""Run all tests."""
print("Running compact CDP concurrency test suite...")
print("=" * 60)
tests = [
test_basic_arun_many,
test_arun_many_with_managed_cdp_browser,
test_concurrent_crawling,
test_concurrency_fix,
test_before_after_behavior,
test_reference_pattern,
]
passed = 0
failed = 0
for test_func in tests:
try:
await test_func()
passed += 1
except Exception as e:
print(f"❌ Test failed: {str(e)}")
failed += 1
print("\n" + "=" * 60)
print(f"Test Results: {passed} passed, {failed} failed")
if failed == 0:
print("🎉 All tests passed! The CDP concurrency fix is working correctly.")
return True
else:
print(f"{failed} test(s) failed!")
return False
if __name__ == "__main__":
success = asyncio.run(main())
sys.exit(0 if success else 1)