Skip to content

Child iframe navigation invalidates main frame's executionContextId for CDP drivers #2400

@staylor

Description

@staylor

Summary

Drivers that drive Lightpanda over CDP and use isolated/utility worlds (Page.createIsolatedWorld / Page.addScriptToEvaluateOnNewDocument with worldName) lose track of the main frame's executionContextId once any child iframe navigates. Pages with cross-origin iframes (analytics, web-pixels, ads, etc.) trigger this near-instantly. Symptoms vary by driver but the root cause is a single shared V8 inspector context group + a single IsolatedWorld V8 context per BrowserContext.

Symptoms

Driver API Observed result
playwright-core 1.59.x await page.title() Returns "" (Playwright catches the underlying error and substitutes empty string)
playwright-core 1.59.x await page.evaluate(() => 1+1) Throws "Execution context was destroyed, most likely because of a navigation." even when only a child iframe — not the main frame — navigated
puppeteer-core 24.42.x await page.title() / await page.evaluate(...) (post-fix paths) Hangs until the surrounding setTimeout/protocol timeout fires

The CDP-level error returned by Lightpanda for the failing Runtime.evaluate is -32000 "Cannot find context with specified id".

Reproduction

Driving from playwright-core (requires #2399 to even reach Page.navigate):

import { chromium } from 'playwright-core';
const browser = await chromium.connectOverCDP('http://127.0.0.1:9222');
const ctx = browser.contexts()[0] ?? await browser.newContext();
const page = ctx.pages()[0] ?? await ctx.newPage();
await page.goto('https://www.allbirds.com/products/mens-wool-runners', { waitUntil: 'load' });
console.log(await page.title());                           // ""
console.log(await page.evaluate(() => document.title));    // throws "Execution context was destroyed..."

Driving from puppeteer-core:

import puppeteer from 'puppeteer-core';
const browser = await puppeteer.connect({ browserWSEndpoint: 'ws://127.0.0.1:9222' });
const ctx = await browser.createBrowserContext();
const page = await ctx.newPage();
await page.goto('https://www.allbirds.com/products/mens-wool-runners');
console.log(await page.title()); // hangs until protocol timeout

The Allbirds product page loads ~11 web-pixel <iframe> sandboxes during initial render, which is enough to reproduce reliably. Any page with cross-origin iframes will exhibit it.

Root cause

src/cdp/domains/page.zig frameNavigated handles every frame_navigated notification — main frame and child iframe alike — by re-emitting Runtime.executionContextCreated for the main world and every isolated world:

const frame = bc.session.currentFrame() orelse return error.FrameNotLoaded;
// ...
{
    // Main world: uses currentFrame().js — ALWAYS the main frame's V8 context,
    // even on a child-iframe navigation.
    var ls: js.Local.Scope = undefined;
    frame.js.localScope(&ls);
    defer ls.deinit();
    bc.inspector_session.inspector.contextCreated(&ls.local, "", origin, aux_data, true);
}
for (bc.isolated_worlds.items) |isolated_world| {
    // Isolated world: uses isolated_world.context — ONE V8 context per
    // BrowserContext, bound to the main frame at createContext time.
    var iw_ls: js.Local.Scope = undefined;
    (isolated_world.context orelse continue).localScope(&iw_ls);
    defer iw_ls.deinit();
    bc.inspector_session.inspector.contextCreated(&iw_ls.local, isolated_world.name, "://", aux_json, false);
}

Two structural issues amplify each other here:

  1. Inspector.zig uses a single shared CONTEXT_GROUP_ID for every context in a BrowserContext. V8's inspector treats every ContextCreated call for a previously-tracked V8 context as a fresh registration with a new executionContextId and (in this group model) effectively invalidates the previous id.
  2. bc.session.currentFrame() is always the main frame, and IsolatedWorld only stores one js.Context. So a child-iframe navigation re-registers the main frame's V8 contexts under the child's frameId, churning the executionContextId for the main frame's main-world and utility-world out from under any driver that pinned to it.

By the time the driver issues a Runtime.evaluate against the contextId it learned during navigation, V8's inspector has already overwritten it.

What I tried, and why none of it shipped

I explored several patches in src/cdp/domains/page.zig frameNavigated (branch was deleted; full investigation in PR #2399 thread):

Approach Playwright title Playwright evaluate Puppeteer evaluate Puppeteer title
status quo (no fix) "" (Playwright's try/catch swallows the underlying error) "context destroyed"
v1 — gate every child-iframe inspector.contextCreated on is_main_frame (only the main frame's main world + isolated worlds get re-registered) ❌ timeout (Puppeteer's IsolatedWorld.evaluate waits forever for the next 'context' event after executionContextsCleared disposed everything; without per-iframe executionContextCreated it never re-arrives) ❌ timeout
v3 — per-frame V8 context for main world (uses findFrameByFrameId(event.frame_id).js); only emit child-iframe main world with is_default_context=false so it doesn't displace the group's default; gate isolated worlds on is_main_frame "context destroyed" (driver-side disposal mismatch — Playwright's evaluate path uses the main world it cached pre-iframe-load) ❌ timeout

Pre-fix gives Puppeteer a working title() + evaluate() essentially by accident: V8's inspector keeps the same V8 context addressable across multiple ContextCreated calls (each call assigns a new id, but the underlying context survives), and Puppeteer happens to keep using the first id it saw for the main frame. Playwright doesn't get the same lucky outcome because it caches/snapshots its execution context references differently and surfaces the resulting "context destroyed" instead of silently absorbing it.

Either narrow patch fixes one client by breaking the other. There is no clean win at this layer without addressing the underlying single-isolated-world / shared-context-group model.

Suggested fix directions

Either of these would resolve the underlying issue cleanly. Both are bigger changes than a one-liner.

A. Per-frame IsolatedWorld storage. Move isolated_worlds: std.ArrayList(*IsolatedWorld) from BrowserContext to per-Frame, with IsolatedWorld.createContext(frame) always creating a fresh js.Context for that frame. frameNavigated would then re-emit inspector.contextCreated for that frame's main world + that frame's isolated worlds, every iframe getting its own pair of executionContextIds. This is the model real Chrome uses.

B. Per-frame V8 inspector context group. Inspector.zig's CONTEXT_GROUP_ID is currently a constant. Allocating one group ID per frame would make V8's inspector keep each frame's contextIds in its own namespace, so re-registering the main frame's V8 context under a child frame's frameId would no longer alias the main frame's id. Lighter touch than (A) but requires plumbing the group id through Inspector.contextCreated and tracking per-frame inspector state.

(A) generalises better since it also unblocks features like frame.evaluate(...) against child iframes, Page.createIsolatedWorld({ frameId }) for a specific iframe, and any future cross-frame postMessage/fetch instrumentation that needs distinct execution contexts. (B) is faster to land if iframe-aware isolated worlds are out of scope for now.

Workarounds today

  • Playwright: page.title() returns ""; use page.content() and parse <title> from the markup instead, or accept the "" return. There is no good workaround for page.evaluate(...) other than waiting on the proper fix.
  • Puppeteer: against pages without cross-origin iframes, the existing flow works correctly. For pages with iframes, page.title() and page.evaluate(...) will hang.
  • Direct lightpanda fetch --dump html|markdown: unaffected (no CDP / driver path), and --dump html already extracts the correct <title>.

Related

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions