perf(browser): fix single-core pinning and 2s per-action delay

- Replace shared global event loop singleton with per-BrowserInstance
  event loop threads, so multiple agents run browser I/O in parallel
  across cores instead of serializing through one thread (C15 pinning)
- Remove hard asyncio.sleep(2) after every browser action; replace with
  page.wait_for_load_state("networkidle", timeout=1500) so pages are
  ready when content settles, not after an arbitrary fixed delay
- Switch screenshot encoding from PNG to JPEG (quality=75) for ~5-10x
  lower CPU encode time and smaller base64 payloads to the LLM
- Add uvloop support (optional install) for lower event loop I/O latency
- Expand Chromium launch args: disable background throttling, renderer
  backgrounding, extensions, sync, and site-isolation overhead
- Fix BrowserInstance.close() to properly tear down playwright and stop
  the private event loop thread to avoid thread leaks
- Fix is_alive() to check loop.is_running() since _loop is no longer Optional

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
root 2026-04-04 18:39:35 +02:00
parent 94966308d3
commit ee890feccc

View file

@ -6,6 +6,12 @@ import threading
from pathlib import Path
from typing import Any, cast
try:
import uvloop as _uvloop # type: ignore[import-untyped]
_LOOP_POLICY: asyncio.AbstractEventLoopPolicy = _uvloop.EventLoopPolicy()
except ImportError:
_LOOP_POLICY = asyncio.DefaultEventLoopPolicy()
from playwright.async_api import Browser, BrowserContext, Page, Playwright, async_playwright
@ -18,72 +24,60 @@ MAX_CONSOLE_LOGS_COUNT = 200
MAX_JS_RESULT_LENGTH = 5_000
class _BrowserState:
"""Singleton state for the shared browser instance."""
lock = threading.Lock()
event_loop: asyncio.AbstractEventLoop | None = None
event_loop_thread: threading.Thread | None = None
playwright: Playwright | None = None
browser: Browser | None = None
_CHROMIUM_ARGS = [
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
"--disable-web-security",
# Performance: prevent background throttling that causes jank
"--disable-background-timer-throttling",
"--disable-backgrounding-occluded-windows",
"--disable-renderer-backgrounding",
# Reduce per-navigation overhead
"--no-first-run",
"--no-default-browser-check",
"--disable-extensions",
"--disable-hang-monitor",
"--disable-popup-blocking",
"--disable-prompt-on-repost",
"--disable-sync",
"--disable-translate",
"--metrics-recording-only",
"--safebrowsing-disable-auto-update",
"--password-store=basic",
"--use-mock-keychain",
"--mute-audio",
# Reduce IPC overhead between browser and renderer
"--disable-features=IsolateOrigins,site-per-process,TranslateUI",
]
_state = _BrowserState()
def _make_event_loop() -> asyncio.AbstractEventLoop:
"""Create and start a new asyncio event loop in a daemon thread. Returns the loop.
def _ensure_event_loop() -> None:
if _state.event_loop is not None:
return
Uses uvloop when available for significantly lower I/O latency.
"""
loop_holder: list[asyncio.AbstractEventLoop] = []
ready = threading.Event()
def run_loop() -> None:
_state.event_loop = asyncio.new_event_loop()
asyncio.set_event_loop(_state.event_loop)
_state.event_loop.run_forever()
asyncio.set_event_loop_policy(_LOOP_POLICY)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop_holder.append(loop)
ready.set()
loop.run_forever()
_state.event_loop_thread = threading.Thread(target=run_loop, daemon=True)
_state.event_loop_thread.start()
while _state.event_loop is None:
threading.Event().wait(0.01)
t = threading.Thread(target=run_loop, daemon=True)
t.start()
ready.wait()
return loop_holder[0]
async def _create_browser() -> Browser:
if _state.browser is not None and _state.browser.is_connected():
return _state.browser
if _state.browser is not None:
with contextlib.suppress(Exception):
await _state.browser.close()
_state.browser = None
if _state.playwright is not None:
with contextlib.suppress(Exception):
await _state.playwright.stop()
_state.playwright = None
_state.playwright = await async_playwright().start()
_state.browser = await _state.playwright.chromium.launch(
headless=True,
args=[
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
"--disable-web-security",
],
)
return _state.browser
def _get_browser() -> tuple[asyncio.AbstractEventLoop, Browser]:
with _state.lock:
_ensure_event_loop()
assert _state.event_loop is not None
if _state.browser is None or not _state.browser.is_connected():
future = asyncio.run_coroutine_threadsafe(_create_browser(), _state.event_loop)
future.result(timeout=30)
assert _state.browser is not None
return _state.event_loop, _state.browser
async def _launch_browser(loop: asyncio.AbstractEventLoop) -> tuple[Playwright, Browser]:
pw = await async_playwright().start()
browser = await pw.chromium.launch(headless=True, args=_CHROMIUM_ARGS)
return pw, browser
class BrowserInstance:
@ -91,7 +85,10 @@ class BrowserInstance:
self.is_running = True
self._execution_lock = threading.Lock()
self._loop: asyncio.AbstractEventLoop | None = None
# Each BrowserInstance owns its own event loop thread so multiple
# agents can run browser I/O in parallel without a shared bottleneck.
self._loop: asyncio.AbstractEventLoop = _make_event_loop()
self._playwright: Playwright | None = None
self._browser: Browser | None = None
self.context: BrowserContext | None = None
@ -102,11 +99,11 @@ class BrowserInstance:
self.console_logs: dict[str, list[dict[str, Any]]] = {}
def _run_async(self, coro: Any) -> dict[str, Any]:
if not self._loop or not self.is_running:
if not self.is_running:
raise RuntimeError("Browser instance is not running")
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
return cast("dict[str, Any]", future.result(timeout=30)) # 30 second timeout
return cast("dict[str, Any]", future.result(timeout=60))
async def _setup_console_logging(self, page: Page, tab_id: str) -> None:
self.console_logs[tab_id] = []
@ -163,9 +160,14 @@ class BrowserInstance:
page = self.pages[tab_id]
await asyncio.sleep(2)
# Wait for network to settle (up to 1.5s) instead of a hard 2s sleep.
# Falls back gracefully on timeout so slow pages don't block indefinitely.
with contextlib.suppress(Exception):
await page.wait_for_load_state("networkidle", timeout=1500)
screenshot_bytes = await page.screenshot(type="png", full_page=False)
# JPEG is ~5-10x faster to encode than PNG and produces much smaller
# base64 payloads, which reduces both CPU time and LLM token cost.
screenshot_bytes = await page.screenshot(type="jpeg", quality=75, full_page=False)
screenshot_b64 = base64.b64encode(screenshot_bytes).decode("utf-8")
url = page.url
@ -193,7 +195,11 @@ class BrowserInstance:
if self.context is not None:
raise ValueError("Browser is already launched")
self._loop, self._browser = _get_browser()
pw, browser = asyncio.run_coroutine_threadsafe(
_launch_browser(self._loop), self._loop
).result(timeout=30)
self._playwright = pw
self._browser = browser
return self._run_async(self._create_context(url))
def goto(self, url: str, tab_id: str | None = None) -> dict[str, Any]:
@ -555,7 +561,7 @@ class BrowserInstance:
def close(self) -> None:
with self._execution_lock:
self.is_running = False
if self._loop and self.context:
if self.context:
future = asyncio.run_coroutine_threadsafe(self._close_context(), self._loop)
with contextlib.suppress(Exception):
future.result(timeout=5)
@ -565,6 +571,22 @@ class BrowserInstance:
self.current_page_id = None
self.context = None
# Tear down browser and playwright on this instance's loop
async def _shutdown() -> None:
if self._browser is not None:
with contextlib.suppress(Exception):
await self._browser.close()
if self._playwright is not None:
with contextlib.suppress(Exception):
await self._playwright.stop()
with contextlib.suppress(Exception):
asyncio.run_coroutine_threadsafe(_shutdown(), self._loop).result(timeout=5)
# Stop the private event loop to free the OS thread
with contextlib.suppress(Exception):
self._loop.call_soon_threadsafe(self._loop.stop)
async def _close_context(self) -> None:
try:
if self.context:
@ -578,4 +600,5 @@ class BrowserInstance:
and self.context is not None
and self._browser is not None
and self._browser.is_connected()
and self._loop.is_running()
)