From 7d23b62573af0f63522fd6f859cf1cef9ddab1d8 Mon Sep 17 00:00:00 2001 From: Francis Bouvier Date: Fri, 10 Jul 2026 16:42:51 +0200 Subject: [PATCH 1/3] webapi: fix AbortSignal dependent-signal abort semantics Fixes 3 failing tests in WPT /dom/abort/abort-signal-any.any.html (and its .worker variant): - "Dependent signals are aborted correctly for reentrant aborts": when a source signal aborted a second source from within an abort listener, the shared dependent signal dispatched its abort event twice. Per the DOM spec, only dependent signals that are not already aborted are appended to the list of signals to dispatch. Dependend.markAborted now reports whether the dependent was newly marked, and abort() only dispatches for those. - "Dependent signals should use the same DOMException instance" (already aborted source / source aborted later): Reason.dom held a DOMException by value, so every .reason access wrapped a fresh JS object and the identity check source.reason === dependent.reason failed. Reason.dom is now a *DOMException allocated once at abort time; the JS bridge identity-maps the pointer so the source and all dependents expose the very same DOMException instance. Coverage: /dom/abort/abort-signal-any.any.worker.html 11/14 -> 14/14, /dom/abort/abort-signal-any.any.html 11/14 -> 14/14. Co-Authored-By: Claude Fable 5 --- src/browser/webapi/AbortSignal.zig | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/src/browser/webapi/AbortSignal.zig b/src/browser/webapi/AbortSignal.zig index ab92efe0d..75d8e69b3 100644 --- a/src/browser/webapi/AbortSignal.zig +++ b/src/browser/webapi/AbortSignal.zig @@ -35,14 +35,18 @@ const Dependend = union(enum) { signal: *AbortSignal, model_context_tool: *ModelContextTool, - fn markAborted(self: Dependend, reason_: ?Reason, exec: *const Execution) !void { + // Returns false if the dependent was already aborted, in which case no + // abort event must be dispatched for it. + fn markAborted(self: Dependend, reason_: ?Reason, exec: *const Execution) !bool { switch (self) { .signal => |dep| { - if (dep._aborted) return; + if (dep._aborted) return false; try dep.markAborted(reason_, exec); + return true; }, .model_context_tool => |dep| { try dep.markAborted(exec); + return true; }, } } @@ -101,8 +105,9 @@ pub fn abort(self: *AbortSignal, reason_: ?Reason, exec: *const Execution) !void // so we never need to recurse here. var to_dispatch: std.ArrayList(Dependend) = .{}; for (self._dependents.items) |dep| { - try dep.markAborted(self._reason, exec); - try to_dispatch.append(exec.arena, dep); + if (try dep.markAborted(self._reason, exec)) { + try to_dispatch.append(exec.arena, dep); + } } try self.dispatchAbortEvent(exec); @@ -123,7 +128,11 @@ fn markAborted(self: *AbortSignal, reason_: ?Reason, exec: *const Execution) !vo .undefined => self._reason = reason, } } else { - self._reason = .{ .dom = DOMException.fromError(error.AbortError).? }; + // Allocate the DOMException so the reason keeps a single JS identity: + // dependent signals must expose the very same DOMException instance. + const dom = try exec.arena.create(DOMException); + dom.* = DOMException.fromError(error.AbortError).?; + self._reason = .{ .dom = dom }; } } @@ -207,7 +216,7 @@ pub fn throwIfAborted(self: *const AbortSignal, exec: *const Execution) !ThrowIf const Reason = union(enum) { js_val: js.Value.Global, - dom: DOMException, + dom: *DOMException, string: []const u8, undefined: void, }; @@ -218,11 +227,17 @@ const TimeoutCallback = struct { fn run(ctx: *anyopaque) !?u32 { const self: *TimeoutCallback = @ptrCast(@alignCast(ctx)); - self.signal.abort(.{ .dom = DOMException.fromError(error.TimeoutError).? }, self.exec) catch |err| { + self.timeoutAbort() catch |err| { log.warn(.app, "abort signal timeout", .{ .err = err }); }; return null; } + + fn timeoutAbort(self: *TimeoutCallback) !void { + const dom = try self.exec.arena.create(DOMException); + dom.* = DOMException.fromError(error.TimeoutError).?; + try self.signal.abort(.{ .dom = dom }, self.exec); + } }; pub const JsApi = struct { From 1d263aabbdcc61bc1dd0d03cafc22ca85dcd033b Mon Sep 17 00:00:00 2001 From: Francis Bouvier Date: Fri, 10 Jul 2026 16:47:42 +0200 Subject: [PATCH 2/3] webapi: keep explicit null as AbortSignal abort reason Fixes 1 failing test in WPT /dom/abort/event.any.html (and its .worker variant): "AbortController abort(null) should set signal.reason". abort() reason parameters were typed ?js.Value.Global, and the JS->Zig conversion collapses an explicit JS null into a missing argument, so controller.abort(null) fell back to the default "AbortError" DOMException. Per the DOM spec, the abort reason defaults to a new AbortError only when the reason is not given (undefined); an explicit null must be stored as-is. The parameters are now ?js.Value, which preserves the missing vs explicit-null distinction, and the new AbortSignal.reasonFromJs helper only falls back to the default for a missing or undefined reason. Coverage: /dom/abort/event.any.worker.html 15/16 -> 16/16, /dom/abort/event.any.html 15/16 -> 16/16. Co-Authored-By: Claude Fable 5 --- src/browser/webapi/AbortController.zig | 4 ++-- src/browser/webapi/AbortSignal.zig | 15 +++++++++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/browser/webapi/AbortController.zig b/src/browser/webapi/AbortController.zig index 3d6a30040..67d3d0453 100644 --- a/src/browser/webapi/AbortController.zig +++ b/src/browser/webapi/AbortController.zig @@ -37,8 +37,8 @@ pub fn getSignal(self: *const AbortController) *AbortSignal { return self._signal; } -pub fn abort(self: *AbortController, reason_: ?js.Value.Global, exec: *const Execution) !void { - try self._signal.abort(if (reason_) |r| .{ .js_val = r } else null, exec); +pub fn abort(self: *AbortController, reason_: ?js.Value, exec: *const Execution) !void { + try self._signal.abort(try AbortSignal.reasonFromJs(reason_), exec); } pub const JsApi = struct { diff --git a/src/browser/webapi/AbortSignal.zig b/src/browser/webapi/AbortSignal.zig index 75d8e69b3..64cbd2752 100644 --- a/src/browser/webapi/AbortSignal.zig +++ b/src/browser/webapi/AbortSignal.zig @@ -149,10 +149,21 @@ fn dispatchAbortEvent(self: *AbortSignal, exec: *const Execution) !void { } } +// Converts an abort(reason) JS argument to a Reason. Per spec, only a +// missing or undefined reason falls back to the default "AbortError" +// DOMException: an explicit null (or any other value) is kept as-is. +pub fn reasonFromJs(reason_: ?js.Value) !?Reason { + const reason = reason_ orelse return null; + if (reason.isUndefined()) { + return null; + } + return .{ .js_val = try reason.persist() }; +} + // Static method to create an already-aborted signal -pub fn createAborted(reason_: ?js.Value.Global, exec: *const Execution) !*AbortSignal { +pub fn createAborted(reason_: ?js.Value, exec: *const Execution) !*AbortSignal { const signal = try init(exec); - try signal.abort(if (reason_) |r| .{ .js_val = r } else null, exec); + try signal.abort(try reasonFromJs(reason_), exec); return signal; } From cc81c5993b45b30427a58ead4557b2bb2b3170b5 Mon Sep 17 00:00:00 2001 From: Francis Bouvier Date: Fri, 10 Jul 2026 16:53:15 +0200 Subject: [PATCH 3/3] webapi: don't fire AbortSignal.timeout() after frame detach Fixes the failing test in WPT /dom/abort/abort-signal-timeout.html: "Signal returned by AbortSignal.timeout() is not aborted after frame detach". The scheduler task registered by AbortSignal.timeout() kept running after the iframe that created the signal was removed from the document, so the signal aborted and the abort event fired. Per the DOM spec, the abort is queued as a global task on the signal's relevant global, and such tasks must not run once the global's document stops being fully active. The timeout callback now walks the signal's frame and its ancestors and skips the abort when a hosting iframe element is no longer connected. Coverage: /dom/abort/abort-signal-timeout.html 0/1 -> 1/1. Co-Authored-By: Claude Fable 5 --- src/browser/webapi/AbortSignal.zig | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/browser/webapi/AbortSignal.zig b/src/browser/webapi/AbortSignal.zig index 64cbd2752..b9e34faec 100644 --- a/src/browser/webapi/AbortSignal.zig +++ b/src/browser/webapi/AbortSignal.zig @@ -245,6 +245,22 @@ const TimeoutCallback = struct { } fn timeoutAbort(self: *TimeoutCallback) !void { + // Per spec, the abort is queued as a global task on the signal's + // global: it must not run when the global's document is no longer + // fully active (e.g. the iframe that created the signal was detached). + switch (self.exec.js.global) { + .frame => |frame| { + var current: ?@TypeOf(frame) = frame; + while (current) |f| : (current = f.parent) { + const iframe = f.iframe orelse continue; + if (!iframe.asNode().isConnected()) { + return; + } + } + }, + .worker => {}, + } + const dom = try self.exec.arena.create(DOMException); dom.* = DOMException.fromError(error.TimeoutError).?; try self.signal.abort(.{ .dom = dom }, self.exec);