fix(crawler): bound the Playwright calls that carry no protocol timeout - #2113
Open
Aitosoft wants to merge 1 commit into
Open
fix(crawler): bound the Playwright calls that carry no protocol timeout#2113Aitosoft wants to merge 1 commit into
Aitosoft wants to merge 1 commit into
Conversation
`page.content()` and `page.evaluate()` are sent to the driver with no `timeout` field. The Python client only injects one when a timeout calculator is passed (_connection.py `_augment_params`), and the driver arms a timer only for a truthy timeout (`progress.js`: `const deadline = timeout ? ... : 0`). So for these two, no timer exists at all — they can end only when they get a reply or the target closes. What they wait on is the frame's execution-context promise, and `frames.js _setContext(world, null)` discards the resolved promise and installs a fresh unresolved `ManualPromise` on every `_contextDestroyed`. A page that keeps committing navigations therefore wedges them forever. Two consequences, both observed in production on a WordPress + Cloudflare Turnstile site: * `page_timeout` does not help. It reaches `page.goto` and the `wait_*` family only. Lowering it from 80s to 30s changed nothing. * Nothing is logged. Both call sites sit inside swallow-all `try/except`, so the request simply stopped for 172 seconds until an external deadline fired. Same race, two outcomes from the same line: if the context dies *during* the call Playwright raises "Unable to retrieve content because the page is navigating and changing the content"; if it is already gone *at* the call, it stalls instead. `page.close()` is unbounded for the same reason, which matters because an outer deadline cancels into the cleanup path. Fix, three bounds, outermost last: * `browser_adapter.bounded_evaluate()` + a `timeout` kwarg on all three adapters — 30s default, tighter (10s) for the optional cosmetic DOM steps (image dimensions, consent/overlay removal) that already degrade gracefully, generous (300s) for the virtual-scroll loop that is legitimately slow. It raises Playwright's own `TimeoutError`, so existing `except Error` / `except Exception` handlers keep working unchanged. * `AsyncPlaywrightCrawlerStrategy._capture_html()` — bounded `page.content()` with settle-and-retry: on the navigation error, wait for the new document to reach domcontentloaded and capture again, which is the documented remedy and far cheaper than re-running the crawl. Retries share a group budget (25s) so a recoverable race stays nearly free while a wedged page pays once, and it bails out early if the page cannot even reach domcontentloaded. * `CrawlerRunConfig.total_timeout` (ms, default None) — one budget shared by every attempt and proxy in `arun()`'s fetch loop. Complements unclecode#1923, which adds a dispatcher-level bound: that covers `arun_many` only, so a direct `arun()` call (e.g. the Docker /crawl path) gets no protection from it. Measured against a local fixture origin that reproduces the race (`max_retries: 1`, `delay_before_return_html: 2.0`, `page_timeout: 80s`): a request that previously hung until an external 180s deadline now returns 200 with full content in 5.0s, and a permanently wedged page fails in bounded time with the exact reason in the log instead of consuming the whole budget silently. Tests: tests/async/test_render_call_bounds.py — 14 tests, no browser, no network.
Aitosoft
added a commit
to Aitosoft/crawl4ai
that referenced
this pull request
Jul 30, 2026
…on) and unclecode#2113 (render bounds) Both filed against unclecode/crawl4ai:develop with their own upstream-style tests (tests/proxy/test_antibot_redirect_status.py, 8 tests; tests/async/test_render_call_bounds.py, 14 tests) and doc updates. file-upstream-prs.md now tracks all three open PRs plus the measured upstream review cadence, so the next session does not re-derive it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
page.content()andpage.evaluate()can block forever, andpage_timeoutdoes not cover them.Neither method accepts a
timeout, so the Python client sends none —_connection.py::_augment_paramsinjectsparams["timeout"]only when a timeout calculator is passed. The driver then arms no timer at all:progress.jsdoesconst deadline = timeout ? monotonicTime() + timeout : 0, and with a falsy timeoutprogress.race()races against nothing but_forceAbortPromise, which is rejected only by page/context/connection close.What they wait on is the frame's execution-context promise (
frames.js::_context(world)), andframes.js::_setContext(world, null)discards the resolved promise and installs a fresh, unresolvedManualPromiseon every_contextDestroyed. So a call issued while the main frame is between documents never returns.Observed in production on a WordPress + Cloudflare Turnstile site that keeps committing navigations after
domcontentloaded:page_timeoutis irrelevant. It reachespage.gotoand thewait_*family only. Lowering it 80s → 30s changed nothing.try/except, so the crawl simply stopped for 172 seconds until an external deadline fired.The same line produces two different symptoms depending on timing: if the context dies during the call,
frames.js:726converts it toUnable to retrieve content because the page is navigating and changing the content; if the context is already gone at the call, it stalls silently instead. I could find no prior report of either.page.close()is unbounded for the same reason, which matters because an outer deadline cancels straight into the cleanup path.The fix — three bounds, outermost last
browser_adapter.bounded_evaluate()plus atimeoutkwarg on all three adapters. 30 s default; 10 s for the optional cosmetic DOM steps (image dimensions, consent/overlay removal) which already degrade gracefully; 300 s for the virtual-scroll loop, which is legitimately slow. It raises Playwright's ownTimeoutError— the same type Playwright raises for operations that do carry a timeout — so every existingexcept Error/except Exceptionhandler keeps working unchanged, and it stays distinguishable from an outer asyncio deadline.AsyncPlaywrightCrawlerStrategy._capture_html()— boundedpage.content()with settle-and-retry. On the navigation error it waits for the new document to reachdomcontentloadedand captures again, which is the documented remedy and far cheaper than re-running the whole crawl. Retries share a group budget (25 s) rather than one timeout each: a recoverable race raises instantly so retries stay nearly free, while a wedged page pays once instead of once per attempt. It also bails out early if the page cannot even reachdomcontentloaded— that means stuck, not "between documents", and another attempt buys nothing.CrawlerRunConfig.total_timeout(ms, defaultNone) — one budget shared by every attempt and every proxy inarun()'s fetch loop, somax_retriescannot silently multiply the caller's deadline.Relationship to #1923: complementary, not overlapping. That PR adds a
total_timeoutat the dispatcher level (MemoryAdaptiveDispatcher/SemaphoreDispatcher), which coversarun_manyonly. A directarun()call — including the Docker/crawlpath, which is where we hit this — gets no protection from it. Its problem statement is otherwise the same as this one, and I kept the name deliberately so the two can converge. Happy to rebase either way.Default behaviour is unchanged.
total_timeoutdefaults toNone(off), and bounds 1 and 2 only fire where the call would otherwise never return.List of files changed and why
crawl4ai/browser_adapter.py—bounded_evaluate()helper +timeoutkwarg onBrowserAdapter.evaluateand all three implementations (PlaywrightAdapter,StealthAdapter,UndetectedAdapter). One funnel bounds ~15evaluatecall sites.crawl4ai/async_crawler_strategy.py—_capture_html()used at all threepage.content()sites (main capture, shadow-DOM fallback,get_delayed_content); explicit ceilings on the optional DOM steps,page.close()and virtual scroll;csp_compliant_waitpasses its own timeout plus headroom so the adapter bound never races the JS polling loop it wraps; module-level constants so every ceiling is tunable in one place.crawl4ai/async_configs.py—CrawlerRunConfig.total_timeout(field, docstring,to_dict).crawl4ai/async_webcrawler.py— the attempt loop computes one deadline and wraps eachcrawler_strategy.crawl()in it; a later attempt that cannot start is skipped with an attributable message rather than run.docs/md_v2/api/parameters.md,docs/md_v2/advanced/anti-bot-and-fallback.md— documenttotal_timeoutand whypage_timeoutis not a substitute.tests/async/test_render_call_bounds.py— new, 14 tests, no browser and no network.How Has This Been Tested?
The unit tests use a fake page whose
content()hangs / raises the navigation error on demand, and a fake crawler strategy withcrawler.ready = True, so no Chromium is launched.Beyond unit tests, I built a fixture origin that reproduces the race deterministically — a page that commits a navigation to a URL whose response headers are delayed — and located the original hang by walking the suspended task's real
awaitchain viacr_await(Task.get_stack()returns only one frame for a suspended coroutine and is useless here). The blocked frame wasadapter.evaluate(update_image_dimensions_js)→_frame.py:320 evaluate()→ a protocol future that never resolves.Measured through the full Docker
/crawlpath against that fixture, withwait_until: domcontentloaded,page_timeout: 80s,max_retries: 1,delay_before_return_html: 2.0:arun()with a 0.4 s budget and 4 attemptsCancellation was checked separately: after a bounded attempt the browser still serves the next crawl (0.24 s) and the page count does not grow.
black --checkandruff checkare clean on the new test. I did not runblackover the touchedcrawl4ai/modules — they are not black-formatted ondevelopand reformatting them would bury the change in noise.Checklist: