Extract Window Scheduling and re-use it in Worker

Add worker.setInterval, clearInterval, setTimeout, clearTimeout by extracting
the scheduling logic from Window and making it use Execution rather than Frame.
This commit is contained in:
Karl Seguin
2026-05-04 17:55:54 +08:00
parent 116cc3aee7
commit d1bf44b686
5 changed files with 386 additions and 187 deletions

View File

@@ -0,0 +1,85 @@
// Exercises setTimeout / setInterval inside a WorkerGlobalScope.
// Mirrors src/browser/tests/window/timers.html.
(async function() {
try {
const results = {};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// setTimeout: returns a number; passes extra args through; `this` is self.
{
let timeout_this = null;
const sum = await new Promise((resolve) => {
const id = setTimeout(function (a, b) {
timeout_this = this;
resolve(a + b);
}, 1, 2, 3);
results.setTimeout_id_is_number = (typeof id === 'number');
});
results.setTimeout_args = sum;
results.setTimeout_this_is_self = (timeout_this === self);
results.setTimeout_length = setTimeout.length;
}
// setInterval fires repeatedly; clearInterval stops it.
// A second timer cleared before its first tick must never fire.
{
let count1 = 0;
const id1 = setInterval(() => { count1 += 1; }, 1);
let fired2 = false;
const id2 = setInterval(() => { fired2 = true; }, 1);
clearInterval(id2);
results.setInterval_ids_distinct = (id1 !== id2);
await sleep(10);
clearInterval(id1);
const after_clear = count1;
await sleep(5);
results.setInterval_fired_multiple = (after_clear >= 1);
results.setInterval_clear_stops = (count1 === after_clear);
results.setInterval_pre_clear_silent = !fired2;
}
// clearTimeout / clearInterval with bogus ids must be silent.
{
let threw = false;
try {
clearTimeout(-1);
clearInterval(-2);
} catch (_) { threw = true; }
results.clear_invalid_silent = !threw;
}
// Legacy: setTimeout("...", n) compiles the string into a function body.
{
self.__st_string_ran = 0;
const id = setTimeout("self.__st_string_ran = 42;", 1);
results.setTimeout_string_id_is_number = (typeof id === 'number');
await sleep(5);
results.setTimeout_string_ran = self.__st_string_ran;
}
// Legacy: setInterval("...", n) compiles the string into a function body.
{
self.__si_string_ran = 0;
const id = setInterval("self.__si_string_ran += 1;", 1);
await sleep(5);
clearInterval(id);
results.setInterval_string_ran = (self.__si_string_ran >= 1);
}
// Non-function, non-string handlers must throw.
{
let threw = false;
try { setTimeout(123, 1); } catch (_) { threw = true; }
results.setTimeout_invalid_throws = threw;
}
postMessage({ ok: true, results });
} catch (e) {
postMessage({ ok: false, err: String(e), stack: e.stack });
}
})();

View File

@@ -276,6 +276,40 @@
}
</script>
<script id="worker_timers" type=module>
{
const state = await testing.async();
const worker = new Worker('./timers-worker.js');
worker.onmessage = function(event) {
state.resolve(event.data);
};
await state.done((data) => {
testing.expectTrue(data.ok, 'worker timers error: ' + data.err);
const r = data.results;
testing.expectEqual(true, r.setTimeout_id_is_number);
testing.expectEqual(5, r.setTimeout_args);
testing.expectEqual(true, r.setTimeout_this_is_self);
testing.expectEqual(1, r.setTimeout_length);
testing.expectEqual(true, r.setInterval_ids_distinct);
testing.expectEqual(true, r.setInterval_fired_multiple);
testing.expectEqual(true, r.setInterval_clear_stops);
testing.expectEqual(true, r.setInterval_pre_clear_silent);
testing.expectEqual(true, r.clear_invalid_silent);
testing.expectEqual(true, r.setTimeout_string_id_is_number);
testing.expectEqual(42, r.setTimeout_string_ran);
testing.expectEqual(true, r.setInterval_string_ran);
testing.expectEqual(true, r.setTimeout_invalid_throws);
});
}
</script>
<script id="importScripts" type=module>
{
const state = await testing.async();

View File

@@ -0,0 +1,205 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
// Shared bookkeeping for setTimeout / setInterval (and Window-only
// setImmediate / requestAnimationFrame / requestIdleCallback). Both Window
// and WorkerGlobalScope embed a Timers and forward their JS-bridged
// methods through `schedule` / `clear`.
const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../js/js.zig");
const log = lp.log;
const Allocator = std.mem.Allocator;
const Timers = @This();
_timer_id: u30 = 0,
_callbacks: std.AutoHashMapUnmanaged(u32, *ScheduleCallback) = .{},
pub const Mode = enum {
idle,
normal,
animation_frame,
};
pub const ScheduleOpts = struct {
repeat: bool,
params: []js.Value.Temp,
name: []const u8,
low_priority: bool = false,
mode: Mode = .normal,
};
pub fn schedule(
self: *Timers,
exec: *js.Execution,
cb: js.Function.Temp,
delay_ms: u32,
opts: ScheduleOpts,
) !u32 {
if (self._callbacks.count() > 512) {
// these are active
return error.TooManyTimeout;
}
const arena = try exec.getArena(.tiny, "Timers.schedule");
errdefer exec.releaseArena(arena);
const timer_id = self._timer_id +% 1;
self._timer_id = timer_id;
var persisted_params: []js.Value.Temp = &.{};
if (opts.params.len > 0) {
persisted_params = try arena.dupe(js.Value.Temp, opts.params);
}
const gop = try self._callbacks.getOrPut(exec.arena, timer_id);
if (gop.found_existing) {
// 2^31 would have to wrap for this to happen.
return error.TooManyTimeout;
}
errdefer _ = self._callbacks.remove(timer_id);
const callback = try arena.create(ScheduleCallback);
callback.* = .{
.cb = cb,
.exec = exec,
.timers = self,
.arena = arena,
.mode = opts.mode,
.name = opts.name,
.timer_id = timer_id,
.params = persisted_params,
.repeat_ms = if (opts.repeat) if (delay_ms == 0) 1 else delay_ms else null,
};
gop.value_ptr.* = callback;
try exec.context.scheduler.add(callback, ScheduleCallback.run, delay_ms, .{
.name = opts.name,
.low_priority = opts.low_priority,
.finalizer = ScheduleCallback.cancelled,
});
return timer_id;
}
pub fn clear(self: *Timers, id: u32) void {
var sc = self._callbacks.fetchRemove(id) orelse return;
sc.value.removed = true;
}
// https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#dom-settimeout
// https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#timerhandler
// TimerHandler = Function or DOMString. When a string is passed, it is
// compiled into an anonymous function body, matching how legacy browsers
// (and all current UAs) interpret `setTimeout("foo()", 100)`.
pub const LegacyHandler = union(enum) {
function: js.Function.Temp,
string: js.String,
pub fn resolve(handler: LegacyHandler, exec: *js.Execution) !js.Function.Temp {
switch (handler) {
.function => |fun| return fun,
.string => |str| {
const fun = try exec.context.local.?.compileFunction(str, &.{}, &.{});
return fun.temp();
},
}
}
};
const ScheduleCallback = struct {
// for debugging
name: []const u8,
// Timers._callbacks key
timer_id: u31,
// delay, in ms, to repeat. When null, removed after first invocation.
repeat_ms: ?u32,
cb: js.Function.Temp,
mode: Mode,
exec: *js.Execution,
timers: *Timers,
arena: Allocator,
removed: bool = false,
params: []const js.Value.Temp,
fn cancelled(ptr: *anyopaque) void {
var self: *ScheduleCallback = @ptrCast(@alignCast(ptr));
self.deinit();
}
fn deinit(self: *ScheduleCallback) void {
self.cb.release();
for (self.params) |param| {
param.release();
}
self.exec.releaseArena(self.arena);
}
fn run(ptr: *anyopaque) !?u32 {
const self: *ScheduleCallback = @ptrCast(@alignCast(ptr));
if (self.removed) {
self.deinit();
return null;
}
var ls: js.Local.Scope = undefined;
self.exec.context.localScope(&ls);
defer ls.deinit();
switch (self.mode) {
.idle => {
const IdleDeadline = @import("IdleDeadline.zig");
ls.toLocal(self.cb).call(void, .{IdleDeadline{}}) catch |err| {
log.warn(.js, "idleCallback", .{ .name = self.name, .err = err });
};
},
.animation_frame => {
// requestAnimationFrame is window-only; if a worker ever
// schedules with this mode it's a programming error.
const window = switch (self.exec.context.global) {
.frame => |frame| frame.window,
.worker => unreachable,
};
ls.toLocal(self.cb).call(void, .{window._performance.now()}) catch |err| {
log.warn(.js, "RAF", .{ .name = self.name, .err = err });
};
},
.normal => {
ls.toLocal(self.cb).call(void, self.params) catch |err| {
log.warn(.js, "timer", .{ .name = self.name, .err = err });
};
},
}
ls.local.runMicrotasks();
if (self.repeat_ms) |ms| {
return ms;
}
defer self.deinit();
_ = self.timers._callbacks.remove(self.timer_id);
return null;
}
};

View File

@@ -44,6 +44,7 @@ const Element = @import("Element.zig");
const CSSStyleProperties = @import("css/CSSStyleProperties.zig");
const CustomElementRegistry = @import("CustomElementRegistry.zig");
const Selection = @import("Selection.zig");
const Timers = @import("Timers.zig");
const Notification = @import("../../Notification.zig");
const log = lp.log;
@@ -77,8 +78,7 @@ _on_rejection_handled: ?js.Function.Global = null,
_on_unhandled_rejection: ?js.Function.Global = null,
_current_event: ?*Event = null,
_location: *Location,
_timer_id: u30 = 0,
_timers: std.AutoHashMapUnmanaged(u32, *ScheduleCallback) = .{},
_timers: Timers = .{},
_custom_elements: CustomElementRegistry = .{},
_scroll_pos: struct {
x: u32,
@@ -286,63 +286,39 @@ pub fn fetch(_: *const Window, input: Fetch.Input, options: ?Fetch.InitOpts, exe
return Fetch.init(input, options, exec);
}
const LegacyHandler = union(enum) {
function: js.Function.Temp,
string: js.String,
};
pub fn setTimeout(self: *Window, handler: LegacyHandler, delay_ms: ?u32, params: []js.Value.Temp, frame: *Frame) !u32 {
const cb = try resolveTimerHandler(handler, frame);
return self.scheduleCallback(cb, delay_ms orelse 0, .{
pub fn setTimeout(self: *Window, handler: Timers.LegacyHandler, delay_ms: ?u32, params: []js.Value.Temp, exec: *js.Execution) !u32 {
const cb = try handler.resolve(exec);
return self._timers.schedule(exec, cb, delay_ms orelse 0, .{
.repeat = false,
.params = params,
.low_priority = false,
.name = "window.setTimeout",
}, frame);
});
}
pub fn setInterval(self: *Window, handler: LegacyHandler, delay_ms: ?u32, params: []js.Value.Temp, frame: *Frame) !u32 {
const cb = try resolveTimerHandler(handler, frame);
return self.scheduleCallback(cb, delay_ms orelse 0, .{
pub fn setInterval(self: *Window, handler: Timers.LegacyHandler, delay_ms: ?u32, params: []js.Value.Temp, exec: *js.Execution) !u32 {
const cb = try handler.resolve(exec);
return self._timers.schedule(exec, cb, delay_ms orelse 0, .{
.repeat = true,
.params = params,
.low_priority = false,
.name = "window.setInterval",
}, frame);
});
}
// https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#dom-settimeout
// https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#timerhandler
// TimerHandler = Function or DOMString. When a string is passed, it is
// compiled into an anonymous function body, matching how legacy browsers
// (and all current UAs) interpret `setTimeout("foo()", 100)`.
fn resolveTimerHandler(handler: LegacyHandler, frame: *Frame) !js.Function.Temp {
switch (handler) {
.function => |fun| return fun,
.string => |str| {
const fun = try frame.js.local.?.compileFunction(str, &.{}, &.{});
return fun.temp();
},
}
}
pub fn setImmediate(self: *Window, cb: js.Function.Temp, params: []js.Value.Temp, frame: *Frame) !u32 {
return self.scheduleCallback(cb, 0, .{
pub fn setImmediate(self: *Window, cb: js.Function.Temp, params: []js.Value.Temp, exec: *js.Execution) !u32 {
return self._timers.schedule(exec, cb, 0, .{
.repeat = false,
.params = params,
.low_priority = false,
.name = "window.setImmediate",
}, frame);
});
}
pub fn requestAnimationFrame(self: *Window, cb: js.Function.Temp, frame: *Frame) !u32 {
return self.scheduleCallback(cb, 5, .{
pub fn requestAnimationFrame(self: *Window, cb: js.Function.Temp, exec: *js.Execution) !u32 {
return self._timers.schedule(exec, cb, 5, .{
.repeat = false,
.params = &.{},
.low_priority = false,
.mode = .animation_frame,
.name = "window.requestAnimationFrame",
}, frame);
});
}
pub fn queueMicrotask(_: *Window, cb: js.Function, frame: *Frame) void {
@@ -350,42 +326,37 @@ pub fn queueMicrotask(_: *Window, cb: js.Function, frame: *Frame) void {
}
pub fn clearTimeout(self: *Window, id: u32) void {
var sc = self._timers.fetchRemove(id) orelse return;
sc.value.removed = true;
self._timers.clear(id);
}
pub fn clearInterval(self: *Window, id: u32) void {
var sc = self._timers.fetchRemove(id) orelse return;
sc.value.removed = true;
self._timers.clear(id);
}
pub fn clearImmediate(self: *Window, id: u32) void {
var sc = self._timers.fetchRemove(id) orelse return;
sc.value.removed = true;
self._timers.clear(id);
}
pub fn cancelAnimationFrame(self: *Window, id: u32) void {
var sc = self._timers.fetchRemove(id) orelse return;
sc.value.removed = true;
self._timers.clear(id);
}
const RequestIdleCallbackOpts = struct {
timeout: ?u32 = null,
};
pub fn requestIdleCallback(self: *Window, cb: js.Function.Temp, opts_: ?RequestIdleCallbackOpts, frame: *Frame) !u32 {
pub fn requestIdleCallback(self: *Window, cb: js.Function.Temp, opts_: ?RequestIdleCallbackOpts, exec: *js.Execution) !u32 {
const opts = opts_ orelse RequestIdleCallbackOpts{};
return self.scheduleCallback(cb, opts.timeout orelse 50, .{
return self._timers.schedule(exec, cb, opts.timeout orelse 50, .{
.mode = .idle,
.repeat = false,
.params = &.{},
.low_priority = true,
.name = "window.requestIdleCallback",
}, frame);
});
}
pub fn cancelIdleCallback(self: *Window, id: u32) void {
var sc = self._timers.fetchRemove(id) orelse return;
sc.value.removed = true;
self._timers.clear(id);
}
pub fn reportError(self: *Window, err: js.Value, frame: *Frame) !void {
@@ -801,140 +772,6 @@ pub const Access = union(enum) {
}
};
const ScheduleOpts = struct {
repeat: bool,
params: []js.Value.Temp,
name: []const u8,
low_priority: bool = false,
animation_frame: bool = false,
mode: ScheduleCallback.Mode = .normal,
};
fn scheduleCallback(self: *Window, cb: js.Function.Temp, delay_ms: u32, opts: ScheduleOpts, frame: *Frame) !u32 {
if (self._timers.count() > 512) {
// these are active
return error.TooManyTimeout;
}
const arena = try frame.getArena(.tiny, "Window.schedule");
errdefer frame.releaseArena(arena);
const timer_id = self._timer_id +% 1;
self._timer_id = timer_id;
const params = opts.params;
var persisted_params: []js.Value.Temp = &.{};
if (params.len > 0) {
persisted_params = try arena.dupe(js.Value.Temp, params);
}
const gop = try self._timers.getOrPut(frame.arena, timer_id);
if (gop.found_existing) {
// 2^31 would have to wrap for this to happen.
return error.TooManyTimeout;
}
errdefer _ = self._timers.remove(timer_id);
const callback = try arena.create(ScheduleCallback);
callback.* = .{
.cb = cb,
.frame = frame,
.arena = arena,
.mode = opts.mode,
.name = opts.name,
.timer_id = timer_id,
.params = persisted_params,
.repeat_ms = if (opts.repeat) if (delay_ms == 0) 1 else delay_ms else null,
};
gop.value_ptr.* = callback;
try frame.js.scheduler.add(callback, ScheduleCallback.run, delay_ms, .{
.name = opts.name,
.low_priority = opts.low_priority,
.finalizer = ScheduleCallback.cancelled,
});
return timer_id;
}
const ScheduleCallback = struct {
// for debugging
name: []const u8,
// window._timers key
timer_id: u31,
// delay, in ms, to repeat. When null, will be removed after the first time
repeat_ms: ?u32,
cb: js.Function.Temp,
mode: Mode,
frame: *Frame,
arena: Allocator,
removed: bool = false,
params: []const js.Value.Temp,
const Mode = enum {
idle,
normal,
animation_frame,
};
fn cancelled(ctx: *anyopaque) void {
var self: *ScheduleCallback = @ptrCast(@alignCast(ctx));
self.deinit();
}
fn deinit(self: *ScheduleCallback) void {
self.cb.release();
for (self.params) |param| {
param.release();
}
self.frame.releaseArena(self.arena);
}
fn run(ctx: *anyopaque) !?u32 {
const self: *ScheduleCallback = @ptrCast(@alignCast(ctx));
const frame = self.frame;
const window = frame.window;
if (self.removed) {
self.deinit();
return null;
}
var ls: js.Local.Scope = undefined;
frame.js.localScope(&ls);
defer ls.deinit();
switch (self.mode) {
.idle => {
const IdleDeadline = @import("IdleDeadline.zig");
ls.toLocal(self.cb).call(void, .{IdleDeadline{}}) catch |err| {
log.warn(.js, "window.idleCallback", .{ .name = self.name, .err = err });
};
},
.animation_frame => {
ls.toLocal(self.cb).call(void, .{window._performance.now()}) catch |err| {
log.warn(.js, "window.RAF", .{ .name = self.name, .err = err });
};
},
.normal => {
ls.toLocal(self.cb).call(void, self.params) catch |err| {
log.warn(.js, "window.timer", .{ .name = self.name, .err = err });
};
},
}
ls.local.runMicrotasks();
if (self.repeat_ms) |ms| {
return ms;
}
defer self.deinit();
_ = window._timers.remove(self.timer_id);
return null;
}
};
const PostMessageCallback = struct {
frame: *Frame,
source: *Window,

View File

@@ -37,6 +37,7 @@ const Event = @import("Event.zig");
const Worker = @import("Worker.zig");
const Crypto = @import("Crypto.zig");
const Console = @import("Console.zig");
const Timers = @import("Timers.zig");
const EventTarget = @import("EventTarget.zig");
const MessageEvent = @import("event/MessageEvent.zig");
const ErrorEvent = @import("event/ErrorEvent.zig");
@@ -97,6 +98,8 @@ _on_unhandled_rejection: ?JS.Function.Global = null,
_on_message: ?JS.Function.Global = null,
_on_messageerror: ?JS.Function.Global = null,
_timers: Timers = .{},
pub fn init(worker: *Worker, url: [:0]const u8) !*WorkerGlobalScope {
const arena = worker._arena;
const parent = worker._frame;
@@ -454,6 +457,36 @@ pub fn fetch(_: *const WorkerGlobalScope, input: Fetch.Input, options: ?Fetch.In
return Fetch.init(input, options, exec);
}
pub fn queueMicrotask(self: *WorkerGlobalScope, cb: JS.Function) void {
self.js.queueMicrotaskFunc(cb);
}
pub fn setTimeout(self: *WorkerGlobalScope, handler: Timers.LegacyHandler, delay_ms: ?u32, params: []JS.Value.Temp, exec: *JS.Execution) !u32 {
const cb = try handler.resolve(exec);
return self._timers.schedule(exec, cb, delay_ms orelse 0, .{
.repeat = false,
.params = params,
.name = "worker.setTimeout",
});
}
pub fn clearTimeout(self: *WorkerGlobalScope, id: u32) void {
self._timers.clear(id);
}
pub fn setInterval(self: *WorkerGlobalScope, handler: Timers.LegacyHandler, delay_ms: ?u32, params: []JS.Value.Temp, exec: *JS.Execution) !u32 {
const cb = try handler.resolve(exec);
return self._timers.schedule(exec, cb, delay_ms orelse 0, .{
.repeat = true,
.params = params,
.name = "worker.setInterval",
});
}
pub fn clearInterval(self: *WorkerGlobalScope, id: u32) void {
self._timers.clear(id);
}
const FunctionSetter = union(enum) {
func: JS.Function.Global,
anything: JS.Value,
@@ -546,6 +579,11 @@ pub const JsApi = struct {
pub const close = bridge.function(WorkerGlobalScope.close, .{});
pub const fetch = bridge.function(WorkerGlobalScope.fetch, .{});
pub const importScripts = bridge.function(WorkerGlobalScope.importScripts, .{ .dom_exception = true });
pub const queueMicrotask = bridge.function(WorkerGlobalScope.queueMicrotask, .{});
pub const setTimeout = bridge.function(WorkerGlobalScope.setTimeout, .{});
pub const clearTimeout = bridge.function(WorkerGlobalScope.clearTimeout, .{});
pub const setInterval = bridge.function(WorkerGlobalScope.setInterval, .{});
pub const clearInterval = bridge.function(WorkerGlobalScope.clearInterval, .{});
pub const onmessage = bridge.accessor(WorkerGlobalScope.getOnMessage, WorkerGlobalScope.setOnMessage, .{});
pub const onmessageerror = bridge.accessor(WorkerGlobalScope.getOnMessageError, WorkerGlobalScope.setOnMessageError, .{});