From 737506d4b20b9bbd076923664ccaa690613e5b8f Mon Sep 17 00:00:00 2001 From: rozin-not-found Date: Sun, 16 Aug 2026 14:10:04 +0800 Subject: [PATCH 1/3] Add reproduction scripts for triage --- bug_triage/repro_1278.py | 19 +++++++++++++++++++ bug_triage/repro_1367.py | 25 +++++++++++++++++++++++++ bug_triage/repro_1455.py | 32 ++++++++++++++++++++++++++++++++ bug_triage/repro_570.py | 19 +++++++++++++++++++ bug_triage/repro_699.py | 20 ++++++++++++++++++++ 5 files changed, 115 insertions(+) create mode 100644 bug_triage/repro_1278.py create mode 100644 bug_triage/repro_1367.py create mode 100644 bug_triage/repro_1455.py create mode 100644 bug_triage/repro_570.py create mode 100644 bug_triage/repro_699.py diff --git a/bug_triage/repro_1278.py b/bug_triage/repro_1278.py new file mode 100644 index 000000000..dab15c3f4 --- /dev/null +++ b/bug_triage/repro_1278.py @@ -0,0 +1,19 @@ +import asyncio +from crawl4ai import AsyncWebCrawler + +async def main(): + async with AsyncWebCrawler() as crawler: + # Target a page explicitly known for HTML tables + result = await crawler.arun(url="https://www.w3schools.com/html/html_tables.asp") + + if result.success: + if " tags are stripped from cleaned_html.") + else: + print("Fixed: Tables are preserved in cleaned_html.") + else: + print(f"Crawl failed: {result.error_message}") + +if __name__ == "__main__": + asyncio.run(main()) + \ No newline at end of file diff --git a/bug_triage/repro_1367.py b/bug_triage/repro_1367.py new file mode 100644 index 000000000..19c0d43e1 --- /dev/null +++ b/bug_triage/repro_1367.py @@ -0,0 +1,25 @@ +import asyncio +from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig + +async def main(): + # Bug often triggers with persistent contexts and multiple concurrent navigations + browser_cfg = BrowserConfig(headless=True, use_persistent_context=True) + crawl_config = CrawlerRunConfig(wait_until="networkidle") + + urls = ["https://example.com", "https://example.org", "https://example.net"] + + async with AsyncWebCrawler(config=browser_cfg) as crawler: + print("Running async crawls to trigger ERR_ABORTED race condition...") + results = await crawler.arun_many(urls=urls, config=crawl_config) + + reproduced = False + for res in results: + if not res.success and "ERR_ABORTED" in str(res.error_message): + print(f"Bug reproduced on {res.url}: {res.error_message}") + reproduced = True + + if not reproduced: + print("Fixed or unable to reproduce: No ERR_ABORTED errors encountered.") + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/bug_triage/repro_1455.py b/bug_triage/repro_1455.py new file mode 100644 index 000000000..0d3d80058 --- /dev/null +++ b/bug_triage/repro_1455.py @@ -0,0 +1,32 @@ +import os +import asyncio +from pydantic import BaseModel +from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig, CacheMode, LLMConfig +from crawl4ai import LLMExtractionStrategy + +class Product(BaseModel): + name: str + +async def main(): + llm_strategy = LLMExtractionStrategy( + llm_config=LLMConfig(provider="openai/gpt-4o-mini", api_token=os.getenv('OPENAI_API_KEY', 'dummy')), + schema=Product.schema_json(), + extraction_type="schema", + instruction="Extract products." + ) + crawl_config = CrawlerRunConfig(extraction_strategy=llm_strategy, cache_mode=CacheMode.ENABLED) + + async with AsyncWebCrawler(config=BrowserConfig(headless=True)) as crawler: + print("First run (caching)...") + await crawler.arun(url="https://example.com", config=crawl_config) + + print("Second run (cache hit)...") + result = await crawler.arun(url="https://example.com", config=crawl_config) + + if not result.extracted_content: + print("Bug reproduced: extracted_content is empty on cache hit.") + else: + print("Fixed: extracted_content populated from cache.") + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/bug_triage/repro_570.py b/bug_triage/repro_570.py new file mode 100644 index 000000000..f760886c9 --- /dev/null +++ b/bug_triage/repro_570.py @@ -0,0 +1,19 @@ +import asyncio +from crawl4ai import AsyncWebCrawler + +async def main(): + async with AsyncWebCrawler() as crawler: + result = await crawler.arun(url="https://docs.crawl4ai.com/") + + if result.success: + markdown = result.markdown or "" + if "<.>" in markdown or "<#>" in markdown: + print("Bug reproduced: Relative URLs are incorrectly formatted with brackets (e.g., <.>).") + else: + print("Fixed: No malformed relative URL brackets found in markdown.") + else: + print(f"Crawl failed: {result.error_message}") + +if __name__ == "__main__": + asyncio.run(main()) + \ No newline at end of file diff --git a/bug_triage/repro_699.py b/bug_triage/repro_699.py new file mode 100644 index 000000000..e01887428 --- /dev/null +++ b/bug_triage/repro_699.py @@ -0,0 +1,20 @@ +import asyncio +from crawl4ai import AsyncWebCrawler, CrawlerRunConfig + +async def main(): + # Enable robots.txt checking + config = CrawlerRunConfig(check_robots_txt=True) + + async with AsyncWebCrawler() as crawler: + # The path /awardsearch/advancedSearch.jsp is disallowed in nsf.gov/robots.txt + url = "https://www.nsf.gov/awardsearch/advancedSearch.jsp" + result = await crawler.arun(url=url, config=config) + + if result.success: + print(f"Bug reproduced: Successfully crawled {url} despite robots.txt disallow.") + else: + print(f"Fixed/Expected behavior: Blocked from crawling. Message: {result.error_message}") + +if __name__ == "__main__": + asyncio.run(main()) + \ No newline at end of file From 24be3dc11135e8f9b7c9a6f71b48ddf3c784166b Mon Sep 17 00:00:00 2001 From: rozin-not-found Date: Fri, 28 Aug 2026 15:35:38 +0800 Subject: [PATCH 2/3] fix: allow single-label hostnames in deep crawling strategies (#2079) --- crawl4ai/deep_crawling/bff_strategy.py | 2 -- crawl4ai/deep_crawling/bfs_strategy.py | 2 -- 2 files changed, 4 deletions(-) diff --git a/crawl4ai/deep_crawling/bff_strategy.py b/crawl4ai/deep_crawling/bff_strategy.py index 511fde692..131322a00 100644 --- a/crawl4ai/deep_crawling/bff_strategy.py +++ b/crawl4ai/deep_crawling/bff_strategy.py @@ -83,8 +83,6 @@ async def can_process_url(self, url: str, depth: int) -> bool: raise ValueError("Missing scheme or netloc") if parsed.scheme not in ("http", "https"): raise ValueError("Invalid scheme") - if "." not in parsed.netloc: - raise ValueError("Invalid domain") except Exception as e: self.logger.warning(f"Invalid URL: {url}, error: {e}") return False diff --git a/crawl4ai/deep_crawling/bfs_strategy.py b/crawl4ai/deep_crawling/bfs_strategy.py index dfb759272..70529a92c 100644 --- a/crawl4ai/deep_crawling/bfs_strategy.py +++ b/crawl4ai/deep_crawling/bfs_strategy.py @@ -70,8 +70,6 @@ async def can_process_url(self, url: str, depth: int) -> bool: raise ValueError("Missing scheme or netloc") if parsed.scheme not in ("http", "https"): raise ValueError("Invalid scheme") - if "." not in parsed.netloc: - raise ValueError("Invalid domain") except Exception as e: self.logger.warning(f"Invalid URL: {url}, error: {e}") return False From 905b4a7856c220b6910e9646d65b2ac5b758fb40 Mon Sep 17 00:00:00 2001 From: rozin-not-found Date: Fri, 28 Aug 2026 16:57:57 +0800 Subject: [PATCH 3/3] fix: ensure dispatcher cleanup on crawler shutdown (#2083) --- crawl4ai/async_dispatcher.py | 52 ++++++++++++++++++++++-------------- crawl4ai/async_webcrawler.py | 32 +++++++++++++++++----- 2 files changed, 58 insertions(+), 26 deletions(-) diff --git a/crawl4ai/async_dispatcher.py b/crawl4ai/async_dispatcher.py index ebbe4cb92..5748d24df 100644 --- a/crawl4ai/async_dispatcher.py +++ b/crawl4ai/async_dispatcher.py @@ -171,6 +171,34 @@ def __init__( self.memory_pressure_mode = False # Flag to indicate when we're in memory pressure mode self.current_memory_percent = 0.0 # Track current memory usage self._high_memory_start_time: Optional[float] = None + self._stream_active_tasks = [] + self._stream_memory_monitor = None + self._stream_cleanup_lock = asyncio.Lock() + + async def cleanup(self) -> None: + async with self._stream_cleanup_lock: + for task in self._stream_active_tasks: + if not task.done(): + task.cancel() + if self._stream_active_tasks: + await asyncio.gather(*self._stream_active_tasks, return_exceptions=True) + + while True: + try: + self.task_queue.get_nowait() + except asyncio.QueueEmpty: + break + + if self._stream_memory_monitor is not None: + self._stream_memory_monitor.cancel() + await asyncio.gather( + self._stream_memory_monitor, return_exceptions=True + ) + self._stream_memory_monitor = None + + self._stream_active_tasks = [] + if self.monitor: + self.monitor.stop() async def _memory_monitor_task(self): """Background task to continuously monitor memory usage and update state""" @@ -535,9 +563,11 @@ async def run_urls_stream( ) -> AsyncGenerator[CrawlerTaskResult, None]: self.crawler = crawler active_tasks = [] + self._stream_active_tasks = active_tasks # Start the memory monitor task memory_monitor = asyncio.create_task(self._memory_monitor_task()) + self._stream_memory_monitor = memory_monitor if self.monitor: self.monitor.start() @@ -606,6 +636,7 @@ async def run_urls_stream( # Update active tasks list active_tasks = list(pending) + self._stream_active_tasks = active_tasks else: # If no active tasks but still waiting, sleep briefly await asyncio.sleep(self.check_interval / 2) @@ -614,26 +645,7 @@ async def run_urls_stream( await self._update_queue_priorities() finally: - # Cancel and await every task owned by this stream before returning - # control to the caller. Otherwise a closed stream can leave crawls - # using browser pages and contexts in the background. - for task in active_tasks: - if not task.done(): - task.cancel() - if active_tasks: - await asyncio.gather(*active_tasks, return_exceptions=True) - - # Discard URLs that were queued by this stream but never started. - while True: - try: - self.task_queue.get_nowait() - except asyncio.QueueEmpty: - break - - memory_monitor.cancel() - await asyncio.gather(memory_monitor, return_exceptions=True) - if self.monitor: - self.monitor.stop() + await self.cleanup() class SemaphoreDispatcher(BaseDispatcher): diff --git a/crawl4ai/async_webcrawler.py b/crawl4ai/async_webcrawler.py index 8216d19bc..4e3425bcc 100644 --- a/crawl4ai/async_webcrawler.py +++ b/crawl4ai/async_webcrawler.py @@ -172,6 +172,7 @@ def __init__( self.url_seeder: Optional[AsyncUrlSeeder] = None self._domain_mapper: Optional[DomainMapper] = None + self._dispatchers = [] async def start(self): """ @@ -194,7 +195,14 @@ async def close(self): 1. Clean up browser resources 2. Close any open pages and contexts """ - await self.crawler_strategy.__aexit__(None, None, None) + try: + for dispatcher in self._dispatchers: + cleanup = getattr(dispatcher, "cleanup", None) + if cleanup: + await cleanup() + finally: + self._dispatchers.clear() + await self.crawler_strategy.__aexit__(None, None, None) async def __aenter__(self): return await self.start() @@ -1065,6 +1073,9 @@ async def _deep_crawl_stream(): ), ) + if dispatcher not in self._dispatchers: + self._dispatchers.append(dispatcher) + def transform_result(task_result): return ( setattr( @@ -1107,14 +1118,23 @@ async def maybe_release_session(): if stream: async def result_transformer(): + inner_stream = dispatcher.run_urls_stream( + crawler=self, urls=urls, config=config + ) try: - async for task_result in dispatcher.run_urls_stream( - crawler=self, urls=urls, config=config - ): + async for task_result in inner_stream: yield transform_result(task_result) + except GeneratorExit: + # Handle early stream breakage (e.g., break in a loop) explicitly + pass finally: - # Auto-release session after streaming completes - await maybe_release_session() + try: + cleanup = getattr(dispatcher, "cleanup", None) + if cleanup: + await cleanup() + finally: + # Auto-release session after streaming completes + await maybe_release_session() return result_transformer() else: