fix(browser): isolate egress DNS workers (#6301)

* fix(browser): isolate egress DNS workers

* fix(browser): release resolver slots after loop close

* test(browser): cover resolver isolation on production loop

* docs(browser): scope egress resolver guidance to browser automation

* test: approve browser automation guidance path

---------

Co-authored-by: mikamikasuki <211269698+mikamikasuki@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
mika 2026-10-30 07:18:54 -07:00 committed by GitHub
parent 882433ae50
commit d50cbf8dc5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 252 additions and 1 deletions

View File

@ -1,3 +1,13 @@
# Browser Automation
### Browser Egress Resolution
SOCKS DNS runs on the private Playwright loop. Use a dedicated bounded executor
so timed-out lookups cannot starve that loop's request guard. Retain permits until
threads exit, including after loop shutdown; reject at capacity and propagate
ContextVars. Keep the Gateway and browser loops separate in starvation tests.
See `backend/tests/test_browser_egress.py`.
### Browser Progress Screenshots
Hidden per-action browser progress frames use JPEG at quality 80 to keep their

View File

@ -18,9 +18,12 @@ from __future__ import annotations
import asyncio
import contextlib
import contextvars
import ipaddress
import logging
import threading
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
logger = logging.getLogger(__name__)
@ -47,6 +50,49 @@ _HANDSHAKE_TIMEOUT_S = 15
_RESOLVE_TIMEOUT_S = 15
_CONNECT_TIMEOUT_S = 15
_CLOSE_TIMEOUT_S = 2
_EGRESS_RESOLVER_WORKERS = 8
# A timed-out synchronous resolver keeps running in its worker thread. Keep
# those calls out of the browser loop's default executor, and acquire a
# permit before submission so the dedicated executor never accumulates an
# unbounded work queue. Saturated egress fails closed instead of queuing DNS
# behind work that may itself be stuck.
_RESOLVER_EXECUTOR = ThreadPoolExecutor(max_workers=_EGRESS_RESOLVER_WORKERS, thread_name_prefix="browser-egress-dns")
_RESOLVER_SLOTS = threading.BoundedSemaphore(_EGRESS_RESOLVER_WORKERS)
class _ResolverCapacityExceeded(RuntimeError):
pass
def _consume_resolver_result(future: asyncio.Future[object]) -> None:
"""Consume abandoned results after a timeout or cancelled SOCKS handler."""
try:
future.exception()
except (asyncio.CancelledError, Exception):
pass
async def _resolve_with_timeout(resolve: EgressResolver, host: str) -> list[str]:
slots = _RESOLVER_SLOTS
if not slots.acquire(blocking=False):
raise _ResolverCapacityExceeded("browser egress resolver capacity is full")
loop = asyncio.get_running_loop()
context = contextvars.copy_context()
try:
worker = _RESOLVER_EXECUTOR.submit(context.run, resolve, host)
except BaseException:
slots.release()
raise
# Release from the concurrent future's callback, which runs with the worker
# completion and does not depend on the event loop still being alive.
worker.add_done_callback(lambda _completed: slots.release())
future = asyncio.wrap_future(worker, loop=loop)
future.add_done_callback(_consume_resolver_result)
# Shield the executor future: cancellation of the SOCKS handler must not
# release its permit before the already-started resolver thread exits.
return await asyncio.wait_for(asyncio.shield(future), timeout=_RESOLVE_TIMEOUT_S)
class _RefusedRequest(Exception):
@ -97,11 +143,15 @@ class BrowserEgressProxy:
await _reply(writer, exc.reply)
return
try:
addresses = await asyncio.wait_for(asyncio.to_thread(self._resolve, host), timeout=_RESOLVE_TIMEOUT_S)
addresses = await _resolve_with_timeout(self._resolve, host)
except ValueError as exc:
logger.warning("browser egress refused for %s:%d: %s", host, port, exc)
await _reply(writer, _REPLY_NOT_ALLOWED)
return
except _ResolverCapacityExceeded:
logger.warning("browser egress resolution capacity full for %s:%d", host, port)
await _reply(writer, _REPLY_HOST_UNREACHABLE)
return
except TimeoutError:
logger.warning("browser egress resolution timed out for %s:%d", host, port)
await _reply(writer, _REPLY_HOST_UNREACHABLE)

View File

@ -34,6 +34,7 @@ EXPECTED_GUIDANCE_PATHS = {
"backend/packages/harness/deerflow/community/ddg_search/AGENTS.md",
"backend/packages/harness/deerflow/community/e2b_sandbox/AGENTS.md",
"backend/packages/harness/deerflow/community/aio_sandbox/AGENTS.md",
"backend/packages/harness/deerflow/community/browser_automation/AGENTS.md",
"backend/packages/harness/deerflow/community/opensandbox/AGENTS.md",
"backend/packages/harness/deerflow/config/AGENTS.md",
"backend/packages/harness/deerflow/extensions/AGENTS.md",

View File

@ -14,12 +14,15 @@ import ipaddress
import socket
import sys
import threading
from concurrent.futures import ThreadPoolExecutor
from contextvars import ContextVar
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from types import ModuleType, SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from app.gateway import context_usage
from deerflow.community import url_safety
from deerflow.community.browser_automation import egress
from deerflow.community.browser_automation import session as session_mod
@ -223,6 +226,193 @@ async def test_proxy_bounds_a_stuck_resolution(monkeypatch):
assert reply == 0x04
def test_timed_out_egress_resolution_does_not_starve_browser_request_guard(monkeypatch):
browser_loop = session_mod._PlaywrightLoopThread()
browser_executor = ThreadPoolExecutor(max_workers=1)
gateway_executor = ThreadPoolExecutor(max_workers=1)
resolver_executor = ThreadPoolExecutor(max_workers=1)
monkeypatch.setattr(egress, "_RESOLVER_EXECUTOR", resolver_executor, raising=False)
monkeypatch.setattr(egress, "_RESOLVER_SLOTS", threading.BoundedSemaphore(1), raising=False)
monkeypatch.setattr(egress, "_RESOLVE_TIMEOUT_S", 0.1)
resolver_started = threading.Event()
release_resolver = threading.Event()
resolver_calls: list[str] = []
def resolve(host: str) -> list[str]:
resolver_calls.append(host)
resolver_started.set()
release_resolver.wait(timeout=10)
return ["127.0.0.1"]
guard = MagicMock(return_value=None)
session = BrowserSession(browser_loop, headless=True, timeout_ms=1000, viewport={}, url_guard=guard, egress_resolver=resolve)
session._context = SimpleNamespace(route=AsyncMock(), close=AsyncMock())
async def start_session_proxy():
asyncio.get_running_loop().set_default_executor(browser_executor)
await session._install_request_guard()
return (await session._egress_proxy_settings())["server"]
async def scenario():
gateway_loop = asyncio.get_running_loop()
gateway_loop.set_default_executor(gateway_executor)
assert gateway_loop is not browser_loop._loop
assert gateway_executor is not browser_executor
messages = [SimpleNamespace(content="hello")]
snapshot = SimpleNamespace(values={"messages": messages})
class Accessor:
async def aget(self, _config):
return snapshot
model = SimpleNamespace(name="test-model", context_window=1000)
app_config = SimpleNamespace(models=[model], get_model_config=lambda _name: model)
class RunStore:
async def list_by_thread(self, *_args, **_kwargs):
return []
async def build_accessor(_request, *, thread_id):
return Accessor(), {"configurable": {"thread_id": thread_id}}
monkeypatch.setattr(context_usage, "_count_messages_approximately", lambda _messages: 250)
monkeypatch.setattr(context_usage, "build_thread_checkpoint_state_accessor", build_accessor)
monkeypatch.setattr(context_usage, "get_config", lambda: app_config)
connect_task = None
guard_task = None
try:
# Use the production session entry point: SOCKS DNS runs on the
# private Playwright loop, not the caller's Gateway loop.
proxy_url = await browser_loop.run(start_session_proxy())
connect_task = asyncio.create_task(_socks_connect(proxy_url, 0x03, _domain("wedged.example"), 80))
assert await asyncio.wait_for(asyncio.to_thread(resolver_started.wait, 2), timeout=3)
_method, reply, _reader, writer = await asyncio.wait_for(connect_task, timeout=2)
writer.close()
await writer.wait_closed()
assert reply == 0x04
assert not release_resolver.is_set()
_method, overloaded_reply, _reader, overloaded_writer = await asyncio.wait_for(_socks_connect(proxy_url, 0x03, _domain("second.example"), 80), timeout=2)
overloaded_writer.close()
await overloaded_writer.wait_closed()
assert overloaded_reply == 0x04
assert resolver_calls == ["wedged.example"]
# Gateway token counting has its own default pool and succeeds on
# both the base and PR head while the browser resolver is held.
assert await asyncio.wait_for(
context_usage.build_context_usage(request=SimpleNamespace(app=SimpleNamespace()), thread_id="thread-1", run_store=RunStore()),
timeout=2,
) == {"token_count": 250, "max_context_tokens": 1000, "percentage": 25.0}
# The actual contention is on the browser loop: its installed route
# callback must still run the synchronous request guard.
route = SimpleNamespace(request=SimpleNamespace(url="https://public.example/"), continue_=AsyncMock(), abort=AsyncMock())
route_handler = session._context.route.await_args.args[1]
guard_task = asyncio.create_task(browser_loop.run(route_handler(route)))
await asyncio.wait_for(guard_task, timeout=2)
guard.assert_called_once_with("https://public.example/")
route.continue_.assert_awaited_once()
route.abort.assert_not_awaited()
assert not release_resolver.is_set()
finally:
release_resolver.set()
for task in (connect_task, guard_task):
if task is not None:
task.cancel()
await asyncio.gather(task, return_exceptions=True)
await browser_loop.run(session._close())
await browser_loop.run(browser_loop._loop.shutdown_default_executor())
try:
asyncio.run(scenario())
finally:
release_resolver.set()
resolver_executor.shutdown(wait=True, cancel_futures=True)
browser_executor.shutdown(wait=True, cancel_futures=True)
gateway_executor.shutdown(wait=True, cancel_futures=True)
browser_loop._loop.call_soon_threadsafe(browser_loop._loop.stop)
browser_loop._thread.join(timeout=3)
assert not browser_loop._thread.is_alive()
browser_loop._loop.close()
@pytest.mark.asyncio
async def test_egress_resolver_propagates_caller_context(monkeypatch):
executor = ThreadPoolExecutor(max_workers=1)
monkeypatch.setattr(egress, "_RESOLVER_EXECUTOR", executor)
monkeypatch.setattr(egress, "_RESOLVER_SLOTS", threading.BoundedSemaphore(1))
resolver_context = ContextVar("test_browser_egress_context", default="worker-default")
token = resolver_context.set("request-context")
try:
assert await egress._resolve_with_timeout(lambda _host: [resolver_context.get()], "public.example") == ["request-context"]
finally:
resolver_context.reset(token)
executor.shutdown(wait=True, cancel_futures=True)
assert resolver_context.get() == "worker-default"
@pytest.mark.asyncio
async def test_egress_resolver_submission_failure_releases_slot(monkeypatch):
executor = ThreadPoolExecutor(max_workers=1)
slots = threading.BoundedSemaphore(1)
monkeypatch.setattr(egress, "_RESOLVER_EXECUTOR", executor)
monkeypatch.setattr(egress, "_RESOLVER_SLOTS", slots)
try:
executor.shutdown(wait=True)
with pytest.raises(RuntimeError, match="cannot schedule new futures after shutdown"):
await egress._resolve_with_timeout(MagicMock(), "public.example")
assert slots.acquire(blocking=False), "failed submission must return its permit"
slots.release()
finally:
executor.shutdown(wait=True, cancel_futures=True)
def test_timed_out_resolver_releases_slot_after_event_loop_closes(monkeypatch):
executor = ThreadPoolExecutor(max_workers=1)
slots = threading.BoundedSemaphore(1)
monkeypatch.setattr(egress, "_RESOLVER_EXECUTOR", executor)
monkeypatch.setattr(egress, "_RESOLVER_SLOTS", slots)
monkeypatch.setattr(egress, "_RESOLVE_TIMEOUT_S", 0.05)
resolver_started = threading.Event()
release_resolver = threading.Event()
resolver_finished = threading.Event()
def resolve(_host: str) -> list[str]:
resolver_started.set()
release_resolver.wait(timeout=10)
resolver_finished.set()
return ["127.0.0.1"]
async def time_out_resolver():
task = asyncio.create_task(egress._resolve_with_timeout(resolve, "wedged.example"))
try:
# Cancelling to_thread's awaiter cannot interrupt Event.wait itself.
assert await asyncio.wait_for(asyncio.to_thread(resolver_started.wait, 2), timeout=3)
with pytest.raises(TimeoutError):
await task
finally:
task.cancel()
await asyncio.gather(task, return_exceptions=True)
loop = asyncio.new_event_loop()
try:
try:
loop.run_until_complete(time_out_resolver())
finally:
loop.close()
release_resolver.set()
assert resolver_finished.wait(timeout=2)
assert slots.acquire(timeout=2), "resolver worker completion must release capacity without its event loop"
slots.release()
finally:
release_resolver.set()
executor.shutdown(wait=True, cancel_futures=True)
@pytest.mark.asyncio
async def test_proxy_close_is_bounded_by_a_tunnel_it_could_not_sweep(monkeypatch):
monkeypatch.setattr(egress, "_CLOSE_TIMEOUT_S", 0.2)