From 08d2ea6a105ea74fe3e079930bbda1daecf8ea65 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 27 Jun 2025 12:39:44 +0800 Subject: [PATCH 1/2] abort controller --- src/browser/html/AbortController.zig | 112 +++++++++++++++++++++++++++ src/browser/html/html.zig | 1 + 2 files changed, 113 insertions(+) create mode 100644 src/browser/html/AbortController.zig diff --git a/src/browser/html/AbortController.zig b/src/browser/html/AbortController.zig new file mode 100644 index 00000000..c08875e9 --- /dev/null +++ b/src/browser/html/AbortController.zig @@ -0,0 +1,112 @@ +// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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 . + +const std = @import("std"); +const parser = @import("../netsurf.zig"); +const EventTarget = @import("../dom/event_target.zig").EventTarget; + +pub const Interfaces = .{ + AbortController, + Signal, +}; + +const AbortController = @This(); + +signal: ?Signal = null, + +pub fn constructor() AbortController { + return .{}; +} + +pub fn get_signal(self: *AbortController) *Signal { + if (self.signal) |*s| { + return s; + } + self.signal = .init; + return &self.signal.?; +} + +pub fn abort(self: *AbortController, reason_: ?[]const u8) void { + const signal = &self.signal; + + signal.aborted = true; + signal.reason = reason_ orelse "AbortError"; + + const abort_event = try parser.eventCreate(); + defer parser.eventDestroy(abort_event); + try parser.eventInit(abort_event, "abort", .{}); + _ = try parser.eventTargetDispatchEvent( + parser.toEventTarget(Signal, signal), + abort_event, + ); +} + +pub const Signal = struct { + pub const prototype = *EventTarget; + + aborted: bool, + reason: ?[]const u8, + proto: parser.EventTargetTBase, + + pub const init: Signal = .{ + .proto = .{}, + .reason = null, + .aborted = false, + }; + + pub fn get_aborted(self: *const Signal) bool { + return self.aborted; + } + + const Reason = union(enum) { + reason: []const u8, + undefined: void, + }; + pub fn get_reason(self: *const Signal) Reason { + if (self.reason) |r| { + return .{ .reason = r }; + } + return .{ .undefined = {} }; + } +}; + +const testing = @import("../../testing.zig"); +test "Browser.HTML.AbortController" { + var runner = try testing.jsRunner(testing.tracking_allocator, .{}); + defer runner.deinit(); + + try runner.testCases(&.{ + .{ "var called = false", null }, + .{ "var a1 = new AbortController()", null }, + .{ "var s1 = a1.signal", null }, + .{ "s1.reason", "undefined" }, + .{ "var target;", null }, + .{ + \\ s1.addEventListener('abort', (e) => { + \\ called = 1; + \\ target = e.target; + \\ + \\ }); + \\ target == s1 + , "true" }, + .{ "a1.abort()", null }, + .{ "s1.aborted", "true" }, + .{ "s1.reason", "undefined" }, + .{ "called", "1" }, + }, .{}); +} diff --git a/src/browser/html/html.zig b/src/browser/html/html.zig index f6134bf8..d722ad53 100644 --- a/src/browser/html/html.zig +++ b/src/browser/html/html.zig @@ -39,4 +39,5 @@ pub const Interfaces = .{ @import("DataSet.zig"), @import("screen.zig").Interfaces, @import("error_event.zig").ErrorEvent, + @import("AbortController.zig").Interfaces, }; From bbd9e5e07c36cfa0c7ec77d977d4655bfd2e759a Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 27 Jun 2025 17:31:25 +0800 Subject: [PATCH 2/2] add AbortController API --- src/browser/console/console.zig | 18 ++-- src/browser/dom/event_target.zig | 24 +++-- src/browser/events/event.zig | 6 +- src/browser/html/AbortController.zig | 142 ++++++++++++++++++++------- src/browser/netsurf.zig | 1 + src/runtime/js.zig | 49 +++++++-- 6 files changed, 180 insertions(+), 60 deletions(-) diff --git a/src/browser/console/console.zig b/src/browser/console/console.zig index 1d6ed189..d2c7ba19 100644 --- a/src/browser/console/console.zig +++ b/src/browser/console/console.zig @@ -30,39 +30,39 @@ pub const Console = struct { timers: std.StringHashMapUnmanaged(u32) = .{}, counts: std.StringHashMapUnmanaged(u32) = .{}, - pub fn static_lp(values: []JsObject, page: *Page) !void { + pub fn _lp(values: []JsObject, page: *Page) !void { if (values.len == 0) { return; } log.fatal(.console, "lightpanda", .{ .args = try serializeValues(values, page) }); } - pub fn static_log(values: []JsObject, page: *Page) !void { + pub fn _log(values: []JsObject, page: *Page) !void { if (values.len == 0) { return; } log.info(.console, "info", .{ .args = try serializeValues(values, page) }); } - pub fn static_info(values: []JsObject, page: *Page) !void { - return static_log(values, page); + pub fn _info(values: []JsObject, page: *Page) !void { + return _log(values, page); } - pub fn static_debug(values: []JsObject, page: *Page) !void { + pub fn _debug(values: []JsObject, page: *Page) !void { if (values.len == 0) { return; } log.debug(.console, "debug", .{ .args = try serializeValues(values, page) }); } - pub fn static_warn(values: []JsObject, page: *Page) !void { + pub fn _warn(values: []JsObject, page: *Page) !void { if (values.len == 0) { return; } log.warn(.console, "warn", .{ .args = try serializeValues(values, page) }); } - pub fn static_error(values: []JsObject, page: *Page) !void { + pub fn _error(values: []JsObject, page: *Page) !void { if (values.len == 0) { return; } @@ -73,7 +73,7 @@ pub const Console = struct { }); } - pub fn static_clear() void {} + pub fn _clear() void {} pub fn _count(self: *Console, label_: ?[]const u8, page: *Page) !void { const label = label_ orelse "default"; @@ -134,7 +134,7 @@ pub const Console = struct { log.warn(.console, "timer stop", .{ .label = label, .elapsed = elapsed - kv.value }); } - pub fn static_assert(assertion: JsObject, values: []JsObject, page: *Page) !void { + pub fn _assert(assertion: JsObject, values: []JsObject, page: *Page) !void { if (assertion.isTruthy()) { return; } diff --git a/src/browser/dom/event_target.zig b/src/browser/dom/event_target.zig index d2a7aae0..37626975 100644 --- a/src/browser/dom/event_target.zig +++ b/src/browser/dom/event_target.zig @@ -33,16 +33,26 @@ pub const EventTarget = struct { pub const Self = parser.EventTarget; pub const Exception = DOMException; - pub fn toInterface(et: *parser.EventTarget, page: *Page) !Union { - // Not all targets are *parser.Nodes. page.zig emits a "load" event - // where the target is a Window, which cannot be cast directly to a node. - // Ideally, we'd remove this duality. Failing that, we'll need to embed - // data into the *parser.EventTarget should we need this for other types. - // For now, for the Window, which is a singleton, we can do this: + pub fn toInterface(e: *parser.Event, et: *parser.EventTarget, page: *Page) !Union { + // libdom assumes that all event targets are libdom nodes. They are not. + + // The window is a common non-node target, but it's easy to handle as + // its a singleton. if (@intFromPtr(et) == @intFromPtr(&page.window.base)) { return .{ .Window = &page.window }; } - return Nod.Node.toInterface(@as(*parser.Node, @ptrCast(et))); + + // AbortSignal is another non-node target. It has a distinct usage though + // so we hijack the event internal type to identity if. + switch (try parser.eventGetInternalType(e)) { + .abort_signal => { + return .{ .AbortSignal = @fieldParentPtr("proto", @as(*parser.EventTargetTBase, @ptrCast(et))) }; + }, + else => { + // some of these probably need to be special-cased like abort_signal + return Nod.Node.toInterface(@as(*parser.Node, @ptrCast(et))); + }, + } } // JS funcs diff --git a/src/browser/events/event.zig b/src/browser/events/event.zig index 14fd7a79..9cdc4a94 100644 --- a/src/browser/events/event.zig +++ b/src/browser/events/event.zig @@ -54,7 +54,7 @@ pub const Event = struct { pub fn toInterface(evt: *parser.Event) !Union { return switch (try parser.eventGetInternalType(evt)) { - .event => .{ .Event = evt }, + .event, .abort_signal => .{ .Event = evt }, .custom_event => .{ .CustomEvent = @as(*CustomEvent, @ptrCast(evt)).* }, .progress_event => .{ .ProgressEvent = @as(*ProgressEvent, @ptrCast(evt)).* }, .mouse_event => .{ .MouseEvent = @as(*parser.MouseEvent, @ptrCast(evt)) }, @@ -77,13 +77,13 @@ pub const Event = struct { pub fn get_target(self: *parser.Event, page: *Page) !?EventTargetUnion { const et = try parser.eventTarget(self); if (et == null) return null; - return try EventTarget.toInterface(et.?, page); + return try EventTarget.toInterface(self, et.?, page); } pub fn get_currentTarget(self: *parser.Event, page: *Page) !?EventTargetUnion { const et = try parser.eventCurrentTarget(self); if (et == null) return null; - return try EventTarget.toInterface(et.?, page); + return try EventTarget.toInterface(self, et.?, page); } pub fn get_eventPhase(self: *parser.Event) !u8 { diff --git a/src/browser/html/AbortController.zig b/src/browser/html/AbortController.zig index c08875e9..bf7214a4 100644 --- a/src/browser/html/AbortController.zig +++ b/src/browser/html/AbortController.zig @@ -17,72 +17,131 @@ // along with this program. If not, see . const std = @import("std"); +const log = @import("../../log.zig"); const parser = @import("../netsurf.zig"); +const Env = @import("../env.zig").Env; +const Page = @import("../page.zig").Page; +const Loop = @import("../../runtime/loop.zig").Loop; const EventTarget = @import("../dom/event_target.zig").EventTarget; pub const Interfaces = .{ AbortController, - Signal, + AbortSignal, }; const AbortController = @This(); -signal: ?Signal = null, +signal: *AbortSignal, -pub fn constructor() AbortController { - return .{}; +pub fn constructor(page: *Page) !AbortController { + // Why do we allocate this rather than storing directly in the struct? + // https://github.com/lightpanda-io/project/discussions/165 + const signal = try page.arena.create(AbortSignal); + signal.* = .init; + + return .{ + .signal = signal, + }; } -pub fn get_signal(self: *AbortController) *Signal { - if (self.signal) |*s| { - return s; - } - self.signal = .init; - return &self.signal.?; +pub fn get_signal(self: *AbortController) *AbortSignal { + return self.signal; } -pub fn abort(self: *AbortController, reason_: ?[]const u8) void { - const signal = &self.signal; - - signal.aborted = true; - signal.reason = reason_ orelse "AbortError"; - - const abort_event = try parser.eventCreate(); - defer parser.eventDestroy(abort_event); - try parser.eventInit(abort_event, "abort", .{}); - _ = try parser.eventTargetDispatchEvent( - parser.toEventTarget(Signal, signal), - abort_event, - ); +pub fn _abort(self: *AbortController, reason_: ?[]const u8) !void { + return self.signal.abort(reason_); } -pub const Signal = struct { +pub const AbortSignal = struct { + const DEFAULT_REASON = "AbortError"; + pub const prototype = *EventTarget; + proto: parser.EventTargetTBase = .{}, aborted: bool, reason: ?[]const u8, - proto: parser.EventTargetTBase, - pub const init: Signal = .{ + pub const init: AbortSignal = .{ .proto = .{}, .reason = null, .aborted = false, }; - pub fn get_aborted(self: *const Signal) bool { + pub fn static_abort(reason_: ?[]const u8) AbortSignal { + return .{ + .aborted = true, + .reason = reason_ orelse DEFAULT_REASON, + }; + } + + pub fn static_timeout(delay: u32, page: *Page) !*AbortSignal { + const callback = try page.arena.create(TimeoutCallback); + callback.* = .{ + .signal = .init, + .node = .{ .func = TimeoutCallback.run }, + }; + + const delay_ms: u63 = @as(u63, delay) * std.time.ns_per_ms; + _ = try page.loop.timeout(delay_ms, &callback.node); + return &callback.signal; + } + + pub fn get_aborted(self: *const AbortSignal) bool { return self.aborted; } + fn abort(self: *AbortSignal, reason_: ?[]const u8) !void { + self.aborted = true; + self.reason = reason_ orelse DEFAULT_REASON; + + const abort_event = try parser.eventCreate(); + try parser.eventSetInternalType(abort_event, .abort_signal); + + defer parser.eventDestroy(abort_event); + try parser.eventInit(abort_event, "abort", .{}); + _ = try parser.eventTargetDispatchEvent( + parser.toEventTarget(AbortSignal, self), + abort_event, + ); + } + const Reason = union(enum) { reason: []const u8, undefined: void, }; - pub fn get_reason(self: *const Signal) Reason { + pub fn get_reason(self: *const AbortSignal) Reason { if (self.reason) |r| { return .{ .reason = r }; } return .{ .undefined = {} }; } + + const ThrowIfAborted = union(enum) { + exception: Env.Exception, + undefined: void, + }; + pub fn _throwIfAborted(self: *const AbortSignal, page: *Page) ThrowIfAborted { + if (self.aborted) { + const ex = page.main_context.throw(self.reason orelse DEFAULT_REASON); + return .{ .exception = ex }; + } + return .{ .undefined = {} }; + } +}; + +const TimeoutCallback = struct { + signal: AbortSignal, + + // This is the internal data that the event loop tracks. We'll get this + // back in run and, from it, can get our TimeoutCallback instance + node: Loop.CallbackNode = undefined, + + fn run(node: *Loop.CallbackNode, _: *?u63) void { + const self: *TimeoutCallback = @fieldParentPtr("node", node); + self.signal.abort("TimeoutError") catch |err| { + log.warn(.app, "abort signal timeout", .{ .err = err }); + }; + } }; const testing = @import("../../testing.zig"); @@ -91,22 +150,39 @@ test "Browser.HTML.AbortController" { defer runner.deinit(); try runner.testCases(&.{ - .{ "var called = false", null }, + .{ "var called = 0", null }, .{ "var a1 = new AbortController()", null }, .{ "var s1 = a1.signal", null }, + .{ "s1.throwIfAborted()", "undefined" }, .{ "s1.reason", "undefined" }, .{ "var target;", null }, .{ \\ s1.addEventListener('abort', (e) => { - \\ called = 1; + \\ called += 1; \\ target = e.target; \\ \\ }); - \\ target == s1 - , "true" }, + , + null, + }, .{ "a1.abort()", null }, .{ "s1.aborted", "true" }, - .{ "s1.reason", "undefined" }, + .{ "target == s1", "true" }, + .{ "s1.reason", "AbortError" }, .{ "called", "1" }, }, .{}); + + try runner.testCases(&.{ + .{ "var s2 = AbortSignal.abort('over 9000')", null }, + .{ "s2.aborted", "true" }, + .{ "s2.reason", "over 9000" }, + .{ "AbortSignal.abort().reason", "AbortError" }, + }, .{}); + + try runner.testCases(&.{ + .{ "var s3 = AbortSignal.timeout(10)", null }, + .{ "s3.aborted", "true" }, + .{ "s3.reason", "TimeoutError" }, + .{ "try { s3.throwIfAborted() } catch (e) { e }", "Error: TimeoutError" }, + }, .{}); } diff --git a/src/browser/netsurf.zig b/src/browser/netsurf.zig index c8b3da02..8fdc2f1b 100644 --- a/src/browser/netsurf.zig +++ b/src/browser/netsurf.zig @@ -526,6 +526,7 @@ pub const EventType = enum(u8) { custom_event = 2, mouse_event = 3, error_event = 4, + abort_signal = 5, }; pub const MutationEvent = c.dom_mutation_event; diff --git a/src/runtime/js.zig b/src/runtime/js.zig index 946f5e0a..fbdb9a91 100644 --- a/src/runtime/js.zig +++ b/src/runtime/js.zig @@ -29,6 +29,8 @@ const ArenaAllocator = std.heap.ArenaAllocator; const CALL_ARENA_RETAIN = 1024 * 16; const CONTEXT_ARENA_RETAIN = 1024 * 64; +const js = @This(); + // Global, should only be initialized once. pub const Platform = struct { inner: v8.Platform, @@ -1272,6 +1274,11 @@ pub fn Env(comptime State: type, comptime WebApis: type) type { return .{ .invalid = {} }; } + pub fn throw(self: *JsContext, err: []const u8) Exception { + const js_value = js.createException(self.isolate, err); + return self.createException(js_value); + } + // Callback from V8, asking us to load a module. The "specifier" is // the src of the module to load. fn resolveModuleCallback( @@ -1821,6 +1828,8 @@ pub fn Env(comptime State: type, comptime WebApis: type) type { inner: v8.Value, js_context: *const JsContext, + const _EXCEPTION_ID_KLUDGE = true; + // the caller needs to deinit the string returned pub fn exception(self: Exception, allocator: Allocator) ![]const u8 { const js_context = self.js_context; @@ -1919,7 +1928,7 @@ pub fn Env(comptime State: type, comptime WebApis: type) type { } else if (comptime std.mem.startsWith(u8, name, "get_")) { generateProperty(Struct, name[4..], isolate, template_proto); } else if (comptime std.mem.startsWith(u8, name, "static_")) { - generateFunction(Struct, name[7..], isolate, template_proto); + generateFunction(Struct, name[7..], isolate, template); } } @@ -2009,7 +2018,7 @@ pub fn Env(comptime State: type, comptime WebApis: type) type { template_proto.set(js_name, function_template, v8.PropertyAttribute.None); } - fn generateFunction(comptime Struct: type, comptime name: []const u8, isolate: v8.Isolate, template_proto: v8.ObjectTemplate) void { + fn generateFunction(comptime Struct: type, comptime name: []const u8, isolate: v8.Isolate, template: v8.FunctionTemplate) void { const js_name = v8.String.initUtf8(isolate, name).toName(); const function_template = v8.FunctionTemplate.initCallback(isolate, struct { fn callback(raw_info: ?*const v8.C_FunctionCallbackInfo) callconv(.c) void { @@ -2023,7 +2032,7 @@ pub fn Env(comptime State: type, comptime WebApis: type) type { }; } }.callback); - template_proto.set(js_name, function_template, v8.PropertyAttribute.None); + template.set(js_name, function_template, v8.PropertyAttribute.None); } fn generateAttribute(comptime Struct: type, comptime name: []const u8, isolate: v8.Isolate, template: v8.FunctionTemplate, template_proto: v8.ObjectTemplate) void { @@ -2318,6 +2327,10 @@ pub fn Env(comptime State: type, comptime WebApis: type) type { return value.js_obj.toValue(); } + if (@hasDecl(T, "_EXCEPTION_ID_KLUDGE")) { + return isolate.throwException(value.inner); + } + if (s.is_tuple) { // return the tuple struct as an array var js_arr = v8.Array.init(isolate, @intCast(s.fields.len)); @@ -2619,10 +2632,12 @@ fn Caller(comptime E: type, comptime State: type) type { } fn method(self: *Self, comptime Struct: type, comptime named_function: NamedFunction, info: v8.FunctionCallbackInfo) !void { + if (comptime isSelfReceiver(Struct, named_function) == false) { + return self.function(Struct, named_function, info); + } + const js_context = self.js_context; const func = @field(Struct, named_function.name); - comptime assertSelfReceiver(Struct, named_function); - var args = try self.getArgs(Struct, named_function, 1, info); const zig_instance = try E.typeTaggedAnyOpaque(named_function, *Receiver(Struct), info.getThis()); @@ -2742,18 +2757,36 @@ fn Caller(comptime E: type, comptime State: type) type { return valueToString(self.call_arena, .{ .handle = name.handle }, self.isolate, self.v8_context); } + fn isSelfReceiver(comptime Struct: type, comptime named_function: NamedFunction) bool { + return checkSelfReceiver(Struct, named_function, false); + } fn assertSelfReceiver(comptime Struct: type, comptime named_function: NamedFunction) void { + _ = checkSelfReceiver(Struct, named_function, true); + } + fn checkSelfReceiver(comptime Struct: type, comptime named_function: NamedFunction, comptime fail: bool) bool { const func = @field(Struct, named_function.name); const params = @typeInfo(@TypeOf(func)).@"fn".params; if (params.len == 0) { - @compileError(named_function.full_name ++ " must have a self parameter"); + if (fail) { + @compileError(named_function.full_name ++ " must have a self parameter"); + } + return false; } - const R = Receiver(Struct); + const R = Receiver(Struct); const first_param = params[0].type.?; if (first_param != *R and first_param != *const R) { - @compileError(std.fmt.comptimePrint("The first parameter to {s} must be a *{s} or *const {s}. Got: {s}", .{ named_function.full_name, @typeName(R), @typeName(R), @typeName(first_param) })); + if (fail) { + @compileError(std.fmt.comptimePrint("The first parameter to {s} must be a *{s} or *const {s}. Got: {s}", .{ + named_function.full_name, + @typeName(R), + @typeName(R), + @typeName(first_param), + })); + } + return false; } + return true; } fn assertIsStateArg(comptime Struct: type, comptime named_function: NamedFunction, index: comptime_int) void {