Repository navigation
fix(extension): handlers iterate ALL matching tabs instead of just the first - #50
Conversation
…e first
Chrome never retroactively injects content scripts into pages that were
already open when an extension is (re)installed — those tabs match the
extension's content_script URL patterns but `chrome.tabs.sendMessage`
to them throws "Receiving end does not exist". The bug: each verb
handler (fetch, read_cookies legacy, read_local_storage, read_session_
storage, read_indexed_db) was picking the first matching tab via
`.find()` and shipping the sendMessage to it. If a pre-reload tab
matched first, every call to that domain failed even when a freshly-
loaded tab with the content script also existed elsewhere in the user's
tab list.
Concrete repro: user has zillow.com tab from before extension reload,
opens a fresh zillow.com tab after, every zillow_search tool call fails
with "tab fetch failed: Could not establish connection. Receiving end
does not exist" — until they refresh the OLD tab.
Fix: extract a `sendToFirstResponsiveTab` helper that walks every
matching tab in `chrome.tabs.query` order, sends the message, and:
- returns the first success
- skips tabs that throw "Receiving end does not exist" and continues
(those are the no-content-script pre-reload tabs)
- fails immediately on any other throw (those are real errors worth
surfacing — permission denied, frame closed, etc.)
- returns a structured no-tab result with a refresh-prompt error
message if no tab matches OR every match lacked a content script
Apply to all four verb handlers. The error message on full miss
explicitly tells the user to refresh the page, so the failure mode is
actionable even if it does happen (e.g. the user has no fresh tabs
open).
Verified live: zillow_search, redfin_get_market_report, and
compass_healthcheck all round-trip successfully through three
concurrent MCPs after this change.
All 484 tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Claude finished @chrischall's task in 5m 14s —— View job Code Review
Overall: the fix is correct and the approach is sound. The Three nits surfaced (all inline):
|
| `Refresh the page in your browser to inject the content script, then retry.` + | ||
| (lastNoListener ? ` Last error: ${lastNoListener}` : ''), | ||
| }; | ||
| } |
There was a problem hiding this comment.
🟡 Nit — no tests for sendToFirstResponsiveTab
This helper has six distinct code paths and none are covered by the test suite:
matches.length === 0→ immediateno-tabtypeof match.id !== 'number'→ silent skipsendMessagesucceeds →responsesendMessagethrows "Receiving end does not exist" → skip + continuesendMessagethrows something else →throw- All tabs exhausted with
lastNoListenerset →no-tabwith embedded error
background.test.ts currently only tests handleServerHello. Since sendToFirstResponsiveTab is the security-sensitive routing path for ALL four verb handlers, dedicated unit tests here would be valuable — especially for cases 4 (the core new behaviour) and 6 (the final no-tab path with embedded error string). Fix this →
| `no tab matching ${tabUrlForError} has the fetchproxy content script loaded ` + | ||
| `(${matches.length} URL match${matches.length === 1 ? '' : 'es'}, none responded). ` + | ||
| `Refresh the page in your browser to inject the content script, then retry.` + | ||
| (lastNoListener ? ` Last error: ${lastNoListener}` : ''), |
There was a problem hiding this comment.
🟡 Nit — new error template not reflected in classify-fetch-error.test.ts
The error-kind.ts header and classify-fetch-error.test.ts both state that every error template emitted by background.ts should be explicitly covered:
"every change to extension-emitted error templates needs to be reflected here in one place"
The "all URL-matched tabs lacked a content script" error introduced here (the lastNoListener !== null path) is a new template. It happens to be correctly classified as content_script_unreachable by the existing Receiving end does not exist check (because lastNoListener embeds that string), but this is implicit — the test file has no case for it.
A downstream engineer reading classify-fetch-error.test.ts to understand what error formats the extension emits would miss this new template. Worth adding a table entry like:
[
'all URL-matched tabs lacked content script (new sendToFirstResponsiveTab format)',
'no tab matching https://www.compass.com/ has the fetchproxy content script loaded ' +
'(2 URL matches, none responded). Refresh the page in your browser to inject the content script, ' +
'then retry. Last error: Error: Could not establish connection. Receiving end does not exist.',
'content_script_unreachable',
],| | { kind: 'throw'; error: string } | ||
| > { | ||
| const tabs = await chrome.tabs.query({}); | ||
| const matches = tabs.filter((t) => t.url && matcher(t.url)); |
There was a problem hiding this comment.
🟡 Nit — matches.length overcounts tabs that will be silently skipped
Tabs where typeof match.id !== 'number' are included in matches (via .filter()) and therefore in matches.length, but they are silently continue-d in the loop without a sendMessage attempt. The final error message says "N URL matches, none responded" — if some of those N had no numeric ID, the count is slightly misleading (they never actually received a message).
In practice Chrome tabs always have numeric IDs, so this edge won't surface for users. But for accuracy the count could be computed after the loop, or the filter could also exclude no-ID tabs:
| const matches = tabs.filter((t) => t.url && matcher(t.url)); | |
| const matches = tabs.filter((t) => t.url && typeof t.id === 'number' && matcher(t.url)); |
Then the typeof match.id !== 'number' guard inside the loop becomes unreachable (which TypeScript will confirm) and can be dropped.
|
Claude finished @chrischall's task in 2m 45s —— View job Code Review (re-run on new push)
Re-review summary: The new push ( Standing nits (all 🟡, none blocking):
Overall verdict: warn. The fix is correct and the approach is sound. No important bugs found. |
…er consolidation 🟡 1. sendToFirstResponsiveTab had no direct unit tests Added `packages/extension-core/tests/send-to-first-responsive-tab.test.ts` with 6 cases covering every code path in the helper: - Zero matching tabs → `no-tab` with the plain "no tab matching X" message. - First matched tab replies → `response`; subsequent matches not attempted. - First matched tab throws "Receiving end does not exist" → skip, try next; second tab's reply is returned. Asserts both tabs were attempted in order AND that buildMessage gets called with each match's actual URL (so verbs that canonicalise tabUrl per attempt see the right value). - All matched tabs throw Receiving-end → `no-tab` with the full diagnostic template (match count, refresh prompt, last underlying chrome error). - First matched tab throws a non-receiving-end error (e.g. permission denied) → `throw` immediately; subsequent tabs NOT attempted (a real fault shouldn't be silently swallowed by retry-the-next-tab). - Tabs with non-numeric id are skipped without inflating the match count — covers the filter consolidation below. The helper is now `export`ed from background.ts (but not from index.ts) so tests can import it. Comment notes this is for testing only; extension-chrome / extension-safari have no reason to invoke it directly. 🟡 2. classify-fetch-error.test.ts missed the multi-tab-fallback template Added a case for the new "no tab matching X has the fetchproxy content script loaded (N URL matches, none responded)..." template that sendToFirstResponsiveTab emits when every matched tab is stale. The embedded "Receiving end does not exist" in the trailing `Last error:` suffix correctly triggers `content_script_unreachable` (so downstream MCPs surface "refresh the page" not "open a tab" — the user has the tab open, it just has no content script). Pins this so a regex tweak that breaks the classification is caught here. 🟡 3. matches.length overcounted tabs with non-numeric ids Folded the `typeof t.id === 'number'` check into the initial `.filter` so a tab whose id is undefined (a tab Chrome hasn't fully created yet, for example) doesn't pad `matches.length`. The "N URL matches, none responded" diagnostic was previously off-by-one-or-more in that case. The inner-loop id check becomes a non-null narrowing comment since the filter is now the authority. 494/494 tests passing (was 487, +7 new: 6 helper, 1 classifier). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Claude finished @chrischall's task in 1m 38s —— View job Code Review (re-review on new push)
Re-review summary: The new push ( Nit resolution:
Additional checks on this push:
Overall verdict: pass. The fix is correct, all previous nits are resolved, test coverage now exercises every code path in the helper, and the classifier test pins the new error template. No important bugs found. |
🤖 I have created a release *beep* *boop* --- ## [0.6.0](v0.5.1...v0.6.0) (2026-05-26) ### Features * **server:** lazy bridge connect — listen() loads identity only ([#51](#51)) ([8309c2b](8309c2b)) ### Bug Fixes * 3 MCPs can work concurrently (peer session renegotiation + pendingPair dict) ([#49](#49)) ([4272e98](4272e98)) * **ci:** prevent labeled event from cancelling auto-review ([#47](#47)) ([40bc4db](40bc4db)) * **extension:** handlers iterate ALL matching tabs instead of just the first ([#50](#50)) ([8bd437b](8bd437b)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Summary
Follow-up to #49 (merged): the four verb handlers in
background.ts(fetch, read_cookies legacy, read_local_storage / read_session_storage, read_indexed_db) were each picking the first matching tab viatabs.find()and callingchrome.tabs.sendMessageon it. If that first tab was a pre-reload page that no longer has a content script —chrome.tabs.sendMessagethen throws "Receiving end does not exist" — the call fails even when a freshly-loaded tab with the content script also exists elsewhere in the user's tab list.Concrete repro: in live testing of #49's fixes, zillow_search succeeded but compass and redfin failed because their first-matched tab was an older page that pre-dated the extension reload. Refreshing those pages fixed it, but that's brittle and surprising.
Fix
Extracted
sendToFirstResponsiveTab(matcher, buildMessage, tabUrlForError)and pointed all four handlers at it. The helper:chrome.tabs.queryorderSo the failure mode is preserved (and improved with a clearer message) when there really IS no working tab, but the common "I just reloaded the extension" case self-heals automatically.
Test plan
npm test— 484/484 passnpm run typecheck— cleannpm run build --workspaces --if-present— cleanVerified live end-to-end: after this fix, zillow_search + redfin_get_market_report + compass_healthcheck all round-trip successfully through three concurrent peer MCPs against real sites with the stale-tab issue present.
🤖 Generated with Claude Code