Repository navigation
Conversation
PinningProxy._handle_connect and _handle_absolute called resolve_and_pin(), a blocking socket.getaddrinfo, directly on the asyncio loop for every Chromium connection. Ad-heavy pages open hundreds of connections, so the server's one event loop sat in DNS lookups and every other request (even a 401 at the auth gate) waited 10 to 39 s behind them. The seed check in both crawl handlers resolved on the loop the same way. The lookups now run in a worker thread (asyncio.to_thread). The egress rule is unchanged: resolve once, reject any non-global answer, pin the IP. Tests: - test_slow_dns_does_not_block_the_event_loop: 2 CONNECTs and 2 absolute-form requests with a 1 s fake lookup; the loop never stalls 0.5 s and the lookups overlap. Fails on develop (4.0 s stall). - test_every_crawl_handler_validates_destination accepts the to_thread call form. - test_chained_plain_http_pinned_absolute_form_no_smuggling: the smuggled write can draw a reset or broken pipe when the proxy has already closed the connection. That race exists on develop (1 in 60 runs locally) and the thread hop makes it a little likelier. Treated as the same "nothing came back"; the assertions that rebind.evil never reaches upstream are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LCwrQkwJvshBArmoS7DQ8C
|
Thanks @greerso, this is a clear and well-measured fix. We reproduced #2423 and tested this branch against What we verified
Two requests before merge
Nits
Out of scope, for a follow-up (no change needed here)
With 20 concurrent |
Closes #2423.
What it does
PinningProxy._handle_connectand_handle_absolutecalledresolve_and_pin(), which runs a blockingsocket.getaddrinfo, directly on the asyncio event loop for every Chromium connection. An ad-heavy page opens hundreds of connections, so the server's one event loop sat in DNS lookups and every other request waited behind them._normalize_and_validate_seedsin both crawl handlers resolved on the loop the same way.The three lookups now run in a worker thread:
The egress rule is unchanged: resolve once, reject any non-global answer, pin the IP.
resolve_and_pinand the seed check only read module constants and callgetaddrinfo, so they are safe to run in a thread.Tests
test_slow_dns_does_not_block_the_event_loop(test_security_egress_proxy.py). It sends 2 CONNECTs and 2 absolute-form requests with a 1 s fakeresolve_and_pin. The event loop must never stall 0.5 s or more, and the lookups must overlap. Ondevelopit fails with a 4.0 s stall.test_every_crawl_handler_validates_destination(test_security_ssrf_crawl.py). It reads the handler source for_normalize_and_validate_seeds(. It now also accepts theto_thread(_normalize_and_validate_seeds,form. Each handler must still name the shared helper.test_chained_plain_http_pinned_absolute_form_no_smuggling.develop: the unchanged test failed 1 in 60 local runs ondevelopand 2 in 60 with this change. With the edit: 0 in 60.rebind.evilnever reaches upstream, and the upstream sees only the pinned IP andConnection: close.Run locally (macOS, Python 3.12), on
developand on this branch:develop: 85 passed.Not caused by this change:
tests/test_issue_2127_docker_pdf.pyandtests/test_docker_pdf_crawler_pairing.pyfail 5 tests ondevelopand on this branch alike. Their stub for_normalize_and_validate_seedsreturns a list, not the(allowed, refused)tuple.Docker testing
There are no Dockerfile or compose changes. We run this patch on top of
unclecode/crawl4ai:0.9.5in production.A/B on the same image and the same load (60 s warm-up, then 4 min at 16 concurrent
/crawlrequests to 120 real news and retail pages, with an unauthenticatedPOST /crawl {}timed every second):First 15 minutes in production after the deploy: 443 probes, p50 0.00 s, max 0.5 s, none over 8 s; about 700 completed crawls an hour.
🤖 Generated with Claude Code
https://claude.ai/code/session_01LCwrQkwJvshBArmoS7DQ8C