Skip to content

fix(extension): handlers iterate ALL matching tabs instead of just the first - #50

Merged
chrischall merged 3 commits into
mainfrom
fix/multi-tab-fallback
May 26, 2026
Merged

chrischall merged 3 commits into
mainfrom
fix/multi-tab-fallback

Conversation

@chrischall

Copy link
Copy Markdown
Owner

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 via tabs.find() and calling chrome.tabs.sendMessage on it. If that first tab was a pre-reload page that no longer has a content script — chrome.tabs.sendMessage then 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:

  • Iterates every matching tab in chrome.tabs.query order
  • Returns the first successful response
  • Skips tabs that throw "Receiving end does not exist" and continues — those are the no-content-script pre-reload tabs that were silently shadowing fresh ones
  • Fails immediately on any other throw — those are real faults worth surfacing (permission denied, frame closed, etc.)
  • On full miss (no tab matched at all OR every match lacked a content script), returns a structured no-tab result with an actionable error message that tells the user to refresh the page

So 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 pass
  • npm run typecheck — clean
  • npm run build --workspaces --if-present — clean
  • Manual: reload the fetchproxy extension at chrome://extensions/ while pages from each declared domain are open in the browser, then call MCP tools — should succeed without the user having to refresh the pre-reload tabs first.

Verified 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

…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>
@chrischall chrischall added the bug Release notes: Bug Fixes label May 26, 2026
@github-actions github-actions Bot added the auto-review Auto-review pipeline is handling this PR label May 26, 2026
@claude

claude Bot commented May 26, 2026 •

Copy link
Copy Markdown

Claude finished @chrischall's task in 5m 14s —— View job


Code Review

  • Gather context (CLAUDE.md read)
  • Read PR diff
  • Review for correctness, edge cases, conventions, test coverage
  • Post inline findings (3 inline comments)
  • Post summary verdict

Overall: the fix is correct and the approach is sound. The sendToFirstResponsiveTab helper cleanly encodes the three terminal states, the fallback logic is right (skip "Receiving end" errors, fail fast on anything else), and all four verb-handler call sites are updated consistently. No important bugs found.

Three nits surfaced (all inline):

  1. sendToFirstResponsiveTab has no unit tests — 6 code paths, none covered. Worth adding tests for the "one stale + one fresh tab" success case and the "all tabs stale" no-tab case in particular (line 1087).

  2. New error template not reflected in classify-fetch-error.test.ts — the file's own header says every background.ts error template must be explicitly tested there; the new "all URL-matched tabs lacked content script" string is missing (line 1085).

  3. matches.length overcounts tabs with non-numeric IDs — minor, since Chrome tabs always have IDs in practice; trivially fixable by folding the typeof match.id === 'number' guard into the initial .filter() (line 1054).

`Refresh the page in your browser to inject the content script, then retry.` +
(lastNoListener ? ` Last error: ${lastNoListener}` : ''),
};
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Nit — no tests for sendToFirstResponsiveTab

This helper has six distinct code paths and none are covered by the test suite:

  1. matches.length === 0 → immediate no-tab
  2. typeof match.id !== 'number' → silent skip
  3. sendMessage succeeds → response
  4. sendMessage throws "Receiving end does not exist" → skip + continue
  5. sendMessage throws something else → throw
  6. All tabs exhausted with lastNoListener set → no-tab with 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}` : ''),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 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:

Suggested change
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

claude Bot commented May 26, 2026 •

Copy link
Copy Markdown

Claude finished @chrischall's task in 2m 45s —— View job


Code Review (re-run on new push)

  • Gather context (CLAUDE.md read)
  • Read new commits / diff since last review
  • Check if previous nits were addressed
  • Review for new issues (correctness, coverage, conventions)
  • Post findings and verdict

Re-review summary: The new push (830da43) is a plain merge from main — no substantive code changes. The three nits from the previous review are still open and no new issues were introduced.

Standing nits (all 🟡, none blocking):

  1. sendToFirstResponsiveTab has no unit tests — 6 distinct code paths, none covered. The most important cases: one-stale-one-fresh tab succeeds on the second; all-stale returns no-tab with embedded error string. Fix this →

  2. New error template not reflected in classify-fetch-error.test.ts — The "all URL-matched tabs lacked content script" message (starting no tab matching … has the fetchproxy content script loaded) gets implicitly classified as content_script_unreachable (the embedded "Receiving end does not exist" string in the Last error: suffix fires the first regex). The classification is correct, but the test table has no case for this new template format. Fix this →

  3. matches.length overcounts tabs that will be silently skipped — Tabs with non-numeric IDs pass the .filter() and inflate matches.length, so the "N URL matches, none responded" error message can overcount. Trivially fixed by folding the ID check into the initial filter. Fix this →

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

claude Bot commented May 26, 2026 •

Copy link
Copy Markdown

Claude finished @chrischall's task in 1m 38s —— View job


Code Review (re-review on new push)

  • Gather context (CLAUDE.md, previous review nits)
  • Read PR diff and changed files
  • Check if previous nits were addressed
  • Review for correctness, edge cases, conventions, test coverage
  • Post findings and verdict

Re-review summary: The new push (e96968a) addresses all three nits from the previous review. No new issues found.

Nit resolution:

# Previous nit Status
1 sendToFirstResponsiveTab had no unit tests ✅ Added send-to-first-responsive-tab.test.ts with 6 cases covering all code paths: zero matches, first-tab success, stale-then-fresh fallback, all-stale no-tab, non-receiving-end throw, and no-id filter exclusion.
2 New error template not in classify-fetch-error.test.ts ✅ Added test case for the "all URL-matched tabs lacked content script" template, pinning its classification as content_script_unreachable.
3 matches.length overcounted tabs with non-numeric IDs ✅ typeof t.id === 'number' folded into the .filter(); inner-loop guard replaced with a narrowing cast + comment.

Additional checks on this push:

  • No remaining tabs.find() calls in background.ts — all four verb handlers (fetch, read_cookies legacy, read_local_storage/read_session_storage, read_indexed_db) use sendToFirstResponsiveTab.
  • captureRequestHeader correctly left unchanged — it uses chrome.webRequest (passive listener), not chrome.tabs.sendMessage.
  • sendToFirstResponsiveTab is exported from background.ts but NOT re-exported from index.ts — correct, the export is test-only.
  • The SendToFirstResponsiveTabResult type is exported alongside the function — clean API boundary for tests.
  • All four handler call sites follow the same three-branch dispatch pattern (no-tab → error response, throw → verb-prefixed error, response → cast + forward) — consistent.

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.

@chrischall chrischall added the ready-to-merge Arms auto-merge — added by the pipeline on a pass/warn verdict, never by hand label May 26, 2026
@chrischall
chrischall merged commit 8bd437b into main May 26, 2026
7 checks passed
@chrischall
chrischall deleted the fix/multi-tab-fallback branch May 26, 2026 14:08
chrischall added a commit that referenced this pull request May 26, 2026
🤖 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auto-review Auto-review pipeline is handling this PR bug Release notes: Bug Fixes ready-to-merge Arms auto-merge — added by the pipeline on a pass/warn verdict, never by hand

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant