diff --git a/src/browser/Session.zig b/src/browser/Session.zig index dd0516aa23..d6720c39d4 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -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 diff --git a/src/browser/tests/cdp/isolated_world.html b/src/browser/tests/cdp/isolated_world.html new file mode 100644 index 0000000000..03a6eeb6fb --- /dev/null +++ b/src/browser/tests/cdp/isolated_world.html @@ -0,0 +1 @@ +Parent jobs diff --git a/src/browser/tests/cdp/isolated_world_one.html b/src/browser/tests/cdp/isolated_world_one.html new file mode 100644 index 0000000000..71ec60c028 --- /dev/null +++ b/src/browser/tests/cdp/isolated_world_one.html @@ -0,0 +1 @@ +Jobs page one diff --git a/src/browser/tests/cdp/isolated_world_two.html b/src/browser/tests/cdp/isolated_world_two.html new file mode 100644 index 0000000000..5f9e88429f --- /dev/null +++ b/src/browser/tests/cdp/isolated_world_two.html @@ -0,0 +1 @@ +Jobs page two diff --git a/src/server/cdp/CDP.zig b/src/server/cdp/CDP.zig index b82491b531..ee9bcf9e8d 100644 --- a/src/server/cdp/CDP.zig +++ b/src/server/cdp/CDP.zig @@ -568,6 +568,7 @@ pub const BrowserContext = struct { try notification.register(.frame_navigated_within_document, self, onFrameNavigatedWithinDocument); try notification.register(.frame_navigate_failed, self, onFrameNavigateFailed); try notification.register(.frame_child_frame_created, self, onFrameChildFrameCreated); + try notification.register(.frame_destroyed, self, onFrameDestroyed); try notification.register(.frame_dom_content_loaded, self, onFrameDOMContentLoaded); try notification.register(.frame_loaded, self, onFrameLoaded); try notification.register(.javascript_dialog_opening, self, onJavascriptDialogOpening); @@ -639,35 +640,35 @@ 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"); errdefer arena.release(); - const call_arena = try browser.arena_pool.acquire(.tiny, "IsolatedWorld.call_arena"); - errdefer call_arena.release(); - - const local_arena = try browser.arena_pool.acquire(.tiny, "IsolatedWorld.local_arena"); - errdefer local_arena.release(); - const world = try arena.create(IsolatedWorld); world.* = .{ .arena = arena, - .call_arena = call_arena, - .local_arena = local_arena, - .context = null, .browser = browser, .name = try arena.dupe(u8, world_name), .grant_universal_access = grant_universal_access, @@ -675,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 { @@ -934,6 +947,11 @@ pub const BrowserContext = struct { return @import("domains/page.zig").frameNavigatedWithinDocument(self, msg); } + pub fn onFrameDestroyed(ctx: *anyopaque, frame: *const Frame) !void { + const self: *BrowserContext = @ptrCast(@alignCast(ctx)); + @import("domains/page.zig").frameDestroyed(self, frame); + } + pub fn onFrameChildFrameCreated(ctx: *anyopaque, msg: *const Notification.FrameChildFrameCreated) !void { const self: *BrowserContext = @ptrCast(@alignCast(ctx)); return @import("domains/page.zig").frameChildFrameCreated(self, msg); @@ -1168,66 +1186,132 @@ 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, }; -/// in the isolated world by using its Context ID or the worldName. -/// grantUniversalAccess Indicates whether the isolated world can reference objects like the DOM or other JS Objects. -/// An isolated world has it's own instance of globals like Window. -/// Generally the client needs to resolve a node into the isolated world to be able to work with it. -/// An object id is unique across all contexts, different object ids can refer to the same Node in different contexts. -const IsolatedWorld = struct { +/// An isolated world is identified by its name and has one V8::Context per +/// 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. +pub const IsolatedWorld = struct { arena: *lp.Arena, - call_arena: *lp.Arena, - local_arena: *lp.Arena, browser: *Browser, name: []const u8, - context: ?*js.Context = null, 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). - // This ensures CDP inspector contexts don't share v8::Globals with 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. identity: js.Identity = .{}, + const FrameContext = struct { + frame: *const Frame, + context: *js.Context, + // Per-context, not per-world: the call_arena is reset when a context's + // call depth returns to 0, which would free the data of another frame's + // in-flight call if they shared one. + call_arena: *lp.Arena, + local_arena: *lp.Arena, + }; + pub fn deinit(self: *IsolatedWorld) void { - self.removeContext(); - self.call_arena.release(); - self.local_arena.release(); + self.removeAllContexts(); self.arena.release(); } - pub fn removeContext(self: *IsolatedWorld) void { - if (self.context) |ctx| { - self.browser.env.destroyContext(ctx); - self.context = null; + pub fn seed(self: *IsolatedWorld, frame_id: u32) !void { + if (self.isSeeded(frame_id)) { + return; } - // I don't think it's possible to have any identity without a context, - // but there's no harm in being safe. - self.identity.deinit(); - self.identity = .{}; + 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. + pub fn contextFor(self: *const IsolatedWorld, frame: *const Frame) ?*js.Context { + for (self.contexts.items) |fc| { + if (fc.frame == frame) { + return fc.context; + } + } + return null; } - // The isolate world must share at least some of the state with the related frame, specifically the DocumentHTML - // (assuming grantUniversalAccess will be set to True!). - // We just created the world and the frame. The frame's state lives in the session, but is update on navigation. - // This also means this pointer becomes invalid after removePage until a new frame is created. - // Currently we have only 1 frame and thus also only 1 state in the isolate world. + // Callers must register the returned context with the inspector pub fn createContext(self: *IsolatedWorld, frame: *Frame) !*js.Context { - if (self.context == null) { - const ctx = try self.browser.env.createContext(frame, .{ - .identity = &self.identity, - .identity_arena = self.arena.allocator(), - .call_arena = self.call_arena.allocator(), - .local_arena = self.local_arena.allocator(), - .debug_name = "IsolatedContext", - }); - self.context = ctx; - } else { - log.warn(.cdp, "not implemented", .{ - .feature = "createContext: Not implemented second isolated context creation", - .info = "reuse existing context", - }); + lp.assert(self.contextFor(frame) == null, "IsolatedWorld.createContext duplicate", .{ .frame_id = frame._frame_id }); + + const browser = self.browser; + const call_arena = try browser.arena_pool.acquire(.tiny, "IsolatedWorld.call_arena"); + errdefer call_arena.release(); + + const local_arena = try browser.arena_pool.acquire(.tiny, "IsolatedWorld.local_arena"); + errdefer local_arena.release(); + + const ctx = try browser.env.createContext(frame, .{ + .identity = &self.identity, + .identity_arena = self.arena.allocator(), + .call_arena = call_arena.allocator(), + .local_arena = local_arena.allocator(), + .debug_name = "IsolatedContext", + }); + errdefer browser.env.destroyContext(ctx); + try ctx.setOrigin(frame.origin); + + try self.contexts.append(self.arena.allocator(), .{ + .frame = frame, + .context = ctx, + .call_arena = call_arena, + .local_arena = local_arena, + }); + return ctx; + } + + pub fn removeContext(self: *IsolatedWorld, frame: *const Frame) void { + for (self.contexts.items, 0..) |fc, i| { + if (fc.frame == frame) { + self.destroyFrameContext(fc); + _ = self.contexts.swapRemove(i); + return; + } } - return self.context.?; + } + + pub fn removeAllContexts(self: *IsolatedWorld) void { + for (self.contexts.items) |fc| { + self.destroyFrameContext(fc); + } + self.contexts.clearRetainingCapacity(); + + // The page's objects are going away with the root frame; wrappers + // keyed by their addresses must not survive to alias a new page's. + self.identity.deinit(); + self.identity = .{}; + } + + fn destroyFrameContext(self: *IsolatedWorld, fc: FrameContext) void { + // A re-navigating child frame keeps its Window, and the identity map + // keeps the window's global proxy; detach it from this context so the + // frame's next context can reattach it (as the main world does). + fc.context.detachGlobal(); + self.browser.env.destroyContext(fc.context); + fc.call_arena.release(); + fc.local_arena.release(); } }; diff --git a/src/server/cdp/domains/dom.zig b/src/server/cdp/domains/dom.zig index 238f0be56e..7ba07da02e 100644 --- a/src/server/cdp/domains/dom.zig +++ b/src/server/cdp/domains/dom.zig @@ -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,40 +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| { - ls.deinit(); - ls_open = false; - - const ctx = (isolated_world.context orelse return error.ContextNotFound); - ctx.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( @@ -396,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, @@ -1129,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 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(); diff --git a/src/server/cdp/domains/page.zig b/src/server/cdp/domains/page.zig index 7ea711e4a9..9025876ce8 100644 --- a/src/server/cdp/domains/page.zig +++ b/src/server/cdp/domains/page.zig @@ -158,6 +158,15 @@ 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) { + _ = try bc.createIsolatedWorld(name, true); + world_name = try bc.arena.dupe(u8, name); + } + } + const script_id = bc.next_script_id; bc.next_script_id += 1; @@ -165,6 +174,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; @@ -246,22 +256,54 @@ fn createIsolatedWorld(cmd: *CDP.Command) !void { } const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; - const world = try bc.createIsolatedWorld(params.worldName, params.grantUniveralAccess); + const frame_id = try id.parseFrameId(params.frameId); + const frame = bc.session.findFrameByFrameId(frame_id) orelse { + return cmd.sendError(-32000, "Frame with the given id does not belong to the target.", .{}); + }; - // An existing world already has a live, inspector-registered context for - // the current document: return its id without re-registering. - if (world.context) |js_context| { - var ls: js.Local.Scope = undefined; - js_context.localScope(&ls); - defer ls.deinit(); - const context_id = bc.inspector_session.inspector.getContextId(&ls.local); - return cmd.sendResult(.{ .executionContextId = context_id }, .{}); - } + const gop = try bc.createIsolatedWorld(params.worldName, params.grantUniveralAccess); + const world = gop.world; - const frame = bc.mainFrame() orelse return error.FrameNotLoaded; + 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); + + var ls: js.Local.Scope = undefined; + js_context.localScope(&ls); + defer ls.deinit(); + const context_id = bc.inspector_session.inspector.getContextId(&ls.local); + return cmd.sendResult(.{ .executionContextId = context_id }, .{}); +} + +// Creates `world`'s context for `frame` and registers it with the inspector +fn createIsolatedWorldContext(arena: Allocator, bc: *CDP.BrowserContext, world: *CDP.IsolatedWorld, frame: *Frame, loader_id: ?[]const u8) !*js.Context { const js_context = try world.createContext(frame); - const aux_data = try std.fmt.allocPrint(cmd.arena, "{{\"isDefault\":false,\"type\":\"isolated\",\"frameId\":\"{s}\"}}", .{params.frameId}); + errdefer world.removeContext(frame); + try registerIsolatedWorldContext(arena, bc, world, js_context, frame, loader_id); + return js_context; +} + +// Registers a world context with the inspector, which assigns the id clients +// 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| + try std.fmt.allocPrint(arena, "{{\"isDefault\":false,\"type\":\"isolated\",\"frameId\":\"{s}\",\"loaderId\":\"{s}\"}}", .{ frame_id, lid }) + else + try std.fmt.allocPrint(arena, "{{\"isDefault\":false,\"type\":\"isolated\",\"frameId\":\"{s}\"}}", .{frame_id}); var ls: js.Local.Scope = undefined; js_context.localScope(&ls); @@ -269,14 +311,11 @@ fn createIsolatedWorld(cmd: *CDP.Command) !void { bc.inspector_session.inspector.contextCreated( &ls.local, - params.worldName, + world.name, frame.origin orelse "", aux_data, false, ); - - const context_id = bc.inspector_session.inspector.getContextId(&ls.local); - return cmd.sendResult(.{ .executionContextId = context_id }, .{}); } fn navigate(cmd: *CDP.Command) !void { @@ -517,7 +556,7 @@ pub fn frameRemove(bc: *CDP.BrowserContext) void { // The main frame is going to be removed, we need to remove contexts from other worlds first. for (bc.isolated_worlds.items) |isolated_world| { - isolated_world.removeContext(); + isolated_world.removeAllContexts(); } // node_registry / node_search_list reference Nodes from the page being @@ -548,10 +587,6 @@ pub fn frameCreated(bc: *CDP.BrowserContext, frame: *Frame) !void { bc.main_world_touched = false; } - for (bc.isolated_worlds.items) |isolated_world| { - _ = try isolated_world.createContext(frame); - } - if (in_commit == false) { // Only retain captured responses until a navigation event. In CDP // terms, this is called a "renderer" and the cache-duration can be @@ -585,6 +620,13 @@ pub fn frameNavigateFailed(bc: *CDP.BrowserContext, event: *const Notification.F }); } +// Fired from Frame.deinit while the frame's JS is still alive. +pub fn frameDestroyed(bc: *CDP.BrowserContext, frame: *const Frame) void { + for (bc.isolated_worlds.items) |isolated_world| { + isolated_world.removeContext(frame); + } +} + pub fn frameChildFrameCreated(bc: *CDP.BrowserContext, event: *const Notification.FrameChildFrameCreated) !void { const session_id = bc.session_id orelse return; @@ -703,50 +745,63 @@ pub fn frameNavigated(arena: Allocator, bc: *CDP.BrowserContext, event: *const N is_root_frame, ); } - // Isolated worlds are session-wide (single V8 context shared across - // navigations). Only re-register them for main frame navigations; - // re-registering during child frame (iframe) navigations would - // re-register the same V8 context under a new inspector id, silently - // invalidating the id the main frame is using. - if (is_root_frame) { - for (bc.isolated_worlds.items) |isolated_world| { - const aux_json = try std.fmt.allocPrint(arena, "{{\"isDefault\":false,\"type\":\"isolated\",\"frameId\":\"{s}\",\"loaderId\":\"{s}\"}}", .{ frame_id, loader_id }); - - // Calling contextCreated will assign a new Id to the context and send the contextCreated event - - var ls: js.Local.Scope = undefined; - (isolated_world.context orelse continue).localScope(&ls); - defer ls.deinit(); - - bc.inspector_session.inspector.contextCreated( - &ls.local, - isolated_world.name, - "://", - aux_json, - false, - ); + // 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). + // A child keeps the id the client was given. The root's id was just + // invalidated by executionContextsCleared: the first navigation of a + // pristine about:blank keeps the Frame and its contexts. + if (!is_root_frame) { + continue; + } + registerIsolatedWorldContext(arena, bc, isolated_world, js_context, frame, loader_id) catch |err| { + log.warn(.cdp, "isolated world context", .{ .err = err, .world = isolated_world.name, .frame_id = frame._frame_id }); + }; + 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 }); + }; } // Evaluate scripts registered via Page.addScriptToEvaluateOnNewDocument. // 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. @@ -1231,29 +1286,319 @@ test "cdp.frame: createIsolatedWorld is idempotent per name" { defer ctx.deinit(); const bc = try ctx.loadBrowserContext(.{ .id = "BID-9", .url = "hi.html", .target_id = "FID-000000000X".* }); + const root = bc.mainFrame() orelse unreachable; + const root_id = id.toFrameId(root._frame_id); try ctx.processMessage(.{ .id = 20, .method = "Page.createIsolatedWorld", .params = .{ - .frameId = "FID-000000000X", + .frameId = &root_id, .worldName = "utility", .grantUniveralAccess = true, } }); try testing.expectEqual(1, bc.isolated_worlds.items.len); - const world_context = bc.isolated_worlds.items[0].context.?; + const world_context = bc.isolated_worlds.items[0].contextFor(root).?; try ctx.processMessage(.{ .id = 21, .method = "Page.createIsolatedWorld", .params = .{ - .frameId = "FID-000000000X", + .frameId = &root_id, .worldName = "utility", .grantUniveralAccess = true, } }); try testing.expectEqual(1, bc.isolated_worlds.items.len); - try testing.expectEqual(world_context, bc.isolated_worlds.items[0].context.?); + try testing.expectEqual(world_context, bc.isolated_worlds.items[0].contextFor(root).?); try ctx.processMessage(.{ .id = 22, .method = "Page.createIsolatedWorld", .params = .{ - .frameId = "FID-000000000X", + .frameId = &root_id, .worldName = "other", .grantUniveralAccess = true, } }); try testing.expectEqual(2, bc.isolated_worlds.items.len); + + try ctx.processMessage(.{ .id = 23, .method = "Page.createIsolatedWorld", .params = .{ + .frameId = "FID-4000000000", + .worldName = "utility", + .grantUniveralAccess = true, + } }); + try ctx.expectSentError(-32000, "Frame with the given id does not belong to the target.", .{ .id = 23 }); +} + +// #3347: a world requested for a child frame must evaluate against that +// frame's document, survive the root world, and follow the child across its +// re-navigation. +test "cdp.frame: createIsolatedWorld targets the requested frame" { + var ctx = try testing.context(); + defer ctx.deinit(); + + const bc = try ctx.loadBrowserContext(.{ .id = "BID-IW", .url = "cdp/isolated_world.html", .target_id = "FID-000000000X".* }); + const root = bc.mainFrame() orelse unreachable; + try testing.expectEqual(1, root.child_frames.items.len); + const child = root.child_frames.items[0]; + const root_id = id.toFrameId(root._frame_id); + const child_id = id.toFrameId(child._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 root_ctx = try isolatedWorldContextId(bc, root); + try ctx.expectSentResult(.{ .executionContextId = root_ctx }, .{ .id = 31 }); + + try ctx.processMessage(.{ .id = 32, .method = "Page.createIsolatedWorld", .params = .{ + .frameId = &child_id, + .worldName = "utility", + .grantUniveralAccess = true, + } }); + const child_ctx = try isolatedWorldContextId(bc, child); + try testing.expect(child_ctx != root_ctx); + try ctx.expectSentResult(.{ .executionContextId = child_ctx }, .{ .id = 32 }); + try ctx.expectSentEvent("Runtime.executionContextCreated", .{ .context = .{ + .id = child_ctx, + .name = "utility", + .auxData = .{ .isDefault = false, .type = "isolated", .frameId = &child_id }, + } }, .{ .session_id = "SID-X" }); + + try ctx.processMessage(.{ .id = 33, .method = "Runtime.evaluate", .sessionId = "SID-X", .params = .{ + .expression = "document.title", + .contextId = child_ctx, + } }); + try ctx.expectSentResult(.{ .result = .{ .type = "string", .value = "Jobs page one" } }, .{ .id = 33 }); + + // Navigate only the child. Its Frame is re-initialized in place: same + // frame id, new document, and a new world context announced for it. + try ctx.processMessage(.{ .id = 34, .method = "Runtime.evaluate", .sessionId = "SID-X", .params = .{ + .expression = "document.querySelector('iframe').src = 'isolated_world_two.html'", + } }); + try testing.waitForPage(bc); + try testing.expectEqual(child, root.child_frames.items[0]); + try testing.expect(std.mem.endsWith(u8, child.url, "/cdp/isolated_world_two.html")); + + try ctx.expectSentEvent("Runtime.executionContextDestroyed", .{ .executionContextId = child_ctx }, .{ .session_id = "SID-X" }); + const child_ctx2 = try isolatedWorldContextId(bc, child); + try testing.expect(child_ctx2 != child_ctx); + try ctx.expectSentEvent("Runtime.executionContextCreated", .{ .context = .{ + .id = child_ctx2, + .name = "utility", + .auxData = .{ .isDefault = false, .type = "isolated", .frameId = &child_id }, + } }, .{ .session_id = "SID-X" }); + + // A driver that re-requests the world gets the announced context. + try ctx.processMessage(.{ .id = 35, .method = "Page.createIsolatedWorld", .params = .{ + .frameId = &child_id, + .worldName = "utility", + .grantUniveralAccess = true, + } }); + try ctx.expectSentResult(.{ .executionContextId = child_ctx2 }, .{ .id = 35 }); + + try ctx.processMessage(.{ .id = 36, .method = "Runtime.evaluate", .sessionId = "SID-X", .params = .{ + .expression = "document.title", + .contextId = child_ctx2, + } }); + try ctx.expectSentResult(.{ .result = .{ .type = "string", .value = "Jobs page two" } }, .{ .id = 36 }); + + // The root world was untouched by the child navigation. + try ctx.processMessage(.{ .id = 37, .method = "Runtime.evaluate", .sessionId = "SID-X", .params = .{ + .expression = "document.title", + .contextId = root_ctx, + } }); + 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). +test "cdp.frame: isolated world survives the in-place first navigation" { + var ctx = try testing.context(); + defer ctx.deinit(); + + try ctx.processMessage(.{ .id = 40, .method = "Target.setAutoAttach", .params = .{ .autoAttach = true, .waitForDebuggerOnStart = false } }); + try ctx.processMessage(.{ .id = 41, .method = "Target.createTarget", .params = .{ .url = "about:blank" } }); + const bc = &ctx.cdp().browser_context.?; + const session_id = bc.session_id.?; + const root = bc.mainFrame() orelse unreachable; + const root_id = id.toFrameId(root._frame_id); + + try ctx.processMessage(.{ .id = 42, .method = "Runtime.enable", .sessionId = session_id }); + try ctx.processMessage(.{ .id = 43, .method = "Page.createIsolatedWorld", .sessionId = session_id, .params = .{ + .frameId = &root_id, + .worldName = "utility", + .grantUniveralAccess = true, + } }); + const blank_ctx = try isolatedWorldContextId(bc, root); + + try ctx.processMessage(.{ .id = 44, .method = "Page.navigate", .sessionId = session_id, .params = .{ + .url = "http://127.0.0.1:9582/src/browser/tests/cdp/isolated_world_one.html", + } }); + try testing.waitForPage(bc); + try testing.expectEqual(root, bc.mainFrame().?); + + const page_ctx = try isolatedWorldContextId(bc, root); + try testing.expect(page_ctx != blank_ctx); + try ctx.expectSentEvent("Runtime.executionContextCreated", .{ .context = .{ + .id = page_ctx, + .name = "utility", + .auxData = .{ .isDefault = false, .type = "isolated", .frameId = &root_id }, + } }, .{ .session_id = session_id }); + + try ctx.processMessage(.{ .id = 45, .method = "Runtime.evaluate", .sessionId = session_id, .params = .{ + .expression = "document.title", + .contextId = page_ctx, + } }); + try ctx.expectSentResult(.{ .result = .{ .type = "string", .value = "Jobs page one" } }, .{ .id = 45 }); +} + +// A committed root navigation tears the old Page down later, with the same +// frame id as the live page. That teardown must not take the live page's +// world context with it. +test "cdp.frame: isolated world survives the old page's deferred teardown" { + var ctx = try testing.context(); + defer ctx.deinit(); + + const bc = try ctx.loadBrowserContext(.{ .id = "BID-IW2", .url = "cdp/isolated_world_one.html", .target_id = "FID-000000000X".* }); + const old_root = bc.mainFrame() orelse unreachable; + const old_frame_id = old_root._frame_id; + const root_id = id.toFrameId(old_frame_id); + + try ctx.processMessage(.{ .id = 50, .method = "Page.createIsolatedWorld", .params = .{ + .frameId = &root_id, + .worldName = "utility", + .grantUniveralAccess = true, + } }); + try testing.expect(bc.isolated_worlds.items[0].contextFor(old_root) != null); + + try ctx.processMessage(.{ .id = 51, .method = "Page.navigate", .sessionId = "SID-X", .params = .{ + .url = "http://127.0.0.1:9582/src/browser/tests/cdp/isolated_world_two.html", + } }); + try testing.waitForPage(bc); + bc.session.processDestroyQueues(); + + // old_root is freed now; only its address is compared. + const root = bc.mainFrame() orelse unreachable; + try testing.expect(root != old_root); + try testing.expectEqual(old_frame_id, root._frame_id); + try testing.expectEqual(1, bc.isolated_worlds.items[0].contexts.items.len); + + const page_ctx = try isolatedWorldContextId(bc, root); + try ctx.processMessage(.{ .id = 52, .method = "Runtime.evaluate", .sessionId = "SID-X", .params = .{ + .expression = "document.title", + .contextId = page_ctx, + } }); + try ctx.expectSentResult(.{ .result = .{ .type = "string", .value = "Jobs page two" } }, .{ .id = 52 }); +} + +test "cdp.frame: isolated world contexts share their frame's origin token" { + var ctx = try testing.context(); + defer ctx.deinit(); + + const bc = try ctx.loadBrowserContext(.{ .id = "BID-IW3", .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); + const child_id = id.toFrameId(child._frame_id); + + try ctx.processMessage(.{ .id = 60, .method = "Runtime.enable", .sessionId = "SID-X" }); + try ctx.processMessage(.{ .id = 61, .method = "Page.createIsolatedWorld", .params = .{ + .frameId = &root_id, + .worldName = "utility", + .grantUniveralAccess = true, + } }); + try ctx.processMessage(.{ .id = 62, .method = "Page.createIsolatedWorld", .params = .{ + .frameId = &child_id, + .worldName = "utility", + .grantUniveralAccess = true, + } }); + + // frames[0] is the child's window in this world; same origin as the root, + // so V8 must let the root's context through. + try ctx.processMessage(.{ .id = 63, .method = "Runtime.evaluate", .sessionId = "SID-X", .params = .{ + .expression = "frames[0].document.title", + .contextId = try isolatedWorldContextId(bc, root), + } }); + try ctx.expectSentResult(.{ .result = .{ .type = "string", .value = "Jobs page one" } }, .{ .id = 63 }); +} + +fn isolatedWorldContextId(bc: *CDP.BrowserContext, frame: *const Frame) !i32 { + const js_context = bc.isolated_worlds.items[0].contextFor(frame) orelse return error.ContextNotFound; + var ls: js.Local.Scope = undefined; + js_context.localScope(&ls); + defer ls.deinit(); + return bc.inspector_session.inspector.getContextId(&ls.local); } test "cdp.frame: child frame metadata" {