Skip to content

fix(browser): capture the tab that was asked for, not the park window - #317

Merged
frenchie4111 merged 1 commit into
mainfrom
fix-screenshot-wrong-tab
Oct 7, 2026
Merged

frenchie4111 merged 1 commit into
mainfrom
fix-screenshot-wrong-tab

Conversation

@frenchie4111

Copy link
Copy Markdown
Collaborator

The bug

screenshot_tab intermittently returned another tab's pixels, or a frozen older frame, while get_tab_url / get_tab_clickables / get_tab_console_logs stayed live and correct for the same tab_id in the same moment.

Both paths resolve the tab through the same instances.get(tabId) lookup, so resolution was never the problem — the bug was in the capture.

Root cause

captureOffscreen asked CDP for Page.captureScreenshot with fromSurface: false. That flag does not mean "render in the renderer process" as the old comment claimed — it routes through RenderWidgetHostImpl::GetSnapshotFromBrowser, which on macOS grabs the native window and crops it to the view's rect.

Every hidden tab is parked in one shared park window, so that crop returns whatever composites on top there: a sibling tab's live page, or a stale window frame. One cause, all three reported symptoms — wrong tab, frozen frame, other tools unaffected.

Repro

Two intermediate theories were disproved first (stacked-view z-order alone is harmless; in-page repaints are not stale). The real BrowserManager was then driven directly under Electron 41. Deterministic trigger:

One tab visible with an emulated viewport taller than its pane (so capturing it detaches and parks the view mid-shot), concurrent with a capture of a parked sibling → the sibling returned the visible tab's page 3/3 rounds. With fromSurface: true: 0/3.

A 20-assertion sweep of the reported sequence (two then three tabs, pane switches, mobile emulation, about:blank, hidden host window, concurrent captures) went from 2 failures to 0.

The fix

src/main/browser-manager.ts:

  1. fromSurface: true — copies the requesting view's own compositor surface, which is what the on-screen path (capturePage()) has always used. A failure now throws → a 500 the agent can act on, instead of silently handing back someone else's pixels.
  2. Captures serialized — the detach→park→restore dance meant two concurrent captures had one reparenting the other's host mid-shot.
  3. Every await inside a capture is bounded (withCaptureTimeout), so serialization can't let one wedged capture stall every later screenshot.

Regression test

src/main/browser-manager-capture.test.ts mocks Electron with fakes that model the measured Chromium semantics — a window-level grab returns the topmost sibling, surface capture returns the view's own frame — so a revert fails the test rather than shipping. Both halves of the fix are independently guarded: reverting fromSurface fails 2 tests, removing serialization fails 1.

Verification

  • npm run typecheck — clean
  • npx electron-vite build — clean
  • npx vitest run — 129 files / 1722 tests pass

Behavior change worth knowing

Surface capture converts through the display color profile, so parked-tab screenshots now carry the same slight color shift on-screen captures always had (pure #00ff00 reads as ~rgb(117,251,76)). Content is correct; saturated colors are slightly washed. Both paths are consistent now instead of differing by tab state.

🤖 Generated with Claude Code

screenshot_tab intermittently returned another tab's pixels, or a frozen
older frame, while get_tab_url / get_tab_clickables stayed correct for the
same id in the same moment. Both paths resolve the tab the same way, so the
bug was never in resolution — it was in the capture.

The offscreen path asked CDP for `Page.captureScreenshot` with
`fromSurface: false`. That routes through GetSnapshotFromBrowser, which on
macOS grabs the *native window* and crops it to the view's rect. Every
hidden tab is parked in one shared park window, so the crop returns
whatever composites on top there — a sibling tab's live page, or a stale
window frame. `fromSurface: true` copies the requesting view's own
compositor surface instead, which is also what the on-screen path has
always used.

Captures are now serialized too: capturing a visible tab whose emulated
viewport is taller than its pane detaches the view and parks it mid-shot,
so two captures at once meant one reparenting the other's host. Every await
inside a capture is bounded so a wedged one can't stall the queue.

Measured in a real Electron run driving BrowserManager directly: one tab
visible under a tall emulated viewport, a parked sibling, captured
concurrently — the sibling came back holding the other tab's page 3/3 times
before, 0/3 after, and a 20-assertion sweep of the reported sequence (two
then three tabs, pane switches, mobile emulation, about:blank, hidden host
window) goes from 2 failures to 0. Surface capture converts through the
display profile, so parked-tab screenshots now have the same slight color
shift on-screen captures already had.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@frenchie4111
frenchie4111 merged commit 51f0cbd into main Oct 7, 2026
1 check 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