From 03bd5d70da2bd7a008599ce62de765629eceaa96 Mon Sep 17 00:00:00 2001 From: Connor Adams Date: Sun, 26 Apr 2026 11:18:32 +0100 Subject: [PATCH 1/3] fix(daemon): skip discarded tabs when attaching to first page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chrome's Memory Saver discards tabs to free memory; their CDP targets remain attachable but the renderer is gone, so renderer-bound calls (Page.enable, Runtime.evaluate, ...) never return. The previous attach_first_page bound self.session to pages[0] regardless, then logged the resulting Page.enable timeout but kept the dead session — every subsequent daemon.handle call deadlocked because that path has no timeout. Use Page.enable as a 2s liveness probe per candidate, detach + skip on timeout, fall back to about:blank if every real page is discarded. DOM/Runtime/Network keep their existing 5s timeout and best-effort logging. Verified against real Chrome with five Memory-Saver-discarded tabs: daemon skips them and attaches to the first live one. --- daemon.py | 52 +++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/daemon.py b/daemon.py index 0f11ed7a..ddf7a590 100644 --- a/daemon.py +++ b/daemon.py @@ -113,19 +113,49 @@ def __init__(self): self.stop = None # asyncio.Event, set inside start() async def attach_first_page(self): - """Attach to a real page (or any page). Sets self.session. Returns attached target or None.""" + """Attach to a real, responsive page. Sets self.session. Returns attached target.""" targets = (await self.cdp.send_raw("Target.getTargets"))["targetInfos"] pages = [t for t in targets if is_real_page(t)] - if not pages: - # No real pages — create one instead of attaching to omnibox popup + + chosen = None + for p in pages: + sid = (await self.cdp.send_raw( + "Target.attachToTarget", {"targetId": p["targetId"], "flatten": True} + ))["sessionId"] + # Page.enable doubles as a liveness probe: discarded tabs (Memory Saver) + # leave the target attachable but the renderer is gone, so it never returns. + try: + await asyncio.wait_for( + self.cdp.send_raw("Page.enable", session_id=sid), timeout=2 + ) + except asyncio.TimeoutError: + log(f"skipping unresponsive target {p['targetId']} ({p.get('url','')[:80]}) — likely discarded") + try: + await self.cdp.send_raw("Target.detachFromTarget", {"sessionId": sid}) + except Exception: + pass + continue + chosen = p + self.session = sid + break + + if chosen is None: + # No real pages, or all unresponsive — fall back to a fresh blank tab tid = (await self.cdp.send_raw("Target.createTarget", {"url": "about:blank"}))["targetId"] - log(f"no real pages found, created about:blank ({tid})") - pages = [{"targetId": tid, "url": "about:blank", "type": "page"}] - self.session = (await self.cdp.send_raw( - "Target.attachToTarget", {"targetId": pages[0]["targetId"], "flatten": True} - ))["sessionId"] - log(f"attached {pages[0]['targetId']} ({pages[0].get('url','')[:80]}) session={self.session}") - for d in ("Page", "DOM", "Runtime", "Network"): + log(f"no live real pages, created about:blank ({tid})") + chosen = {"targetId": tid, "url": "about:blank", "type": "page"} + self.session = (await self.cdp.send_raw( + "Target.attachToTarget", {"targetId": tid, "flatten": True} + ))["sessionId"] + try: + await asyncio.wait_for( + self.cdp.send_raw("Page.enable", session_id=self.session), timeout=5 + ) + except Exception as e: + log(f"enable Page: {e}") + + log(f"attached {chosen['targetId']} ({chosen.get('url','')[:80]}) session={self.session}") + for d in ("DOM", "Runtime", "Network"): try: await asyncio.wait_for( self.cdp.send_raw(f"{d}.enable", session_id=self.session), @@ -133,7 +163,7 @@ async def attach_first_page(self): ) except Exception as e: log(f"enable {d}: {e}") - return pages[0] + return chosen async def start(self): self.stop = asyncio.Event() From 1e990d38deac13d604ef04a18412766c55db0abf Mon Sep 17 00:00:00 2001 From: Connor Adams Date: Sun, 26 Apr 2026 11:48:07 +0100 Subject: [PATCH 2/3] fixup! fix(daemon): skip discarded tabs when attaching to first page --- daemon.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/daemon.py b/daemon.py index ddf7a590..05393f74 100644 --- a/daemon.py +++ b/daemon.py @@ -128,8 +128,8 @@ async def attach_first_page(self): await asyncio.wait_for( self.cdp.send_raw("Page.enable", session_id=sid), timeout=2 ) - except asyncio.TimeoutError: - log(f"skipping unresponsive target {p['targetId']} ({p.get('url','')[:80]}) — likely discarded") + except Exception as e: + log(f"skipping unresponsive target {p['targetId']} ({p.get('url','')[:80]}) — {e or 'likely discarded'}") try: await self.cdp.send_raw("Target.detachFromTarget", {"sessionId": sid}) except Exception: From eda56de3e6a4cdbb828c9a334ffb2df46208dc99 Mon Sep 17 00:00:00 2001 From: Connor Adams Date: Sun, 26 Apr 2026 11:57:57 +0100 Subject: [PATCH 3/3] fixup! fix(daemon): skip discarded tabs when attaching to first page --- daemon.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/daemon.py b/daemon.py index 05393f74..c766cc7f 100644 --- a/daemon.py +++ b/daemon.py @@ -152,7 +152,7 @@ async def attach_first_page(self): self.cdp.send_raw("Page.enable", session_id=self.session), timeout=5 ) except Exception as e: - log(f"enable Page: {e}") + raise RuntimeError(f"failed to enable Page on fresh about:blank tab: {e}") log(f"attached {chosen['targetId']} ({chosen.get('url','')[:80]}) session={self.session}") for d in ("DOM", "Runtime", "Network"):