Browse documentation

WHATWG EventTarget base for AbortSignal

Date: 2026-08-05 Area: runtime

GocciaScript provides a real EventTarget base class and a minimal Event value, and AbortSignal genuinely inherits from it. This supersedes the narrower non-goal recorded in ADR 0031, which stated that EventTarget listeners and the abort event were out of scope for the fetch-only cancellation surface. The rest of ADR 0031 stands unchanged: no general event loop, no timer task queue, and fetch completions still drain through the existing pump.

EventTarget is constructible (new EventTarget()), exposes addEventListener, removeEventListener, and dispatchEvent, and follows WHATWG DOM § 2.7 listener-list semantics: a listener is identified by the (type, callback, capture) triple and is not added twice, once removes the listener before invoking it, removal during a dispatch is respected, and listeners appended during a dispatch are not invoked for that event. Non-callable object listeners are invoked through handleEvent with the listener object as the receiver. Event (§ 2.2) carries type, target, currentTarget, bubbles, cancelable, defaultPrevented, and preventDefault(). dispatchEvent throws an InvalidStateError DOMException when the event's dispatch flag is already set.

The prototype chain is real rather than simulated: AbortSignal.prototype's [[Prototype]] is EventTarget.prototype and AbortSignal's [[Prototype]] is EventTarget, so signal instanceof EventTarget holds and the shared listener machinery is inherited rather than duplicated. AbortSignal follows § 3.2 signal abort ordering, and does so through the spec's own mechanism: the signal carries an abort-algorithms set that hosts register into. An already-aborted signal returns early, the abort reason is set, the registered algorithms run and the set is emptied, and the abort event is fired last. fetch is the one host that registers an algorithm today — it adds one per in-flight request that rejects that request's promise and drops its pending entry — so controller.abort() settles the fetch synchronously, before any listener observes the abort, rather than deferring the rejection to the next completion pump. Registering an algorithm on an already-aborted signal is refused, the same rule that governs listeners, and the host removes its algorithm when a request completes normally. A signal aborts at most once, so the abort event fires at most once and a listener registered after the abort never runs. onabort is an event handler IDL attribute (§ 8.1.5.1): its listener is registered when a non-null handler is first assigned and keeps that registration position, so assigning null clears the handler without reordering the remaining listeners.

Because there is no timer task queue, an AbortSignal.timeout() signal aborts at the moment the host observes its expiry, and the abort event is delivered at that same observation point — reading .aborted or .reason after expiry both flips the state and dispatches the event, exactly once. Timeout expiry is the one case where the state flip is separated from the algorithms and the event: it is detected while the fetch pump walks its pending-request list, and an abort algorithm mutates that list, so the pump flips the state during the walk and then runs the algorithms and fires the event once the walk is over. No script executes in that window, so a listener still cannot be registered after a signal aborted but before its event fires.

A listener that throws during that pump-driven dispatch propagates out of the pumping call (typically an await on a fetch). The containment is deliberate and bounded: each signal's algorithms and event are processed as a unit, so the requests already rejected stay rejected and the signals not yet reached keep their pending state and are settled by the next pump. Completions already queued by worker threads are not lost either — they remain queued and settle at the next pump rather than being discarded.

EventTarget and Event ship with the same runtime extension as AbortController, AbortSignal, Headers, and Response rather than as core realm globals, because the core GocciaScript realm is ECMA-262 only and every WHATWG surface is opt-in through a runtime profile.

One deliberate deviation concerns listener exceptions. Under WHATWG DOM § 2.9, inner invoke reports an exception thrown by a listener rather than propagating it, which is why dispatchEvent and controller.abort() never throw on a listener's behalf in a browser. GocciaScript deviates: it has no global error-reporting channel to report into, so an exception thrown by a listener propagates out of dispatchEvent (and out of controller.abort()) instead of being swallowed. Making it observable is preferred over discarding it; the dispatch state is still unwound, so the event and the target remain usable afterwards, and a signal's abort event is still marked as fired and never re-dispatched.

A second deliberate deviation: the accessors this runtime installs on Event.prototype — and onabort on AbortSignal — are non-enumerable, where WebIDL specifies interface attributes as enumerable. This follows the house convention already set by the pre-existing aborted and reason accessors, so Object.keys(Event.prototype) is empty and every accessor's descriptor reports enumerable: false. Consistency within the runtime is preferred over matching WebIDL enumerability for one interface.

Deliberately out of scope: there is no node tree, so bubbles and capture are recorded and reported faithfully but produce no propagation — eventPhase, stopPropagation, stopImmediatePropagation, composed, and composedPath are absent. Event.timeStamp and isTrusted are omitted rather than backed by an invented time origin. The passive listener option is accepted and ignored, the signal member of AddEventListenerOptions is not supported, and AbortSignal.any() and its dependent-signal propagation (§ 3.2 signal abort step 5) remain unimplemented. CustomEvent is not provided.