Skip to content

fix(crawler): bound the Playwright calls that carry no protocol timeout - #2113

Open
Aitosoft wants to merge 1 commit into
unclecode:developfrom
Aitosoft:fix/bound-untimed-render-calls
Open

fix(crawler): bound the Playwright calls that carry no protocol timeout#2113
Aitosoft wants to merge 1 commit into
unclecode:developfrom
Aitosoft:fix/bound-untimed-render-calls

Conversation

@Aitosoft

Copy link
Copy Markdown

Summary

page.content() and page.evaluate() can block forever, and page_timeout does not cover them.

Neither method accepts a timeout, so the Python client sends none — _connection.py::_augment_params injects params["timeout"] only when a timeout calculator is passed. The driver then arms no timer at all: progress.js does const deadline = timeout ? monotonicTime() + timeout : 0, and with a falsy timeout progress.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)), and frames.js::_setContext(world, null) discards the resolved promise and installs a fresh, unresolved ManualPromise on 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_timeout is irrelevant. It reaches page.goto and the wait_* family only. Lowering it 80s → 30s changed nothing.
  • Nothing is logged. Both call sites sit inside swallow-all 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:726 converts it to Unable 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

  1. browser_adapter.bounded_evaluate() plus a timeout kwarg 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 own TimeoutError — the same type Playwright raises for operations that do carry a timeout — so every existing except Error / except Exception handler keeps working unchanged, and it stays distinguishable from an outer asyncio deadline.

  2. AsyncPlaywrightCrawlerStrategy._capture_html() — bounded page.content() with settle-and-retry. On the navigation error it waits for the new document to reach domcontentloaded and 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 reach domcontentloaded — that means stuck, not "between documents", and another attempt buys nothing.

  3. CrawlerRunConfig.total_timeout (ms, default None) — one budget shared by every attempt and every proxy in arun()'s fetch loop, so max_retries cannot silently multiply the caller's deadline.

Relationship to #1923: complementary, not overlapping. That PR adds a total_timeout at the dispatcher level (MemoryAdaptiveDispatcher / SemaphoreDispatcher), which covers arun_many only. A direct arun() call — including the Docker /crawl path, 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_timeout defaults to None (off), and bounds 1 and 2 only fire where the call would otherwise never return.

List of files changed and why

  • crawl4ai/browser_adapter.pybounded_evaluate() helper + timeout kwarg on BrowserAdapter.evaluate and all three implementations (PlaywrightAdapter, StealthAdapter, UndetectedAdapter). One funnel bounds ~15 evaluate call sites.
  • crawl4ai/async_crawler_strategy.py_capture_html() used at all three page.content() sites (main capture, shadow-DOM fallback, get_delayed_content); explicit ceilings on the optional DOM steps, page.close() and virtual scroll; csp_compliant_wait passes 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.pyCrawlerRunConfig.total_timeout (field, docstring, to_dict).
  • crawl4ai/async_webcrawler.py — the attempt loop computes one deadline and wraps each crawler_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 — document total_timeout and why page_timeout is not a substitute.
  • tests/async/test_render_call_bounds.py — new, 14 tests, no browser and no network.

How Has This Been Tested?

$ pytest tests/async/test_render_call_bounds.py -q
14 passed in 4.00s

The unit tests use a fake page whose content() hangs / raises the navigation error on demand, and a fake crawler strategy with crawler.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 await chain via cr_await (Task.get_stack() returns only one frame for a suspended coroutine and is useless here). The blocked frame was adapter.evaluate(update_image_dimensions_js)_frame.py:320 evaluate() → a protocol future that never resolves.

Measured through the full Docker /crawl path against that fixture, with wait_until: domcontentloaded, page_timeout: 80s, max_retries: 1, delay_before_return_html: 2.0:

Case Before After
page commits a navigation during capture hung until an external 180 s deadline HTTP 200, full content, 5.0 s
page permanently between documents hung until an external 180 s deadline, no diagnostic bounded failure with the exact reason logged
arun() with a 0.4 s budget and 4 attempts unbounded ≤ 2 attempts, bounded

Cancellation 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 --check and ruff check are clean on the new test. I did not run black over the touched crawl4ai/ modules — they are not black-formatted on develop and reformatting them would bury the change in noise.

Checklist:

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • I have added/updated unit tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes

`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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant