From 1689057df3c859863784f2094c1d3501bb044dc0 Mon Sep 17 00:00:00 2001 From: honor2030 <19909783+honor2030@users.noreply.github.com> Date: Mon, 18 May 2026 10:57:14 +0900 Subject: [PATCH] fix(daemon): bound CDP request waits --- src/browser_harness/daemon.py | 16 ++++++++++++++-- tests/unit/test_daemon.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/browser_harness/daemon.py b/src/browser_harness/daemon.py index 0f0f2555..fd9ade91 100644 --- a/src/browser_harness/daemon.py +++ b/src/browser_harness/daemon.py @@ -33,6 +33,7 @@ def _load_env_file(p): LOG = str(ipc.log_path(NAME)) PID = str(ipc.pid_path(NAME)) BUF = 500 +CDP_CALL_TIMEOUT = 4.5 PROFILES = [ Path.home() / "Library/Application Support/Google/Chrome", Path.home() / "Library/Application Support/Google/Chrome Canary", @@ -229,6 +230,17 @@ 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 send_cdp(self, method, params=None, session_id=None): + if self.cdp is None: + raise RuntimeError("CDP client not started") + try: + return await asyncio.wait_for( + self.cdp.send_raw(method, params, session_id=session_id), + timeout=CDP_CALL_TIMEOUT, + ) + except asyncio.TimeoutError: + raise TimeoutError(f"{method} timed out after {CDP_CALL_TIMEOUT:g}s") from None + async def start(self): self.stop = asyncio.Event() url = get_ws_url() @@ -346,13 +358,13 @@ async def disable_old(): # For everything else, explicit session in req wins; else default. sid = None if method.startswith("Target.") else (req.get("session_id") or self.session) try: - return {"result": await self.cdp.send_raw(method, params, session_id=sid)} + return {"result": await self.send_cdp(method, params, session_id=sid)} except Exception as e: msg = str(e) 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)} + return {"result": await self.send_cdp(method, params, session_id=self.session)} return {"error": msg} diff --git a/tests/unit/test_daemon.py b/tests/unit/test_daemon.py index 90c5bc85..56471693 100644 --- a/tests/unit/test_daemon.py +++ b/tests/unit/test_daemon.py @@ -293,3 +293,36 @@ def test_current_tab_meta_returns_not_attached_when_no_target_id(): assert result == {"error": "not_attached"} # No CDP call should have been issued. assert d.cdp.calls == [] + + +def test_regular_cdp_calls_timeout_before_client_socket_deadline(monkeypatch): + """If Chrome/CDP stops answering, daemon.handle() must return a useful + error before the helpers.py IPC client hits its 5s socket timeout. + + Otherwise the helper sees only a client-side timeout at _ipc.request(), + and the daemon can later log ``conn: Connection lost`` when it finally + tries to write to a socket the helper already closed. + """ + class _HangingCDP: + def __init__(self): + self.calls = [] + + async def send_raw(self, method, params=None, session_id=None): + self.calls.append((method, params, session_id)) + await asyncio.Event().wait() + + d = daemon.Daemon() + d.cdp = _HangingCDP() + d.session = "session-current" + monkeypatch.setattr(daemon, "CDP_CALL_TIMEOUT", 0.01, raising=False) + + async def run(): + return await asyncio.wait_for( + d.handle({"method": "Page.navigate", "params": {"url": "https://example.com"}}), + timeout=0.5, + ) + + result = asyncio.run(run()) + + assert result == {"error": "Page.navigate timed out after 0.01s"} + assert d.cdp.calls == [("Page.navigate", {"url": "https://example.com"}, "session-current")]