Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 12 additions & 10 deletions src/browser/Session.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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).
//
Expand All @@ -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;
}
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/browser/tests/cdp/isolated_world.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<!doctype html><title>Parent jobs</title><iframe src="isolated_world_one.html"></iframe>
1 change: 1 addition & 0 deletions src/browser/tests/cdp/isolated_world_one.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<!doctype html><title>Jobs page one</title>
1 change: 1 addition & 0 deletions src/browser/tests/cdp/isolated_world_two.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<!doctype html><title>Jobs page two</title>
200 changes: 142 additions & 58 deletions src/server/cdp/CDP.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -639,43 +640,55 @@ 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,
};

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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 = .{},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't it a problem having multiple frame context sharing the same identity map?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, this is why the identity map is stored on the page. Two frames in the same origin get the same v8::Object instance.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But that did make me realize that we don't give the context an origin, so it keeps its opaque origin and never gets the correct SecurityToken. That's fixed now.


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);
Comment thread
karlseguin marked this conversation as resolved.
}

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();
}
};

Expand Down
Loading
Loading