mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-08-03 03:13:05 -04:00
Merge pull request #3094 from lightpanda-io/webapi-scheduler
webapi: Add Scheduler (window and worker)
This commit is contained in:
@@ -32,6 +32,7 @@ const MouseEvent = @import("webapi/event/MouseEvent.zig");
|
||||
const Element = @import("webapi/Element.zig");
|
||||
const Document = @import("webapi/Document.zig");
|
||||
const EventTarget = @import("webapi/EventTarget.zig");
|
||||
const AbortSignal = @import("webapi/AbortSignal.zig");
|
||||
const XMLHttpRequestEventTarget = @import("webapi/net/XMLHttpRequestEventTarget.zig");
|
||||
const Blob = @import("webapi/Blob.zig");
|
||||
const AbstractRange = @import("webapi/AbstractRange.zig");
|
||||
@@ -513,6 +514,12 @@ pub fn xhrEventTarget(_: *const Factory, allocator: Allocator, child: anytype) !
|
||||
).create(allocator, child);
|
||||
}
|
||||
|
||||
pub fn taskSignal(self: *Factory, child: anytype) !*@TypeOf(child) {
|
||||
return try AutoPrototypeChain(
|
||||
&.{ EventTarget, AbortSignal, @TypeOf(child) },
|
||||
).create(self._slab.allocator(), child);
|
||||
}
|
||||
|
||||
pub fn textTrackCue(self: *Factory, child: anytype) !*@TypeOf(child) {
|
||||
const allocator = self._slab.allocator();
|
||||
const TextTrackCue = @import("webapi/media/TextTrackCue.zig");
|
||||
|
||||
@@ -68,6 +68,7 @@ pub fn reset(self: *Scheduler) void {
|
||||
|
||||
const AddOpts = struct {
|
||||
name: []const u8 = "",
|
||||
front: bool = false, // run before any timed tasks, multi-fronts are FIFO amongst themselves
|
||||
low_priority: bool = false,
|
||||
finalizer: ?Finalizer = null,
|
||||
};
|
||||
@@ -84,7 +85,7 @@ pub fn add(self: *Scheduler, ctx: *anyopaque, cb: Callback, run_in_ms: u32, opts
|
||||
.sequence = seq,
|
||||
.name = opts.name,
|
||||
.finalizer = opts.finalizer,
|
||||
.run_at = lp.datetime.milliTimestamp(.boot) + run_in_ms,
|
||||
.run_at = if (opts.front) 0 else lp.datetime.milliTimestamp(.boot) + run_in_ms,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -932,6 +932,10 @@ pub const SubType = enum {
|
||||
pub const PageJsApis = flattenTypes(&.{
|
||||
@import("../webapi/AbortController.zig"),
|
||||
@import("../webapi/AbortSignal.zig"),
|
||||
@import("../webapi/Scheduler.zig"),
|
||||
@import("../webapi/TaskController.zig"),
|
||||
@import("../webapi/TaskSignal.zig"),
|
||||
@import("../webapi/event/TaskPriorityChangeEvent.zig"),
|
||||
@import("../webapi/CData.zig"),
|
||||
@import("../webapi/cdata/Comment.zig"),
|
||||
@import("../webapi/cdata/Text.zig"),
|
||||
@@ -1259,6 +1263,10 @@ const worker_common_apis = [_]type{
|
||||
@import("../webapi/encoding/TextDecoderStream.zig"),
|
||||
@import("../webapi/AbortSignal.zig"),
|
||||
@import("../webapi/AbortController.zig"),
|
||||
@import("../webapi/Scheduler.zig"),
|
||||
@import("../webapi/TaskController.zig"),
|
||||
@import("../webapi/TaskSignal.zig"),
|
||||
@import("../webapi/event/TaskPriorityChangeEvent.zig"),
|
||||
@import("../webapi/URL.zig"),
|
||||
@import("../webapi/canvas/OffscreenCanvas.zig"),
|
||||
@import("../webapi/canvas/OffscreenCanvasRenderingContext2D.zig"),
|
||||
|
||||
259
src/browser/tests/scheduler/scheduler.html
Normal file
259
src/browser/tests/scheduler/scheduler.html
Normal file
@@ -0,0 +1,259 @@
|
||||
<!DOCTYPE html>
|
||||
<script src="../testing.js"></script>
|
||||
|
||||
<script id=schedulerSurface>
|
||||
{
|
||||
testing.expectEqual('function', typeof scheduler.postTask);
|
||||
testing.expectEqual('function', typeof scheduler.yield);
|
||||
testing.expectEqual(true, scheduler instanceof Scheduler);
|
||||
testing.expectEqual('function', typeof TaskController);
|
||||
testing.expectEqual('function', typeof TaskSignal);
|
||||
testing.expectEqual('function', typeof TaskPriorityChangeEvent);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=taskControllerSignal>
|
||||
{
|
||||
const controller = new TaskController();
|
||||
const signal = controller.signal;
|
||||
testing.expectEqual(true, signal instanceof TaskSignal);
|
||||
testing.expectEqual(true, signal instanceof AbortSignal);
|
||||
testing.expectEqual('user-visible', signal.priority);
|
||||
testing.expectEqual(false, signal.aborted);
|
||||
|
||||
const bg = new TaskController({ priority: 'background' });
|
||||
testing.expectEqual('background', bg.signal.priority);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=setPriorityFiresPriorityChange>
|
||||
{
|
||||
const controller = new TaskController({ priority: 'user-blocking' });
|
||||
const signal = controller.signal;
|
||||
|
||||
let fired = 0;
|
||||
signal.addEventListener('prioritychange', (event) => {
|
||||
fired += 1;
|
||||
testing.expectEqual('prioritychange', event.type);
|
||||
testing.expectEqual('user-blocking', event.previousPriority);
|
||||
testing.expectEqual('background', signal.priority);
|
||||
});
|
||||
|
||||
controller.setPriority('background');
|
||||
testing.expectEqual(1, fired);
|
||||
|
||||
// same priority: no event
|
||||
controller.setPriority('background');
|
||||
testing.expectEqual(1, fired);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=taskPriorityChangeEventConstructor>
|
||||
{
|
||||
const event = new TaskPriorityChangeEvent('prioritychange', { previousPriority: 'background' });
|
||||
testing.expectEqual('background', event.previousPriority);
|
||||
testing.expectEqual(false, event.isTrusted);
|
||||
|
||||
let threw = false;
|
||||
try {
|
||||
new TaskPriorityChangeEvent('prioritychange');
|
||||
} catch (e) {
|
||||
threw = true;
|
||||
}
|
||||
testing.expectEqual(true, threw);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=postTaskResolves type=module>
|
||||
{
|
||||
const state = await testing.async();
|
||||
const value = await scheduler.postTask(() => 42);
|
||||
state.resolve(value);
|
||||
await state.done((v) => {
|
||||
testing.expectEqual(42, v);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=postTaskAdoptsAsyncCallback type=module>
|
||||
{
|
||||
const state = await testing.async();
|
||||
const value = await scheduler.postTask(async () => 'async-result');
|
||||
state.resolve(value);
|
||||
await state.done((v) => {
|
||||
testing.expectEqual('async-result', v);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=postTaskRejectsOnThrow type=module>
|
||||
{
|
||||
const state = await testing.async();
|
||||
try {
|
||||
await scheduler.postTask(() => { throw new Error('boom'); });
|
||||
state.resolve('resolved');
|
||||
} catch (e) {
|
||||
state.resolve(e.message);
|
||||
}
|
||||
await state.done((v) => {
|
||||
testing.expectEqual('boom', v);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=postTaskOrderingAndDelay type=module>
|
||||
{
|
||||
const state = await testing.async();
|
||||
const order = [];
|
||||
const delayed = scheduler.postTask(() => order.push('delayed'), { delay: 5 });
|
||||
const first = scheduler.postTask(() => order.push('first'));
|
||||
const second = scheduler.postTask(() => order.push('second'));
|
||||
await Promise.all([delayed, first, second]);
|
||||
state.resolve(order.join(','));
|
||||
await state.done((v) => {
|
||||
testing.expectEqual('first,second,delayed', v);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=postTaskPreAbortedSignal type=module>
|
||||
{
|
||||
const state = await testing.async();
|
||||
const controller = new TaskController();
|
||||
controller.abort('nope');
|
||||
let ran = false;
|
||||
try {
|
||||
await scheduler.postTask(() => { ran = true; }, { signal: controller.signal });
|
||||
state.resolve('resolved');
|
||||
} catch (e) {
|
||||
state.resolve(e);
|
||||
}
|
||||
await state.done((v) => {
|
||||
testing.expectEqual('nope', v);
|
||||
testing.expectEqual(false, ran);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=postTaskAbortBeforeRun type=module>
|
||||
{
|
||||
const state = await testing.async();
|
||||
const controller = new TaskController();
|
||||
let ran = false;
|
||||
const task = scheduler.postTask(() => { ran = true; }, { signal: controller.signal, delay: 50 });
|
||||
controller.abort('cancelled');
|
||||
try {
|
||||
await task;
|
||||
state.resolve('resolved');
|
||||
} catch (e) {
|
||||
state.resolve(e);
|
||||
}
|
||||
await state.done((v) => {
|
||||
testing.expectEqual('cancelled', v);
|
||||
testing.expectEqual(false, ran);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=postTaskPlainAbortControllerWorks type=module>
|
||||
{
|
||||
const state = await testing.async();
|
||||
const controller = new AbortController();
|
||||
const task = scheduler.postTask(() => 'ok', { signal: controller.signal, delay: 50 });
|
||||
controller.abort();
|
||||
try {
|
||||
await task;
|
||||
state.resolve('resolved');
|
||||
} catch (e) {
|
||||
state.resolve(e.name);
|
||||
}
|
||||
await state.done((v) => {
|
||||
testing.expectEqual('AbortError', v);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=postTaskInvalidPriority type=module>
|
||||
{
|
||||
const state = await testing.async();
|
||||
try {
|
||||
await scheduler.postTask(() => {}, { priority: 'bogus' });
|
||||
state.resolve('resolved');
|
||||
} catch (e) {
|
||||
state.resolve(e.name);
|
||||
}
|
||||
await state.done((v) => {
|
||||
testing.expectEqual('TypeError', v);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=postTaskAbortDuringRun type=module>
|
||||
{
|
||||
const state = await testing.async();
|
||||
const controller = new TaskController();
|
||||
try {
|
||||
await scheduler.postTask(() => {
|
||||
controller.abort('mid');
|
||||
return 'value';
|
||||
}, { signal: controller.signal });
|
||||
state.resolve('resolved');
|
||||
} catch (e) {
|
||||
state.resolve('rejected:' + e);
|
||||
}
|
||||
await state.done((v) => {
|
||||
testing.expectEqual('rejected:mid', v);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=yieldInterleavesTasks type=module>
|
||||
{
|
||||
const state = await testing.async();
|
||||
const order = [];
|
||||
const yielding = scheduler.postTask(async () => {
|
||||
order.push('a');
|
||||
await scheduler.yield();
|
||||
order.push('c');
|
||||
});
|
||||
const other = scheduler.postTask(() => order.push('b'));
|
||||
await Promise.all([yielding, other]);
|
||||
state.resolve(order.join(','));
|
||||
await state.done((v) => {
|
||||
// the yield continuation jumps ahead of the already-queued task
|
||||
testing.expectEqual('a,c,b', v);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=yieldInheritsAbortedSignal type=module>
|
||||
{
|
||||
const state = await testing.async();
|
||||
const controller = new TaskController();
|
||||
const task = scheduler.postTask(async () => {
|
||||
controller.abort('stop');
|
||||
try {
|
||||
await scheduler.yield();
|
||||
state.resolve('not-interrupted');
|
||||
} catch (e) {
|
||||
state.resolve('interrupted:' + e);
|
||||
}
|
||||
}, { signal: controller.signal });
|
||||
// the abort also rejects the task promise itself (see postTaskAbortDuringRun)
|
||||
task.catch(() => {});
|
||||
await state.done((v) => {
|
||||
testing.expectEqual('interrupted:stop', v);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=yieldOutsideTask type=module>
|
||||
{
|
||||
const state = await testing.async();
|
||||
await scheduler.yield();
|
||||
state.resolve('done');
|
||||
await state.done((v) => {
|
||||
testing.expectEqual('done', v);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -22,6 +22,7 @@ const lp = @import("lightpanda");
|
||||
const js = @import("../js/js.zig");
|
||||
|
||||
const Event = @import("Event.zig");
|
||||
const Scheduler = @import("Scheduler.zig");
|
||||
const EventTarget = @import("EventTarget.zig");
|
||||
const DOMException = @import("DOMException.zig");
|
||||
const ModelContextTool = @import("ModelContext.zig").Tool;
|
||||
@@ -36,6 +37,9 @@ pub const Proto = EventTarget;
|
||||
const Dependend = union(enum) {
|
||||
signal: *AbortSignal,
|
||||
model_context_tool: *ModelContextTool,
|
||||
// Handled by the owning signal's markAborted (which runs for dependent
|
||||
// signals too, unlike this union's markAborted).
|
||||
scheduler_task: *Scheduler.Task,
|
||||
|
||||
// Returns false if the dependent was already aborted, in which case no
|
||||
// abort event must be dispatched for it.
|
||||
@@ -50,18 +54,20 @@ const Dependend = union(enum) {
|
||||
try dep.markAborted(exec);
|
||||
return true;
|
||||
},
|
||||
.scheduler_task => return false,
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatchAbortEvent(self: Dependend, exec: *const Execution) !void {
|
||||
switch (self) {
|
||||
.signal => |dep| try dep.dispatchAbortEvent(exec),
|
||||
.model_context_tool => {},
|
||||
.model_context_tool, .scheduler_task => {},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_proto: *EventTarget,
|
||||
_type: Type = .generic,
|
||||
_aborted: bool = false,
|
||||
_is_dependent: bool = false,
|
||||
_reason: Reason = .undefined,
|
||||
@@ -69,6 +75,11 @@ _on_abort: ?js.Function.Global = null,
|
||||
_dependents: std.ArrayList(Dependend) = .empty,
|
||||
_source_signals: std.ArrayList(*AbortSignal) = .empty,
|
||||
|
||||
pub const Type = union(enum) {
|
||||
generic: void,
|
||||
task_signal: *@import("TaskSignal.zig"),
|
||||
};
|
||||
|
||||
pub fn init(exec: *const Execution) !*AbortSignal {
|
||||
return exec._factory.eventTarget(AbortSignal{
|
||||
._proto = undefined,
|
||||
@@ -136,18 +147,23 @@ fn markAborted(self: *AbortSignal, reason_: ?Reason, exec: *const Execution) !vo
|
||||
dom.* = DOMException.fromError(error.AbortError).?;
|
||||
self._reason = .{ .dom = dom };
|
||||
}
|
||||
|
||||
// Unlike the loop in abort(), this runs for dependent signals too, so a
|
||||
// task registered on an any() signal still gets rejected.
|
||||
for (self._dependents.items) |dep| {
|
||||
switch (dep) {
|
||||
.scheduler_task => |task| task.onAbort(self._reason, exec),
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatchAbortEvent(self: *AbortSignal, exec: *const Execution) !void {
|
||||
const target = self.asEventTarget();
|
||||
const on_abort = self._on_abort;
|
||||
switch (exec.js.global) {
|
||||
inline else => |g| {
|
||||
if (g._event_manager.hasDirectListeners(target, "abort", on_abort)) {
|
||||
const event = try Event.initTrusted(comptime .wrap("abort"), .{}, g._page);
|
||||
try g.dispatch(target, event, on_abort, .{ .context = "abort signal" });
|
||||
}
|
||||
},
|
||||
if (exec.hasDirectListeners(target, "abort", on_abort)) {
|
||||
const event = try Event.initTrusted(comptime .wrap("abort"), .{}, exec.page);
|
||||
try exec.dispatch(target, event, on_abort, .{ .context = "abort signal" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,13 +248,23 @@ pub fn throwIfAborted(self: *const AbortSignal, exec: *const Execution) !ThrowIf
|
||||
return .undefined;
|
||||
}
|
||||
|
||||
const Reason = union(enum) {
|
||||
pub const Reason = union(enum) {
|
||||
js_val: js.Value.Global,
|
||||
dom: *DOMException,
|
||||
string: []const u8,
|
||||
undefined: void,
|
||||
};
|
||||
|
||||
// The reason as a JS value, e.g. to reject a promise with it.
|
||||
pub fn reasonJsValue(reason: Reason, local: *const js.Local) !js.Value {
|
||||
return switch (reason) {
|
||||
.dom => |dom| local.zigValueToJs(dom, .{}),
|
||||
.string => |str| local.zigValueToJs(str, .{}),
|
||||
.js_val => |global| local.toLocal(global),
|
||||
.undefined => local.zigValueToJs(DOMException.fromError(error.AbortError).?, .{}),
|
||||
};
|
||||
}
|
||||
|
||||
const TimeoutCallback = struct {
|
||||
exec: *const Execution,
|
||||
signal: *AbortSignal,
|
||||
|
||||
@@ -96,6 +96,7 @@ pub const Type = union(enum) {
|
||||
cookie_change_event: *@import("event/CookieChangeEvent.zig"),
|
||||
idb_version_change_event: *@import("storage/idb/IDBVersionChangeEvent.zig"),
|
||||
toggle_event: *@import("event/ToggleEvent.zig"),
|
||||
task_priority_change_event: *@import("event/TaskPriorityChangeEvent.zig"),
|
||||
};
|
||||
|
||||
pub const Options = struct {
|
||||
@@ -209,6 +210,7 @@ pub fn is(self: *Event, comptime T: type) ?*T {
|
||||
.cookie_change_event => |e| return if (T == @import("event/CookieChangeEvent.zig")) e else null,
|
||||
.idb_version_change_event => |e| return if (T == @import("storage/idb/IDBVersionChangeEvent.zig")) e else null,
|
||||
.toggle_event => |e| return if (T == @import("event/ToggleEvent.zig")) e else null,
|
||||
.task_priority_change_event => |e| return if (T == @import("event/TaskPriorityChangeEvent.zig")) e else null,
|
||||
.ui_event => |e| {
|
||||
if (T == @import("event/UIEvent.zig")) {
|
||||
return e;
|
||||
|
||||
284
src/browser/webapi/Scheduler.zig
Normal file
284
src/browser/webapi/Scheduler.zig
Normal file
@@ -0,0 +1,284 @@
|
||||
// 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/>.
|
||||
|
||||
const lp = @import("lightpanda");
|
||||
|
||||
const js = @import("../js/js.zig");
|
||||
|
||||
const AbortSignal = @import("AbortSignal.zig");
|
||||
|
||||
const log = lp.log;
|
||||
const Execution = js.Execution;
|
||||
|
||||
const Scheduler = @This();
|
||||
|
||||
// State of the postTask callback (or yield continuation) currently running,
|
||||
// so scheduler.yield() can inherit its priority and abort signal.
|
||||
_current: ?TaskState = null,
|
||||
|
||||
pub const Priority = enum {
|
||||
@"user-blocking",
|
||||
@"user-visible",
|
||||
background,
|
||||
|
||||
pub const js_enum_from_string = true;
|
||||
|
||||
pub fn toString(self: Priority) []const u8 {
|
||||
return @tagName(self);
|
||||
}
|
||||
};
|
||||
|
||||
const TaskState = struct {
|
||||
priority: Priority,
|
||||
signal: ?*AbortSignal,
|
||||
};
|
||||
|
||||
const PostTaskOptions = struct {
|
||||
priority: ?Priority = null,
|
||||
delay: ?u32 = null,
|
||||
signal: ?*AbortSignal = null,
|
||||
};
|
||||
|
||||
pub fn postTask(self: *Scheduler, cb: js.Function.Global, options_: ?js.Value, exec: *js.Execution) !js.Promise {
|
||||
const local = exec.js.local.?;
|
||||
const resolver = local.createPromiseResolver();
|
||||
|
||||
const opts: PostTaskOptions = blk: {
|
||||
const options_val = options_ orelse break :blk .{};
|
||||
if (options_val.isNullOrUndefined()) {
|
||||
break :blk .{};
|
||||
}
|
||||
break :blk options_val.toZig(PostTaskOptions) catch {
|
||||
cb.release();
|
||||
resolver.rejectError("scheduler.postTask", .{ .type_error = "invalid postTask options" });
|
||||
return resolver.promise();
|
||||
};
|
||||
};
|
||||
|
||||
if (opts.signal) |signal| {
|
||||
if (signal._aborted) {
|
||||
cb.release();
|
||||
rejectWithReason(resolver, signal._reason, local);
|
||||
return resolver.promise();
|
||||
}
|
||||
}
|
||||
|
||||
// An explicit priority wins over the signal's
|
||||
const priority: Priority = opts.priority orelse blk: {
|
||||
const signal = opts.signal orelse break :blk .@"user-visible";
|
||||
break :blk switch (signal._type) {
|
||||
.task_signal => |ts| ts._priority,
|
||||
.generic => .@"user-visible",
|
||||
};
|
||||
};
|
||||
|
||||
const task = try Task.create(opts.signal, exec);
|
||||
task.cb = cb;
|
||||
task.scheduler = self;
|
||||
task.resolver = try resolver.persist();
|
||||
task.state = .{ .priority = priority, .signal = opts.signal };
|
||||
if (opts.signal) |signal| {
|
||||
try signal._dependents.append(exec.arena, .{ .scheduler_task = task });
|
||||
}
|
||||
try exec.js.scheduler.add(task, Task.run, opts.delay orelse 0, .{
|
||||
.name = "scheduler.postTask",
|
||||
.finalizer = Task.finalize,
|
||||
});
|
||||
return resolver.promise();
|
||||
}
|
||||
|
||||
pub fn yield(self: *Scheduler, exec: *js.Execution) !js.Promise {
|
||||
const local = exec.js.local.?;
|
||||
const resolver = local.createPromiseResolver();
|
||||
|
||||
const state = self._current orelse TaskState{ .priority = .@"user-visible", .signal = null };
|
||||
if (state.signal) |signal| {
|
||||
if (signal._aborted) {
|
||||
rejectWithReason(resolver, signal._reason, local);
|
||||
return resolver.promise();
|
||||
}
|
||||
}
|
||||
|
||||
const task = try Task.create(state.signal, exec);
|
||||
task.cb = null;
|
||||
task.scheduler = self;
|
||||
task.resolver = try resolver.persist();
|
||||
task.state = state;
|
||||
if (state.signal) |signal| {
|
||||
try signal._dependents.append(exec.arena, .{ .scheduler_task = task });
|
||||
}
|
||||
try exec.js.scheduler.add(task, Task.run, 0, .{
|
||||
.name = "scheduler.yield",
|
||||
.front = true,
|
||||
.finalizer = Task.finalize,
|
||||
});
|
||||
return resolver.promise();
|
||||
}
|
||||
|
||||
fn rejectWithReason(resolver: js.PromiseResolver, reason: AbortSignal.Reason, local: *const js.Local) void {
|
||||
const value = AbortSignal.reasonJsValue(reason, local) catch {
|
||||
resolver.rejectError("scheduler task abort", .{ .dom_exception = .{ .err = error.AbortError } });
|
||||
return;
|
||||
};
|
||||
resolver.reject("scheduler task abort", value);
|
||||
}
|
||||
|
||||
pub const Task = struct {
|
||||
// null for a yield continuation
|
||||
cb: ?js.Function.Global,
|
||||
exec: *js.Execution,
|
||||
scheduler: *Scheduler,
|
||||
// null once settled (ran, aborted, or finalized)
|
||||
resolver: ?js.PromiseResolver.Global,
|
||||
state: TaskState,
|
||||
// Pooled backing arena, released when the queued task is consumed. null
|
||||
// when a signal is attached, the Task is pinned by the signal.
|
||||
arena: ?*lp.Arena,
|
||||
|
||||
fn create(signal: ?*AbortSignal, exec: *js.Execution) !*Task {
|
||||
if (signal != null) {
|
||||
const task = try exec.arena.create(Task);
|
||||
task.exec = exec;
|
||||
task.arena = null;
|
||||
return task;
|
||||
}
|
||||
const arena = try exec.getArena(.tiny, "scheduler.task");
|
||||
const task = try arena.create(Task);
|
||||
task.exec = exec;
|
||||
task.arena = arena;
|
||||
return task;
|
||||
}
|
||||
|
||||
fn deinit(self: *Task) void {
|
||||
if (self.resolver) |resolver| {
|
||||
resolver.release();
|
||||
self.resolver = null;
|
||||
}
|
||||
if (self.cb) |cb| {
|
||||
cb.release();
|
||||
self.cb = null;
|
||||
}
|
||||
if (self.arena) |arena| {
|
||||
self.arena = null;
|
||||
arena.release();
|
||||
}
|
||||
}
|
||||
|
||||
fn finalize(ctx: *anyopaque) void {
|
||||
const self: *Task = @ptrCast(@alignCast(ctx));
|
||||
self.deinit();
|
||||
}
|
||||
|
||||
fn run(ctx: *anyopaque) !?u32 {
|
||||
const self: *Task = @ptrCast(@alignCast(ctx));
|
||||
defer self.deinit();
|
||||
|
||||
if (self.resolver == null) {
|
||||
return null; // onAbort fired
|
||||
}
|
||||
|
||||
const prev = self.scheduler._current;
|
||||
self.scheduler._current = self.state;
|
||||
defer self.scheduler._current = prev;
|
||||
|
||||
var ls: js.Local.Scope = undefined;
|
||||
self.exec.js.localScope(&ls);
|
||||
defer ls.deinit();
|
||||
|
||||
const cb_global = self.cb orelse {
|
||||
// yield continuation: the awaiting code runs in the microtasks
|
||||
// the resolve triggers, with _current set so it keeps inheriting.
|
||||
const resolver_global = self.resolver.?;
|
||||
self.resolver = null;
|
||||
defer resolver_global.release();
|
||||
ls.toLocal(resolver_global).resolve("scheduler.yield", {});
|
||||
return null;
|
||||
};
|
||||
|
||||
self.cb = null;
|
||||
defer cb_global.release();
|
||||
|
||||
var try_catch: js.TryCatch = undefined;
|
||||
try_catch.init(&ls.local);
|
||||
defer try_catch.deinit();
|
||||
|
||||
// callback could still abort this...
|
||||
const call_result = ls.toLocal(cb_global).callRethrow(js.Value, .{});
|
||||
|
||||
if (call_result) |result| {
|
||||
const resolver_global = self.resolver orelse return null;
|
||||
self.resolver = null;
|
||||
defer resolver_global.release();
|
||||
ls.toLocal(resolver_global).resolve("scheduler.postTask", result);
|
||||
} else |err| {
|
||||
if (err == error.ExecutionTerminated) {
|
||||
return err;
|
||||
}
|
||||
self.exec.page.recordJsError(err);
|
||||
|
||||
const resolver_global = self.resolver orelse return null;
|
||||
self.resolver = null;
|
||||
defer resolver_global.release();
|
||||
|
||||
const resolver = ls.toLocal(resolver_global);
|
||||
if (try_catch.exceptionValue()) |exception| {
|
||||
resolver.reject("scheduler.postTask", exception);
|
||||
} else {
|
||||
resolver.rejectError("scheduler.postTask", .{ .generic_error = "postTask callback failed" });
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Called by AbortSignal when our signal aborts before the task ran. The task
|
||||
// will still run, but resolver will be null and it'll exit.
|
||||
pub fn onAbort(self: *Task, reason: AbortSignal.Reason, exec: *const Execution) void {
|
||||
const resolver_global = self.resolver orelse return;
|
||||
defer resolver_global.release();
|
||||
|
||||
self.resolver = null;
|
||||
|
||||
if (self.cb) |cb| {
|
||||
cb.release();
|
||||
self.cb = null;
|
||||
}
|
||||
|
||||
var ls: js.Local.Scope = undefined;
|
||||
exec.js.localScope(&ls);
|
||||
defer ls.deinit();
|
||||
rejectWithReason(resolver_global.local(&ls.local), reason, &ls.local);
|
||||
}
|
||||
};
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(Scheduler);
|
||||
|
||||
pub const Meta = struct {
|
||||
pub const name = "Scheduler";
|
||||
pub const prototype_chain = bridge.prototypeChain();
|
||||
pub var class_id: bridge.ClassId = undefined;
|
||||
};
|
||||
|
||||
pub const postTask = bridge.function(Scheduler.postTask, .{});
|
||||
pub const yield = bridge.function(Scheduler.yield, .{});
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "WebApi: Scheduler" {
|
||||
try testing.htmlRunner("scheduler/scheduler.html", .{});
|
||||
}
|
||||
63
src/browser/webapi/TaskController.zig
Normal file
63
src/browser/webapi/TaskController.zig
Normal file
@@ -0,0 +1,63 @@
|
||||
// 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/>.
|
||||
|
||||
const js = @import("../js/js.zig");
|
||||
|
||||
const Scheduler = @import("Scheduler.zig");
|
||||
const TaskSignal = @import("TaskSignal.zig");
|
||||
const AbortController = @import("AbortController.zig");
|
||||
|
||||
const Execution = js.Execution;
|
||||
|
||||
// https://wicg.github.io/scheduling-apis/#sec-task-controller
|
||||
const TaskController = @This();
|
||||
|
||||
pub const Proto = AbortController;
|
||||
|
||||
_proto: *AbortController,
|
||||
|
||||
const Options = struct {
|
||||
priority: Scheduler.Priority = .@"user-visible",
|
||||
};
|
||||
|
||||
pub fn init(options_: ?Options, exec: *const Execution) !*TaskController {
|
||||
const opts = options_ orelse Options{};
|
||||
const signal = try TaskSignal.init(opts.priority, exec);
|
||||
return exec._factory.chained(.{
|
||||
AbortController{ ._signal = signal.asAbortSignal() },
|
||||
TaskController{ ._proto = undefined },
|
||||
});
|
||||
}
|
||||
|
||||
pub fn setPriority(self: *TaskController, priority: Scheduler.Priority, exec: *const Execution) !void {
|
||||
return self._proto._signal._type.task_signal.setPriority(priority, exec);
|
||||
}
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(TaskController);
|
||||
|
||||
pub const Meta = struct {
|
||||
pub const name = "TaskController";
|
||||
|
||||
pub const prototype_chain = bridge.prototypeChain();
|
||||
pub var class_id: bridge.ClassId = undefined;
|
||||
};
|
||||
|
||||
pub const constructor = bridge.constructor(TaskController.init, .{});
|
||||
pub const setPriority = bridge.function(TaskController.setPriority, .{});
|
||||
};
|
||||
99
src/browser/webapi/TaskSignal.zig
Normal file
99
src/browser/webapi/TaskSignal.zig
Normal file
@@ -0,0 +1,99 @@
|
||||
// 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/>.
|
||||
|
||||
const js = @import("../js/js.zig");
|
||||
|
||||
const Scheduler = @import("Scheduler.zig");
|
||||
const AbortSignal = @import("AbortSignal.zig");
|
||||
const EventTarget = @import("EventTarget.zig");
|
||||
const TaskPriorityChangeEvent = @import("event/TaskPriorityChangeEvent.zig");
|
||||
|
||||
const Execution = js.Execution;
|
||||
|
||||
// https://wicg.github.io/scheduling-apis/#sec-task-signal
|
||||
const TaskSignal = @This();
|
||||
|
||||
pub const Proto = AbortSignal;
|
||||
|
||||
_proto: *AbortSignal,
|
||||
_priority: Scheduler.Priority,
|
||||
_priority_changing: bool = false,
|
||||
_on_prioritychange: ?js.Function.Global = null,
|
||||
|
||||
pub fn init(priority: Scheduler.Priority, exec: *const Execution) !*TaskSignal {
|
||||
return exec._factory.taskSignal(TaskSignal{
|
||||
._proto = undefined,
|
||||
._priority = priority,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn asAbortSignal(self: *TaskSignal) *AbortSignal {
|
||||
return self._proto;
|
||||
}
|
||||
|
||||
pub fn asEventTarget(self: *TaskSignal) *EventTarget {
|
||||
return self._proto.asEventTarget();
|
||||
}
|
||||
|
||||
pub fn getPriority(self: *const TaskSignal) Scheduler.Priority {
|
||||
return self._priority;
|
||||
}
|
||||
|
||||
pub fn getOnPriorityChange(self: *const TaskSignal) ?js.Function.Global {
|
||||
return self._on_prioritychange;
|
||||
}
|
||||
|
||||
pub fn setOnPriorityChange(self: *TaskSignal, cb: ?js.Function.Global) !void {
|
||||
self._on_prioritychange = cb;
|
||||
}
|
||||
|
||||
pub fn setPriority(self: *TaskSignal, priority: Scheduler.Priority, exec: *const Execution) !void {
|
||||
if (self._priority_changing) {
|
||||
// re-entrant priority changes (from a prioritychange listener) throw.
|
||||
return error.NotSupportedError;
|
||||
}
|
||||
if (priority == self._priority) {
|
||||
return;
|
||||
}
|
||||
self._priority_changing = true;
|
||||
defer self._priority_changing = false;
|
||||
|
||||
const previous = self._priority;
|
||||
self._priority = priority;
|
||||
|
||||
const target = self.asEventTarget();
|
||||
const on_prioritychange = self._on_prioritychange;
|
||||
if (exec.hasDirectListeners(target, "prioritychange", on_prioritychange)) {
|
||||
const event = try TaskPriorityChangeEvent.initTrusted(.{ .previousPriority = previous }, exec.page);
|
||||
try exec.dispatch(target, event.asEvent(), on_prioritychange, .{ .context = "task signal" });
|
||||
}
|
||||
}
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(TaskSignal);
|
||||
|
||||
pub const Meta = struct {
|
||||
pub const name = "TaskSignal";
|
||||
|
||||
pub const prototype_chain = bridge.prototypeChain();
|
||||
pub var class_id: bridge.ClassId = undefined;
|
||||
};
|
||||
|
||||
pub const priority = bridge.accessor(TaskSignal.getPriority, null, .{});
|
||||
pub const onprioritychange = bridge.accessor(TaskSignal.getOnPriorityChange, TaskSignal.setOnPriorityChange, .{});
|
||||
};
|
||||
@@ -50,6 +50,7 @@ const CSSStyleProperties = @import("css/CSSStyleProperties.zig");
|
||||
const CustomElementRegistry = @import("CustomElementRegistry.zig");
|
||||
const Selection = @import("Selection.zig");
|
||||
const Timers = @import("Timers.zig");
|
||||
const Scheduler = @import("Scheduler.zig");
|
||||
const Notification = @import("../../Notification.zig");
|
||||
|
||||
const log = lp.log;
|
||||
@@ -94,6 +95,7 @@ _reporting_error: bool = false,
|
||||
_current_event: ?*Event = null,
|
||||
_location: *Location,
|
||||
_timers: Timers = .{},
|
||||
_scheduler: Scheduler = .{},
|
||||
_custom_elements: CustomElementRegistry = .{},
|
||||
_scroll_pos: struct {
|
||||
x: u32,
|
||||
@@ -246,6 +248,10 @@ pub fn getNavigator(self: *Window) *Navigator {
|
||||
return &self._navigator;
|
||||
}
|
||||
|
||||
pub fn getScheduler(self: *Window) *Scheduler {
|
||||
return &self._scheduler;
|
||||
}
|
||||
|
||||
pub fn getModelContext(self: *Window) *ModelContext {
|
||||
return &self._model_context;
|
||||
}
|
||||
@@ -1172,6 +1178,7 @@ pub const JsApi = struct {
|
||||
pub const window = bridge.accessor(Window.getWindow, null, .{});
|
||||
pub const parent = bridge.accessor(Window.getParent, Window.setParent, .{});
|
||||
pub const navigator = bridge.accessor(Window.getNavigator, null, .{});
|
||||
pub const scheduler = bridge.accessor(Window.getScheduler, null, .{});
|
||||
pub const screen = bridge.accessor(Window.getScreen, Window.setScreen, .{});
|
||||
pub const visualViewport = bridge.accessor(Window.getVisualViewport, Window.setVisualViewport, .{});
|
||||
pub const performance = bridge.accessor(Window.getPerformance, Window.setPerformance, .{});
|
||||
|
||||
@@ -38,6 +38,7 @@ const Crypto = @import("Crypto.zig");
|
||||
const Console = @import("Console.zig");
|
||||
const Navigator = @import("Navigator.zig");
|
||||
const Timers = @import("Timers.zig");
|
||||
const Scheduler = @import("Scheduler.zig");
|
||||
const EventTarget = @import("EventTarget.zig");
|
||||
const Performance = @import("Performance.zig");
|
||||
const WorkerLocation = @import("WorkerLocation.zig");
|
||||
@@ -118,6 +119,7 @@ _cookie_store: ?*CookieStore = null,
|
||||
_location: WorkerLocation,
|
||||
|
||||
_timers: Timers = .{},
|
||||
_scheduler: Scheduler = .{},
|
||||
|
||||
pub const Type = union(enum) {
|
||||
shared: *SharedWorkerGlobalScope,
|
||||
@@ -305,6 +307,10 @@ pub fn getNavigator(self: *WorkerGlobalScope) *Navigator {
|
||||
return &self._navigator;
|
||||
}
|
||||
|
||||
pub fn getScheduler(self: *WorkerGlobalScope) *Scheduler {
|
||||
return &self._scheduler;
|
||||
}
|
||||
|
||||
pub fn performance(self: *WorkerGlobalScope) *Performance {
|
||||
return &self._performance;
|
||||
}
|
||||
@@ -585,6 +591,7 @@ pub const JsApi = struct {
|
||||
pub const console = bridge.accessor(WorkerGlobalScope.getConsole, WorkerGlobalScope.setConsole, .{});
|
||||
pub const crypto = bridge.accessor(WorkerGlobalScope.getCrypto, null, .{});
|
||||
pub const navigator = bridge.accessor(WorkerGlobalScope.getNavigator, null, .{});
|
||||
pub const scheduler = bridge.accessor(WorkerGlobalScope.getScheduler, null, .{});
|
||||
pub const performance = bridge.accessor(struct {
|
||||
// Unnecessary, But, our WebAPI getters are ALWAYS `fn getPerformance()...`.
|
||||
// But for performance, we _need_ to have fn performance() *Performance to
|
||||
|
||||
92
src/browser/webapi/event/TaskPriorityChangeEvent.zig
Normal file
92
src/browser/webapi/event/TaskPriorityChangeEvent.zig
Normal file
@@ -0,0 +1,92 @@
|
||||
// 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/>.
|
||||
|
||||
const lp = @import("lightpanda");
|
||||
|
||||
const js = @import("../../js/js.zig");
|
||||
const Page = @import("../../Page.zig");
|
||||
|
||||
const Event = @import("../Event.zig");
|
||||
const Scheduler = @import("../Scheduler.zig");
|
||||
|
||||
const String = lp.String;
|
||||
|
||||
/// https://wicg.github.io/scheduling-apis/#sec-task-priority-change-event
|
||||
const TaskPriorityChangeEvent = @This();
|
||||
|
||||
pub const Proto = Event;
|
||||
|
||||
_proto: *Event,
|
||||
_previous_priority: Scheduler.Priority,
|
||||
|
||||
const TaskPriorityChangeEventOptions = struct {
|
||||
previousPriority: Scheduler.Priority,
|
||||
};
|
||||
|
||||
const Options = Event.inheritOptions(TaskPriorityChangeEvent, TaskPriorityChangeEventOptions);
|
||||
|
||||
pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*TaskPriorityChangeEvent {
|
||||
// previousPriority is a required member
|
||||
const opts = opts_ orelse return error.TypeError;
|
||||
const arena = try page.getArena(.tiny, "TaskPriorityChangeEvent");
|
||||
errdefer arena.release();
|
||||
const type_string = try String.init(arena.allocator(), typ, .{});
|
||||
return initWithTrusted(arena, type_string, opts, false, page);
|
||||
}
|
||||
|
||||
pub fn initTrusted(opts: Options, page: *Page) !*TaskPriorityChangeEvent {
|
||||
const arena = try page.getArena(.tiny, "TaskPriorityChangeEvent.trusted");
|
||||
errdefer arena.release();
|
||||
const type_string = try String.init(arena.allocator(), "prioritychange", .{});
|
||||
return initWithTrusted(arena, type_string, opts, true, page);
|
||||
}
|
||||
|
||||
fn initWithTrusted(arena: *lp.Arena, typ: String, opts: Options, trusted: bool, page: *Page) !*TaskPriorityChangeEvent {
|
||||
const event = try page.factory.event(
|
||||
arena,
|
||||
typ,
|
||||
TaskPriorityChangeEvent{
|
||||
._proto = undefined,
|
||||
._previous_priority = opts.previousPriority,
|
||||
},
|
||||
);
|
||||
|
||||
Event.populatePrototypes(event, opts, trusted);
|
||||
return event;
|
||||
}
|
||||
|
||||
pub fn asEvent(self: *TaskPriorityChangeEvent) *Event {
|
||||
return self._proto;
|
||||
}
|
||||
|
||||
pub fn getPreviousPriority(self: *const TaskPriorityChangeEvent) Scheduler.Priority {
|
||||
return self._previous_priority;
|
||||
}
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(TaskPriorityChangeEvent);
|
||||
|
||||
pub const Meta = struct {
|
||||
pub const name = "TaskPriorityChangeEvent";
|
||||
pub const prototype_chain = bridge.prototypeChain();
|
||||
pub var class_id: bridge.ClassId = undefined;
|
||||
};
|
||||
|
||||
pub const constructor = bridge.constructor(TaskPriorityChangeEvent.init, .{});
|
||||
pub const previousPriority = bridge.accessor(TaskPriorityChangeEvent.getPreviousPriority, null, .{});
|
||||
};
|
||||
Reference in New Issue
Block a user