Skip to content

Fix CDP reconnect when Chrome has zero open tabs#17

Merged
huangruoqi merged 13 commits into
embeddingvc:mainfrom
huangruoqi:issue-6-reconnect
Jul 6, 2026
Merged

Fix CDP reconnect when Chrome has zero open tabs#17
huangruoqi merged 13 commits into
embeddingvc:mainfrom
huangruoqi:issue-6-reconnect

Conversation

@huangruoqi

Copy link
Copy Markdown
Collaborator

Summary

  • LinkedInBrowser._attach now handles the case where connect_over_cdp itself throws Browser.setDownloadBehavior: Browser context management is not supported when Chrome has zero open tabs, instead of only handling an empty contexts list after a successful connect
  • bin/browser-service now prefers Playwright's bundled Chromium ("Chrome for Testing") over system Chrome, which avoids this class of CDP error entirely
  • Bumped VERSION to 1.0.0.7 and updated CHANGELOG.md

Test plan

  • uv run pytest testing/tests/test_browser_attach.py passes
  • Verified live: restarted the managed browser service, reproduced the zero-tab reconnect failure against scrape_profile, confirmed the fix path via the CDP HTTP fallback

🤖 Generated with Claude Code

huangruoqi and others added 4 commits July 5, 2026 18:07
connect_over_cdp throws Browser.setDownloadBehavior instead of returning
an empty contexts list when no tabs are open, so the empty-contexts
fallback never ran. Wrap the first connect attempt too, and prefer
Playwright's bundled Chromium in browser-service since it supports the
CDP multi-context calls system Chrome rejects.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Reusing the same profile dir doesn't carry the session over when
bin/browser-service switches CHROME_BIN to Playwright's Chromium —
operators hit "not logged in" until they log in again in the new window.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
install.sh's persona sync unconditionally re-ran on every reinstall
(defaulting yes interactively, always running non-interactively),
overwriting an already-configured persona.json. Now it detects an
existing, non-example persona.json and keeps it by default.

outreach-upgrade also hardcoded CHROME_BIN to system Chrome before
calling browser-service install, undoing this branch's preference for
Playwright's Chromium on every upgrade. It now lets resolve_chrome pick.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Comment thread outreach/browser.py
if self._browser.contexts
else await self._browser.new_context()
)
self._ctx = self._browser.contexts[0]

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Possible IndexError if contexts is still empty after the retry.

self._browser.contexts[0] has no guard. Trace: if connect_over_cdp throws (line 656), we open a blank tab and reconnect (661-662); we then fall into if not self._browser.contexts (665) unconditionally and, if still empty, open another blank tab and reconnect again (671-672) — but there's no check after that. If the CDP HTTP /json/new call races with Playwright's context enumeration (tab created but not yet visible to the fresh CDP connection, or something closes it in between), contexts can still be [] here and this raises IndexError, whereas the old ... else await self._browser.new_context() fallback would have degraded gracefully instead of crashing.

Comment thread outreach/browser.py Outdated
self._browser = await self._pw.chromium.connect_over_cdp(self.cdp_url)
try:
self._browser = await self._pw.chromium.connect_over_cdp(self.cdp_url)
except Error:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

except Error: is broader than the documented zero-tabs case.

Playwright's Error is the base exception for basically all CDP failures — wrong port, Chrome not listening, profile lock, auth issues. All of these now silently trigger an extra _open_blank_tab HTTP round-trip (with its own 5s timeout) plus a blind reconnect before the real failure surfaces, instead of failing fast with the original, more diagnostic error. Worth narrowing to the specific zero-tabs signature (e.g. matching the Browser.setDownloadBehavior message mentioned in the CHANGELOG) so unrelated connection failures aren't delayed and re-reported as a different exception type from _open_blank_tab's own urlopen call.

Comment thread outreach/browser.py Outdated
self._browser = await self._pw.chromium.connect_over_cdp(self.cdp_url)
self._is_attached = True

if not self._browser.contexts:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Duplicated retry logic — same three-line recovery sequence written twice.

The except Error branch (656-662) and this if not self._browser.contexts branch (665-672) both do: call _open_blank_tab, then connect_over_cdp again. Same recipe, different trigger. Worth collapsing into one small retry loop (e.g. for _ in range(2): try connect; if contexts: break; except Error: pass; open_blank_tab()) so a future fix (backoff, retry limit, better error message) only has to be applied once instead of risking the two copies drifting apart.

empty_browser = MagicMock(contexts=[])
populated_browser = MagicMock(contexts=[ctx])
pw_chromium = MagicMock()
pw_chromium.connect_over_cdp = AsyncMock(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The actual root-cause path (connect_over_cdp raising) is never tested.

Per the CHANGELOG, the real bug is that connect_over_cdp itself throws when there are zero open tabs (not that it returns an empty contexts list). But this test's side_effect=[empty_browser, populated_browser] makes connect_over_cdp return successfully both times — it exercises the if not self._browser.contexts: branch (browser.py:665), not the except Error: branch (browser.py:656) that the CHANGELOG says is the actual fix. Consider adding a case where connect_over_cdp's first call raises (e.g. side_effect=[Error("..."), fake_browser]) to cover that branch directly.

Comment thread install.sh
local persona="${REPO_ROOT}/outreach/config/persona.json"
local example="${REPO_ROOT}/outreach/config/persona.json.example"
[[ -f "${persona}" ]] || return 1
[[ -f "${example}" ]] && cmp -s "${persona}" "${example}" && return 1

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

No corruption/validity check before keeping an existing persona.json.

persona_already_configured only compares against persona.json.example via cmp. If a previous install was interrupted (Ctrl-C, disk full) leaving a truncated/invalid JSON file, this still returns "configured" (it differs from the example), and a non-interactive reinstall now permanently keeps the corrupt file (return 0 at line 666) instead of the old behavior of always re-syncing. Might be worth a quick validity check (e.g. python -c "import json; json.load(open(...))") before treating it as configured.

Comment thread bin/outreach-upgrade
# Let browser-service resolve_chrome pick the binary (Playwright's
# Chromium first, then system Chrome) instead of forcing system Chrome
# here — forcing it would undo that preference on every upgrade.
if OUTREACH_REPO_ROOT="$REPO_ROOT" "$BROWSER_SVC" install; then

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Removed guard for "no Chrome binary found at all" changes the failure message, not just the Chrome-choice logic.

The old code only called browser-service install when a chrome_bin was actually found; now it's called unconditionally. On a machine with no system Chrome and no Playwright Chromium installed yet, resolve_chrome fails inside browser-service, this if takes the else branch, and prints "skipped (no launchd/systemd)" (line 169) — which is the wrong diagnostic; the real cause is "no Chrome binary found," unrelated to launchd/systemd, and will misdirect anyone troubleshooting from this log line.

Comment thread bin/browser-service
# multi-context calls (Browser.setDownloadBehavior, Target.createBrowserContext)
# that a real Google Chrome / system Chromium install rejects — using it here
# avoids that class of CDP error entirely instead of working around it.
command -v uv >/dev/null 2>&1 || return 1

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

resolve_playwright_chromium spawns a full uv run + Python + Playwright-import subprocess with no caching.

This runs on every resolve_chrome call, i.e. every install/daemon/start invocation — including launchd/systemd KeepAlive respawns. A Chrome crash-loop now also crash-loops this subprocess each cycle. Since the resolved path only changes when Playwright's browser is (re)installed, worth caching it (e.g. a file next to REPO_ROOT invalidated by playwright version, or just resolving once at install time and relying on the CHROME_BIN already baked into the plist/unit).

Comment thread outreach/browser.py
# ── Browser wrapper ───────────────────────────────────────────────────────────


def _open_blank_tab(cdp_url: str) -> None:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Reimplements a workaround that already exists in install.sh.

install.sh's open_linkedin_tab_in_cdp (install.sh:498-503) already does the identical PUT-then-GET-fallback to {cdp}/json/new to work around the same Chrome CDP quirk. Now there are two independent implementations (bash + Python) of the same workaround to keep in sync if Chrome's CDP behavior changes again.

Comment thread outreach/browser.py Outdated
req = urllib.request.Request(f"{cdp_url}/json/new", method="PUT")
try:
urllib.request.urlopen(req, timeout=5).close()
except urllib.error.URLError:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

PUT succeeding server-side but the response read timing out still triggers a second (GET) tab-open.

except urllib.error.URLError catches more than "PUT unsupported" — a slow CDP endpoint that creates the tab but doesn't respond within the 5s timeout also lands here, and the GET retry then opens a second blank tab. Since about: URLs are in _pick_tab's skip list, neither leftover blank tab gets reused, so duplicate about:blank tabs can accumulate across attach cycles on a slow/loaded machine.

@huangruoqi

Copy link
Copy Markdown
Collaborator Author

Fresh installs never get the Playwright-Chromium preference this PR adds.

launch_chrome_cdp resolves chrome_binary() (system Chrome/Chromium only, line 509) and force-exports CHROME_BIN to it before calling bin/browser-service install. resolve_chrome in browser-service checks CHROME_BIN first and returns immediately if it's already set+executable (bin/browser-service:65-67) — so resolve_playwright_chromium never runs here.

Only bin/outreach-upgrade had the system-Chrome forcing removed. Net effect: a brand-new install.sh run still launches system Chrome and can hit the exact zero-tab CDP bug this PR fixes at the code level — it only avoids the bug entirely (via Playwright's Chromium) on upgrades, not on first install. Is that intentional, or should this export CHROME_BIN="${chrome}" also defer to resolve_chrome's preference (e.g. only export it if resolve_playwright_chromium fails)?

resolve_chrome already warns "Chrome / Chromium not found" when no
binary is available; the old message here blamed missing launchd/
systemd regardless of the actual cause, misdirecting troubleshooting.
persona_already_configured only compared against the example template,
so a truncated/invalid file left by an interrupted prior install would
be kept forever on a non-interactive reinstall instead of re-synced.
launch_chrome_cdp force-exported CHROME_BIN to system Chrome before
calling bin/browser-service install, so resolve_chrome's new
Playwright-Chromium preference (added for this same CDP bug) never
took effect on a first install — only on bin/outreach-upgrade, which
already dropped this forcing. Fresh installs now get the same
preference upgrades do.
resolve_chrome runs on every install/daemon/start invocation, including
launchd KeepAlive respawns, and resolve_playwright_chromium was paying
a full `uv run` + Playwright-import subprocess each time with no
caching — turning a Chrome crash-loop into a subprocess crash-loop too.
Cache the resolved path in ~/.ebase and revalidate with -x so a
Playwright upgrade (new versioned install dir) invalidates it naturally.
Catching the broad URLError meant a slow-but-successful PUT (timeout
reading the response) also triggered the GET fallback, opening a
second blank tab. Narrow the fallback to HTTPError (an actual "this
doesn't work" response, e.g. 404/405 on older Chrome) so a timeout or
connection failure propagates instead of risking a duplicate tab.
_open_blank_tab and install.sh's open_linkedin_tab_in_cdp both PUT-then-
GET-fallback to Chrome's /json/new endpoint. Can't share the
implementation across bash and Python (install.sh runs before there's a
project env to call into), so just flag the duplication for future
maintainers touching either one.
Catching the base playwright.Error swallowed every connect_over_cdp
failure — wrong port, Chrome not running, auth issues — and silently
padded each with a doomed retry (open a tab, reconnect, fail again)
before the real error surfaced. Match on the known zero-tab error text
instead and re-raise anything else immediately.

Also closes the test-coverage gap this left: no test exercised the
except branch itself (connect_over_cdp raising), only the separate
empty-contexts branch.
If opening a blank tab via CDP and reconnecting still left contexts
empty (a plausible race between the HTTP tab-open and Playwright's
context enumeration), _attach indexed into an empty list and crashed
with a bare IndexError. Raise a RuntimeError that names the actual
failure instead.
The except-Error branch and the empty-contexts branch each did the
same "open a blank tab via CDP, then reconnect" recovery, just on
different triggers — two copies to keep in sync. Extract
_connect_with_zero_tab_retry() so both paths share one retry loop with
one attempt limit and one error message.
@huangruoqi huangruoqi merged commit 7e9d44d into embeddingvc:main Jul 6, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant