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:
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.
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
Summary
Drivers that drive Lightpanda over CDP and use isolated/utility worlds (
Page.createIsolatedWorld/Page.addScriptToEvaluateOnNewDocumentwithworldName) 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 singleIsolatedWorldV8 context perBrowserContext.Symptoms
playwright-core1.59.xawait page.title()""(Playwright catches the underlying error and substitutes empty string)playwright-core1.59.xawait page.evaluate(() => 1+1)"Execution context was destroyed, most likely because of a navigation."even when only a child iframe — not the main frame — navigatedpuppeteer-core24.42.xawait page.title()/await page.evaluate(...)(post-fix paths)setTimeout/protocol timeout firesThe CDP-level error returned by Lightpanda for the failing
Runtime.evaluateis-32000 "Cannot find context with specified id".Reproduction
Driving from
playwright-core(requires #2399 to even reachPage.navigate):Driving from
puppeteer-core: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.zigframeNavigatedhandles everyframe_navigatednotification — main frame and child iframe alike — by re-emittingRuntime.executionContextCreatedfor the main world and every isolated world:Two structural issues amplify each other here:
Inspector.ziguses a single sharedCONTEXT_GROUP_IDfor every context in aBrowserContext. V8's inspector treats everyContextCreatedcall for a previously-tracked V8 context as a fresh registration with a newexecutionContextIdand (in this group model) effectively invalidates the previous id.bc.session.currentFrame()is always the main frame, andIsolatedWorldonly stores onejs.Context. So a child-iframe navigation re-registers the main frame's V8 contexts under the child'sframeId, 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.evaluateagainst 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.zigframeNavigated(branch was deleted; full investigation in PR #2399 thread):titleevaluateevaluatetitle""(Playwright'stry/catchswallows the underlying error)"context destroyed"inspector.contextCreatedonis_main_frame(only the main frame's main world + isolated worlds get re-registered)IsolatedWorld.evaluatewaits forever for the next'context'event afterexecutionContextsCleareddisposed everything; without per-iframeexecutionContextCreatedit never re-arrives)findFrameByFrameId(event.frame_id).js); only emit child-iframe main world withis_default_context=falseso it doesn't displace the group's default; gate isolated worlds onis_main_frame"context destroyed"(driver-side disposal mismatch — Playwright's evaluate path uses the main world it cached pre-iframe-load)Pre-fix gives Puppeteer a working
title()+evaluate()essentially by accident: V8's inspector keeps the same V8 context addressable across multipleContextCreatedcalls (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
IsolatedWorldstorage. Moveisolated_worlds: std.ArrayList(*IsolatedWorld)fromBrowserContextto per-Frame, withIsolatedWorld.createContext(frame)always creating a freshjs.Contextfor that frame.frameNavigatedwould then re-emitinspector.contextCreatedfor 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'sCONTEXT_GROUP_IDis 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'sframeIdwould no longer alias the main frame's id. Lighter touch than (A) but requires plumbing the group id throughInspector.contextCreatedand 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
page.title()returns""; usepage.content()and parse<title>from the markup instead, or accept the "" return. There is no good workaround forpage.evaluate(...)other than waiting on the proper fix.page.title()andpage.evaluate(...)will hang.lightpanda fetch --dump html|markdown: unaffected (no CDP / driver path), and--dump htmlalready extracts the correct<title>.Related
connectOverCDP(required to reachPage.navigatefrom Playwright at all). Without it, the symptoms above are masked by Playwright timing out earlier inpage.goto.