From f70865e1745862d91b5ed2c744fba7d5cbf3f82e Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 19 Mar 2026 18:46:35 +0800 Subject: [PATCH] Take 2. History: We started with 1 context and thus only had 1 identity map. Frames were added, and we tried to stick with 1 identity map per context. That didn't work - it breaks cross-frame scripting. We introduced "Origin" so that all frames on the same origin share the same objects. That almost worked, by the v8::Inspector isn't bound by a Context's SecurityToken. So we tried 1 global identity map. But that doesn't work. CDP IsolateWorlds do, in fact, need some isolation. They need new v8::Objects created in their context, even if the object already exists in the main context. In the end, you end up with something like this: A page (and all its frames) needs 1 view of the data. And each IsolateWorld needs it own view. This commit introduces a js.Identity which is referenced by the context. The Session has a js.Identity (used by all pages), and each IsolateWorld has its own js.Identity. As a bonus, the arena pool memory-leak detection has been moved out of the session and into the ArenaPool. This means _all_ arena pool access is audited (in debug mode). This seems superfluous, but it's actually necessary since IsolateWorlds (which now own their own identity) can outlive the Page so there's no clear place to "check" for leaks - except on ArenaPool deinit. --- src/ArenaPool.zig | 80 ++++++++++++--- src/browser/Page.zig | 5 +- src/browser/Session.zig | 188 +++++------------------------------- src/browser/js/Context.zig | 62 +++++++++++- src/browser/js/Env.zig | 15 ++- src/browser/js/Function.zig | 11 ++- src/browser/js/Identity.zig | 76 +++++++++++++++ src/browser/js/Local.zig | 13 ++- src/browser/js/Origin.zig | 7 +- src/browser/js/Promise.zig | 12 ++- src/browser/js/Value.zig | 11 ++- src/browser/js/bridge.zig | 7 +- src/browser/js/js.zig | 1 + src/cdp/cdp.zig | 14 ++- 14 files changed, 290 insertions(+), 212 deletions(-) create mode 100644 src/browser/js/Identity.zig diff --git a/src/ArenaPool.zig b/src/ArenaPool.zig index 2f8de17f..998838df 100644 --- a/src/ArenaPool.zig +++ b/src/ArenaPool.zig @@ -17,12 +17,15 @@ // along with this program. If not, see . const std = @import("std"); +const builtin = @import("builtin"); const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const ArenaPool = @This(); +const IS_DEBUG = builtin.mode == .Debug; + allocator: Allocator, retain_bytes: usize, free_list_len: u16 = 0, @@ -30,10 +33,17 @@ free_list: ?*Entry = null, free_list_max: u16, entry_pool: std.heap.MemoryPool(Entry), mutex: std.Thread.Mutex = .{}, +// Debug mode: track acquire/release counts per debug name to detect leaks and double-frees +_leak_track: if (IS_DEBUG) std.StringHashMapUnmanaged(isize) else void = if (IS_DEBUG) .empty else {}, const Entry = struct { next: ?*Entry, arena: ArenaAllocator, + debug: if (IS_DEBUG) []const u8 else void = if (IS_DEBUG) "" else {}, +}; + +pub const DebugInfo = struct { + debug: []const u8 = "", }; pub fn init(allocator: Allocator, free_list_max: u16, retain_bytes: usize) ArenaPool { @@ -42,10 +52,26 @@ pub fn init(allocator: Allocator, free_list_max: u16, retain_bytes: usize) Arena .free_list_max = free_list_max, .retain_bytes = retain_bytes, .entry_pool = .init(allocator), + ._leak_track = if (IS_DEBUG) .empty else {}, }; } pub fn deinit(self: *ArenaPool) void { + if (IS_DEBUG) { + var has_leaks = false; + var it = self._leak_track.iterator(); + while (it.next()) |kv| { + if (kv.value_ptr.* != 0) { + std.debug.print("ArenaPool leak detected: '{s}' count={d}\n", .{ kv.key_ptr.*, kv.value_ptr.* }); + has_leaks = true; + } + } + if (has_leaks) { + @panic("ArenaPool: leaked arenas detected"); + } + self._leak_track.deinit(self.allocator); + } + var entry = self.free_list; while (entry) |e| { entry = e.next; @@ -54,13 +80,21 @@ pub fn deinit(self: *ArenaPool) void { self.entry_pool.deinit(); } -pub fn acquire(self: *ArenaPool) !Allocator { +pub fn acquire(self: *ArenaPool, dbg: DebugInfo) !Allocator { self.mutex.lock(); defer self.mutex.unlock(); if (self.free_list) |entry| { self.free_list = entry.next; self.free_list_len -= 1; + if (IS_DEBUG) { + entry.debug = dbg.debug; + const gop = try self._leak_track.getOrPut(self.allocator, dbg.debug); + if (!gop.found_existing) { + gop.value_ptr.* = 0; + } + gop.value_ptr.* += 1; + } return entry.arena.allocator(); } @@ -68,8 +102,17 @@ pub fn acquire(self: *ArenaPool) !Allocator { entry.* = .{ .next = null, .arena = ArenaAllocator.init(self.allocator), + .debug = if (IS_DEBUG) dbg.debug else {}, }; + if (IS_DEBUG) { + const gop = try self._leak_track.getOrPut(self.allocator, dbg.debug); + if (!gop.found_existing) { + gop.value_ptr.* = 0; + } + gop.value_ptr.* += 1; + } + return entry.arena.allocator(); } @@ -83,6 +126,19 @@ pub fn release(self: *ArenaPool, allocator: Allocator) void { self.mutex.lock(); defer self.mutex.unlock(); + if (IS_DEBUG) { + if (self._leak_track.getPtr(entry.debug)) |count| { + count.* -= 1; + if (count.* < 0) { + std.debug.print("ArenaPool double-free detected: '{s}'\n", .{entry.debug}); + @panic("ArenaPool: double-free detected"); + } + } else { + std.debug.print("ArenaPool release of untracked arena: '{s}'\n", .{entry.debug}); + @panic("ArenaPool: release of untracked arena"); + } + } + const free_list_len = self.free_list_len; if (free_list_len == self.free_list_max) { arena.deinit(); @@ -106,7 +162,7 @@ test "arena pool - basic acquire and use" { var pool = ArenaPool.init(testing.allocator, 512, 1024 * 16); defer pool.deinit(); - const alloc = try pool.acquire(); + const alloc = try pool.acquire(.{ .debug = "test" }); const buf = try alloc.alloc(u8, 64); @memset(buf, 0xAB); try testing.expectEqual(@as(u8, 0xAB), buf[0]); @@ -118,14 +174,14 @@ test "arena pool - reuse entry after release" { var pool = ArenaPool.init(testing.allocator, 512, 1024 * 16); defer pool.deinit(); - const alloc1 = try pool.acquire(); + const alloc1 = try pool.acquire(.{ .debug = "test" }); try testing.expectEqual(@as(u16, 0), pool.free_list_len); pool.release(alloc1); try testing.expectEqual(@as(u16, 1), pool.free_list_len); // The same entry should be returned from the free list. - const alloc2 = try pool.acquire(); + const alloc2 = try pool.acquire(.{ .debug = "test" }); try testing.expectEqual(@as(u16, 0), pool.free_list_len); try testing.expectEqual(alloc1.ptr, alloc2.ptr); @@ -136,9 +192,9 @@ test "arena pool - multiple concurrent arenas" { var pool = ArenaPool.init(testing.allocator, 512, 1024 * 16); defer pool.deinit(); - const a1 = try pool.acquire(); - const a2 = try pool.acquire(); - const a3 = try pool.acquire(); + const a1 = try pool.acquire(.{ .debug = "test1" }); + const a2 = try pool.acquire(.{ .debug = "test2" }); + const a3 = try pool.acquire(.{ .debug = "test3" }); // All three must be distinct arenas. try testing.expect(a1.ptr != a2.ptr); @@ -161,8 +217,8 @@ test "arena pool - free list respects max limit" { var pool = ArenaPool.init(testing.allocator, 1, 1024 * 16); defer pool.deinit(); - const a1 = try pool.acquire(); - const a2 = try pool.acquire(); + const a1 = try pool.acquire(.{ .debug = "test1" }); + const a2 = try pool.acquire(.{ .debug = "test2" }); pool.release(a1); try testing.expectEqual(@as(u16, 1), pool.free_list_len); @@ -176,7 +232,7 @@ test "arena pool - reset clears memory without releasing" { var pool = ArenaPool.init(testing.allocator, 512, 1024 * 16); defer pool.deinit(); - const alloc = try pool.acquire(); + const alloc = try pool.acquire(.{ .debug = "test" }); const buf = try alloc.alloc(u8, 128); @memset(buf, 0xFF); @@ -200,8 +256,8 @@ test "arena pool - deinit with entries in free list" { // detected by the test allocator). var pool = ArenaPool.init(testing.allocator, 512, 1024 * 16); - const a1 = try pool.acquire(); - const a2 = try pool.acquire(); + const a1 = try pool.acquire(.{ .debug = "test1" }); + const a2 = try pool.acquire(.{ .debug = "test2" }); _ = try a1.alloc(u8, 256); _ = try a2.alloc(u8, 512); pool.release(a1); diff --git a/src/browser/Page.zig b/src/browser/Page.zig index c3a6b5a3..148654a0 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -302,7 +302,10 @@ pub fn init(self: *Page, frame_id: u32, session: *Session, parent: ?*Page) !void self._script_manager = ScriptManager.init(browser.allocator, browser.http_client, self); errdefer self._script_manager.deinit(); - self.js = try browser.env.createContext(self); + self.js = try browser.env.createContext(self, .{ + .identity = &session.identity, + .identity_arena = session.page_arena, + }); errdefer self.js.deinit(); document._page = self; diff --git a/src/browser/Session.zig b/src/browser/Session.zig index 6376ff03..e318e486 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -66,30 +66,14 @@ page_arena: Allocator, // Origin map for same-origin context sharing. Scoped to the root page lifetime. origins: std.StringHashMapUnmanaged(*js.Origin) = .empty, -// Session-scoped identity tracking (for the lifetime of the root page). -// Maps Zig instance pointers to their v8::Global(Object) wrappers. -identity_map: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty, - -// Tracked global v8 objects that need to be released when the page is reset. -globals: std.ArrayList(v8.Global) = .empty, - -// Temporary v8 globals that can be released early. Key is global.data_ptr. -temps: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty, - -// Finalizer callbacks for weak references. Key is @intFromPtr of the Zig instance. -finalizer_callbacks: std.AutoHashMapUnmanaged(usize, *FinalizerCallback) = .empty, +// Identity tracking for the main world. All main world contexts share this, +// ensuring object identity works across same-origin frames. +identity: js.Identity = .{}, // Shared resources for all pages in this session. // These live for the duration of the page tree (root + frames). arena_pool: *ArenaPool, -// In Debug, we use this to see if anything fails to release an arena back to -// the pool. -_arena_pool_leak_track: if (IS_DEBUG) std.AutoHashMapUnmanaged(usize, struct { - owner: []const u8, - count: usize, -}) else void = if (IS_DEBUG) .empty else {}, - page: ?Page, queued_navigation: std.ArrayList(*Page), @@ -105,10 +89,10 @@ pub fn init(self: *Session, browser: *Browser, notification: *Notification) !voi const allocator = browser.app.allocator; const arena_pool = browser.arena_pool; - const arena = try arena_pool.acquire(); + const arena = try arena_pool.acquire(.{ .debug = "Session" }); errdefer arena_pool.release(arena); - const page_arena = try arena_pool.acquire(); + const page_arena = try arena_pool.acquire(.{ .debug = "Session.page_arena" }); errdefer arena_pool.release(page_arena); self.* = .{ @@ -183,32 +167,11 @@ pub const GetArenaOpts = struct { }; pub fn getArena(self: *Session, opts: GetArenaOpts) !Allocator { - const allocator = try self.arena_pool.acquire(); - if (comptime IS_DEBUG) { - // Use session's arena (not page_arena) since page_arena gets reset between pages - const gop = try self._arena_pool_leak_track.getOrPut(self.page_arena, @intFromPtr(allocator.ptr)); - if (gop.found_existing and gop.value_ptr.count != 0) { - log.err(.bug, "ArenaPool Double Use", .{ .owner = gop.value_ptr.*.owner }); - @panic("ArenaPool Double Use"); - } - gop.value_ptr.* = .{ .owner = opts.debug, .count = 1 }; - } - return allocator; + return self.arena_pool.acquire(.{ .debug = opts.debug }); } pub fn releaseArena(self: *Session, allocator: Allocator) void { - if (comptime IS_DEBUG) { - const found = self._arena_pool_leak_track.getPtr(@intFromPtr(allocator.ptr)).?; - if (found.count != 1) { - log.err(.bug, "ArenaPool Double Free", .{ .owner = found.owner, .count = found.count }); - if (comptime builtin.is_test) { - @panic("ArenaPool Double Free"); - } - return; - } - found.count = 0; - } - return self.arena_pool.release(allocator); + self.arena_pool.release(allocator); } pub fn getOrCreateOrigin(self: *Session, key_: ?[]const u8) !*js.Origin { @@ -249,44 +212,8 @@ pub fn releaseOrigin(self: *Session, origin: *js.Origin) void { /// Reset page_arena and factory for a clean slate. /// Called when root page is removed. fn resetPageResources(self: *Session) void { - { - var it = self.finalizer_callbacks.valueIterator(); - while (it.next()) |finalizer| { - finalizer.*.deinit(); - } - self.finalizer_callbacks = .empty; - } - - { - var it = self.identity_map.valueIterator(); - while (it.next()) |global| { - v8.v8__Global__Reset(global); - } - self.identity_map = .empty; - } - - for (self.globals.items) |*global| { - v8.v8__Global__Reset(global); - } - self.globals = .empty; - - { - var it = self.temps.valueIterator(); - while (it.next()) |global| { - v8.v8__Global__Reset(global); - } - self.temps = .empty; - } - - if (comptime IS_DEBUG) { - var it = self._arena_pool_leak_track.valueIterator(); - while (it.next()) |value_ptr| { - if (value_ptr.count > 0) { - log.err(.bug, "ArenaPool Leak", .{ .owner = value_ptr.owner }); - } - } - self._arena_pool_leak_track = .empty; - } + self.identity.deinit(); + self.identity = .{}; if (comptime IS_DEBUG) { std.debug.assert(self.origins.count() == 0); @@ -670,16 +597,6 @@ fn processRootQueuedNavigation(self: *Session) !void { defer self.arena_pool.release(qn.arena); - // HACK - // Mark as released in tracking BEFORE removePage clears the map. - // We can't call releaseArena() because that would also return the arena - // to the pool, making the memory invalid before we use qn.url/qn.opts. - if (comptime IS_DEBUG) { - if (self._arena_pool_leak_track.getPtr(@intFromPtr(qn.arena.ptr))) |found| { - found.count = 0; - } - } - self.removePage(); self.page = @as(Page, undefined); @@ -711,77 +628,6 @@ pub fn nextPageId(self: *Session) u32 { return id; } -// These methods manage the mapping between Zig instances and v8 objects, -// scoped to the lifetime of the root page. -pub fn trackGlobal(self: *Session, global: v8.Global) !void { - return self.globals.append(self.page_arena, global); -} - -pub const IdentityResult = struct { - value_ptr: *v8.Global, - found_existing: bool, -}; - -pub fn addIdentity(self: *Session, ptr: usize) !IdentityResult { - const gop = try self.identity_map.getOrPut(self.page_arena, ptr); - return .{ - .value_ptr = gop.value_ptr, - .found_existing = gop.found_existing, - }; -} - -pub fn trackTemp(self: *Session, global: v8.Global) !void { - return self.temps.put(self.page_arena, global.data_ptr, global); -} - -pub fn releaseTemp(self: *Session, global: v8.Global) void { - if (self.temps.fetchRemove(global.data_ptr)) |kv| { - var g = kv.value; - v8.v8__Global__Reset(&g); - } -} - -/// Release an item from the identity_map (called after finalizer runs from V8) -pub fn releaseIdentity(self: *Session, item: *anyopaque) void { - var global = self.identity_map.fetchRemove(@intFromPtr(item)) orelse { - if (comptime IS_DEBUG) { - std.debug.assert(false); - } - return; - }; - v8.v8__Global__Reset(&global.value); - - // The item has been finalized, remove it from the finalizer callback so that - // we don't try to call it again on shutdown. - const kv = self.finalizer_callbacks.fetchRemove(@intFromPtr(item)) orelse { - if (comptime IS_DEBUG) { - std.debug.assert(false); - } - return; - }; - const fc = kv.value; - self.releaseArena(fc.arena); -} - -pub fn createFinalizerCallback( - self: *Session, - global: v8.Global, - ptr: *anyopaque, - zig_finalizer: *const fn (ptr: *anyopaque, session: *Session) void, -) !*FinalizerCallback { - const arena = try self.getArena(.{ .debug = "FinalizerCallback" }); - errdefer self.releaseArena(arena); - const fc = try arena.create(FinalizerCallback); - fc.* = .{ - .arena = arena, - .session = self, - .ptr = ptr, - .global = global, - .zig_finalizer = zig_finalizer, - }; - return fc; -} - // A type that has a finalizer can have its finalizer called one of two ways. // The first is from V8 via the WeakCallback we give to weakRef. But that isn't // guaranteed to fire, so we track this in finalizer_callbacks and call them on @@ -791,10 +637,26 @@ pub const FinalizerCallback = struct { session: *Session, ptr: *anyopaque, global: v8.Global, + identity: *js.Identity, zig_finalizer: *const fn (ptr: *anyopaque, session: *Session) void, pub fn deinit(self: *FinalizerCallback) void { self.zig_finalizer(self.ptr, self.session); self.session.releaseArena(self.arena); } + + /// Release this item from the identity tracking maps (called after finalizer runs from V8) + pub fn releaseIdentity(self: *FinalizerCallback) void { + const session = self.session; + const id = @intFromPtr(self.ptr); + + if (self.identity.identity_map.fetchRemove(id)) |kv| { + var global = kv.value; + v8.v8__Global__Reset(&global); + } + + _ = self.identity.finalizer_callbacks.remove(id); + + session.releaseArena(self.arena); + } }; diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index 5ea9454f..2b861e7a 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -79,6 +79,16 @@ local: ?*const js.Local = null, origin: *Origin, +// Identity tracking for this context. For main world contexts, this points to +// Session's Identity. For isolated world contexts (CDP inspector), this points +// to IsolatedWorld's Identity. This ensures same-origin frames share object +// identity while isolated worlds have separate identity tracking. +identity: *js.Identity, + +// Allocator to use for identity map operations. For main world contexts this is +// session.page_arena, for isolated worlds it's the isolated world's arena. +identity_arena: Allocator, + // Unlike other v8 types, like functions or objects, modules are not shared // across origins. global_modules: std.ArrayList(v8.Global) = .empty, @@ -202,16 +212,16 @@ pub fn setOrigin(self: *Context, key: ?[]const u8) !void { } pub fn trackGlobal(self: *Context, global: v8.Global) !void { - return self.session.trackGlobal(global); + return self.identity.globals.append(self.identity_arena, global); } pub fn trackTemp(self: *Context, global: v8.Global) !void { - return self.session.trackTemp(global); + return self.identity.temps.put(self.identity_arena, global.data_ptr, global); } pub fn weakRef(self: *Context, obj: anytype) void { const resolved = js.Local.resolveValue(obj); - const fc = self.session.finalizer_callbacks.get(@intFromPtr(resolved.ptr)) orelse { + const fc = self.identity.finalizer_callbacks.get(@intFromPtr(resolved.ptr)) orelse { if (comptime IS_DEBUG) { // should not be possible std.debug.assert(false); @@ -223,7 +233,7 @@ pub fn weakRef(self: *Context, obj: anytype) void { pub fn safeWeakRef(self: *Context, obj: anytype) void { const resolved = js.Local.resolveValue(obj); - const fc = self.session.finalizer_callbacks.get(@intFromPtr(resolved.ptr)) orelse { + const fc = self.identity.finalizer_callbacks.get(@intFromPtr(resolved.ptr)) orelse { if (comptime IS_DEBUG) { // should not be possible std.debug.assert(false); @@ -236,7 +246,7 @@ pub fn safeWeakRef(self: *Context, obj: anytype) void { pub fn strongRef(self: *Context, obj: anytype) void { const resolved = js.Local.resolveValue(obj); - const fc = self.session.finalizer_callbacks.get(@intFromPtr(resolved.ptr)) orelse { + const fc = self.identity.finalizer_callbacks.get(@intFromPtr(resolved.ptr)) orelse { if (comptime IS_DEBUG) { // should not be possible std.debug.assert(false); @@ -246,6 +256,48 @@ pub fn strongRef(self: *Context, obj: anytype) void { v8.v8__Global__ClearWeak(&fc.global); } +pub const IdentityResult = struct { + value_ptr: *v8.Global, + found_existing: bool, +}; + +pub fn addIdentity(self: *Context, ptr: usize) !IdentityResult { + const gop = try self.identity.identity_map.getOrPut(self.identity_arena, ptr); + return .{ + .value_ptr = gop.value_ptr, + .found_existing = gop.found_existing, + }; +} + +pub fn releaseTemp(self: *Context, global: v8.Global) void { + if (self.identity.temps.fetchRemove(global.data_ptr)) |kv| { + var g = kv.value; + v8.v8__Global__Reset(&g); + } +} + +pub fn createFinalizerCallback( + self: *Context, + global: v8.Global, + ptr: *anyopaque, + zig_finalizer: *const fn (ptr: *anyopaque, session: *Session) void, +) !*Session.FinalizerCallback { + const session = self.session; + const arena = try session.getArena(.{ .debug = "FinalizerCallback" }); + errdefer session.releaseArena(arena); + const fc = try arena.create(Session.FinalizerCallback); + fc.* = .{ + .arena = arena, + .session = session, + .ptr = ptr, + .global = global, + .zig_finalizer = zig_finalizer, + // Store identity pointer for cleanup when V8 GCs the object + .identity = self.identity, + }; + return fc; +} + // Any operation on the context have to be made from a local. pub fn localScope(self: *Context, ls: *js.Local.Scope) void { const isolate = self.isolate; diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index 07f1a479..d248a411 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -34,6 +34,7 @@ const Snapshot = @import("Snapshot.zig"); const Inspector = @import("Inspector.zig"); const Page = @import("../Page.zig"); +const Session = @import("../Session.zig"); const Window = @import("../webapi/Window.zig"); const JsApis = bridge.JsApis; @@ -254,8 +255,14 @@ pub fn deinit(self: *Env) void { allocator.destroy(self.isolate_params); } -pub fn createContext(self: *Env, page: *Page) !*Context { - const context_arena = try self.app.arena_pool.acquire(); +pub const ContextParams = struct { + identity: *js.Identity, + identity_arena: Allocator, + debug_name: []const u8 = "Context", +}; + +pub fn createContext(self: *Env, page: *Page, params: ContextParams) !*Context { + const context_arena = try self.app.arena_pool.acquire(.{ .debug = params.debug_name }); errdefer self.app.arena_pool.release(context_arena); const isolate = self.isolate; @@ -322,12 +329,14 @@ pub fn createContext(self: *Env, page: *Page) !*Context { .microtask_queue = microtask_queue, .script_manager = &page._script_manager, .scheduler = .init(context_arena), + .identity = params.identity, + .identity_arena = params.identity_arena, }; { // Multiple contexts can be created for the same Window (via CDP). We only // need to register the first one. - const gop = try session.identity_map.getOrPut(session.page_arena, @intFromPtr(page.window)); + const gop = try params.identity.identity_map.getOrPut(params.identity_arena, @intFromPtr(page.window)); if (gop.found_existing == false) { // our window wrapped in a v8::Global var global_global: v8.Global = undefined; diff --git a/src/browser/js/Function.zig b/src/browser/js/Function.zig index e2c10e9f..4c84a08f 100644 --- a/src/browser/js/Function.zig +++ b/src/browser/js/Function.zig @@ -211,10 +211,10 @@ fn _persist(self: *const Function, comptime is_global: bool) !(if (is_global) Gl v8.v8__Global__New(ctx.isolate.handle, self.handle, &global); if (comptime is_global) { try ctx.trackGlobal(global); - return .{ .handle = global, .session = {} }; + return .{ .handle = global, .temps = {} }; } try ctx.trackTemp(global); - return .{ .handle = global, .session = ctx.session }; + return .{ .handle = global, .temps = &ctx.identity.temps }; } pub fn tempWithThis(self: *const Function, value: anytype) !Temp { @@ -238,7 +238,7 @@ const GlobalType = enum(u8) { fn G(comptime global_type: GlobalType) type { return struct { handle: v8.Global, - session: if (global_type == .temp) *Session else void, + temps: if (global_type == .temp) *std.AutoHashMapUnmanaged(usize, v8.Global) else void, const Self = @This(); @@ -258,7 +258,10 @@ fn G(comptime global_type: GlobalType) type { } pub fn release(self: *const Self) void { - self.session.releaseTemp(self.handle); + if (self.temps.fetchRemove(self.handle.data_ptr)) |kv| { + var g = kv.value; + v8.v8__Global__Reset(&g); + } } }; } diff --git a/src/browser/js/Identity.zig b/src/browser/js/Identity.zig new file mode 100644 index 00000000..323ae909 --- /dev/null +++ b/src/browser/js/Identity.zig @@ -0,0 +1,76 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Identity manages the mapping between Zig instances and their v8::Object wrappers. +// This provides object identity semantics - the same Zig instance always maps to +// the same JS object within a given Identity scope. +// +// Main world contexts share a single Identity (on Session), ensuring that +// `window.top.document === top's document` works across same-origin frames. +// +// Isolated worlds (CDP inspector) have their own Identity, ensuring their +// v8::Global wrappers don't leak into the main world. + +const std = @import("std"); +const js = @import("js.zig"); + +const Session = @import("../Session.zig"); + +const v8 = js.v8; +const Allocator = std.mem.Allocator; + +const Identity = @This(); + +// Maps Zig instance pointers to their v8::Global(Object) wrappers. +identity_map: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty, + +// Tracked global v8 objects that need to be released on cleanup. +globals: std.ArrayList(v8.Global) = .empty, + +// Temporary v8 globals that can be released early. Key is global.data_ptr. +temps: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty, + +// Finalizer callbacks for weak references. Key is @intFromPtr of the Zig instance. +finalizer_callbacks: std.AutoHashMapUnmanaged(usize, *Session.FinalizerCallback) = .empty, + +pub fn deinit(self: *Identity) void { + { + var it = self.finalizer_callbacks.valueIterator(); + while (it.next()) |finalizer| { + finalizer.*.deinit(); + } + } + + { + var it = self.identity_map.valueIterator(); + while (it.next()) |global| { + v8.v8__Global__Reset(global); + } + } + + for (self.globals.items) |*global| { + v8.v8__Global__Reset(global); + } + + { + var it = self.temps.valueIterator(); + while (it.next()) |global| { + v8.v8__Global__Reset(global); + } + } +} diff --git a/src/browser/js/Local.zig b/src/browser/js/Local.zig index ca09fc91..9080e3bc 100644 --- a/src/browser/js/Local.zig +++ b/src/browser/js/Local.zig @@ -202,21 +202,20 @@ pub fn compileAndRun(self: *const Local, src: []const u8, name: ?[]const u8) !js // we can just grab it from the identity_map) pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object, value: anytype) !js.Object { const ctx = self.ctx; - const session = ctx.session; - const page_arena = session.page_arena; + const context_arena = ctx.arena; const T = @TypeOf(value); switch (@typeInfo(T)) { .@"struct" => { // Struct, has to be placed on the heap - const heap = try page_arena.create(T); + const heap = try context_arena.create(T); heap.* = value; return self.mapZigInstanceToJs(js_obj_handle, heap); }, .pointer => |ptr| { const resolved = resolveValue(value); - const gop = try session.addIdentity(@intFromPtr(resolved.ptr)); + const gop = try ctx.addIdentity(@intFromPtr(resolved.ptr)); if (gop.found_existing) { // we've seen this instance before, return the same object return (js.Object.Global{ .handle = gop.value_ptr.* }).local(self); @@ -245,7 +244,7 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object, // The TAO contains the pointer to our Zig instance as // well as any meta data we'll need to use it later. // See the TaggedOpaque struct for more details. - const tao = try page_arena.create(TaggedOpaque); + const tao = try context_arena.create(TaggedOpaque); tao.* = .{ .value = resolved.ptr, .prototype_chain = resolved.prototype_chain.ptr, @@ -277,10 +276,10 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object, // Instead, we check if the base has finalizer. The assumption // here is that if a resolve type has a finalizer, then the base // should have a finalizer too. - const fc = try session.createFinalizerCallback(gop.value_ptr.*, resolved.ptr, resolved.finalizer_from_zig.?); + const fc = try ctx.createFinalizerCallback(gop.value_ptr.*, resolved.ptr, resolved.finalizer_from_zig.?); { errdefer fc.deinit(); - try session.finalizer_callbacks.put(page_arena, @intFromPtr(resolved.ptr), fc); + try ctx.identity.finalizer_callbacks.put(ctx.identity_arena, @intFromPtr(resolved.ptr), fc); } conditionallyReference(value); diff --git a/src/browser/js/Origin.zig b/src/browser/js/Origin.zig index 6f79b005..c6c6bf81 100644 --- a/src/browser/js/Origin.zig +++ b/src/browser/js/Origin.zig @@ -20,8 +20,9 @@ // Multiple contexts (frames) from the same origin share a single Origin, // which provides the V8 SecurityToken that allows cross-context access. // -// Note: Identity tracking (mapping Zig instances to v8::Objects) is now -// handled at the Session level, scoped to the root page lifetime. +// Note: Identity tracking (mapping Zig instances to v8::Objects) is managed +// separately via js.Identity - Session has the main world Identity, and +// IsolatedWorlds have their own Identity instances. const std = @import("std"); const js = @import("js.zig"); @@ -44,7 +45,7 @@ key: []const u8, security_token: v8.Global, pub fn init(app: *App, isolate: js.Isolate, key: []const u8) !*Origin { - const arena = try app.arena_pool.acquire(); + const arena = try app.arena_pool.acquire(.{ .debug = "Origin" }); errdefer app.arena_pool.release(arena); var hs: js.HandleScope = undefined; diff --git a/src/browser/js/Promise.zig b/src/browser/js/Promise.zig index 0a08f424..4e83c098 100644 --- a/src/browser/js/Promise.zig +++ b/src/browser/js/Promise.zig @@ -16,6 +16,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const std = @import("std"); const js = @import("js.zig"); const v8 = js.v8; @@ -65,10 +66,10 @@ fn _persist(self: *const Promise, comptime is_global: bool) !(if (is_global) Glo v8.v8__Global__New(ctx.isolate.handle, self.handle, &global); if (comptime is_global) { try ctx.trackGlobal(global); - return .{ .handle = global, .session = {} }; + return .{ .handle = global, .temps = {} }; } try ctx.trackTemp(global); - return .{ .handle = global, .session = ctx.session }; + return .{ .handle = global, .temps = &ctx.identity.temps }; } pub const Temp = G(.temp); @@ -82,7 +83,7 @@ const GlobalType = enum(u8) { fn G(comptime global_type: GlobalType) type { return struct { handle: v8.Global, - session: if (global_type == .temp) *Session else void, + temps: if (global_type == .temp) *std.AutoHashMapUnmanaged(usize, v8.Global) else void, const Self = @This(); @@ -98,7 +99,10 @@ fn G(comptime global_type: GlobalType) type { } pub fn release(self: *const Self) void { - self.session.releaseTemp(self.handle); + if (self.temps.fetchRemove(self.handle.data_ptr)) |kv| { + var g = kv.value; + v8.v8__Global__Reset(&g); + } } }; } diff --git a/src/browser/js/Value.zig b/src/browser/js/Value.zig index edbbe4e3..d71e2f83 100644 --- a/src/browser/js/Value.zig +++ b/src/browser/js/Value.zig @@ -301,10 +301,10 @@ fn _persist(self: *const Value, comptime is_global: bool) !(if (is_global) Globa v8.v8__Global__New(ctx.isolate.handle, self.handle, &global); if (comptime is_global) { try ctx.trackGlobal(global); - return .{ .handle = global, .session = {} }; + return .{ .handle = global, .temps = {} }; } try ctx.trackTemp(global); - return .{ .handle = global, .session = ctx.session }; + return .{ .handle = global, .temps = &ctx.identity.temps }; } pub fn toZig(self: Value, comptime T: type) !T { @@ -362,7 +362,7 @@ const GlobalType = enum(u8) { fn G(comptime global_type: GlobalType) type { return struct { handle: v8.Global, - session: if (global_type == .temp) *Session else void, + temps: if (global_type == .temp) *std.AutoHashMapUnmanaged(usize, v8.Global) else void, const Self = @This(); @@ -382,7 +382,10 @@ fn G(comptime global_type: GlobalType) type { } pub fn release(self: *const Self) void { - self.session.releaseTemp(self.handle); + if (self.temps.fetchRemove(self.handle.data_ptr)) |kv| { + var g = kv.value; + v8.v8__Global__Reset(&g); + } } }; } diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 57cff1fa..8135e7d7 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -118,11 +118,10 @@ pub fn Builder(comptime T: type) type { const ptr = v8.v8__WeakCallbackInfo__GetParameter(handle.?).?; const fc: *Session.FinalizerCallback = @ptrCast(@alignCast(ptr)); - const session = fc.session; const value_ptr = fc.ptr; - if (session.finalizer_callbacks.contains(@intFromPtr(value_ptr))) { - func(@ptrCast(@alignCast(value_ptr)), false, session); - session.releaseIdentity(value_ptr); + if (fc.identity.finalizer_callbacks.contains(@intFromPtr(value_ptr))) { + func(@ptrCast(@alignCast(value_ptr)), false, fc.session); + fc.releaseIdentity(); } else { // A bit weird, but v8 _requires_ that we release it // If we don't. We'll 100% crash. diff --git a/src/browser/js/js.zig b/src/browser/js/js.zig index 0c196e5b..10867167 100644 --- a/src/browser/js/js.zig +++ b/src/browser/js/js.zig @@ -25,6 +25,7 @@ pub const Env = @import("Env.zig"); pub const bridge = @import("bridge.zig"); pub const Caller = @import("Caller.zig"); pub const Origin = @import("Origin.zig"); +pub const Identity = @import("Identity.zig"); pub const Context = @import("Context.zig"); pub const Local = @import("Local.zig"); pub const Inspector = @import("Inspector.zig"); diff --git a/src/cdp/cdp.zig b/src/cdp/cdp.zig index 58ed11b9..d6b35d83 100644 --- a/src/cdp/cdp.zig +++ b/src/cdp/cdp.zig @@ -489,7 +489,7 @@ pub fn BrowserContext(comptime CDP_T: type) type { pub fn createIsolatedWorld(self: *Self, world_name: []const u8, grant_universal_access: bool) !*IsolatedWorld { const browser = &self.cdp.browser; - const arena = try browser.arena_pool.acquire(); + const arena = try browser.arena_pool.acquire(.{ .debug = "IsolatedWorld" }); errdefer browser.arena_pool.release(arena); const world = try arena.create(IsolatedWorld); @@ -750,8 +750,13 @@ const IsolatedWorld = struct { context: ?*js.Context = null, grant_universal_access: bool, + // Identity tracking for this isolated world (separate from main world). + // This ensures CDP inspector contexts don't share v8::Globals with main world. + identity: js.Identity = .{}, + pub fn deinit(self: *IsolatedWorld) void { self.removeContext() catch {}; + self.identity.deinit(); self.browser.arena_pool.release(self.arena); } @@ -768,7 +773,12 @@ const IsolatedWorld = struct { // Currently we have only 1 page/frame and thus also only 1 state in the isolate world. pub fn createContext(self: *IsolatedWorld, page: *Page) !*js.Context { if (self.context == null) { - self.context = try self.browser.env.createContext(page); + const ctx = try self.browser.env.createContext(page, .{ + .identity = &self.identity, + .identity_arena = self.arena, + .debug_name = "IsolatedContext", + }); + self.context = ctx; } else { log.warn(.cdp, "not implemented", .{ .feature = "createContext: Not implemented second isolated context creation",