Fixes a few context issues.

First, for macrotasks, it ensures that the correct context is entered.

More important, for microtasks, it protects against use-after-free. This is
something we already did, to some degree, in page.deinit: we would run
microtasks before erasing the page, in case any microtasks referenced the page.

The new approach moves the guard to the context (since we have multiple
contexts) and protects against a microtasks enqueue a new task during shutdown
(something the original never did).
This commit is contained in:
Karl Seguin
2026-02-04 18:29:24 +08:00
parent 2cf2db3eef
commit 0671be870d
4 changed files with 116 additions and 39 deletions

View File

@@ -234,15 +234,6 @@ pub fn deinit(self: *Page) void {
// stats.print(&stream) catch unreachable; // stats.print(&stream) catch unreachable;
} }
{
// some MicroTasks might be referencing the page, we need to drain it while
// the page still exists
var ls: JS.Local.Scope = undefined;
self.js.localScope(&ls);
defer ls.deinit();
ls.local.runMicrotasks();
}
const session = self._session; const session = self._session;
session.browser.env.destroyContext(self.js); session.browser.env.destroyContext(self.js);

View File

@@ -122,6 +122,10 @@ script_manager: ?*ScriptManager,
// Our macrotasks // Our macrotasks
scheduler: Scheduler, scheduler: Scheduler,
// Prevents us from enqueuing a microtask for this context while we're shutting
// down.
shutting_down: bool = false,
const ModuleEntry = struct { const ModuleEntry = struct {
// Can be null if we're asynchrously loading the module, in // Can be null if we're asynchrously loading the module, in
// which case resolver_promise cannot be null. // which case resolver_promise cannot be null.
@@ -154,12 +158,21 @@ pub fn fromIsolate(isolate: js.Isolate) *Context {
} }
pub fn deinit(self: *Context) void { pub fn deinit(self: *Context) void {
var page = self.page; defer self.env.app.arena_pool.release(self.arena);
const prev_context = page.js;
page.js = self;
defer page.js = prev_context;
// This can release JS objects var hs: js.HandleScope = undefined;
const entered = self.enter(&hs);
defer entered.exit();
// We might have microtasks in the isolate that refence this context. The
// only option we have is to run them. But a microtask could queue another
// microtask, so we set the shutting_down flag, so that any such microtask
// will be a noop (this isn't automatic, when v8 calls our microtask callback
// the first thing we'll check is if self.shutting_down == true).
self.shutting_down = true;
self.env.runMicrotasks();
// can release objects
self.scheduler.deinit(); self.scheduler.deinit();
{ {
@@ -214,13 +227,10 @@ pub fn deinit(self: *Context) void {
} }
if (self.entered) { if (self.entered) {
var ls: js.Local.Scope = undefined; v8.v8__Context__Exit(@ptrCast(v8.v8__Global__Get(&self.handle, self.isolate.handle)));
self.localScope(&ls);
defer ls.deinit();
v8.v8__Context__Exit(ls.local.handle);
} }
v8.v8__Global__Reset(&self.handle); v8.v8__Global__Reset(&self.handle);
self.env.app.arena_pool.release(self.arena);
} }
pub fn weakRef(self: *Context, obj: anytype) void { pub fn weakRef(self: *Context, obj: anytype) void {
@@ -880,41 +890,90 @@ fn resolveDynamicModule(self: *Context, state: *DynamicModuleResolveState, modul
}; };
} }
// Microtasks // Used to make temporarily enter and exit a context, updating and restoring
pub fn queueMutationDelivery(self: *Context) !void { // page.js:
self.isolate.enqueueMicrotask(struct { // var hs: js.HandleScope = undefined;
fn run(data: ?*anyopaque) callconv(.c) void { // const entered = ctx.enter(&hs);
const page: *Page = @ptrCast(@alignCast(data.?)); // defer entered.exit();
page.deliverMutations(); pub fn enter(self: *Context, hs: *js.HandleScope) Entered {
const isolate = self.isolate;
js.HandleScope.init(hs, isolate);
const page = self.page;
const original = page.js;
page.js = self;
const handle: *const v8.Context = @ptrCast(v8.v8__Global__Get(&self.handle, isolate.handle));
v8.v8__Context__Enter(handle);
return .{.original = original, .handle = handle, .handle_scope = hs};
}
const Entered = struct {
// the context we should restore on the page
original: *Context,
// the handle of the entered context
handle: *const v8.Context,
handle_scope: *js.HandleScope,
pub fn exit(self: Entered) void {
self.original.page.js = self.original;
v8.v8__Context__Exit(self.handle);
self.handle_scope.deinit();
} }
}.run, self.page); };
pub fn queueMutationDelivery(self: *Context) !void {
self.enqueueMicrotask(struct {
fn run(ctx: *Context) void {
ctx.page.deliverMutations();
}
}.run);
} }
pub fn queueIntersectionChecks(self: *Context) !void { pub fn queueIntersectionChecks(self: *Context) !void {
self.isolate.enqueueMicrotask(struct { self.enqueueMicrotask(struct {
fn run(data: ?*anyopaque) callconv(.c) void { fn run(ctx: *Context) void {
const page: *Page = @ptrCast(@alignCast(data.?)); ctx.page.performScheduledIntersectionChecks();
page.performScheduledIntersectionChecks();
} }
}.run, self.page); }.run);
} }
pub fn queueIntersectionDelivery(self: *Context) !void { pub fn queueIntersectionDelivery(self: *Context) !void {
self.isolate.enqueueMicrotask(struct { self.enqueueMicrotask(struct {
fn run(data: ?*anyopaque) callconv(.c) void { fn run(ctx: *Context) void {
const page: *Page = @ptrCast(@alignCast(data.?)); ctx.page.deliverIntersections();
page.deliverIntersections();
} }
}.run, self.page); }.run);
} }
pub fn queueSlotchangeDelivery(self: *Context) !void { pub fn queueSlotchangeDelivery(self: *Context) !void {
self.enqueueMicrotask(struct {
fn run(ctx: *Context) void {
ctx.page.deliverSlotchangeEvents();
}
}.run);
}
// Helper for executing a Microtask on this Context. In V8, microtasks aren't
// associated to a Context - they are just functions to execute in an Isolate.
// But for these Context microtasks, we want to (a) make sure the context isn't
// being shut down and (b) that it's entered.
fn enqueueMicrotask(self: *Context, callback: anytype) void {
self.isolate.enqueueMicrotask(struct { self.isolate.enqueueMicrotask(struct {
fn run(data: ?*anyopaque) callconv(.c) void { fn run(data: ?*anyopaque) callconv(.c) void {
const page: *Page = @ptrCast(@alignCast(data.?)); const ctx: *Context = @ptrCast(@alignCast(data.?));
page.deliverSlotchangeEvents(); if (ctx.shutting_down) {
return;
} }
}.run, self.page);
var hs: js.HandleScope = undefined;
const entered = ctx.enter(&hs);
defer entered.exit();
callback(ctx);
}
}.run, self);
} }
pub fn queueMicrotaskFunc(self: *Context, cb: js.Function) void { pub fn queueMicrotaskFunc(self: *Context, cb: js.Function) void {

View File

@@ -18,6 +18,8 @@
const std = @import("std"); const std = @import("std");
const js = @import("js.zig"); const js = @import("js.zig");
const builtin = @import("builtin");
const v8 = js.v8; const v8 = js.v8;
const App = @import("../../App.zig"); const App = @import("../../App.zig");
@@ -35,7 +37,7 @@ const Window = @import("../webapi/Window.zig");
const JsApis = bridge.JsApis; const JsApis = bridge.JsApis;
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const IS_DEBUG = @import("builtin").mode == .Debug; const IS_DEBUG = builtin.mode == .Debug;
// The Env maps to a V8 isolate, which represents a isolated sandbox for // The Env maps to a V8 isolate, which represents a isolated sandbox for
// executing JavaScript. The Env is where we'll define our V8 <-> Zig bindings, // executing JavaScript. The Env is where we'll define our V8 <-> Zig bindings,
@@ -284,6 +286,20 @@ pub fn runMicrotasks(self: *const Env) void {
pub fn runMacrotasks(self: *Env) !?u64 { pub fn runMacrotasks(self: *Env) !?u64 {
var ms_to_next_task: ?u64 = null; var ms_to_next_task: ?u64 = null;
for (self.contexts.items) |ctx| { for (self.contexts.items) |ctx| {
if (comptime builtin.is_test == false) {
// I hate this comptime check as much as you do. But we have tests
// which rely on short execution before shutdown. In real world, it's
// underterministic whether a timer will or won't run before the
// page shutsdown. But for tests, we need to run them to their end.
if (ctx.scheduler.hasReadyTasks() == false) {
continue;
}
}
var hs: js.HandleScope = undefined;
const entered = ctx.enter(&hs);
defer entered.exit();
const ms = (try ctx.scheduler.run()) orelse continue; const ms = (try ctx.scheduler.run()) orelse continue;
if (ms_to_next_task == null or ms < ms_to_next_task.?) { if (ms_to_next_task == null or ms < ms_to_next_task.?) {
ms_to_next_task = ms; ms_to_next_task = ms;

View File

@@ -74,11 +74,17 @@ pub fn add(self: *Scheduler, ctx: *anyopaque, cb: Callback, run_in_ms: u32, opts
}); });
} }
pub fn run(self: *Scheduler) !?u64 { pub fn run(self: *Scheduler) !?u64 {
_ = try self.runQueue(&self.low_priority); _ = try self.runQueue(&self.low_priority);
return self.runQueue(&self.high_priority); return self.runQueue(&self.high_priority);
} }
pub fn hasReadyTasks(self: *Scheduler) bool {
const now = milliTimestamp(.monotonic);
return queueuHasReadyTask(&self.low_priority, now) or queueuHasReadyTask(&self.high_priority, now);
}
fn runQueue(self: *Scheduler, queue: *Queue) !?u64 { fn runQueue(self: *Scheduler, queue: *Queue) !?u64 {
if (queue.count() == 0) { if (queue.count() == 0) {
return null; return null;
@@ -112,6 +118,11 @@ fn runQueue(self: *Scheduler, queue: *Queue) !?u64 {
return null; return null;
} }
fn queueuHasReadyTask(queue: *Queue, now: u64) bool {
const task = queue.peek() orelse return false;
return task.run_at <= now;
}
fn finalizeTasks(queue: *Queue) void { fn finalizeTasks(queue: *Queue) void {
var it = queue.iterator(); var it = queue.iterator();
while (it.next()) |t| { while (it.next()) |t| {