Add opt-in crawl_timeout and shield page cleanup from cancellation - #2236
SohamKukreti wants to merge 2 commits into
Conversation
…on (#2205) crawl_timeout (ms, default None) bounds the whole page visit and force-closes a hung page the finally cleanup is shielded so a cancel can no longer skip page.close()
ntohidi
left a comment
There was a problem hiding this comment.
Ran it locally. New tests pass, config + unit suites pass. The shielded finally looks right, and the blank-tab trick in _close_unresponsive_page is a good catch.
Two things though.
The session path still hangs. _crawl_web does await page.evaluate("window.stop()") before the wait_for, so if a session page went busy after its last crawl, the next arun() on that session waits forever. Same bug as #2205.
I hit it with a page that spins up a while(true) 4s after load: first crawl fine, second one never returns (I gave it 25s, crawl_timeout was 5s). Bounding that evaluate to 2s makes it fail in 7.5s with the right error. The existing except Exception: pass already swallows the TimeoutError, so it's a one-liner. Would add the case to test_crawl_timeout.py.
Second, the Docker default only lands on /crawl. /md, /screenshot, /pdf, /execute_js and the two spots in api.py (269, 393) build CrawlerRunConfig() themselves and get nothing. That's the path that pins a renderer, so I'd rather not leave it to a follow-up.
Minor stuff:
- The
RuntimeErrorgoes through the proxy/retry loop inasync_webcrawler.py:527, so with retries or a proxy list you pay the timeout once per attempt. Fine at the Docker defaults, but worth a line in the docs. _cleanup()bounds the console calls but notrelease_page_with_context()/page.close(). Cancel now waits on those, so a wedged close makesarun()uncancellable where before it returned right away.create_taskinsidewait_foris redundant.
One thing I checked because it worried me: an untrusted crawl_timeout: null gets capped at 60s rather than passing through as "no limit". Good.
…_config to every endpoint (#2205) Review follow-up: a hung session page no longer blocks the next arun(); /md, /html, /screenshot, /pdf, /execute_js, /llm and /crawl/stream now get crawl_timeout from config.yml; docs note the per-attempt behaviour with retries or proxies.
|
@ntohidi thanks for the review. Pushed a7913ca on top with the fixes. Session path hang. Reproduced with a page that goes busy 4 s after load: first crawl fine, second Docker default on every endpoint. Added Retries. Added a sentence to Minor points.
Verification.
|
Fixes #2205
A page whose JS thread goes busy after navigation hung
arun()forever.page_timeoutonly coversgoto;page.evaluateandpage.content()have no timeout and are not covered byset_default_timeout, so every later step waited indefinitely and the page was never released. A task cancel landing inside thefinallycleanup also skippedpage.close(), leaking the page.This PR:
CrawlerRunConfig.crawl_timeout(ms, defaultNone= no limit). It wraps the whole page visit, from navigation to final HTML includingjs_codeand hooks, inasyncio.wait_for. On expiry the page is force-closed (a session is dropped, with a warning) and the crawl fails withCrawl exceeded crawl_timeout of N ms.finallycleanup as a shielded task and re-awaits it on cancel, sopage.close()always completes before the cancel propagates. The two console cleanup calls are bounded to 5 s so a hung page cannot block the cancel.base_configsetscrawl_timeoutto 180 s. Untrusted requests are capped at 60 s, same as the other timeouts.Known gaps, by design:
/crawlbatch path; the other endpoints buildCrawlerRunConfig()directly (follow-up).List of files changed and why
crawl4ai/async_configs.py- newcrawl_timeoutparameter (docstring, constructor,to_dict), added to the untrusted allowlist and the timeout cap.crawl4ai/async_crawler_strategy.py-_crawl_webwraps the new_crawl_pageinasyncio.wait_forwhencrawl_timeoutis set;finallycleanup moved into a shielded_cleanup()task with bounded console calls; new_close_unresponsive_pageto force-close the page or drop the session on timeout.deploy/docker/config.yml-crawl_timeout: 180000undercrawler.base_config.docs/md_v2/api/parameters.md- parameter table row.docs/md_v2/core/browser-crawler-config.md- field list entry.docs/md_v2/core/page-interaction.md- "Timing Control" entry.tests/test_crawl_timeout.py- new tests (see below).How Has This Been Tested?
tests/test_crawl_timeout.py(7 tests, all pass) uses a local HTTP server that serves a "trap" page whose JS thread goes busy after load:crawl_timeoutwith the expected error, page closed, context refcount 0remove_overlay_elements=Truedoes not hangsession_idworks againcrawl_timeout=Nonekeeps the old behaviour (crawl still running after 8 s), cancel leaves refcount 0Regression:
tests/browser(excluding the docker dir) and the config tests give an identical pass/fail list on this branch and on cleandevelop; the failures there are pre-existing (missing asyncio markers, import errors).Live crawls (headless, Python 3.13): 8 real sites with and without
crawl_timeout=30000succeed with the same HTML;js_code,wait_for,screenshot, console and network capture,scan_full_page, overlay removal anddelay_before_return_htmlall work under the timer; trap page fails in 5.5 s and the next crawl works; session trap drops the session and the session id is reusable;arun_manywith a trap mixed in finishes in about the timeout with only the trap failing; 5 mid-crawl cancels leave refcount 0. No orphan Chrome processes after the runs.Also verified: headed managed Chrome survives a forced close of its last tab (a blank tab is opened first), and
dump/load/clone/from_kwargspreservecrawl_timeout.Checklist: