From c11fa122af9bb7b6601560c5e0f840e4dbf78c24 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 28 Jan 2026 12:17:07 +0800 Subject: [PATCH 1/3] Update page.js based on the current context. page.js currently always references the page context. But through the inspector JavaScript can be executed in different contexts. When we go from V8->Zig we correctly capture the current context within the caller's Local. And, because of this, mapping or anything else that happens against local.ctx, happens in the right context. EXCEPT...our code still accesses page.js. So you can have a v8->zig call happening in Context-2, and our Zig call then tries to do something on Context-1 via page.js. I'm introducing a change that updates page.js based on the current Caller and restores it at the end of the Caller. This change is super small, but potentially has major impact. It's hard to imagine that we haven't run into problems with this before, and it's hard to imagine what problems this change might introduce. Certainly, if anyone copies page.js, they'll be in for a rude surprise, but i don't think we do that anywhere. --- src/browser/js/Caller.zig | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/browser/js/Caller.zig b/src/browser/js/Caller.zig index 9f48d345..b999c6e0 100644 --- a/src/browser/js/Caller.zig +++ b/src/browser/js/Caller.zig @@ -35,6 +35,7 @@ const IS_DEBUG = @import("builtin").mode == .Debug; const Caller = @This(); local: js.Local, prev_local: ?*const js.Local, +prev_context: *Context, // Takes the raw v8 isolate and extracts the context from it. pub fn init(self: *Caller, v8_isolate: *v8.Isolate) void { @@ -53,7 +54,9 @@ pub fn init(self: *Caller, v8_isolate: *v8.Isolate) void { .isolate = .{ .handle = v8_isolate }, }, .prev_local = ctx.local, + .prev_context = ctx.page.js, }; + ctx.page.js = ctx; ctx.local = &self.local; } @@ -79,6 +82,7 @@ pub fn deinit(self: *Caller) void { ctx.call_depth = call_depth; ctx.local = self.prev_local; + ctx.page.js = self.prev_context; } pub const CallOpts = struct { From 20931eb9d6cbb044916f0a8e1860a25a1c7e08d2 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 28 Jan 2026 14:24:28 +0800 Subject: [PATCH 2/3] update page.js on context.deinit --- src/browser/js/Context.zig | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index 119dbf23..948bb1f5 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -150,6 +150,11 @@ pub fn fromIsolate(isolate: js.Isolate) *Context { } pub fn deinit(self: *Context) void { + var page = self.page; + const prev_context = page.js; + page.js = self; + defer page.js = prev_context; + { var it = self.identity_map.valueIterator(); while (it.next()) |global| { From 5dd6dc2d6946ca846ce01d99948b010cd39c017e Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 2 Feb 2026 12:40:54 +0800 Subject: [PATCH 3/3] per-context scheduler --- src/browser/Browser.zig | 4 ++++ src/browser/Page.zig | 28 +++++++++------------------- src/browser/ScriptManager.zig | 2 +- src/browser/js/Context.zig | 7 +++++++ src/browser/js/Env.zig | 12 ++++++++++++ src/browser/{ => js}/Scheduler.zig | 4 ++-- src/browser/webapi/AbortSignal.zig | 2 +- src/browser/webapi/MessagePort.zig | 2 +- src/browser/webapi/Window.zig | 8 ++++---- 9 files changed, 41 insertions(+), 28 deletions(-) rename src/browser/{ => js}/Scheduler.zig (97%) diff --git a/src/browser/Browser.zig b/src/browser/Browser.zig index 42171d6e..408af7e4 100644 --- a/src/browser/Browser.zig +++ b/src/browser/Browser.zig @@ -112,6 +112,10 @@ pub fn runMicrotasks(self: *const Browser) void { self.env.runMicrotasks(); } +pub fn runMacrotasks(self: *Browser) !?u64 { + return try self.env.runMacrotasks(); +} + pub fn runMessageLoop(self: *const Browser) void { while (self.env.pumpMessageLoop()) { if (comptime IS_DEBUG) { diff --git a/src/browser/Page.zig b/src/browser/Page.zig index b417b4ad..fe4fdde6 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -33,7 +33,6 @@ const String = @import("../string.zig").String; const Mime = @import("Mime.zig"); const Factory = @import("Factory.zig"); const Session = @import("Session.zig"); -const Scheduler = @import("Scheduler.zig"); const EventManager = @import("EventManager.zig"); const ScriptManager = @import("ScriptManager.zig"); @@ -202,8 +201,6 @@ document: *Document, // DOM version used to invalidate cached state of "live" collections version: usize, -scheduler: Scheduler, - _req_id: ?usize = null, _navigated_options: ?NavigatedOpts = null, @@ -237,10 +234,6 @@ pub fn deinit(self: *Page) void { // stats.print(&stream) catch unreachable; } - // This can release JS objects, so we need to do this while the js.Context - // is still around. - self.scheduler.deinit(); - { // some MicroTasks might be referencing the page, we need to drain it while // the page still exists @@ -273,8 +266,6 @@ fn reset(self: *Page, comptime initializing: bool) !void { const browser = self._session.browser; if (comptime initializing == false) { - self.scheduler.deinit(); - browser.env.destroyContext(self.js); // removing a context can trigger finalizers, so we can only check for @@ -297,7 +288,6 @@ fn reset(self: *Page, comptime initializing: bool) !void { } self._factory = Factory.init(self); - self.scheduler = Scheduler.init(self.arena); self.version = 0; self.url = "about:blank"; @@ -379,7 +369,7 @@ fn registerBackgroundTasks(self: *Page) !void { const Browser = @import("Browser.zig"); - try self.scheduler.add(self._session.browser, struct { + try self.js.scheduler.add(self._session.browser, struct { fn runMessageLoop(ctx: *anyopaque) !?u32 { const b: *Browser = @ptrCast(@alignCast(ctx)); b.runMessageLoop(); @@ -891,8 +881,8 @@ fn _wait(self: *Page, wait_ms: u32) !Session.WaitResult { var timer = try std.time.Timer.start(); var ms_remaining = wait_ms; - var scheduler = &self.scheduler; - var http_client = self._session.browser.http_client; + const browser = self._session.browser; + var http_client = browser.http_client; // I'd like the page to know NOTHING about cdp_socket / CDP, but the // fact is that the behavior of wait changes depending on whether or @@ -945,7 +935,7 @@ fn _wait(self: *Page, wait_ms: u32) !Session.WaitResult { // scheduler.run could trigger new http transfers, so do not // store http_client.active BEFORE this call and then use // it AFTER. - const ms_to_next_task = try scheduler.run(); + const ms_to_next_task = try browser.runMacrotasks(); const http_active = http_client.active; const total_network_activity = http_active + http_client.intercepted; @@ -1081,16 +1071,16 @@ fn printWaitAnalysis(self: *Page) void { const now = milliTimestamp(.monotonic); { - std.debug.print("\nhigh_priority schedule: {d}\n", .{self.scheduler.high_priority.count()}); - var it = self.scheduler.high_priority.iterator(); + std.debug.print("\nhigh_priority schedule: {d}\n", .{self.js.scheduler.high_priority.count()}); + var it = self.js.scheduler.high_priority.iterator(); while (it.next()) |task| { std.debug.print(" - {s} schedule: {d}ms\n", .{ task.name, task.run_at - now }); } } { - std.debug.print("\nlow_priority schedule: {d}\n", .{self.scheduler.low_priority.count()}); - var it = self.scheduler.low_priority.iterator(); + std.debug.print("\nlow_priority schedule: {d}\n", .{self.js.scheduler.low_priority.count()}); + var it = self.js.scheduler.low_priority.iterator(); while (it.next()) |task| { std.debug.print(" - {s} schedule: {d}ms\n", .{ task.name, task.run_at - now }); } @@ -1262,7 +1252,7 @@ pub fn notifyPerformanceObservers(self: *Page, entry: *Performance.Entry) !void } self._performance_delivery_scheduled = true; - return self.scheduler.add( + return self.js.scheduler.add( self, struct { fn run(_page: *anyopaque) anyerror!?u32 { diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index be968870..95c6150e 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -846,7 +846,7 @@ pub const Script = struct { defer { // We should run microtasks even if script execution fails. local.runMicrotasks(); - _ = page.scheduler.run() catch |err| { + _ = page.js.scheduler.run() catch |err| { log.err(.page, "scheduler", .{ .err = err }); }; } diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index 948bb1f5..d2676c84 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -23,6 +23,7 @@ const log = @import("../../log.zig"); const js = @import("js.zig"); const Env = @import("Env.zig"); const bridge = @import("bridge.zig"); +const Scheduler = @import("Scheduler.zig"); const Page = @import("../Page.zig"); const ScriptManager = @import("../ScriptManager.zig"); @@ -118,6 +119,9 @@ module_identifier: std.AutoHashMapUnmanaged(u32, [:0]const u8) = .empty, // the page's script manager script_manager: ?*ScriptManager, +// Our macrotasks +scheduler: Scheduler, + const ModuleEntry = struct { // Can be null if we're asynchrously loading the module, in // which case resolver_promise cannot be null. @@ -155,6 +159,9 @@ pub fn deinit(self: *Context) void { page.js = self; defer page.js = prev_context; + // This can release JS objects + self.scheduler.deinit(); + { var it = self.identity_map.valueIterator(); while (it.next()) |global| { diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index b45ba00a..7cc5e3e7 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -240,6 +240,7 @@ pub fn createContext(self: *Env, page: *Page, enter: bool) !*Context { .templates = self.templates, .call_arena = page.call_arena, .script_manager = &page._script_manager, + .scheduler = .init(context_arena), }; try context.identity_map.putNoClobber(context_arena, @intFromPtr(page.window), global_global); @@ -271,6 +272,17 @@ pub fn runMicrotasks(self: *const Env) void { self.isolate.performMicrotasksCheckpoint(); } +pub fn runMacrotasks(self: *Env) !?u64 { + var ms_to_next_task: ?u64 = null; + for (self.contexts.items) |ctx| { + const ms = (try ctx.scheduler.run()) orelse continue; + if (ms_to_next_task == null or ms < ms_to_next_task.?) { + ms_to_next_task = ms; + } + } + return ms_to_next_task; +} + pub fn pumpMessageLoop(self: *const Env) bool { var hs: v8.HandleScope = undefined; v8.v8__HandleScope__CONSTRUCT(&hs, self.isolate.handle); diff --git a/src/browser/Scheduler.zig b/src/browser/js/Scheduler.zig similarity index 97% rename from src/browser/Scheduler.zig rename to src/browser/js/Scheduler.zig index 1bd3e60a..cbd83b7b 100644 --- a/src/browser/Scheduler.zig +++ b/src/browser/js/Scheduler.zig @@ -19,8 +19,8 @@ const std = @import("std"); const builtin = @import("builtin"); -const log = @import("../log.zig"); -const milliTimestamp = @import("../datetime.zig").milliTimestamp; +const log = @import("../../log.zig"); +const milliTimestamp = @import("../../datetime.zig").milliTimestamp; const IS_DEBUG = builtin.mode == .Debug; diff --git a/src/browser/webapi/AbortSignal.zig b/src/browser/webapi/AbortSignal.zig index 0bb93886..e5039b28 100644 --- a/src/browser/webapi/AbortSignal.zig +++ b/src/browser/webapi/AbortSignal.zig @@ -99,7 +99,7 @@ pub fn createTimeout(delay: u32, page: *Page) !*AbortSignal { .signal = try init(page), }; - try page.scheduler.add(callback, TimeoutCallback.run, delay, .{ + try page.js.scheduler.add(callback, TimeoutCallback.run, delay, .{ .name = "AbortSignal.timeout", }); diff --git a/src/browser/webapi/MessagePort.zig b/src/browser/webapi/MessagePort.zig index 15fa5091..58dd699c 100644 --- a/src/browser/webapi/MessagePort.zig +++ b/src/browser/webapi/MessagePort.zig @@ -65,7 +65,7 @@ pub fn postMessage(self: *MessagePort, message: js.Value.Global, page: *Page) !v .message = message, }); - try page.scheduler.add(callback, PostMessageCallback.run, 0, .{ + try page.js.scheduler.add(callback, PostMessageCallback.run, 0, .{ .name = "MessagePort.postMessage", .low_priority = false, }); diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 9adf87a8..bbcaa3b0 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -366,7 +366,7 @@ pub fn postMessage(self: *Window, message: js.Value.Global, target_origin: ?[]co .message = message, .origin = try arena.dupe(u8, origin), }; - try page.scheduler.add(callback, PostMessageCallback.run, 0, .{ + try page.js.scheduler.add(callback, PostMessageCallback.run, 0, .{ .name = "postMessage", .low_priority = false, .finalizer = PostMessageCallback.cancelled, @@ -447,7 +447,7 @@ pub fn scrollTo(self: *Window, opts: ScrollToOpts, y: ?i32, page: *Page) !void { // We dispatch scroll event asynchronously after 10ms. So we can throttle // them. - try page.scheduler.add( + try page.js.scheduler.add( page, struct { fn dispatch(_page: *anyopaque) anyerror!?u32 { @@ -471,7 +471,7 @@ pub fn scrollTo(self: *Window, opts: ScrollToOpts, y: ?i32, page: *Page) !void { .{ .low_priority = true }, ); // We dispatch scrollend event asynchronously after 20ms. - try page.scheduler.add( + try page.js.scheduler.add( page, struct { fn dispatch(_page: *anyopaque) anyerror!?u32 { @@ -545,7 +545,7 @@ fn scheduleCallback(self: *Window, cb: js.Function.Temp, delay_ms: u32, opts: Sc }; gop.value_ptr.* = callback; - try page.scheduler.add(callback, ScheduleCallback.run, delay_ms, .{ + try page.js.scheduler.add(callback, ScheduleCallback.run, delay_ms, .{ .name = opts.name, .low_priority = opts.low_priority, .finalizer = ScheduleCallback.cancelled,