cdp: Give isolated worlds a context per frame

Previously, we had a single root-bound context which we'd re-announce for every
frame. Now, Page.createIsolatedWorld and addScriptToEvaluateOnNewDocument seed
the context per frame(s).
This commit is contained in:
Karl Seguin committed 2026-09-04 11:01:20 +08:00
1 parent 7263acebcd
commit 8937a7b4e0
4 files changed
+341 -72

No files matched your search

+12 -10
View File
@@ -335,8 +335,8 @@ fn tearDownPage(self: *Session, page: *Page) void {
}
// Allocate a Page in a free slot, publish it as the active page, and
// dispatch `frame_created` so CDP creates fresh isolated-world V8
// contexts. Used by createPage and by the synthetic-nav path. Does NOT
// dispatch `frame_created` so CDP can bind its page handle to the new
// frame. Used by createPage and by the synthetic-nav path. Does NOT
// dispatch `frame_navigate` — the caller does that (or doesn't, for a
// blank initial page).
//
@@ -351,8 +351,8 @@ fn installNewActivePage(self: *Session, frame_id: u32) !*Frame {
errdefer _ = self.pages.pop();
const frame = &page.frame;
// Inform CDP the main frame has been created such that additional
// context for other Worlds can be created as well.
// Inform CDP the main frame has been created so it can point its page
// handle at the new frame.
self.notification.dispatch(.frame_created, frame);
return frame;
}
@@ -890,12 +890,14 @@ pub fn initiateRootNavigation(self: *Session, frame_id: u32, url: [:0]const u8,
// isolated world contexts plus the node_registry. OLD is still the live
// page and its memory is alive (intentional: CDP teardown can walk
// old-page state without UAF).
// 2. frame_created dispatch — CDP creates fresh isolated world contexts
// against the new frame. `replacement.replaces` is still set, so the
// session still reports an in-flight nav and CDP's frameCreated skips
// its frame_arena reset and captured_responses zeroing (the captured
// response for the request we are committing was just inserted by
// onHttpResponseHeadersDone moments earlier and must survive).
// 2. frame_created dispatch — CDP rebinds its page handle to the new
// frame. `replacement.replaces` is still set, so the session still
// reports an in-flight nav and CDP's frameCreated skips its frame_arena
// reset and captured_responses zeroing (the captured response for the
// request we are committing was just inserted by
// onHttpResponseHeadersDone moments earlier and must survive). The
// isolated worlds emptied in step 1 are NOT refilled here — CDP rebuilds
// their contexts on the frame_navigate the caller dispatches afterwards.
// 3. Promote: clear `replaces` and unlink OLD from `pages`, so
// `currentFrame()` / `livePage()` now resolve to `replacement`. Done AFTER
// step 2 so the in-commit signal (replaces != null) survives the dispatch
+50 -11
View File
@@ -640,18 +640,27 @@ pub const BrowserContext = struct {
self.set_child_nodes_sent.clearRetainingCapacity();
}
pub fn createIsolatedWorld(self: *BrowserContext, world_name: []const u8, grant_universal_access: bool) !*IsolatedWorld {
// The name is the world's identity (matching Chrome). Clients re-issue
// this call after every navigation; appending a duplicate each time
// would grow the per-page context count without bound.
pub const GetOrPutIsolatedWorld = struct {
world: *IsolatedWorld,
found_existing: bool,
};
pub fn findIsolatedWorld(self: *const BrowserContext, world_name: []const u8) ?*IsolatedWorld {
for (self.isolated_worlds.items) |world| {
if (std.mem.eql(u8, world.name, world_name)) {
if (world.grant_universal_access != grant_universal_access) {
log.warn(.cdp, "isolated world mismatch", .{ .name = world_name, .gua = grant_universal_access });
}
return world;
}
}
return null;
}
pub fn createIsolatedWorld(self: *BrowserContext, world_name: []const u8, grant_universal_access: bool) !GetOrPutIsolatedWorld {
if (self.findIsolatedWorld(world_name)) |world| {
if (world.grant_universal_access != grant_universal_access) {
log.warn(.cdp, "isolated world mismatch", .{ .name = world_name, .gua = grant_universal_access });
}
return .{ .world = world, .found_existing = true };
}
const browser = &self.cdp.browser;
const arena = try browser.arena_pool.acquire(.small, "IsolatedWorld");
@@ -667,7 +676,19 @@ pub const BrowserContext = struct {
try self.isolated_worlds.append(self.arena, world);
return world;
return .{ .world = world, .found_existing = false };
}
// only called when we fail to fully create a world (e.g. errdefer in
// Page.createIsolatedWorld).
pub fn removeIsolatedWorld(self: *BrowserContext, world: *IsolatedWorld) void {
for (self.isolated_worlds.items, 0..) |w, i| {
if (w == world) {
_ = self.isolated_worlds.swapRemove(i);
world.deinit();
return;
}
}
}
pub fn nodeWriter(self: *BrowserContext, root: *const NodeRegistry.Node, opts: Node.Writer.Opts) Node.Writer {
@@ -1165,12 +1186,16 @@ pub const BrowserContext = struct {
const ScriptOnNewDocument = struct {
identifier: u32,
source: []const u8,
// Page.addScriptToEvaluateOnNewDocument's worldName. null means the main
// world. A named world is seeded into every frame (see IsolatedWorld).
world_name: ?[]const u8,
};
/// An isolated world is identified by its name and has one V8::Context per
// it was requested it. Generally the client needs to resolve a node into the
// isolated world to be able to work with it.
///
/// frame it has been seeded into. A world enters a frame on an explicit
/// trigger: Page.createIsolatedWorld or, or a preload script which is seeded
/// into every frame. Once seeded, the frame's context is rebuilt on every
/// navigation with no further client involvement.
/// Frame ids are stable across a child frame's re-navigation (the Frame is
/// torn down and re-initialized in place), so a per-frame context is removed
/// on frame_destroyed and created again on the frame's next frame_navigated.
@@ -1181,6 +1206,9 @@ pub const IsolatedWorld = struct {
grant_universal_access: bool,
contexts: std.ArrayList(FrameContext) = .empty,
// Frames this world has been seeded into, by frame id.
seeded_frames: std.ArrayList(u32) = .empty,
// Identity tracking for this isolated world (separate from main world).
// Shared by all of the world's frame contexts, like the main world shares
// Page.identity across frames, and reset with them on root teardown.
@@ -1201,6 +1229,17 @@ pub const IsolatedWorld = struct {
self.arena.release();
}
pub fn seed(self: *IsolatedWorld, frame_id: u32) !void {
if (self.isSeeded(frame_id)) {
return;
}
return self.seeded_frames.append(self.arena.allocator(), frame_id);
}
pub fn isSeeded(self: *const IsolatedWorld, frame_id: u32) bool {
return std.mem.indexOfScalar(u32, self.seeded_frames.items, frame_id) != null;
}
// Keyed by Frame, not frame id: a retired root Page keeps its frame id
// while its deferred teardown is pending, and that teardown must not
// touch the live page's context.
+144 -36
View File
@@ -25,14 +25,15 @@ const NodeRegistry = @import("../../../NodeRegistry.zig");
const dump = @import("../../../browser/dump.zig");
const js = @import("../../../browser/js/js.zig");
const DOMNode = @import("../../../browser/webapi/Node.zig");
const Selector = @import("../../../browser/webapi/selector/Selector.zig");
const xpath = @import("../../../browser/xpath/Evaluator.zig");
const Input = @import("../../../browser/webapi/element/html/Input.zig");
const Page = @import("../../../browser/Page.zig");
const Frame = @import("../../../browser/Frame.zig");
const File = @import("../../../browser/webapi/File.zig");
const Blob = @import("../../../browser/webapi/Blob.zig");
const Factory = @import("../../../browser/Factory.zig");
const Page = @import("../../../browser/Page.zig");
const xpath = @import("../../../browser/xpath/Evaluator.zig");
const DOMNode = @import("../../../browser/webapi/Node.zig");
const Input = @import("../../../browser/webapi/element/html/Input.zig");
const Selector = @import("../../../browser/webapi/selector/Selector.zig");
const log = lp.log;
const Allocator = std.mem.Allocator;
@@ -343,41 +344,24 @@ fn resolveNode(cmd: *CDP.Command) !void {
})) orelse return error.InvalidParams;
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const frame = bc.mainFrame() orelse return error.FrameNotLoaded;
var ls: js.Local.Scope = undefined;
var ls_open = false;
defer if (ls_open) {
ls.deinit();
};
if (params.executionContextId) |context_id| blk: {
frame.js.localScope(&ls);
ls_open = true;
if (ls.local.debugContextId() == context_id) {
break :blk;
}
// not the default scope, check the other ones
for (bc.isolated_worlds.items) |isolated_world| {
for (isolated_world.contexts.items) |fc| {
ls.deinit();
ls_open = false;
fc.context.localScope(&ls);
ls_open = true;
if (ls.local.debugContextId() == context_id) {
break :blk;
}
}
} else return error.ContextNotFound;
} else {
frame.js.localScope(&ls);
ls_open = true;
}
const root = bc.mainFrame() orelse return error.FrameNotLoaded;
const input_node_id = params.nodeId orelse params.backendNodeId orelse return error.InvalidParam;
const node = bc.node_registry.lookup_by_id.get(input_node_id) orelse return error.UnknownNode;
// Chrome resolves into the named context, else into the main world of the
// node's own document's frame. Drivers adopt a handle found in a child's
// utility world into that child's main world this way, so the root's
// contexts are not enough.
const js_context = if (params.executionContextId) |context_id|
findContext(bc, root, context_id) orelse return error.ContextNotFound
else
nodeFrame(node.dom, root).js;
var ls: js.Local.Scope = undefined;
js_context.localScope(&ls);
defer ls.deinit();
// node._node is a *DOMNode we need this to be able to find its most derived type e.g. Node -> Element -> HTMLElement
// So we use the Node.Union when retrieve the value from the environment
const remote_object = try bc.inspector_session.getRemoteObject(
@@ -397,6 +381,51 @@ fn resolveNode(cmd: *CDP.Command) !void {
} }, .{});
}
// The frame owning the node's document. Synthetic documents (DOMParser,
// DOMImplementation) have no frame and fall back to the root.
fn nodeFrame(dom_node: *DOMNode, root: *Frame) *Frame {
const document = if (dom_node._type == .document)
dom_node.subtype(DOMNode.Document)
else
dom_node.ownerDocument(root) orelse return root;
return document._frame orelse root;
}
// The context the inspector announced under `context_id`: any frame's main
// world, then any isolated world's per-frame contexts.
fn findContext(bc: *CDP.BrowserContext, root: *Frame, context_id: u32) ?*js.Context {
if (findMainWorldContext(root, context_id)) |js_context| {
return js_context;
}
for (bc.isolated_worlds.items) |isolated_world| {
for (isolated_world.contexts.items) |fc| {
if (contextIdOf(fc.context) == context_id) {
return fc.context;
}
}
}
return null;
}
fn findMainWorldContext(frame: *Frame, context_id: u32) ?*js.Context {
if (contextIdOf(frame.js) == context_id) {
return frame.js;
}
for (frame.child_frames.items) |child| {
if (findMainWorldContext(child, context_id)) |js_context| {
return js_context;
}
}
return null;
}
fn contextIdOf(js_context: *js.Context) i32 {
var ls: js.Local.Scope = undefined;
js_context.localScope(&ls);
defer ls.deinit();
return ls.local.debugContextId();
}
fn describeNode(cmd: *CDP.Command) !void {
const params = (try cmd.params(struct {
nodeId: ?NodeRegistry.Id = null,
@@ -1130,6 +1159,85 @@ test "cdp.dom: querySelector Nodes found" {
try ctx.expectSentResult(.{ .nodeIds = &.{7} }, .{ .id = 5 });
}
// Drivers find an element in a child frame's utility world, then adopt the
// handle into that child's main world with DOM.resolveNode. Both the named
// context and the default (the node's own frame) must be the child's, not the
// root's.
test "cdp.dom: resolveNode into a child frame's context" {
var ctx = try testing.context();
defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{ .id = "BID-RN", .url = "cdp/isolated_world.html", .target_id = "FID-000000000X".* });
const root = bc.mainFrame() orelse unreachable;
const child = root.child_frames.items[0];
const child_main = try mainWorldContextId(bc, child);
try testing.expect(child_main != try mainWorldContextId(bc, root));
// Register the child's <html> the way DOM.describeNode(objectId) would.
const html = child.document.getDocumentElement() orelse unreachable;
const node = try bc.node_registry.register(html.asNode());
try ctx.processMessage(.{ .id = 10, .method = "Runtime.enable", .sessionId = "SID-X" });
// Into the context the client names.
try ctx.processMessage(.{ .id = 11, .method = "DOM.resolveNode", .sessionId = "SID-X", .params = .{
.backendNodeId = node.id,
.executionContextId = child_main,
} });
const named = try sentObjectId(&ctx, 11);
try ctx.processMessage(.{ .id = 12, .method = "Runtime.callFunctionOn", .sessionId = "SID-X", .params = .{
.objectId = named,
.functionDeclaration = "function() { return globalThis.document.title + '|' + (this.ownerDocument === globalThis.document); }",
.returnByValue = true,
} });
try ctx.expectSentResult(.{ .result = .{ .type = "string", .value = "Jobs page one|true" } }, .{ .id = 12 });
// Into the node's own frame when no context is named.
try ctx.processMessage(.{ .id = 13, .method = "DOM.resolveNode", .sessionId = "SID-X", .params = .{
.backendNodeId = node.id,
} });
const default = try sentObjectId(&ctx, 13);
try ctx.processMessage(.{ .id = 14, .method = "Runtime.callFunctionOn", .sessionId = "SID-X", .params = .{
.objectId = default,
.functionDeclaration = "function() { return globalThis.document.title; }",
.returnByValue = true,
} });
try ctx.expectSentResult(.{ .result = .{ .type = "string", .value = "Jobs page one" } }, .{ .id = 14 });
try ctx.processMessage(.{ .id = 15, .method = "DOM.resolveNode", .sessionId = "SID-X", .params = .{
.backendNodeId = node.id,
.executionContextId = 9999,
} });
try ctx.expectSentError(-31998, "ContextNotFound", .{ .id = 15 });
}
fn mainWorldContextId(bc: *CDP.BrowserContext, frame: *const Frame) !i32 {
var ls: js.Local.Scope = undefined;
frame.js.localScope(&ls);
defer ls.deinit();
return bc.inspector_session.inspector.getContextId(&ls.local);
}
// The result.object.objectId of the response to command `msg_id`.
fn sentObjectId(ctx: *testing.TestContext, msg_id: i64) ![]const u8 {
var i: usize = 0;
while (try ctx.getSentMessage(i)) |msg| : (i += 1) {
const obj = switch (msg) {
.object => |o| o,
else => continue,
};
const id_value = obj.get("id") orelse continue;
if (id_value != .integer or id_value.integer != msg_id) {
continue;
}
const result = obj.get("result") orelse return error.NoResult;
const object = result.object.get("object") orelse return error.NoObject;
const object_id = object.object.get("objectId") orelse return error.NoObjectId;
return object_id.string;
}
return error.MessageNotFound;
}
test "cdp.dom: getBoxModel" {
var ctx = try testing.context();
defer ctx.deinit();
+135 -15
View File
@@ -158,6 +158,18 @@ fn addScriptToEvaluateOnNewDocument(cmd: *CDP.Command) !void {
log.warn(.not_implemented, "addScriptOnNewDocument", .{ .param = "runImmediately" });
}
// A worldName registers the world itself:
var world_name: ?[]const u8 = null;
if (params.worldName) |name| {
if (name.len > 0) {
const gop = try bc.createIsolatedWorld(name, true);
// Borrowed from the world's arena, which outlives the script: a
// world is only ever removed on the createIsolatedWorld command's
// own error path, before any script can name it.
world_name = gop.world.name;
}
}
const script_id = bc.next_script_id;
bc.next_script_id += 1;
@@ -165,6 +177,7 @@ fn addScriptToEvaluateOnNewDocument(cmd: *CDP.Command) !void {
try bc.scripts_on_new_document.append(bc.arena, .{
.identifier = script_id,
.source = source_dupe,
.world_name = world_name,
});
var id_buf: [16]u8 = undefined;
@@ -251,7 +264,16 @@ fn createIsolatedWorld(cmd: *CDP.Command) !void {
return cmd.sendError(-32000, "Frame with the given id does not belong to the target.", .{});
};
const world = try bc.createIsolatedWorld(params.worldName, params.grantUniveralAccess);
const gop = try bc.createIsolatedWorld(params.worldName, params.grantUniveralAccess);
const world = gop.world;
errdefer if (gop.found_existing == false) {
bc.removeIsolatedWorld(world);
};
// Seed before creating: frameNavigated only rebuilds contexts for frames
// the world was seeded into.
try world.seed(frame._frame_id);
// use the existing world context for a frame if we have it, else create one
const js_context = world.contextFor(frame) orelse try createIsolatedWorldContext(cmd.arena, bc, world, frame, null);
@@ -272,8 +294,13 @@ fn createIsolatedWorldContext(arena: Allocator, bc: *CDP.BrowserContext, world:
}
// Registers a world context with the inspector, which assigns the id clients
// use and sends Runtime.executionContextCreated. Registering a context again
// assigns it a new id, so this only happens when the previous id is gone.
// use and sends Runtime.executionContextCreated. We may re-register a living
// context, the client will get a new id, but both ids are still valid. This
// happens when we fast-path via `canNavigateInPlace`, `frameNavigated` still
// fires so registerIsolatedWorldContext gets re-called for the same context.
// Not the end of the world since it's bound to a single about:blank -> navigate
// and necessary since we call executionContextsCleared which tells the client
// the old id is invalid
fn registerIsolatedWorldContext(arena: Allocator, bc: *CDP.BrowserContext, world: *CDP.IsolatedWorld, js_context: *js.Context, frame: *const Frame, loader_id: ?[]const u8) !void {
const frame_id = &id.toFrameId(frame._frame_id);
const aux_data = if (loader_id) |lid|
@@ -721,7 +748,17 @@ pub fn frameNavigated(arena: Allocator, bc: *CDP.BrowserContext, event: *const N
is_root_frame,
);
}
// Each known world must get a context per frame
// A worldName preload script seeds its world into every frame. This is the
// only way for a world to reach a frame besides the explicit Page.createIsolatedWorld.
for (bc.scripts_on_new_document.items) |script| {
const world = bc.findIsolatedWorld(script.world_name orelse continue) orelse continue;
world.seed(frame._frame_id) catch |err| {
log.warn(.cdp, "isolated world seed", .{ .err = err, .world = world.name, .frame_id = frame._frame_id });
};
}
// Every world seeded into this frame gets a context, rebuilt on each
// navigation as blink rebuilds a detached isolated-world window proxy.
for (bc.isolated_worlds.items) |isolated_world| {
if (isolated_world.contextFor(frame)) |js_context| {
// The context was already created ahead of time (createIsolatedWorld).
@@ -737,6 +774,10 @@ pub fn frameNavigated(arena: Allocator, bc: *CDP.BrowserContext, event: *const N
continue;
}
if (!isolated_world.isSeeded(frame._frame_id)) {
continue;
}
_ = createIsolatedWorldContext(arena, bc, isolated_world, frame, loader_id) catch |err| {
log.warn(.cdp, "isolated world context", .{ .err = err, .world = isolated_world.name, .frame_id = frame._frame_id });
};
@@ -746,21 +787,24 @@ pub fn frameNavigated(arena: Allocator, bc: *CDP.BrowserContext, event: *const N
// Must run after the execution context is created but before the client
// receives frameNavigated/loadEventFired so polyfills are available for
// subsequent CDP commands.
if (bc.scripts_on_new_document.items.len > 0) {
for (bc.scripts_on_new_document.items) |script| {
const js_context = if (script.world_name) |name| blk: {
const world = bc.findIsolatedWorld(name) orelse continue;
break :blk world.contextFor(frame) orelse continue;
} else frame.js;
var ls: js.Local.Scope = undefined;
frame.js.localScope(&ls);
js_context.localScope(&ls);
defer ls.deinit();
for (bc.scripts_on_new_document.items) |script| {
var try_catch: lp.js.TryCatch = undefined;
try_catch.init(&ls.local);
defer try_catch.deinit();
var try_catch: lp.js.TryCatch = undefined;
try_catch.init(&ls.local);
defer try_catch.deinit();
ls.local.eval(script.source, null) catch |err| {
const caught = try_catch.caughtOrError(arena, err);
log.warn(.cdp, "script on new doc", .{ .caught = caught });
};
}
ls.local.eval(script.source, null) catch |err| {
const caught = try_catch.caughtOrError(arena, err);
log.warn(.cdp, "script on new doc", .{ .caught = caught });
};
}
// The DOM.documentUpdated event must be send after the frameNavigated one.
@@ -1363,6 +1407,82 @@ test "cdp.frame: createIsolatedWorld targets the requested frame" {
try ctx.expectSentResult(.{ .result = .{ .type = "string", .value = "Parent jobs" } }, .{ .id = 37 });
}
// Chrome only puts a world in a frame on an explicit trigger. A world the
// client asked for on the root must not appear in a sibling frame just
// because that frame navigated afterwards.
test "cdp.frame: an unseeded frame gets no isolated world context" {
var ctx = try testing.context();
defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{ .id = "BID-IWS", .url = "cdp/isolated_world.html", .target_id = "FID-000000000X".* });
const root = bc.mainFrame() orelse unreachable;
const child = root.child_frames.items[0];
const root_id = id.toFrameId(root._frame_id);
try ctx.processMessage(.{ .id = 30, .method = "Runtime.enable", .sessionId = "SID-X" });
try ctx.processMessage(.{ .id = 31, .method = "Page.createIsolatedWorld", .params = .{
.frameId = &root_id,
.worldName = "utility",
.grantUniveralAccess = true,
} });
const world = bc.findIsolatedWorld("utility") orelse unreachable;
try testing.expect(world.contextFor(root) != null);
// Navigating the child is what used to create a context for it.
try ctx.processMessage(.{ .id = 32, .method = "Runtime.evaluate", .sessionId = "SID-X", .params = .{
.expression = "document.querySelector('iframe').src = 'isolated_world_two.html'",
} });
try testing.waitForPage(bc);
try testing.expect(world.isSeeded(root._frame_id));
try testing.expect(world.isSeeded(child._frame_id) == false);
try testing.expect(world.contextFor(child) == null);
}
// A worldName preload script is the one trigger that reaches frames the client
// never named, matching blink's InjectScripts. Puppeteer relies on it to give
// dynamically-added iframes a utility world.
test "cdp.frame: a worldName preload script seeds every frame" {
var ctx = try testing.context();
defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{ .id = "BID-IWP", .url = "cdp/isolated_world.html", .target_id = "FID-000000000X".* });
const root = bc.mainFrame() orelse unreachable;
const child = root.child_frames.items[0];
try ctx.processMessage(.{ .id = 30, .method = "Runtime.enable", .sessionId = "SID-X" });
try ctx.processMessage(.{ .id = 31, .method = "Page.addScriptToEvaluateOnNewDocument", .params = .{
.source = "globalThis.__seeded = 'yes';",
.worldName = "utility",
} });
// Registering the script registers the world, but seeds nothing yet.
const world = bc.findIsolatedWorld("utility") orelse unreachable;
try testing.expect(world.contextFor(child) == null);
try ctx.processMessage(.{ .id = 32, .method = "Runtime.evaluate", .sessionId = "SID-X", .params = .{
.expression = "document.querySelector('iframe').src = 'isolated_world_two.html'",
} });
try testing.waitForPage(bc);
// The child was never named in a Page.createIsolatedWorld, but the script
// seeded it on navigation.
try testing.expect(world.isSeeded(child._frame_id));
const child_ctx = try isolatedWorldContextId(bc, child);
try ctx.processMessage(.{ .id = 33, .method = "Runtime.evaluate", .sessionId = "SID-X", .params = .{
.expression = "__seeded",
.contextId = child_ctx,
} });
try ctx.expectSentResult(.{ .result = .{ .type = "string", .value = "yes" } }, .{ .id = 33 });
// ...and ran there, not in the main world.
try ctx.processMessage(.{ .id = 34, .method = "Runtime.evaluate", .sessionId = "SID-X", .params = .{
.expression = "typeof globalThis.__seeded",
} });
try ctx.expectSentResult(.{ .result = .{ .type = "string", .value = "undefined" } }, .{ .id = 34 });
}
// puppeteer: the utility world is created on the bootstrap about:blank and
// must be announced again for the first document, which navigates the
// pristine Frame in place (no teardown, no frame_destroyed).