Merge pull request #2409 from lightpanda-io/abortsignal_any

add AbortSignal.any static + AbortSignal reason can be a DOMException
This commit is contained in:
Karl Seguin
2026-05-12 10:49:09 +08:00
committed by GitHub
3 changed files with 148 additions and 15 deletions

View File

@@ -1488,11 +1488,14 @@ pub fn parseJSON(self: *const Local, json: []const u8) !js.Value {
};
}
pub fn throw(self: *const Local, err: []const u8) js.Exception {
const handle = self.isolate.createError(err);
pub fn newException(self: *const Local, ex: anytype) js.Exception {
const js_val = self.zigValueToJs(ex, .{}) catch {
return .{ .local = self, .handle = self.isolate.createError("internal error") };
};
return .{
.local = self,
.handle = handle,
.handle = js_val.handle,
};
}

View File

@@ -123,7 +123,8 @@
const signal = AbortSignal.abort();
testing.expectEqual(true, signal.aborted);
testing.expectEqual("AbortError", signal.reason);
testing.expectEqual(true, signal.reason instanceof DOMException);
testing.expectEqual("AbortError", signal.reason.name);
}
</script>
@@ -229,7 +230,8 @@
a1.abort();
testing.expectEqual(true, s1.aborted)
testing.expectEqual(s1, target)
testing.expectEqual('AbortError', s1.reason)
testing.expectEqual(true, s1.reason instanceof DOMException)
testing.expectEqual('AbortError', s1.reason.name)
testing.expectEqual(1, called)
</script>
@@ -237,7 +239,80 @@
var s2 = AbortSignal.abort('over 9000');
testing.expectEqual(true, s2.aborted);
testing.expectEqual('over 9000', s2.reason);
testing.expectEqual('AbortError', AbortSignal.abort().reason);
testing.expectEqual('AbortError', AbortSignal.abort().reason.name);
</script>
<script id=abortSignalAnyEmpty>
{
const signal = AbortSignal.any([]);
testing.expectEqual(false, signal.aborted);
}
</script>
<script id=abortSignalAnyAlreadyAborted>
{
const aborted = AbortSignal.abort("already gone");
const c = new AbortController();
const signal = AbortSignal.any([c.signal, aborted]);
testing.expectEqual(true, signal.aborted);
testing.expectEqual("already gone", signal.reason);
}
</script>
<script id=abortSignalAnyPropagates>
{
const c1 = new AbortController();
const c2 = new AbortController();
const signal = AbortSignal.any([c1.signal, c2.signal]);
let eventFired = false;
signal.addEventListener('abort', () => {
eventFired = true;
});
testing.expectEqual(false, signal.aborted);
c2.abort("second");
testing.expectEqual(true, signal.aborted);
testing.expectEqual("second", signal.reason);
testing.expectEqual(true, eventFired);
}
</script>
<script id=abortSignalAnyFirstSourceWins>
{
const c1 = new AbortController();
const c2 = new AbortController();
const signal = AbortSignal.any([c1.signal, c2.signal]);
c1.abort("first");
c2.abort("second");
testing.expectEqual(true, signal.aborted);
testing.expectEqual("first", signal.reason);
}
</script>
<script id=abortSignalAnyTransitive>
{
const c = new AbortController();
const mid = AbortSignal.any([c.signal]);
const leaf = AbortSignal.any([mid]);
let leafFired = false;
leaf.addEventListener('abort', () => {
leafFired = true;
});
c.abort("root");
testing.expectEqual(true, mid.aborted);
testing.expectEqual(true, leaf.aborted);
testing.expectEqual("root", leaf.reason);
testing.expectEqual(true, leafFired);
}
</script>
<script id=abortsignal_timeout type=module>
@@ -250,8 +325,9 @@
await state.done(() => {
testing.expectEqual(true, s3.aborted);
testing.expectEqual('TimeoutError', s3.reason);
testing.expectError('Error: TimeoutError', () => {
testing.expectEqual(true, s3.reason instanceof DOMException);
testing.expectEqual('TimeoutError', s3.reason.name);
testing.expectError('TimeoutError: The operation timed out', () => {
s3.throwIfAborted()
});
});

View File

@@ -23,6 +23,7 @@ const js = @import("../js/js.zig");
const Event = @import("Event.zig");
const EventTarget = @import("EventTarget.zig");
const DOMException = @import("DOMException.zig");
const log = lp.log;
const Execution = js.Execution;
@@ -31,8 +32,11 @@ const AbortSignal = @This();
_proto: *EventTarget,
_aborted: bool = false,
_is_dependent: bool = false,
_reason: Reason = .undefined,
_on_abort: ?js.Function.Global = null,
_dependents: std.ArrayList(*AbortSignal) = .{},
_source_signals: std.ArrayList(*AbortSignal) = .{},
pub fn init(exec: *const Execution) !*AbortSignal {
return exec._factory.eventTarget(AbortSignal{
@@ -65,19 +69,41 @@ pub fn abort(self: *AbortSignal, reason_: ?Reason, exec: *const Execution) !void
return;
}
self._aborted = true;
try self.markAborted(reason_, exec);
// Store the abort reason (default to a simple string if none provided)
// Per spec: mark all direct dependents aborted (with this signal's reason)
// BEFORE firing any abort events. The graph is flattened at any() creation,
// so we never need to recurse here.
var to_dispatch: std.ArrayList(*AbortSignal) = .{};
for (self._dependents.items) |dep| {
if (dep._aborted) continue;
try dep.markAborted(self._reason, exec);
try to_dispatch.append(exec.arena, dep);
}
try self.dispatchAbortEvent(exec);
for (to_dispatch.items) |dep| {
dep.dispatchAbortEvent(exec) catch |err| {
log.warn(.app, "abort dependent dispatch", .{ .err = err });
};
}
}
fn markAborted(self: *AbortSignal, reason_: ?Reason, exec: *const Execution) !void {
self._aborted = true;
if (reason_) |reason| {
switch (reason) {
.dom => |dom| self._reason = .{ .dom = dom },
.js_val => |js_val| self._reason = .{ .js_val = js_val },
.string => |str| self._reason = .{ .string = try exec.dupeString(str) },
.undefined => self._reason = reason,
}
} else {
self._reason = .{ .string = "AbortError" };
self._reason = .{ .dom = DOMException.fromError(error.AbortError).? };
}
}
fn dispatchAbortEvent(self: *AbortSignal, exec: *const Execution) !void {
const target = self.asEventTarget();
const on_abort = self._on_abort;
switch (exec.context.global) {
@@ -97,6 +123,31 @@ pub fn createAborted(reason_: ?js.Value.Global, exec: *const Execution) !*AbortS
return signal;
}
pub fn createAny(signals: []const *AbortSignal, exec: *const Execution) !*AbortSignal {
const result = try init(exec);
for (signals) |source| {
if (source._aborted) {
try result.abort(source._reason, exec);
return result;
}
}
result._is_dependent = true;
for (signals) |source| {
if (!source._is_dependent) {
try source._dependents.append(exec.arena, result);
try result._source_signals.append(exec.arena, source);
} else {
for (source._source_signals.items) |s| {
try s._dependents.append(exec.arena, result);
try result._source_signals.append(exec.arena, s);
}
}
}
return result;
}
pub fn createTimeout(delay: u32, exec: *const Execution) !*AbortSignal {
const callback = try exec.arena.create(TimeoutCallback);
callback.* = .{
@@ -120,9 +171,10 @@ pub fn throwIfAborted(self: *const AbortSignal, exec: *const Execution) !ThrowIf
if (self._aborted) {
const exception = switch (self._reason) {
.string => |str| local.throw(str),
.js_val => |js_val| local.throw(try local.toLocal(js_val).toStringSlice()),
.undefined => local.throw("AbortError"),
.dom => |err| local.newException(err),
.string => |str| local.newException(str),
.js_val => |js_val| local.newException(js_val),
.undefined => local.newException(DOMException.fromError(error.AbortError).?),
};
return .{ .exception = exception };
}
@@ -131,6 +183,7 @@ pub fn throwIfAborted(self: *const AbortSignal, exec: *const Execution) !ThrowIf
const Reason = union(enum) {
js_val: js.Value.Global,
dom: DOMException,
string: []const u8,
undefined: void,
};
@@ -141,7 +194,7 @@ const TimeoutCallback = struct {
fn run(ctx: *anyopaque) !?u32 {
const self: *TimeoutCallback = @ptrCast(@alignCast(ctx));
self.signal.abort(.{ .string = "TimeoutError" }, self.exec) catch |err| {
self.signal.abort(.{ .dom = DOMException.fromError(error.TimeoutError).? }, self.exec) catch |err| {
log.warn(.app, "abort signal timeout", .{ .err = err });
};
return null;
@@ -169,5 +222,6 @@ pub const JsApi = struct {
// Static method
pub const abort = bridge.function(AbortSignal.createAborted, .{ .static = true });
pub const any = bridge.function(AbortSignal.createAny, .{ .static = true });
pub const timeout = bridge.function(AbortSignal.createTimeout, .{ .static = true });
};