From 7f865551ca36fae92fc23e4c2e283fd38a9f8855 Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Tue, 28 Apr 2026 04:48:29 +0200 Subject: [PATCH 1/6] page: implement Page.getNavigationHistory and Page.navigateToHistoryEntry Both CDP methods returned -31998 UnknownMethod even though the underlying browsing-context history is fully tracked in webapi/navigation/Navigation.zig. Add the two methods to the Page dispatch enum + switch and wire them to the existing Navigation._entries / navigateInner traverse path. getNavigationHistory walks _entries and reports {currentIndex, entries: [{id, url, userTypedURL, title, transitionType}]}. navigateToHistoryEntry looks up the entry by id and calls frame.navigate(url, .{ .reason = .history, .kind = .{ .traverse = idx } }), reusing commitNavigation's traverse arm which only updates _index without pushing a new entry. URL is duplicated into the new frame's arena because replacePage releases the prior arena. Closes #2288 --- src/cdp/domains/page.zig | 196 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 196 insertions(+) diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index ee6f0990..0e24771a 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -39,11 +39,13 @@ pub fn processMessage(cmd: *CDP.Command) !void { const action = std.meta.stringToEnum(enum { enable, getFrameTree, + getNavigationHistory, setLifecycleEventsEnabled, addScriptToEvaluateOnNewDocument, removeScriptToEvaluateOnNewDocument, createIsolatedWorld, navigate, + navigateToHistoryEntry, reload, stopLoading, close, @@ -56,11 +58,13 @@ pub fn processMessage(cmd: *CDP.Command) !void { switch (action) { .enable => return cmd.sendResult(null, .{}), .getFrameTree => return getFrameTree(cmd), + .getNavigationHistory => return getNavigationHistory(cmd), .setLifecycleEventsEnabled => return setLifecycleEventsEnabled(cmd), .addScriptToEvaluateOnNewDocument => return addScriptToEvaluateOnNewDocument(cmd), .removeScriptToEvaluateOnNewDocument => return removeScriptToEvaluateOnNewDocument(cmd), .createIsolatedWorld => return createIsolatedWorld(cmd), .navigate => return navigate(cmd), + .navigateToHistoryEntry => return navigateToHistoryEntry(cmd), .reload => return doReload(cmd), .stopLoading => return cmd.sendResult(null, .{}), .close => return close(cmd), @@ -368,6 +372,92 @@ fn doReload(cmd: *CDP.Command) !void { }); } +const NavigationEntry = struct { + id: i64, + url: []const u8, + userTypedURL: []const u8, + title: []const u8, + transitionType: []const u8, +}; + +fn getNavigationHistory(cmd: *CDP.Command) !void { + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + if (bc.session_id == null) { + return error.SessionIdNotLoaded; + } + + const nav = &bc.session.navigation; + const entries_in = nav._entries.items; + + const entries_out = try cmd.arena.alloc(NavigationEntry, entries_in.len); + for (entries_in, 0..) |entry, i| { + // Internal _id is a decimal-formatted monotonically-increasing counter + // (see Navigation.pushEntry); fall back to index if parse ever fails. + const eid = std.fmt.parseInt(i64, entry._id, 10) catch @as(i64, @intCast(i)); + entries_out[i] = .{ + .id = eid, + .url = entry._url orelse "", + .userTypedURL = entry._url orelse "", + .title = "", + .transitionType = "other", + }; + } + + return cmd.sendResult(.{ + .currentIndex = @as(i64, @intCast(nav._index)), + .entries = entries_out, + }, .{}); +} + +fn navigateToHistoryEntry(cmd: *CDP.Command) !void { + const params = (try cmd.params(struct { + entryId: i64, + })) orelse return error.InvalidParams; + + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + if (bc.session_id == null) { + return error.SessionIdNotLoaded; + } + + const session = bc.session; + const nav = &session.navigation; + + var target_index: ?usize = null; + var target_url: ?[:0]const u8 = null; + for (nav._entries.items, 0..) |entry, i| { + const eid = std.fmt.parseInt(i64, entry._id, 10) catch continue; + if (eid == params.entryId) { + target_index = i; + target_url = entry._url; + break; + } + } + + const idx = target_index orelse return error.InvalidParams; + const url = target_url orelse return error.InvalidParams; + + var frame = session.currentFrame() orelse return error.FrameNotLoaded; + if (frame._load_state != .waiting) { + // Reset isolated world identities to disable V8 weak callbacks before + // resetPageResources releases refs. Mirrors the navigate / doReload path. + for (bc.isolated_worlds.items) |isolated_world| { + isolated_world.identity.deinit(); + isolated_world.identity = .{}; + } + frame = try session.replacePage(); + } + + // Duplicate the URL into the new frame's arena: replacePage above released + // the previous arena that backed the entry's _url string. + const dup_url = try frame.arena.dupeZ(u8, url); + + try frame.navigate(dup_url, .{ + .reason = .history, + .cdp_id = cmd.input.id, + .kind = .{ .traverse = idx }, + }); +} + pub fn frameNavigate(bc: *CDP.BrowserContext, event: *const Notification.FrameNavigate) !void { // detachTarget could be called, in which case, we still have a frame doing // things, but no session. @@ -1233,3 +1323,109 @@ test "cdp.frame: addScriptToEvaluateOnNewDocument" { try testing.expectEqual(2, try test_val.toI32()); } } + +test "cdp.frame: getNavigationHistory + navigateToHistoryEntry" { + var ctx = try testing.context(); + defer ctx.deinit(); + + { + // No browser context — should error. + try ctx.processMessage(.{ .id = 10, .method = "Page.getNavigationHistory" }); + try ctx.expectSentError(-31998, "BrowserContextNotLoaded", .{ .id = 10 }); + } + { + try ctx.processMessage(.{ .id = 11, .method = "Page.navigateToHistoryEntry", .params = .{ .entryId = 0 } }); + try ctx.expectSentError(-31998, "BrowserContextNotLoaded", .{ .id = 11 }); + } + + var bc = try ctx.loadBrowserContext(.{ .id = "BID-B2", .url = "cdp/dom1.html", .target_id = "TID-B2-0000000".* }); + + // Build up history: dom1.html (from loadBrowserContext) → dom2.html → dom3.html. + { + try ctx.processMessage(.{ + .id = 20, + .method = "Page.navigate", + .params = .{ .url = "http://127.0.0.1:9582/src/browser/tests/cdp/dom2.html" }, + }); + var runner = try bc.session.runner(.{}); + try runner.wait(.{ .ms = 2000 }); + } + { + try ctx.processMessage(.{ + .id = 21, + .method = "Page.navigate", + .params = .{ .url = "http://127.0.0.1:9582/src/browser/tests/cdp/dom3.html" }, + }); + var runner = try bc.session.runner(.{}); + try runner.wait(.{ .ms = 2000 }); + } + + // Three entries (ids 0, 1, 2), currentIndex points at the most-recent. + { + try ctx.processMessage(.{ .id = 30, .method = "Page.getNavigationHistory" }); + try ctx.expectSentResult(.{ + .currentIndex = 2, + .entries = &[_]NavigationEntry{ + .{ + .id = 0, + .url = "http://127.0.0.1:9582/src/browser/tests/cdp/dom1.html", + .userTypedURL = "http://127.0.0.1:9582/src/browser/tests/cdp/dom1.html", + .title = "", + .transitionType = "other", + }, + .{ + .id = 1, + .url = "http://127.0.0.1:9582/src/browser/tests/cdp/dom2.html", + .userTypedURL = "http://127.0.0.1:9582/src/browser/tests/cdp/dom2.html", + .title = "", + .transitionType = "other", + }, + .{ + .id = 2, + .url = "http://127.0.0.1:9582/src/browser/tests/cdp/dom3.html", + .userTypedURL = "http://127.0.0.1:9582/src/browser/tests/cdp/dom3.html", + .title = "", + .transitionType = "other", + }, + }, + }, .{ .id = 30 }); + } + + // Traverse back to the first entry. + { + try ctx.processMessage(.{ + .id = 40, + .method = "Page.navigateToHistoryEntry", + .params = .{ .entryId = 0 }, + }); + var runner = try bc.session.runner(.{}); + try runner.wait(.{ .ms = 2000 }); + + const f = bc.session.currentFrame() orelse unreachable; + try testing.expectEqualSlices(u8, "http://127.0.0.1:9582/src/browser/tests/cdp/dom1.html", f.url); + } + + // Traverse forward to the middle entry. + { + try ctx.processMessage(.{ + .id = 41, + .method = "Page.navigateToHistoryEntry", + .params = .{ .entryId = 1 }, + }); + var runner = try bc.session.runner(.{}); + try runner.wait(.{ .ms = 2000 }); + + const f = bc.session.currentFrame() orelse unreachable; + try testing.expectEqualSlices(u8, "http://127.0.0.1:9582/src/browser/tests/cdp/dom2.html", f.url); + } + + // Unknown entryId — InvalidParams. + { + try ctx.processMessage(.{ + .id = 42, + .method = "Page.navigateToHistoryEntry", + .params = .{ .entryId = 9999 }, + }); + try ctx.expectSentError(-31998, "InvalidParams", .{ .id = 42 }); + } +} From ff93f4fcc29b1867adda6fa2804a865316a01337 Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Wed, 29 Apr 2026 04:41:47 +0200 Subject: [PATCH 2/6] cdp: panic on non-integer Navigation entry _id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Navigation.pushEntry always allocPrints _id as a decimal usize counter, so parseInt(i64, _id, 10) can't fail on real entries — a parse error means the internal invariant has been violated. The previous fallbacks (index in getNavigationHistory, `continue` in navigateToHistoryEntry) masked that as either a wrong-but-plausible id or a misleading InvalidParams response. Surface it instead. --- src/cdp/domains/page.zig | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index 0af1db08..99097908 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -391,9 +391,10 @@ fn getNavigationHistory(cmd: *CDP.Command) !void { const entries_out = try cmd.arena.alloc(NavigationEntry, entries_in.len); for (entries_in, 0..) |entry, i| { - // Internal _id is a decimal-formatted monotonically-increasing counter - // (see Navigation.pushEntry); fall back to index if parse ever fails. - const eid = std.fmt.parseInt(i64, entry._id, 10) catch @as(i64, @intCast(i)); + // Navigation.pushEntry always formats _id as a decimal usize counter, + // so parse failure here is an internal invariant violation, not a + // recoverable runtime error. + const eid = std.fmt.parseInt(i64, entry._id, 10) catch @panic("Navigation entry _id is not a base-10 integer"); entries_out[i] = .{ .id = eid, .url = entry._url orelse "", @@ -425,7 +426,7 @@ fn navigateToHistoryEntry(cmd: *CDP.Command) !void { var target_index: ?usize = null; var target_url: ?[:0]const u8 = null; for (nav._entries.items, 0..) |entry, i| { - const eid = std.fmt.parseInt(i64, entry._id, 10) catch continue; + const eid = std.fmt.parseInt(i64, entry._id, 10) catch @panic("Navigation entry _id is not a base-10 integer"); if (eid == params.entryId) { target_index = i; target_url = entry._url; From c6336175443aa999fa7b4649e90b05924378f8b0 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 8 May 2026 10:34:47 +0800 Subject: [PATCH 3/6] Add timeslice to scheduler Give scheduler a 500ms timeslice to run per queue (high/low priority). A site can load hundreds of timeouts to all execute at the same time. These can be relatively expensive (e.g. lots of calls directly or indirectly to getBoundingClientRect). As-is, the scheduler drains its queue to completion and other timeouts, like --wait-ms can't do what they're meant to do. By adding timeslice, we prevent many tasks all scheduled for the same time to go unchecked. I was initially planning on putting this higher in runMacrotasks, but that could lead to starvation, i.e. if the first context used up all the time. Having it per context is more fair, at the cost of running 500ms * context. But, (a) the number of context we allow is fixed and (b) the reality is that most sites have few contexts and normally only the first one is doing anything interesting. --- src/browser/js/Scheduler.zig | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/browser/js/Scheduler.zig b/src/browser/js/Scheduler.zig index a4ed54fd..fa64229a 100644 --- a/src/browser/js/Scheduler.zig +++ b/src/browser/js/Scheduler.zig @@ -80,9 +80,8 @@ pub fn add(self: *Scheduler, ctx: *anyopaque, cb: Callback, run_in_ms: u32, opts } pub fn run(self: *Scheduler) !void { - const now = milliTimestamp(.monotonic); - try self.runQueue(&self.low_priority, now); - try self.runQueue(&self.high_priority, now); + try self.runQueue(&self.low_priority); + try self.runQueue(&self.high_priority); } pub fn hasReadyTasks(self: *Scheduler) bool { @@ -99,10 +98,12 @@ pub fn msToNextHigh(self: *Scheduler) ?u64 { return @intCast(task.run_at - now); } -fn runQueue(self: *Scheduler, queue: *Queue, now: u64) !void { +fn runQueue(self: *Scheduler, queue: *Queue) !void { if (queue.count() == 0) { return; } + const start = milliTimestamp(.monotonic); + var now = start; while (queue.peek()) |*task_| { if (task_.run_at > now) { @@ -126,6 +127,11 @@ fn runQueue(self: *Scheduler, queue: *Queue, now: u64) !void { task.run_at = now + ms; try self.low_priority.add(task); } + + now = milliTimestamp(.monotonic); + if (now - start > 500) { + return; + } } return; } From d7e283fed956317d1d3b4492c7f90aa1b302d252 Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Sat, 9 May 2026 14:57:08 +0200 Subject: [PATCH 4/6] Don't propagate http_client.request errors from Fetch.init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When http_client.request fails synchronously (e.g. RobotsLayer returning RobotsBlocked because robots.txt is already cached), Client.request invokes our error_callback before returning the error. httpErrorCallback rejects the promise and releases response._arena. Letting the error propagate from Fetch.init also fires the `errdefer response.deinit`, double-freeing the arena and corrupting the arena pool — eventually surfacing as a malloc abort during teardown. Fixes #2403. --- src/browser/webapi/net/Fetch.zig | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index c186b818..bb9e97a7 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -94,7 +94,12 @@ pub fn init(input: Input, options: ?InitOpts, exec: *const Execution) !js.Promis .@"same-origin" => if (exec.isSameOrigin(request._url)) &session.cookie_jar else null, }; - try http_client.request(.{ + // Synchronous failures from request layers (e.g. RobotsLayer returning + // RobotsBlocked when robots.txt is already cached) are dispatched to + // httpErrorCallback by Client.request, which rejects the promise and + // releases response._arena. Propagating the error from here would also + // fire the `errdefer response.deinit` above and double-free the arena. + http_client.request(.{ .ctx = fetch, .params = .{ .url = request._url, @@ -114,7 +119,7 @@ pub fn init(input: Input, options: ?InitOpts, exec: *const Execution) !js.Promis .done_callback = httpDoneCallback, .error_callback = httpErrorCallback, .shutdown_callback = httpShutdownCallback, - }); + }) catch {}; return resolver.promise(); } From 92607ad7650f19ab73a3b101239b6be35b10bf03 Mon Sep 17 00:00:00 2001 From: Scott Taylor Date: Fri, 8 May 2026 14:55:54 -0400 Subject: [PATCH 5/6] Defer page teardown while worker scripts are evaluating Worker scripts can call importScripts(), which performs a synchronous HTTP request via HttpClient.syncRequest. To stay responsive during a long fetch, syncRequest pumps the CDP socket (cdp.blocking_read) while waiting. If a CDP message such as Target.closeTarget arrives on that socket mid-fetch, the previous code path tore down the page immediately: Worker JS -> importScripts -> syncRequest -> blocking_read -> CDP dispatch -> Target.closeTarget -> Session.removePage -> Page.deinit -> Frame.deinit -> Worker.deinit (frees worker arena + identity_map) When control unwound back into the worker's eval, the next operation that hit ctx.identity.identity_map.getOrPut dereferenced the freed metadata pointer and segfaulted (sometimes immediately, sometimes a few connections later as the arena got recycled). Reproducer: any URL that loads dedicated workers calling importScripts during initial eval, driven via puppeteer-core's connectOverCDP. The allbirds.com product page (which loads ~8 web-pixel workers each calling importScripts) reliably triggered it within ~10 connections. Session.removePage already deferred when the frame's own ScriptManager.is_evaluating was set; that guard never tripped because worker scripts don't go through the frame's ScriptManager. Fix: * Worker.loadInitialScript now sets the worker's own _worker_scope._script_manager.is_evaluating around the eval, with save/restore so nested worker evals compose correctly. * WorkerGlobalScope.importScript also sets its own _script_manager.is_evaluating around the syncRequest + runMacrotasks. The typical caller (Worker.loadInitialScript) already sets this around its outer eval, so the outer guard usually covers us; the inner mark is defense-in-depth for callers that reach importScripts() from a setTimeout / microtask outside the loadInitialScript scope. * New Frame.anyScriptEvaluating method walks the frame tree (frame ScriptManager + every worker's ScriptManager + child frames) and returns true if any is mid-eval. Session.removePage and CDP.disposeBrowserContext use this in place of the frame-only check, deferring teardown until all evals unwind. Final cleanup happens at CDP.deinit on connection close, matching the existing deferred-teardown contract. Verified by running the puppeteer-core repro back-to-back against a single Lightpanda serve; all returned 200 with the right title, no UAF crashes (was previously crashing within 1-10 runs). All 521 unit tests still pass. Note: a separate, pre-existing latent V8 issue surfaces under stress on this same code path. After many iterations a Runtime.evaluate promise tracked by V8's inspector PromiseHandlerTracker is discarded during garbage collection's first-pass weak callbacks; the discard sends a failure response which triggers v8::String::NewFromOneByte, hitting the debug-only assertion AllowHeapAllocation::IsAllowed() in heap-allocator-inl.h:79 (no allocations allowed during weak callbacks). This reproduces on a baseline build of this PR commit and on a baseline build of just the original two-line is_evaluating fix \u2014 i.e. it is not introduced by the deferral logic. The deferral makes it more visible because inspector callbacks now live longer before teardown, so they are more likely to be alive during a GC. Tracking this as a follow-up; the fix here still resolves the UAF that was crashing the server immediately. --- src/browser/Frame.zig | 18 ++++++++++++++++++ src/browser/Session.zig | 6 +++++- src/browser/webapi/Worker.zig | 15 +++++++++++++++ src/browser/webapi/WorkerGlobalScope.zig | 20 ++++++++++++++++++++ src/cdp/CDP.zig | 2 +- 5 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 4b5c60c9..dd583022 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -1244,6 +1244,24 @@ pub fn isGoingAway(self: *const Frame) bool { return parent.isGoingAway(); } +// True if this frame, any descendant frame, or any worker owned by any +// of those frames is currently inside script evaluation. Used as a +// reentrancy guard before tearing down a page from a CDP message that +// may have been drained while a Zig->JS->Zig stack (e.g. Worker +// importScripts -> syncRequest -> blocking_read) is mid-flight. +// Recursive over child frames so an evaluating subframe also defers +// parent teardown. +pub fn anyScriptEvaluating(self: *const Frame) bool { + if (self._script_manager.base.is_evaluating) return true; + for (self.workers.items) |worker| { + if (worker._worker_scope._script_manager.is_evaluating) return true; + } + for (self.child_frames.items) |child| { + if (child.anyScriptEvaluating()) return true; + } + return false; +} + pub fn scriptAddedCallback(self: *Frame, comptime from_parser: bool, script: *Element.Html.Script) !void { if (self.isGoingAway()) { // if we're planning on navigating to another frame, don't run this script diff --git a/src/browser/Session.zig b/src/browser/Session.zig index e8f80e38..cbe14f4f 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -200,8 +200,12 @@ pub fn removePage(self: *Session) void { lp.assert(false, "Session.removePage - page is null", .{}); }; - if (page.frame._script_manager.base.is_evaluating) { + if (page.frame.anyScriptEvaluating()) { // Reentrant teardown from a CDP message drained inside syncRequest; + // either the page's own script (frame ScriptManager.is_evaluating) + // or a Worker eval (Worker.loadInitialScript marks its + // _worker_scope._script_manager.is_evaluating). Tearing down here + // would free the arena/identity_map underneath the active eval. // Session.deinit reclaims the page when the connection closes. return; } diff --git a/src/browser/webapi/Worker.zig b/src/browser/webapi/Worker.zig index 5a271793..67d24526 100644 --- a/src/browser/webapi/Worker.zig +++ b/src/browser/webapi/Worker.zig @@ -187,6 +187,21 @@ fn loadInitialScript(self: *Worker, script: []const u8) !void { try_catch.init(&ls.local); defer try_catch.deinit(); + // Mark this worker's ScriptManager as evaluating for the lifetime of + // the eval. Worker scripts can call importScripts() which performs a + // synchronous HTTP request that pumps the CDP socket while waiting + // (HttpClient.syncRequest -> cdp.blocking_read). A CDP message such + // as Target.closeTarget arriving on that socket would otherwise tear + // down the page (Session.removePage -> Page.deinit -> Frame.deinit -> + // Worker.deinit) while this eval is mid-flight, freeing the worker's + // arena and identity_map underneath us. Session.removePage walks + // every frame's workers and bails out when any is_evaluating, so the + // teardown is deferred until the eval unwinds. + const sm = &self._worker_scope._script_manager; + const was_evaluating = sm.is_evaluating; + sm.is_evaluating = true; + defer sm.is_evaluating = was_evaluating; + _ = ls.local.eval(script, self._url) catch |err| { const caught = try_catch.caughtOrError(self._arena, err); log.err(.browser, "worker script error", .{ .url = self._url, .caught = caught }); diff --git a/src/browser/webapi/WorkerGlobalScope.zig b/src/browser/webapi/WorkerGlobalScope.zig index e9b8fe13..ea194382 100644 --- a/src/browser/webapi/WorkerGlobalScope.zig +++ b/src/browser/webapi/WorkerGlobalScope.zig @@ -374,6 +374,26 @@ fn importScript(self: *WorkerGlobalScope, arena: Allocator, url: [:0]const u8) ! var headers = try http_client.newHeaders(); try self.headersForRequest(&headers); + // Mark the worker's ScriptManager as evaluating for the duration of + // the synchronous fetch. syncRequest pumps the CDP socket while + // waiting (HttpClient.syncRequest -> cdp.blocking_read). A CDP + // message such as Target.closeTarget arriving on that socket would + // otherwise tear down the page (Session.removePage -> Page.deinit -> + // Frame.deinit -> Worker.deinit) while we're mid-fetch, freeing the + // worker's arena and identity_map underneath us. Frame.anyScriptEvaluating + // walks every frame's workers, so the teardown is deferred until the + // outer call unwinds. + // + // The typical caller (Worker.loadInitialScript) already sets this + // around its own eval, so this is a defense-in-depth nesting: a worker + // script that calls importScripts() from a setTimeout callback or a + // microtask wouldn't have the outer guard, but would still be safe + // because of this one. + const sm = &self._script_manager; + const was_evaluating = sm.is_evaluating; + sm.is_evaluating = true; + defer sm.is_evaluating = was_evaluating; + const response = http_client.syncRequest(arena, .{ .url = resolved_url, .method = .GET, diff --git a/src/cdp/CDP.zig b/src/cdp/CDP.zig index a235fda2..38f7c5ea 100644 --- a/src/cdp/CDP.zig +++ b/src/cdp/CDP.zig @@ -371,7 +371,7 @@ pub fn disposeBrowserContext(self: *CDP, browser_context_id: []const u8) bool { // (see Session.removePage's matching guard). Defer cleanup to // CDP.deinit at connection close, by which time eval has unwound. if (bc.session.currentPage()) |page| { - if (page.frame._script_manager.base.is_evaluating) { + if (page.frame.anyScriptEvaluating()) { return true; } } From 0bbddb3179586542e89a177873323ecccbd44c8e Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 11 May 2026 11:25:40 +0800 Subject: [PATCH 6/6] Try to fix a bad merge https://github.com/lightpanda-io/browser/pull/2289 and https://github.com/lightpanda-io/browser/pull/2297 --- src/cdp/domains/page.zig | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index 81b941bf..a06a2908 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -431,26 +431,19 @@ fn navigateToHistoryEntry(cmd: *CDP.Command) !void { const idx = target_index orelse return error.InvalidParams; const url = target_url orelse return error.InvalidParams; - var frame = session.currentFrame() orelse return error.FrameNotLoaded; - if (frame._load_state != .waiting) { - // Reset isolated world identities to disable V8 weak callbacks before - // resetPageResources releases refs. Mirrors the navigate / doReload path. - for (bc.isolated_worlds.items) |isolated_world| { - isolated_world.identity.deinit(); - isolated_world.identity = .{}; - } - frame = try session.replacePage(); - } + const frame = session.currentFrame() orelse return error.FrameNotLoaded; - // Duplicate the URL into the new frame's arena: replacePage above released - // the previous arena that backed the entry's _url string. - const dup_url = try frame.arena.dupeZ(u8, url); - - try frame.navigate(dup_url, .{ + const opts = Frame.NavigateOpts{ .reason = .history, .cdp_id = cmd.input.id, .kind = .{ .traverse = idx }, - }); + }; + + if (frame._load_state == .waiting) { + return frame.navigate(url, opts); + } + + try session.initiateRootNavigation(frame._frame_id, url, opts); } pub fn frameNavigate(bc: *CDP.BrowserContext, event: *const Notification.FrameNavigate) !void {