From d9c5f5650085f9bfabb131312fb10cc28fb66c42 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 18 Mar 2026 18:59:09 +0800 Subject: [PATCH 1/4] Remove Origins js.Origin was added to allow frames on the same origin to share our zig<->js maps / identity. It assumes that scripts on different origins will never be allowed (by v8) to access the same zig instances. If two different origins DID access the same zig instance, we'd have a few different problems. First, while the mapping would exist in Origin1's identity_map, when the zig instance was returned to a script in Origin2, it would not be found in Origin2's identity_map, and thus create a new v8::Object. Thus we'd end up with 2 v8::Objects for the same Zig instance. This is potentially not the end of the world, but not great either as any zig-native data _would_ be shared (it's the same instance after all), but js-native data wouldn't. The real problem this introduces though is with Finalizers. A weak reference that falls out of scope in Origin1 will get cleaned up, even though it's still referenced from Origin2. Now, under normal circumstances, this isn't an issue; v8 _does_ ensure that cross-origin access isn't allowed (because we set a SecurityToken on the v8::Context). But it seems like the v8::Inspector isn't bound by these restrictions and can happily access and share objects across origin. The simplest solution I can come up with is to move the mapping from the Origin to the Session. This does mean that objects might live longer than they have to. When all references to an origin go out of scope, we can do some cleanup. Not so when the Session owns this data. But really, how often are iframes on different origins being created and deleted within the lifetime of a page? When Origins were first introduces, the Session got burdened with having to manage multiple lifecycles: 1 - The page-surviving data (e.g. history) 2 - The root page lifecycle (e.g. page_arena, queuedNavigation) 3 - The origin lookup This commit doesn't change that, but it makes the session responsible for _a lot_ more of the root page lifecycle (#2 above). I lied. js.Origin still exists, but it's a shell of its former self. It only exists to store the SecurityToken name that is re-used for every context with the same origin. The v8 namespace leaks into Session. MutationObserver and IntersectionObserver are now back to using weak/strong refs which was one of the failing cases before this change. --- src/browser/Session.zig | 146 +++++++++++++- src/browser/js/Context.zig | 13 +- src/browser/js/Env.zig | 17 +- src/browser/js/Function.zig | 9 +- src/browser/js/Local.zig | 13 +- src/browser/js/Origin.zig | 204 +------------------- src/browser/js/Promise.zig | 10 +- src/browser/js/String.zig | 2 +- src/browser/js/Value.zig | 9 +- src/browser/js/bridge.zig | 11 +- src/browser/webapi/IntersectionObserver.zig | 22 ++- src/browser/webapi/MutationObserver.zig | 18 +- 12 files changed, 215 insertions(+), 259 deletions(-) diff --git a/src/browser/Session.zig b/src/browser/Session.zig index 73b6b26e..6376ff03 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -24,6 +24,7 @@ const log = @import("../log.zig"); const App = @import("../App.zig"); const js = @import("js/js.zig"); +const v8 = js.v8; const storage = @import("webapi/storage/storage.zig"); const Navigation = @import("webapi/navigation/Navigation.zig"); const History = @import("webapi/History.zig"); @@ -65,6 +66,19 @@ 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, + // Shared resources for all pages in this session. // These live for the duration of the page tree (root + frames). arena_pool: *ArenaPool, @@ -84,8 +98,8 @@ queued_navigation: std.ArrayList(*Page), // about:blank navigations (which may add to queued_navigation). queued_queued_navigation: std.ArrayList(*Page), -page_id_gen: u32, -frame_id_gen: u32, +page_id_gen: u32 = 0, +frame_id_gen: u32 = 0, pub fn init(self: *Session, browser: *Browser, notification: *Notification) !void { const allocator = browser.app.allocator; @@ -104,8 +118,6 @@ pub fn init(self: *Session, browser: *Browser, notification: *Notification) !voi .page_arena = page_arena, .factory = Factory.init(page_arena), .history = .{}, - .page_id_gen = 0, - .frame_id_gen = 0, // The prototype (EventTarget) for Navigation is created when a Page is created. .navigation = .{ ._proto = undefined }, .storage_shed = .{}, @@ -174,7 +186,7 @@ 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.arena, @intFromPtr(allocator.ptr)); + 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"); @@ -237,7 +249,35 @@ 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 { - // Check for arena leaks before releasing + { + 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| { @@ -245,10 +285,9 @@ fn resetPageResources(self: *Session) void { log.err(.bug, "ArenaPool Leak", .{ .owner = value_ptr.owner }); } } - self._arena_pool_leak_track.clearRetainingCapacity(); + self._arena_pool_leak_track = .empty; } - // All origins should have been released when contexts were destroyed if (comptime IS_DEBUG) { std.debug.assert(self.origins.count() == 0); } @@ -259,10 +298,9 @@ fn resetPageResources(self: *Session) void { while (it.next()) |value| { value.*.deinit(app); } - self.origins.clearRetainingCapacity(); + self.origins = .empty; } - // Release old page_arena and acquire fresh one self.frame_id_gen = 0; self.arena_pool.reset(self.page_arena, 64 * 1024); self.factory = Factory.init(self.page_arena); @@ -672,3 +710,91 @@ pub fn nextPageId(self: *Session) u32 { self.page_id_gen = id; 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 +// page reset. +pub const FinalizerCallback = struct { + arena: Allocator, + session: *Session, + ptr: *anyopaque, + global: v8.Global, + 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); + } +}; diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index da7362aa..5ea9454f 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -185,9 +185,8 @@ pub fn setOrigin(self: *Context, key: ?[]const u8) !void { lp.assert(self.origin.rc == 1, "Ref opaque origin", .{ .rc = self.origin.rc }); const origin = try self.session.getOrCreateOrigin(key); - errdefer self.session.releaseOrigin(origin); - try origin.takeover(self.origin); + self.session.releaseOrigin(self.origin); self.origin = origin; { @@ -203,16 +202,16 @@ pub fn setOrigin(self: *Context, key: ?[]const u8) !void { } pub fn trackGlobal(self: *Context, global: v8.Global) !void { - return self.origin.trackGlobal(global); + return self.session.trackGlobal(global); } pub fn trackTemp(self: *Context, global: v8.Global) !void { - return self.origin.trackTemp(global); + return self.session.trackTemp(global); } pub fn weakRef(self: *Context, obj: anytype) void { const resolved = js.Local.resolveValue(obj); - const fc = self.origin.finalizer_callbacks.get(@intFromPtr(resolved.ptr)) orelse { + const fc = self.session.finalizer_callbacks.get(@intFromPtr(resolved.ptr)) orelse { if (comptime IS_DEBUG) { // should not be possible std.debug.assert(false); @@ -224,7 +223,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.origin.finalizer_callbacks.get(@intFromPtr(resolved.ptr)) orelse { + const fc = self.session.finalizer_callbacks.get(@intFromPtr(resolved.ptr)) orelse { if (comptime IS_DEBUG) { // should not be possible std.debug.assert(false); @@ -237,7 +236,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.origin.finalizer_callbacks.get(@intFromPtr(resolved.ptr)) orelse { + const fc = self.session.finalizer_callbacks.get(@intFromPtr(resolved.ptr)) orelse { if (comptime IS_DEBUG) { // should not be possible std.debug.assert(false); diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index 09117eb0..c417a6e5 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -307,16 +307,17 @@ pub fn createContext(self: *Env, page: *Page) !*Context { const context_id = self.context_id; self.context_id = context_id + 1; - const origin = try page._session.getOrCreateOrigin(null); - errdefer page._session.releaseOrigin(origin); + const session = page._session; + const origin = try session.getOrCreateOrigin(null); + errdefer session.releaseOrigin(origin); const context = try context_arena.create(Context); context.* = .{ .env = self, .page = page, - .session = page._session, .origin = origin, .id = context_id, + .session = session, .isolate = isolate, .arena = context_arena, .handle = context_global, @@ -326,7 +327,15 @@ pub fn createContext(self: *Env, page: *Page) !*Context { .script_manager = &page._script_manager, .scheduler = .init(context_arena), }; - try context.origin.identity_map.putNoClobber(origin.arena, @intFromPtr(page.window), global_global); + + { + // 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)); + if (gop.found_existing == false) { + gop.value_ptr.* = global_global; + } + } // Store a pointer to our context inside the v8 context so that, given // a v8 context, we can get our context out diff --git a/src/browser/js/Function.zig b/src/browser/js/Function.zig index bfb5e53d..e2c10e9f 100644 --- a/src/browser/js/Function.zig +++ b/src/browser/js/Function.zig @@ -21,6 +21,7 @@ const js = @import("js.zig"); const v8 = js.v8; const log = @import("../../log.zig"); +const Session = @import("../Session.zig"); const Function = @This(); @@ -210,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, .origin = {} }; + return .{ .handle = global, .session = {} }; } try ctx.trackTemp(global); - return .{ .handle = global, .origin = ctx.origin }; + return .{ .handle = global, .session = ctx.session }; } pub fn tempWithThis(self: *const Function, value: anytype) !Temp { @@ -237,7 +238,7 @@ const GlobalType = enum(u8) { fn G(comptime global_type: GlobalType) type { return struct { handle: v8.Global, - origin: if (global_type == .temp) *js.Origin else void, + session: if (global_type == .temp) *Session else void, const Self = @This(); @@ -257,7 +258,7 @@ fn G(comptime global_type: GlobalType) type { } pub fn release(self: *const Self) void { - self.origin.releaseTemp(self.handle); + self.session.releaseTemp(self.handle); } }; } diff --git a/src/browser/js/Local.zig b/src/browser/js/Local.zig index a45b35df..ca09fc91 100644 --- a/src/browser/js/Local.zig +++ b/src/browser/js/Local.zig @@ -202,20 +202,21 @@ 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 origin_arena = ctx.origin.arena; + const session = ctx.session; + const page_arena = session.page_arena; const T = @TypeOf(value); switch (@typeInfo(T)) { .@"struct" => { // Struct, has to be placed on the heap - const heap = try origin_arena.create(T); + const heap = try page_arena.create(T); heap.* = value; return self.mapZigInstanceToJs(js_obj_handle, heap); }, .pointer => |ptr| { const resolved = resolveValue(value); - const gop = try ctx.origin.addIdentity(@intFromPtr(resolved.ptr)); + const gop = try session.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); @@ -244,7 +245,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 origin_arena.create(TaggedOpaque); + const tao = try page_arena.create(TaggedOpaque); tao.* = .{ .value = resolved.ptr, .prototype_chain = resolved.prototype_chain.ptr, @@ -276,10 +277,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 ctx.origin.createFinalizerCallback(ctx.session, gop.value_ptr.*, resolved.ptr, resolved.finalizer_from_zig.?); + const fc = try session.createFinalizerCallback(gop.value_ptr.*, resolved.ptr, resolved.finalizer_from_zig.?); { errdefer fc.deinit(); - try ctx.origin.finalizer_callbacks.put(ctx.origin.arena, @intFromPtr(resolved.ptr), fc); + try session.finalizer_callbacks.put(page_arena, @intFromPtr(resolved.ptr), fc); } conditionallyReference(value); diff --git a/src/browser/js/Origin.zig b/src/browser/js/Origin.zig index 9dc5857b..6f79b005 100644 --- a/src/browser/js/Origin.zig +++ b/src/browser/js/Origin.zig @@ -16,19 +16,20 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -// Origin represents the shared Zig<->JS bridge state for all contexts within -// the same origin. Multiple contexts (frames) from the same origin share a -// single Origin, ensuring that JS objects maintain their identity across frames. +// Origin represents the security token for contexts within the same origin. +// 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. const std = @import("std"); const js = @import("js.zig"); const App = @import("../../App.zig"); -const Session = @import("../Session.zig"); const v8 = js.v8; const Allocator = std.mem.Allocator; -const IS_DEBUG = @import("builtin").mode == .Debug; const Origin = @This(); @@ -38,38 +39,10 @@ arena: Allocator, // The key, e.g. lightpanda.io:443 key: []const u8, -// Security token - all contexts in this realm must use the same v8::Value instance +// Security token - all contexts in this origin must use the same v8::Value instance // as their security token for V8 to allow cross-context access security_token: v8.Global, -// Serves two purposes. Like `global_objects`, this is used to free -// every Global(Object) we've created during the lifetime of the realm. -// More importantly, it serves as an identity map - for a given Zig -// instance, we map it to the same Global(Object). -// The key is the @intFromPtr of the Zig value -identity_map: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty, - -// Some web APIs have to manage opaque values. Ideally, they use an -// js.Object, but the js.Object has no lifetime guarantee beyond the -// current call. They can call .persist() on their js.Object to get -// a `Global(Object)`. We need to track these to free them. -// This used to be a map and acted like identity_map; the key was -// the @intFromPtr(js_obj.handle). But v8 can re-use address. Without -// a reliable way to know if an object has already been persisted, -// we now simply persist every time persist() is called. -globals: std.ArrayList(v8.Global) = .empty, - -// Temp variants stored in HashMaps for O(1) early cleanup. -// Key is global.data_ptr. -temps: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty, - -// Any type that is stored in the identity_map which has a finalizer declared -// will have its finalizer stored here. This is only used when shutting down -// if v8 hasn't called the finalizer directly itself. -finalizer_callbacks: std.AutoHashMapUnmanaged(usize, *FinalizerCallback) = .empty, - -taken_over: std.ArrayList(*Origin), - pub fn init(app: *App, isolate: js.Isolate, key: []const u8) !*Origin { const arena = try app.arena_pool.acquire(); errdefer app.arena_pool.release(arena); @@ -88,175 +61,12 @@ pub fn init(app: *App, isolate: js.Isolate, key: []const u8) !*Origin { .rc = 1, .arena = arena, .key = owned_key, - .temps = .empty, - .globals = .empty, - .taken_over = .empty, .security_token = token_global, }; return self; } pub fn deinit(self: *Origin, app: *App) void { - for (self.taken_over.items) |o| { - o.deinit(app); - } - - // Call finalizers before releasing anything - { - var it = self.finalizer_callbacks.valueIterator(); - while (it.next()) |finalizer| { - finalizer.*.deinit(); - } - } - v8.v8__Global__Reset(&self.security_token); - - { - 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); - } - } - app.arena_pool.release(self.arena); } - -pub fn trackGlobal(self: *Origin, global: v8.Global) !void { - return self.globals.append(self.arena, global); -} - -pub const IdentityResult = struct { - value_ptr: *v8.Global, - found_existing: bool, -}; - -pub fn addIdentity(self: *Origin, ptr: usize) !IdentityResult { - const gop = try self.identity_map.getOrPut(self.arena, ptr); - return .{ - .value_ptr = gop.value_ptr, - .found_existing = gop.found_existing, - }; -} - -pub fn trackTemp(self: *Origin, global: v8.Global) !void { - return self.temps.put(self.arena, global.data_ptr, global); -} - -pub fn releaseTemp(self: *Origin, 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 release(self: *Origin, 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; - fc.session.releaseArena(fc.arena); -} - -pub fn createFinalizerCallback( - self: *Origin, - session: *Session, - global: v8.Global, - ptr: *anyopaque, - zig_finalizer: *const fn (ptr: *anyopaque, session: *Session) void, -) !*FinalizerCallback { - const arena = try session.getArena(.{ .debug = "FinalizerCallback" }); - errdefer session.releaseArena(arena); - const fc = try arena.create(FinalizerCallback); - fc.* = .{ - .arena = arena, - .origin = self, - .session = session, - .ptr = ptr, - .global = global, - .zig_finalizer = zig_finalizer, - }; - return fc; -} - -pub fn takeover(self: *Origin, original: *Origin) !void { - const arena = self.arena; - - try self.globals.ensureUnusedCapacity(arena, original.globals.items.len); - for (original.globals.items) |obj| { - self.globals.appendAssumeCapacity(obj); - } - original.globals.clearRetainingCapacity(); - - { - try self.temps.ensureUnusedCapacity(arena, original.temps.count()); - var it = original.temps.iterator(); - while (it.next()) |kv| { - try self.temps.put(arena, kv.key_ptr.*, kv.value_ptr.*); - } - original.temps.clearRetainingCapacity(); - } - - { - try self.finalizer_callbacks.ensureUnusedCapacity(arena, original.finalizer_callbacks.count()); - var it = original.finalizer_callbacks.iterator(); - while (it.next()) |kv| { - kv.value_ptr.*.origin = self; - try self.finalizer_callbacks.put(arena, kv.key_ptr.*, kv.value_ptr.*); - } - original.finalizer_callbacks.clearRetainingCapacity(); - } - - { - try self.identity_map.ensureUnusedCapacity(arena, original.identity_map.count()); - var it = original.identity_map.iterator(); - while (it.next()) |kv| { - try self.identity_map.put(arena, kv.key_ptr.*, kv.value_ptr.*); - } - original.identity_map.clearRetainingCapacity(); - } - - try self.taken_over.append(self.arena, original); -} - -// 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 -// origin shutdown. -pub const FinalizerCallback = struct { - arena: Allocator, - origin: *Origin, - session: *Session, - ptr: *anyopaque, - global: v8.Global, - 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); - } -}; diff --git a/src/browser/js/Promise.zig b/src/browser/js/Promise.zig index 372d2578..0a08f424 100644 --- a/src/browser/js/Promise.zig +++ b/src/browser/js/Promise.zig @@ -19,6 +19,8 @@ const js = @import("js.zig"); const v8 = js.v8; +const Session = @import("../Session.zig"); + const Promise = @This(); local: *const js.Local, @@ -63,10 +65,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, .origin = {} }; + return .{ .handle = global, .session = {} }; } try ctx.trackTemp(global); - return .{ .handle = global, .origin = ctx.origin }; + return .{ .handle = global, .session = ctx.session }; } pub const Temp = G(.temp); @@ -80,7 +82,7 @@ const GlobalType = enum(u8) { fn G(comptime global_type: GlobalType) type { return struct { handle: v8.Global, - origin: if (global_type == .temp) *js.Origin else void, + session: if (global_type == .temp) *Session else void, const Self = @This(); @@ -96,7 +98,7 @@ fn G(comptime global_type: GlobalType) type { } pub fn release(self: *const Self) void { - self.origin.releaseTemp(self.handle); + self.session.releaseTemp(self.handle); } }; } diff --git a/src/browser/js/String.zig b/src/browser/js/String.zig index 47be227a..2cbe6a17 100644 --- a/src/browser/js/String.zig +++ b/src/browser/js/String.zig @@ -56,7 +56,7 @@ fn _toSlice(self: String, comptime null_terminate: bool, allocator: Allocator) ! pub fn toSSO(self: String, comptime global: bool) !(if (global) SSO.Global else SSO) { if (comptime global) { - return .{ .str = try self.toSSOWithAlloc(self.local.ctx.origin.arena) }; + return .{ .str = try self.toSSOWithAlloc(self.local.ctx.session.page_arena) }; } return self.toSSOWithAlloc(self.local.call_arena); } diff --git a/src/browser/js/Value.zig b/src/browser/js/Value.zig index 8e05690b..edbbe4e3 100644 --- a/src/browser/js/Value.zig +++ b/src/browser/js/Value.zig @@ -25,6 +25,7 @@ const v8 = js.v8; const IS_DEBUG = @import("builtin").mode == .Debug; const Allocator = std.mem.Allocator; +const Session = @import("../Session.zig"); const Value = @This(); @@ -300,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, .origin = {} }; + return .{ .handle = global, .session = {} }; } try ctx.trackTemp(global); - return .{ .handle = global, .origin = ctx.origin }; + return .{ .handle = global, .session = ctx.session }; } pub fn toZig(self: Value, comptime T: type) !T { @@ -361,7 +362,7 @@ const GlobalType = enum(u8) { fn G(comptime global_type: GlobalType) type { return struct { handle: v8.Global, - origin: if (global_type == .temp) *js.Origin else void, + session: if (global_type == .temp) *Session else void, const Self = @This(); @@ -381,7 +382,7 @@ fn G(comptime global_type: GlobalType) type { } pub fn release(self: *const Self) void { - self.origin.releaseTemp(self.handle); + self.session.releaseTemp(self.handle); } }; } diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 4fc96b7e..57cff1fa 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -27,7 +27,6 @@ const v8 = js.v8; const Caller = @import("Caller.zig"); const Context = @import("Context.zig"); -const Origin = @import("Origin.zig"); const IS_DEBUG = @import("builtin").mode == .Debug; @@ -117,13 +116,13 @@ pub fn Builder(comptime T: type) type { .from_v8 = struct { fn wrap(handle: ?*const v8.WeakCallbackInfo) callconv(.c) void { const ptr = v8.v8__WeakCallbackInfo__GetParameter(handle.?).?; - const fc: *Origin.FinalizerCallback = @ptrCast(@alignCast(ptr)); + const fc: *Session.FinalizerCallback = @ptrCast(@alignCast(ptr)); - const origin = fc.origin; + const session = fc.session; const value_ptr = fc.ptr; - if (origin.finalizer_callbacks.contains(@intFromPtr(value_ptr))) { - func(@ptrCast(@alignCast(value_ptr)), false, fc.session); - origin.release(value_ptr); + if (session.finalizer_callbacks.contains(@intFromPtr(value_ptr))) { + func(@ptrCast(@alignCast(value_ptr)), false, session); + session.releaseIdentity(value_ptr); } else { // A bit weird, but v8 _requires_ that we release it // If we don't. We'll 100% crash. diff --git a/src/browser/webapi/IntersectionObserver.zig b/src/browser/webapi/IntersectionObserver.zig index 8586a11d..74a5d79e 100644 --- a/src/browser/webapi/IntersectionObserver.zig +++ b/src/browser/webapi/IntersectionObserver.zig @@ -93,12 +93,12 @@ pub fn init(callback: js.Function.Temp, options: ?ObserverInit, page: *Page) !*I } pub fn deinit(self: *IntersectionObserver, shutdown: bool, session: *Session) void { - if (shutdown) { - self._callback.release(); - session.releaseArena(self._arena); - } else if (comptime IS_DEBUG) { - std.debug.assert(false); + self._callback.release(); + if ((comptime IS_DEBUG) and !shutdown) { + std.debug.assert(self._observing.items.len == 0); } + + session.releaseArena(self._arena); } pub fn observe(self: *IntersectionObserver, target: *Element, page: *Page) !void { @@ -111,6 +111,7 @@ pub fn observe(self: *IntersectionObserver, target: *Element, page: *Page) !void // Register with page if this is our first observation if (self._observing.items.len == 0) { + page.js.strongRef(self); try page.registerIntersectionObserver(self); } @@ -145,18 +146,22 @@ pub fn unobserve(self: *IntersectionObserver, target: *Element, page: *Page) voi break; } } + + if (self._observing.items.len == 0) { + page.js.safeWeakRef(self); + } } pub fn disconnect(self: *IntersectionObserver, page: *Page) void { + page.unregisterIntersectionObserver(self); + self._observing.clearRetainingCapacity(); self._previous_states.clearRetainingCapacity(); for (self._pending_entries.items) |entry| { entry.deinit(false, page._session); } self._pending_entries.clearRetainingCapacity(); - - self._observing.clearRetainingCapacity(); - page.unregisterIntersectionObserver(self); + page.js.safeWeakRef(self); } pub fn takeRecords(self: *IntersectionObserver, page: *Page) ![]*IntersectionObserverEntry { @@ -358,6 +363,7 @@ pub const JsApi = struct { pub const name = "IntersectionObserver"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; + pub const weak = true; pub const finalizer = bridge.finalizer(IntersectionObserver.deinit); }; diff --git a/src/browser/webapi/MutationObserver.zig b/src/browser/webapi/MutationObserver.zig index df86d1e1..b8608381 100644 --- a/src/browser/webapi/MutationObserver.zig +++ b/src/browser/webapi/MutationObserver.zig @@ -86,12 +86,12 @@ pub fn init(callback: js.Function.Temp, page: *Page) !*MutationObserver { } pub fn deinit(self: *MutationObserver, shutdown: bool, session: *Session) void { - if (shutdown) { - self._callback.release(); - session.releaseArena(self._arena); - } else if (comptime IS_DEBUG) { - std.debug.assert(false); + self._callback.release(); + if ((comptime IS_DEBUG) and !shutdown) { + std.debug.assert(self._observing.items.len == 0); } + + session.releaseArena(self._arena); } pub fn observe(self: *MutationObserver, target: *Node, options: ObserveOptions, page: *Page) !void { @@ -158,6 +158,7 @@ pub fn observe(self: *MutationObserver, target: *Node, options: ObserveOptions, // Register with page if this is our first observation if (self._observing.items.len == 0) { + page.js.strongRef(self); try page.registerMutationObserver(self); } @@ -168,13 +169,13 @@ pub fn observe(self: *MutationObserver, target: *Node, options: ObserveOptions, } pub fn disconnect(self: *MutationObserver, page: *Page) void { + page.unregisterMutationObserver(self); + self._observing.clearRetainingCapacity(); for (self._pending_records.items) |record| { record.deinit(false, page._session); } self._pending_records.clearRetainingCapacity(); - - self._observing.clearRetainingCapacity(); - page.unregisterMutationObserver(self); + page.js.safeWeakRef(self); } pub fn takeRecords(self: *MutationObserver, page: *Page) ![]*MutationRecord { @@ -440,6 +441,7 @@ pub const JsApi = struct { pub const name = "MutationObserver"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; + pub const weak = true; pub const finalizer = bridge.finalizer(MutationObserver.deinit); }; From 38e9f86088c6158eb2de4302787e169437271327 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 19 Mar 2026 15:42:29 +0800 Subject: [PATCH 2/4] fix context-leak --- src/browser/js/Env.zig | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index c417a6e5..07f1a479 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -300,10 +300,6 @@ pub fn createContext(self: *Env, page: *Page) !*Context { v8.v8__Object__SetAlignedPointerInInternalField(global_obj, 0, tao); } - // our window wrapped in a v8::Global - var global_global: v8.Global = undefined; - v8.v8__Global__New(isolate.handle, global_obj, &global_global); - const context_id = self.context_id; self.context_id = context_id + 1; @@ -333,6 +329,9 @@ pub fn createContext(self: *Env, page: *Page) !*Context { // need to register the first one. const gop = try session.identity_map.getOrPut(session.page_arena, @intFromPtr(page.window)); if (gop.found_existing == false) { + // our window wrapped in a v8::Global + var global_global: v8.Global = undefined; + v8.v8__Global__New(isolate.handle, global_obj, &global_global); gop.value_ptr.* = global_global; } } From f70865e1745862d91b5ed2c744fba7d5cbf3f82e Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 19 Mar 2026 18:46:35 +0800 Subject: [PATCH 3/4] 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", From 88681b1fdba7464c6646bd530cdeee653978a9cc Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 20 Mar 2026 16:50:03 +0800 Subject: [PATCH 4/4] Fix Context's call_arena The Context's call_arena should be based on the source, e.g. the IsolateWorld or the Page, not always the page. There's no rule that says all Contexts have to be a subset of the Page, and thus some might live longer and by doing so outlive the page_arena. Also, on context cleanup, isolate worlds now cleanup their identity. --- src/ArenaPool.zig | 1 - src/browser/Page.zig | 1 + src/browser/js/Context.zig | 4 +++- src/browser/js/Env.zig | 3 ++- src/cdp/cdp.zig | 9 +++++++++ 5 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/ArenaPool.zig b/src/ArenaPool.zig index 998838df..2e3f25a4 100644 --- a/src/ArenaPool.zig +++ b/src/ArenaPool.zig @@ -112,7 +112,6 @@ pub fn acquire(self: *ArenaPool, dbg: DebugInfo) !Allocator { } gop.value_ptr.* += 1; } - return entry.arena.allocator(); } diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 148654a0..eef70bbc 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -305,6 +305,7 @@ pub fn init(self: *Page, frame_id: u32, session: *Session, parent: ?*Page) !void self.js = try browser.env.createContext(self, .{ .identity = &session.identity, .identity_arena = session.page_arena, + .call_arena = self.call_arena, }); errdefer self.js.deinit(); diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index 2b861e7a..b46ec11d 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -63,7 +63,9 @@ templates: []*const v8.FunctionTemplate, // Arena for the lifetime of the context arena: Allocator, -// The page.call_arena +// The call_arena for this context. For main world contexts this is +// page.call_arena. For isolated world contexts this is a separate arena +// owned by the IsolatedWorld. call_arena: Allocator, // Because calls can be nested (i.e.a function calling a callback), diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index d248a411..9b3a1b4c 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -258,6 +258,7 @@ pub fn deinit(self: *Env) void { pub const ContextParams = struct { identity: *js.Identity, identity_arena: Allocator, + call_arena: Allocator, debug_name: []const u8 = "Context", }; @@ -325,7 +326,7 @@ pub fn createContext(self: *Env, page: *Page, params: ContextParams) !*Context { .arena = context_arena, .handle = context_global, .templates = self.templates, - .call_arena = page.call_arena, + .call_arena = params.call_arena, .microtask_queue = microtask_queue, .script_manager = &page._script_manager, .scheduler = .init(context_arena), diff --git a/src/cdp/cdp.zig b/src/cdp/cdp.zig index d6b35d83..d3a2f64e 100644 --- a/src/cdp/cdp.zig +++ b/src/cdp/cdp.zig @@ -492,9 +492,13 @@ pub fn BrowserContext(comptime CDP_T: type) type { const arena = try browser.arena_pool.acquire(.{ .debug = "IsolatedWorld" }); errdefer browser.arena_pool.release(arena); + const call_arena = try browser.arena_pool.acquire(.{ .debug = "IsolatedWorld.call_arena" }); + errdefer browser.arena_pool.release(call_arena); + const world = try arena.create(IsolatedWorld); world.* = .{ .arena = arena, + .call_arena = call_arena, .context = null, .browser = browser, .name = try arena.dupe(u8, world_name), @@ -745,6 +749,7 @@ pub fn BrowserContext(comptime CDP_T: type) type { /// An object id is unique across all contexts, different object ids can refer to the same Node in different contexts. const IsolatedWorld = struct { arena: Allocator, + call_arena: Allocator, browser: *Browser, name: []const u8, context: ?*js.Context = null, @@ -757,6 +762,7 @@ const IsolatedWorld = struct { pub fn deinit(self: *IsolatedWorld) void { self.removeContext() catch {}; self.identity.deinit(); + self.browser.arena_pool.release(self.call_arena); self.browser.arena_pool.release(self.arena); } @@ -764,6 +770,8 @@ const IsolatedWorld = struct { const ctx = self.context orelse return error.NoIsolatedContextToRemove; self.browser.env.destroyContext(ctx); self.context = null; + self.identity.deinit(); + self.identity = .{}; } // The isolate world must share at least some of the state with the related page, specifically the DocumentHTML @@ -776,6 +784,7 @@ const IsolatedWorld = struct { const ctx = try self.browser.env.createContext(page, .{ .identity = &self.identity, .identity_arena = self.arena, + .call_arena = self.call_arena, .debug_name = "IsolatedContext", }); self.context = ctx;