-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Fix/timeout and reconnect #543
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
547dc0e
d1bfcdd
5ad7144
c40af8d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,2 +1,8 @@ | ||
| # Copy to .env — auto-loaded. Only needed for remote browsers. | ||
| BROWSER_USE_API_KEY=bu_your_key_here | ||
|
|
||
| # Optional. Per-call read budget (seconds) for every CDP command the helpers | ||
| # send to the daemon. Default 30 (screenshots 60). Raise it for pages with very | ||
| # slow navigations or long awaited promises; lower it to fail fast. Individual | ||
| # calls can also override via cdp(..., _timeout=N) / js(..., timeout=N). | ||
| # BH_CMD_TIMEOUT=30 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -179,6 +179,29 @@ def is_real_page(t): | |
| return t["type"] == "page" and not t.get("url", "").startswith(INTERNAL) | ||
|
|
||
|
|
||
| def _conn_dead(e): | ||
| """True when an exception from send_raw means the CDP websocket is gone. | ||
|
|
||
| cdp_use surfaces a drop three ways: ConnectionError('WebSocket connection | ||
| closed') / ('Client is stopping') when a pending future is failed, a bare | ||
| RuntimeError('Client is not started') when self.ws was already None, and a | ||
| websockets ConnectionClosed raised straight out of ws.send(). We match by | ||
| type where we can and fall back to message text so a websockets version | ||
| bump can't silently drop the ConnectionClosed case.""" | ||
| if isinstance(e, ConnectionError): | ||
| return True | ||
| name = type(e).__name__ | ||
| if "ConnectionClosed" in name: | ||
| return True | ||
| msg = str(e) | ||
| return ( | ||
| "WebSocket connection closed" in msg | ||
| or "Client is not started" in msg | ||
| or "Client is stopping" in msg | ||
| or "no close frame received" in msg | ||
| ) | ||
|
|
||
|
|
||
| class Daemon: | ||
| def __init__(self): | ||
| self.cdp = None | ||
|
|
@@ -187,6 +210,9 @@ def __init__(self): | |
| self.events = deque(maxlen=BUF) | ||
| self.dialog = None | ||
| self.stop = None # asyncio.Event, set inside start() | ||
| # Serializes CDP-client rebuilds so concurrent handlers that all see the | ||
| # same dropped websocket don't each spin up a fresh connection. | ||
| self._reconnect_lock = asyncio.Lock() | ||
|
|
||
| async def attach_first_page(self): | ||
| """Attach to a real page (or any page). Sets self.session. Returns attached target or None.""" | ||
|
|
@@ -229,8 +255,27 @@ async def enable_one(d): | |
| log(f"enable {d} on {session_id}: {e}") | ||
| await asyncio.gather(*(enable_one(d) for d in ("Page", "DOM", "Runtime", "Network"))) | ||
|
|
||
| async def start(self): | ||
| self.stop = asyncio.Event() | ||
| def _install_tap(self): | ||
| """Wrap the current CDP client's event dispatch so the daemon mirrors | ||
| events into its buffer, tracks dialogs, and re-marks tab titles. Must be | ||
| re-run against every fresh CDP client (each has its own event registry), | ||
| so it lives here rather than inline in start().""" | ||
| orig = self.cdp._event_registry.handle_event | ||
| mark_js = "if(!document.title.startsWith('\U0001F434'))document.title='\U0001F434 '+document.title" | ||
| async def tap(method, params, session_id=None): | ||
| self.events.append({"method": method, "params": params, "session_id": session_id}) | ||
| if method == "Page.javascriptDialogOpening": | ||
| self.dialog = params | ||
| elif method == "Page.javascriptDialogClosed": | ||
| self.dialog = None | ||
| elif method in ("Page.loadEventFired", "Page.domContentEventFired"): | ||
| asyncio.create_task(_silent(asyncio.wait_for(self.cdp.send_raw("Runtime.evaluate", {"expression": mark_js}, session_id=self.session), timeout=2))) | ||
| return await orig(method, params, session_id) | ||
| self.cdp._event_registry.handle_event = tap | ||
|
|
||
| async def _connect_cdp(self): | ||
| """Build the CDP client, connect the websocket, attach a page, and install | ||
| the event tap. Shared by initial start() and reconnect().""" | ||
| url = get_ws_url() | ||
| log(f"connecting to {url}") | ||
| self.cdp = CDPClient(url) | ||
|
|
@@ -245,18 +290,27 @@ async def start(self): | |
| ) | ||
| raise RuntimeError(f"CDP WS handshake failed: {e} -- click Allow in Chrome if prompted, then retry") | ||
| await self.attach_first_page() | ||
| orig = self.cdp._event_registry.handle_event | ||
| mark_js = "if(!document.title.startsWith('\U0001F434'))document.title='\U0001F434 '+document.title" | ||
| async def tap(method, params, session_id=None): | ||
| self.events.append({"method": method, "params": params, "session_id": session_id}) | ||
| if method == "Page.javascriptDialogOpening": | ||
| self.dialog = params | ||
| elif method == "Page.javascriptDialogClosed": | ||
| self.dialog = None | ||
| elif method in ("Page.loadEventFired", "Page.domContentEventFired"): | ||
| asyncio.create_task(_silent(asyncio.wait_for(self.cdp.send_raw("Runtime.evaluate", {"expression": mark_js}, session_id=self.session), timeout=2))) | ||
| return await orig(method, params, session_id) | ||
| self.cdp._event_registry.handle_event = tap | ||
| self._install_tap() | ||
|
|
||
| async def _reconnect(self, failed_cdp): | ||
| """Rebuild the CDP connection after the websocket dropped. | ||
|
|
||
| `failed_cdp` is the client the caller saw fail. Under the lock we bail if | ||
| someone already swapped in a live client, so only the first failing | ||
| handler rebuilds and the rest reuse the result. For a local Chrome, | ||
| get_ws_url() re-resolves the live websocket, so this also recovers from a | ||
| Chrome restart. For a remote BU_CDP_WS the URL is fixed; if that endpoint | ||
| is gone the rebuild raises and the original error is returned.""" | ||
| async with self._reconnect_lock: | ||
| if self.cdp is not failed_cdp: | ||
| return | ||
| log("CDP websocket dropped — reconnecting") | ||
| await _silent(failed_cdp.stop()) | ||
| await self._connect_cdp() | ||
|
|
||
| async def start(self): | ||
| self.stop = asyncio.Event() | ||
| await self._connect_cdp() | ||
|
|
||
| async def handle(self, req): | ||
| # Token guard for Windows TCP loopback: any local process can otherwise | ||
|
|
@@ -352,7 +406,27 @@ async def disable_old(): | |
| if "Session with given id not found" in msg and sid == self.session and sid: | ||
| log(f"stale session {sid}, re-attaching") | ||
| if await self.attach_first_page(): | ||
| return {"result": await self.cdp.send_raw(method, params, session_id=self.session)} | ||
| try: | ||
| return {"result": await self.cdp.send_raw(method, params, session_id=self.session)} | ||
| except Exception as e2: | ||
| e, msg = e2, str(e2) | ||
| if _conn_dead(e): | ||
| # Websocket dropped (Chrome closed/restarted, remote hiccup). | ||
| # Rebuild the connection and retry once. After a rebuild every | ||
| # prior session id is void, so a non-Target call runs on the | ||
| # freshly attached default session rather than the stale id the | ||
| # caller computed client-side. | ||
| failed = self.cdp | ||
| try: | ||
| await self._reconnect(failed) | ||
| except Exception as re: | ||
| log(f"reconnect failed: {re}") | ||
| return {"error": f"{msg} -- CDP reconnect failed: {re}"} | ||
| retry_sid = None if method.startswith("Target.") else self.session | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: After reconnect, an explicitly targeted command can execute in the wrong tab or frame because this line replaces its stale session with the daemon's newly attached default session. Reattach the requested target or fail the retry when the request supplied an explicit session rather than silently changing its execution target. Prompt for AI agents |
||
| try: | ||
| return {"result": await self.cdp.send_raw(method, params, session_id=retry_sid)} | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: A dropped websocket does not prove that CDP failed to execute the request, so this retry can apply side-effecting commands twice; for example, typing, clicks, navigation, JavaScript mutations, or tab creation may be duplicated. Reconnect without retrying unknown-outcome commands, or restrict retries to operations with an idempotency guarantee. Prompt for AI agents |
||
| except Exception as e3: | ||
| return {"error": str(e3)} | ||
| return {"error": msg} | ||
|
|
||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -38,20 +38,49 @@ def _load_env_file(p): | |
| SOCK = ipc.sock_addr(NAME) | ||
| INTERNAL = ("chrome://", "chrome-untrusted://", "devtools://", "chrome-extension://", "about:") | ||
|
|
||
| # Establishing the AF_UNIX/loopback socket to the (local) daemon should be | ||
| # instant, so the connect budget stays short. The command budget is the time we | ||
| # allow the daemon to round-trip a CDP call to Chrome and answer — screenshots, | ||
| # heavy DOM reads, and awaited promises routinely need more than a couple of | ||
| # seconds. These were previously fused into a single 5s value (connect() sets | ||
| # both the connect AND read timeout), which hard-capped every operation at 5s | ||
| # and turned any slow-but-valid call into a failure. | ||
| _CONNECT_TIMEOUT = 5.0 | ||
| _DEFAULT_CMD_TIMEOUT = 30.0 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: A Chrome restart can still make the original helper call fail despite the new reconnect path: daemon recovery can take 30 seconds of discovery plus attach/retry work, while Prompt for AI agents |
||
| _SCREENSHOT_CMD_TIMEOUT = 60.0 | ||
|
|
||
|
|
||
| def _cmd_timeout(default=_DEFAULT_CMD_TIMEOUT): | ||
| """Per-call read budget in seconds. BH_CMD_TIMEOUT overrides the default.""" | ||
| v = os.environ.get("BH_CMD_TIMEOUT") | ||
| if v: | ||
| try: | ||
| return float(v) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Invalid timeout values now make browser operations fail before sending the request: Prompt for AI agents |
||
| except ValueError: | ||
| pass | ||
| return default | ||
|
|
||
| def _send(req): | ||
| c, token = ipc.connect(NAME, timeout=5.0) | ||
|
|
||
| def _send(req, timeout=None): | ||
| # Connect on the short budget, then raise the socket's read timeout to the | ||
| # command budget before waiting on the daemon's reply. Without the second | ||
| # settimeout, the reply had to arrive within _CONNECT_TIMEOUT. | ||
| c, token = ipc.connect(NAME, timeout=_CONNECT_TIMEOUT) | ||
| try: | ||
| c.settimeout(_cmd_timeout() if timeout is None else timeout) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Helpers with shorter timeouts can now exceed those timeouts when a daemon response stalls: for example, Prompt for AI agents |
||
| r = ipc.request(c, token, req) | ||
| finally: | ||
| c.close() | ||
| if "error" in r: raise RuntimeError(r["error"]) | ||
| return r | ||
|
|
||
|
|
||
| def cdp(method, session_id=None, **params): | ||
| """Raw CDP. cdp('Page.navigate', url='...'), cdp('DOM.getDocument', depth=-1).""" | ||
| return _send({"method": method, "params": params, "session_id": session_id}).get("result", {}) | ||
| def cdp(method, session_id=None, _timeout=None, **params): | ||
| """Raw CDP. cdp('Page.navigate', url='...'), cdp('DOM.getDocument', depth=-1). | ||
|
|
||
| _timeout overrides the per-call read budget in seconds (env BH_CMD_TIMEOUT | ||
| sets the default). Underscored so it can't collide with a CDP param name.""" | ||
| return _send({"method": method, "params": params, "session_id": session_id}, timeout=_timeout).get("result", {}) | ||
|
|
||
|
|
||
| def drain_events(): return _send({"meta": "drain_events"})["events"] | ||
|
|
@@ -109,19 +138,51 @@ def _runtime_value(response, expression): | |
| return None | ||
|
|
||
|
|
||
| def _runtime_evaluate(expression, session_id=None, await_promise=False): | ||
| def _runtime_evaluate(expression, session_id=None, await_promise=False, timeout=None): | ||
| try: | ||
| r = cdp("Runtime.evaluate", session_id=session_id, expression=expression, returnByValue=True, awaitPromise=await_promise) | ||
| r = cdp("Runtime.evaluate", session_id=session_id, expression=expression, returnByValue=True, awaitPromise=await_promise, _timeout=timeout) | ||
| except TimeoutError as e: | ||
| raise RuntimeError(f"Runtime.evaluate timed out; expression: {_js_snippet(expression)}") from e | ||
| return _runtime_value(r, expression) | ||
|
|
||
|
|
||
| _CONTROL_KEYWORDS = frozenset(("if", "for", "while", "switch", "catch", "with")) | ||
|
|
||
|
|
||
| def _word_before(expression, i): | ||
| """The identifier immediately preceding index i, skipping whitespace.""" | ||
| j = i - 1 | ||
| while j >= 0 and expression[j].isspace(): | ||
| j -= 1 | ||
| end = j + 1 | ||
| while j >= 0 and (expression[j] == "_" or expression[j] == "$" or expression[j].isalnum()): | ||
| j -= 1 | ||
| return expression[j + 1:end] | ||
|
|
||
|
|
||
| def _opens_function_body(sig, sig_ctl): | ||
| """Whether the `{` about to be consumed opens a function/arrow body rather than a block.""" | ||
| if len(sig) >= 2 and sig[-1] == ">" and sig[-2] == "=": | ||
| return True # `=> {` | ||
| if sig and sig[-1] == ")": | ||
| return not sig_ctl[-1] # `) {` is a function body unless it closed `if(...)` etc | ||
| return False # object literal, bare block, `else {`, `try {`, `do {` | ||
|
|
||
|
|
||
| def _has_return_statement(expression): | ||
| """True only when `expression` has a `return` at the snippet's own top level. | ||
|
|
||
| A `return` inside a nested function or arrow body belongs to that callback, so | ||
| wrapping the snippet in an IIFE would discard the value the caller wanted. | ||
| """ | ||
| i = 0 | ||
| n = len(expression) | ||
| state = "code" | ||
| quote = "" | ||
| sig = [] # significant (non-whitespace) code characters, in order | ||
| sig_ctl = [] # parallel to sig: True when a ')' closed a control-statement paren | ||
| paren_ctl = [] # stack: was each open '(' introduced by a control keyword | ||
| brace_fn = [] # stack: does each open '{' delimit a function body | ||
| while i < n: | ||
| ch = expression[i] | ||
| nxt = expression[i + 1] if i + 1 < n else "" | ||
|
|
@@ -136,7 +197,20 @@ def _has_return_statement(expression): | |
| before = expression[i - 1] if i > 0 else "" | ||
| after = expression[i + 6] if i + 6 < n else "" | ||
| if not (before == "_" or before.isalnum()) and not (after == "_" or after.isalnum()): | ||
| return True | ||
| if not any(brace_fn): | ||
| return True | ||
| i += 6; continue | ||
| if ch == "(": | ||
| paren_ctl.append(_word_before(expression, i) in _CONTROL_KEYWORDS) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Valid JavaScript regex literals can corrupt the new nesting stacks, causing a return inside a top-level Prompt for AI agents
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Prompt for AI agents |
||
| elif ch == ")": | ||
| ctl = paren_ctl.pop() if paren_ctl else False | ||
| sig.append(ch); sig_ctl.append(ctl); i += 1; continue | ||
| elif ch == "{": | ||
| brace_fn.append(_opens_function_body(sig, sig_ctl)) | ||
| elif ch == "}": | ||
| if brace_fn: brace_fn.pop() | ||
| if not ch.isspace(): | ||
| sig.append(ch); sig_ctl.append(False) | ||
| i += 1; continue | ||
| if state == "line_comment": | ||
| if ch == "\n": | ||
|
|
@@ -270,7 +344,10 @@ def capture_screenshot(path=None, full=False, max_dim=None): | |
| """Save a PNG of the current viewport. Set max_dim=1800 on a 2× display to | ||
| keep the file under the 2000px-per-side limit some image-aware LLMs enforce.""" | ||
| path = path or str(ipc._TMP / "shot.png") | ||
| r = cdp("Page.captureScreenshot", format="png", captureBeyondViewport=full) | ||
| # Screenshots serialize a base64 PNG back over the socket — on a large or | ||
| # retina page that can take longer than the default command budget, so give | ||
| # it a wider one (still overridable via BH_CMD_TIMEOUT). | ||
| r = cdp("Page.captureScreenshot", format="png", captureBeyondViewport=full, _timeout=_cmd_timeout(_SCREENSHOT_CMD_TIMEOUT)) | ||
| open(path, "wb").write(base64.b64decode(r["data"])) | ||
| if max_dim: | ||
| from PIL import Image | ||
|
|
@@ -432,16 +509,19 @@ def wait_for_network_idle(timeout=10.0, idle_ms=500): | |
| time.sleep(0.1) | ||
| return False | ||
|
|
||
| def js(expression, target_id=None): | ||
| def js(expression, target_id=None, timeout=None): | ||
| """Run JS in the attached tab (default) or inside an iframe target (via iframe_target()). | ||
|
|
||
| Expressions with top-level `return` are automatically wrapped in an IIFE, so both | ||
| `document.title` and `const x = 1; return x` are valid inputs. | ||
|
|
||
| Pass timeout (seconds) to widen the read budget for a deliberately slow awaited | ||
| promise; env BH_CMD_TIMEOUT sets the default for all calls. | ||
| """ | ||
| sid = cdp("Target.attachToTarget", targetId=target_id, flatten=True)["sessionId"] if target_id else None | ||
| if _has_return_statement(expression) and not expression.strip().startswith("("): | ||
| if _has_return_statement(expression): | ||
| expression = f"(function(){{{expression}}})()" | ||
| return _runtime_evaluate(expression, session_id=sid, await_promise=True) | ||
| return _runtime_evaluate(expression, session_id=sid, await_promise=True, timeout=timeout) | ||
|
|
||
|
|
||
| _KC = {"Enter": 13, "Tab": 9, "Escape": 27, "Backspace": 8, " ": 32, "ArrowLeft": 37, "ArrowUp": 38, "ArrowRight": 39, "ArrowDown": 40} | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: If Chrome disappears while a JavaScript dialog is open, the reconnected daemon can keep reporting that stale dialog and make
page_info()skip evaluation on the new page. Dialog state should be reset or revalidated whenever the CDP client is replaced.Prompt for AI agents