Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion src/browser_harness/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -360,12 +360,30 @@ def __init__(self):
self.cdp = None
self.session = None
self.target_id = None
self.owns_target = False # True when this daemon created its own tab
self.events = deque(maxlen=BUF)
self.dialog = None
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."""
# Named daemons (BU_NAME != "default") share one browser with other
# daemons — attaching to the first page makes parallel daemons fight
# over a single tab (navigations clobber each other). Give each named
# daemon its own dedicated tab instead. REMOTE_ID (cloud) browsers are
# already exclusive to this daemon, so first-page attach stays.
if NAME != "default" and not REMOTE_ID:
tid = (await self.cdp.send_raw("Target.createTarget", {"url": "about:blank"}))["targetId"]

@cubic-dev-ai cubic-dev-ai Bot Aug 15, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: On a stale-session re-attach, a named daemon creates a brand-new dedicated tab and overwrites owns_target/target_id, so the previous tab it created in attach_first_page() is never closed. Only the last tab is closed at shutdown, so each re-attach leaks one about:blank into the shared browser. Track the previous owned target and Target.closeTarget it (best-effort) before re-creating a tab when self.owns_target and self.target_id is already set.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/browser_harness/daemon.py, line 376:

<comment>On a stale-session re-attach, a named daemon creates a brand-new dedicated tab and overwrites `owns_target`/`target_id`, so the previous tab it created in `attach_first_page()` is never closed. Only the last tab is closed at shutdown, so each re-attach leaks one `about:blank` into the shared browser. Track the previous owned target and `Target.closeTarget` it (best-effort) before re-creating a tab when `self.owns_target and self.target_id` is already set.</comment>

<file context>
@@ -360,12 +360,30 @@ def __init__(self):
+        # daemon its own dedicated tab instead. REMOTE_ID (cloud) browsers are
+        # already exclusive to this daemon, so first-page attach stays.
+        if NAME != "default" and not REMOTE_ID:
+            tid = (await self.cdp.send_raw("Target.createTarget", {"url": "about:blank"}))["targetId"]
+            self.owns_target = True
+            log(f"named daemon {NAME}: created dedicated tab ({tid})")
</file context>
Fix with cubic

self.owns_target = True

@cubic-dev-ai cubic-dev-ai Bot Aug 15, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: If target creation succeeds but attach or startup setup fails, the daemon exits before its cleanup block and leaves an orphan tab in the shared browser. Record the created ID immediately and clean it up from the attach/startup failure path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/browser_harness/daemon.py, line 377:

<comment>If target creation succeeds but attach or startup setup fails, the daemon exits before its cleanup block and leaves an orphan tab in the shared browser. Record the created ID immediately and clean it up from the attach/startup failure path.</comment>

<file context>
@@ -360,12 +360,30 @@ def __init__(self):
+        # already exclusive to this daemon, so first-page attach stays.
+        if NAME != "default" and not REMOTE_ID:
+            tid = (await self.cdp.send_raw("Target.createTarget", {"url": "about:blank"}))["targetId"]
+            self.owns_target = True
+            log(f"named daemon {NAME}: created dedicated tab ({tid})")
+            page = {"targetId": tid, "url": "about:blank", "type": "page"}
</file context>
Fix with cubic

log(f"named daemon {NAME}: created dedicated tab ({tid})")
page = {"targetId": tid, "url": "about:blank", "type": "page"}
self.session = (await self.cdp.send_raw(
"Target.attachToTarget", {"targetId": tid, "flatten": True}
))["sessionId"]
self.target_id = tid
log(f"attached {tid} (about:blank) session={self.session}")
await self._enable_default_domains(self.session)
return page

@cubic-dev-ai cubic-dev-ai Bot Aug 15, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When startup follows the Chrome remote-debugging recovery flow, this early return skips inspect-tab cleanup and marker removal. Run the same cleanup for the named local path before returning.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/browser_harness/daemon.py, line 386:

<comment>When startup follows the Chrome remote-debugging recovery flow, this early return skips inspect-tab cleanup and marker removal. Run the same cleanup for the named local path before returning.</comment>

<file context>
@@ -360,12 +360,30 @@ def __init__(self):
+            self.target_id = tid
+            log(f"attached {tid} (about:blank) session={self.session}")
+            await self._enable_default_domains(self.session)
+            return page
         targets = (await self.cdp.send_raw("Target.getTargets"))["targetInfos"]
         pages = [t for t in targets if is_real_page(t)]
</file context>
Fix with cubic

targets = (await self.cdp.send_raw("Target.getTargets"))["targetInfos"]
pages = [t for t in targets if is_real_page(t)]
if not pages:
Expand Down Expand Up @@ -615,7 +633,21 @@ async def handler(reader, writer):
async def main():
d = Daemon()
await d.start()
await serve(d)
try:
await serve(d)
finally:
# A named daemon owns the tab it created — close it on shutdown so
# parallel workers don't leak about:blank/leftover tabs into the
# shared browser. Best-effort: the WS may already be gone.
if d.owns_target and d.target_id:

@cubic-dev-ai cubic-dev-ai Bot Aug 15, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: After a named daemon switches tabs, self.target_id no longer identifies the tab it created. Shutdown therefore closes the selected tab and leaks the owned tab; track a separate owned_target_id and close that ID.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/browser_harness/daemon.py, line 642:

<comment>After a named daemon switches tabs, `self.target_id` no longer identifies the tab it created. Shutdown therefore closes the selected tab and leaks the owned tab; track a separate `owned_target_id` and close that ID.</comment>

<file context>
@@ -615,7 +633,21 @@ async def handler(reader, writer):
+        # A named daemon owns the tab it created — close it on shutdown so
+        # parallel workers don't leak about:blank/leftover tabs into the
+        # shared browser. Best-effort: the WS may already be gone.
+        if d.owns_target and d.target_id:
+            try:
+                await asyncio.wait_for(
</file context>
Fix with cubic

try:
await asyncio.wait_for(
d.cdp.send_raw("Target.closeTarget", {"targetId": d.target_id}),
timeout=2,
)
log(f"closed owned tab {d.target_id}")
except Exception as e:
log(f"close owned tab {d.target_id}: {e}")


def already_running():
Expand Down
78 changes: 78 additions & 0 deletions tests/unit/test_daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,3 +293,81 @@ 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 == []


class _AttachCDP(_FakeCDP):
"""FakeCDP with realistic responses for the attach flow."""

def __init__(self, targets=None):
super().__init__()
self.targets = targets or []
self.created = 0

async def send_raw(self, method, params=None, session_id=None):
self.calls.append((method, params, session_id))
if method == "Target.getTargets":
return {"targetInfos": self.targets}
if method == "Target.createTarget":
self.created += 1
return {"targetId": f"created-{self.created}"}
if method == "Target.attachToTarget":
return {"sessionId": f"session-for-{params['targetId']}"}
return {}


def test_named_daemon_creates_dedicated_tab(monkeypatch):
"""A named daemon (BU_NAME != default) on a shared local/CDP browser must
create its own tab rather than attaching to the first existing page —
otherwise parallel named daemons all grab the same tab and clobber each
other's navigations (#375 / #582)."""
monkeypatch.setattr(daemon, "NAME", "worker-a")
monkeypatch.setattr(daemon, "REMOTE_ID", None)
existing = [{"targetId": "someone-elses-tab", "url": "https://example.com/", "type": "page"}]
d = daemon.Daemon()
d.cdp = _AttachCDP(existing)

page = asyncio.run(d.attach_first_page())

assert page["targetId"] == "created-1"
assert d.target_id == "created-1"
assert d.owns_target is True
assert d.session == "session-for-created-1"
# It must NOT have attached to the pre-existing tab.
attach_calls = [p for (m, p, _s) in d.cdp.calls if m == "Target.attachToTarget"]
assert attach_calls == [{"targetId": "created-1", "flatten": True}]
# Domains enabled on the new session (parity with default attach).
enabled = {m for (m, _p, s) in d.cdp.calls if s == d.session and m.endswith(".enable")}
assert enabled == {"Page.enable", "DOM.enable", "Runtime.enable", "Network.enable"}


def test_default_daemon_still_attaches_first_page(monkeypatch):
"""The default daemon keeps the existing attach-to-first-real-page
behavior (single-user flow: reuse the tab the user is looking at)."""
monkeypatch.setattr(daemon, "NAME", "default")
monkeypatch.setattr(daemon, "REMOTE_ID", None)
existing = [{"targetId": "user-tab", "url": "https://example.com/", "type": "page"}]
d = daemon.Daemon()
d.cdp = _AttachCDP(existing)

page = asyncio.run(d.attach_first_page())

assert page["targetId"] == "user-tab"
assert d.owns_target is False
assert d.cdp.created == 0


def test_named_remote_daemon_keeps_first_page_attach(monkeypatch):
"""A named CLOUD daemon (REMOTE_ID set) has the whole browser to itself —
creating an extra tab would just leak one. First-page attach stays."""
monkeypatch.setattr(daemon, "NAME", "r7k2")
monkeypatch.setattr(daemon, "REMOTE_ID", "remote-browser-id")
monkeypatch.setattr(daemon, "BROWSER_KIND", "cloud")
existing = [{"targetId": "cloud-blank", "url": "about:blank", "type": "page"}]
d = daemon.Daemon()
d.cdp = _AttachCDP(existing)

page = asyncio.run(d.attach_first_page())

assert page["targetId"] == "cloud-blank"
assert d.owns_target is False
assert d.cdp.created == 0