From 671b15a172af41001b176eacdd4dd008d7794253 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 24 Aug 2026 13:50:52 -0300 Subject: [PATCH 1/3] feat: add AbortController and AbortSignal Install the DOM abort primitives as globals in every isolate, modeled on Node's internal/abort_controller.js: AbortController, and AbortSignal with the abort/timeout/any statics, onabort with HTML event-handler semantics, and WebIDL-shaped interfaces (enumerable members, Symbol.toStringTag, brand-checked accessors). The builtin (internal/abort-signal.js) runs from Events::Init right after the Event/EventTarget builtin it is layered on. Deviations from Node, documented in docs/abort-signal.md: no DOMException (default reasons are Error instances with name patched to AbortError/TimeoutError, the same stand-in performance.js and structured-clone.js use) and no WeakRef bookkeeping (a timeout() timer holds its signal until it fires; any() links source -> dependent strongly and unlinks as soon as either side aborts). Adds RangeError and NumberIsInteger to primordials and the eslint restriction lists, and a 20-spec Jasmine suite. Mirrors the same commit on the iOS runtime (NativeScript/ios#447). --- docs/README.md | 4 + docs/abort-signal.md | 50 +++ eslint.config.mjs | 3 +- test-app/app/src/main/assets/app/mainpage.js | 2 + .../main/assets/app/tests/testAbortSignal.js | 250 +++++++++++++ test-app/runtime/CMakeLists.txt | 1 + test-app/runtime/src/main/cpp/Events.cpp | 7 + test-app/runtime/src/main/cpp/Events.h | 5 +- .../runtime/src/main/cpp/js/abort-signal.js | 342 ++++++++++++++++++ .../runtime/src/main/cpp/js/primordials.js | 2 + 10 files changed, 663 insertions(+), 3 deletions(-) create mode 100644 docs/abort-signal.md create mode 100644 test-app/app/src/main/assets/app/tests/testAbortSignal.js create mode 100644 test-app/runtime/src/main/cpp/js/abort-signal.js diff --git a/docs/README.md b/docs/README.md index 318a25341..dd55fc567 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,10 @@ timing, performance timeline with `PerformanceObserver`), per-isolate time origins for workers, the native clock hook that future `requestAnimationFrame` work must share, and the documented spec deviations. +- [AbortController / AbortSignal](abort-signal.md) — the DOM abort primitives + (`AbortController`, `AbortSignal` with the `abort`/`timeout`/`any` statics) + layered on the runtime's `EventTarget`, and the documented deviations: no + `DOMException` (name-patched `Error` reasons) and no `WeakRef` bookkeeping. - [Error handling](error-handling.md) — global `error`/`unhandledrejection` events, `reportError`, catching Java exceptions in JS (`error.nativeException`), forwarding JS throws to Java callers (`interop.escapeException`), JS stacks on Java exceptions (`com.tns.JavaScriptStackTrace`), configuration flags, and crash-reporter integration. - [structuredClone](structured-clone.md) — the WHATWG `structuredClone(value, { transfer })` global: what clones, how graph identity and cycles are preserved, `ArrayBuffer` transfer, and the `DataCloneError`-named `Error` that stands in for `DOMException`. - [Implementing additional Chrome DevTools protocol Domains](extending-inspector.md) diff --git a/docs/abort-signal.md b/docs/abort-signal.md new file mode 100644 index 000000000..2a3a23b99 --- /dev/null +++ b/docs/abort-signal.md @@ -0,0 +1,50 @@ +# AbortController / AbortSignal + +The runtime installs the DOM Standard's abort primitives as globals in every +isolate (main and workers): `AbortController`, and `AbortSignal` with the +`abort`, `timeout` and `any` statics. The implementation is +`test-app/runtime/src/main/cpp/js/abort-signal.js`, evaluated during `Events::Init` +right after the `Event`/`EventTarget` builtin it is layered on, so the +interfaces exist before any user code runs. `AbortSignal` extends the +runtime's `EventTarget`; `new AbortSignal()` throws `TypeError: Illegal +constructor` — instances come from a controller or one of the statics. + +## Surface + +- `new AbortController()` — `controller.signal` (stable identity) and + `controller.abort(reason?)`. +- `signal.aborted`, `signal.reason`, `signal.throwIfAborted()`, and the + `abort` event (`addEventListener("abort", …)` or the `onabort` handler + attribute with HTML event-handler semantics). +- `AbortSignal.abort(reason?)` — an already-aborted signal; no event fires. +- `AbortSignal.timeout(delay)` — aborts with a `TimeoutError`-named reason + after `delay` ms. `delay` must be an integer in `[0, 2^32 − 1]` + (`TypeError` for non-numbers, `RangeError` otherwise), matching Node's + validation. +- `AbortSignal.any(signals)` — a composite signal that aborts with the first + source's reason. Accepts any iterable whose members are all `AbortSignal`s + (`TypeError` otherwise). Composites are flattened: `any([any([a]), b])` + follows `a` and `b` directly. Per spec, every affected signal's + `aborted`/`reason` flips before the first `abort` event fires. + +## Deviations from Node / the web + +- **No `DOMException`.** As with [structuredClone](structured-clone.md) and + the [Performance API](performance.md), default reasons are `Error` + instances with `name` patched: `"AbortError"` (default abort) and + `"TimeoutError"` (timeout). `instanceof DOMException` checks cannot work; + match on `reason.name`. +- **No `WeakRef` bookkeeping.** Node wraps timeout signals and `any()` + linkage in `WeakRef`s/`FinalizationRegistry`s so an unobserved signal can + be collected early and timers never keep the process alive. Here a + `timeout()` timer holds its signal strongly until it fires — retention is + bounded by the delay, and looper timers don't gate process liveness — and + `any()` links source → dependent strongly, unlinking as soon as either + side aborts. The visible behavior is the same; only collection timing + differs. +- Abort events carry no `isTrusted` flag (the runtime's `Event` doesn't + model it). + +Listener errors during the abort dispatch go through the runtime's standard +listener-error pipeline (see [error handling](error-handling.md)); a throwing +listener never prevents the remaining listeners from running. diff --git a/eslint.config.mjs b/eslint.config.mjs index 7e80b3653..e1d4ead32 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -16,6 +16,7 @@ const capturedStatics = [ ['ArrayBuffer', 'isView', 'ArrayBufferIsView'], ['JSON', 'stringify', 'JSONStringify'], ['Number', 'isFinite', 'NumberIsFinite'], + ['Number', 'isInteger', 'NumberIsInteger'], ['Number', 'isNaN', 'NumberIsNaN'], ['Number', 'parseFloat', 'NumberParseFloat'], ['Number', 'parseInt', 'NumberParseInt'], @@ -31,7 +32,7 @@ const capturedStatics = [ // Captured constructors. A destructure from `primordials` shadows the global, // so these only fire on the unguarded reference. -const restrictedGlobals = ['Date', 'Map', 'Number', 'Proxy', 'Set', 'String', 'TypeError'].map((name) => ({ +const restrictedGlobals = ['Date', 'Map', 'Number', 'Proxy', 'RangeError', 'Set', 'String', 'TypeError'].map((name) => ({ name, message: `Destructure ${name} from primordials — builtins must not read intrinsics off globals user code can replace.`, })); diff --git a/test-app/app/src/main/assets/app/mainpage.js b/test-app/app/src/main/assets/app/mainpage.js index 7bd233d20..4a15c9440 100644 --- a/test-app/app/src/main/assets/app/mainpage.js +++ b/test-app/app/src/main/assets/app/mainpage.js @@ -80,6 +80,8 @@ require('./tests/testURLSearchParamsImpl.js'); require('./tests/testQueueMicrotask'); require('./tests/testErrorEvents'); require('./tests/testUnhandledRejections'); +// AbortController/AbortSignal (abort/timeout/any) on top of EventTarget +require('./tests/testAbortSignal'); require('./tests/testEscapeException'); require('./tests/testUncaughtErrorPolicy'); // Runtime builtins keep working when app code replaces the intrinsics they use diff --git a/test-app/app/src/main/assets/app/tests/testAbortSignal.js b/test-app/app/src/main/assets/app/tests/testAbortSignal.js new file mode 100644 index 000000000..3716c8998 --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/testAbortSignal.js @@ -0,0 +1,250 @@ +describe("AbortController / AbortSignal", function () { + it("controller exposes a stable, initially-live signal", function () { + const controller = new AbortController(); + const signal = controller.signal; + expect(signal instanceof AbortSignal).toBe(true); + expect(signal instanceof EventTarget).toBe(true); + expect(controller.signal).toBe(signal); + expect(signal.aborted).toBe(false); + expect(signal.reason).toBeUndefined(); + expect(signal.onabort).toBeNull(); + }); + + it("AbortSignal constructor is not user-invocable", function () { + expect(function () { new AbortSignal(); }).toThrowError(TypeError); + expect(function () { new AbortSignal({}); }).toThrowError(TypeError); + }); + + it("abort() flips state before firing a single abort event", function () { + const controller = new AbortController(); + const signal = controller.signal; + const seen = []; + signal.addEventListener("abort", function (event) { + seen.push({ + type: event.type, + target: event.target, + aborted: signal.aborted, + reasonName: signal.reason && signal.reason.name, + }); + }); + controller.abort(); + controller.abort(); // second abort is a no-op + expect(seen.length).toBe(1); + expect(seen[0].type).toBe("abort"); + expect(seen[0].target).toBe(signal); + expect(seen[0].aborted).toBe(true); + expect(seen[0].reasonName).toBe("AbortError"); + expect(signal.aborted).toBe(true); + expect(signal.reason instanceof Error).toBe(true); + expect(signal.reason.name).toBe("AbortError"); + }); + + it("abort(reason) keeps the given reason by identity, including null", function () { + const custom = { my: "reason" }; + const c1 = new AbortController(); + c1.abort(custom); + expect(c1.signal.reason).toBe(custom); + + const c2 = new AbortController(); + c2.abort(null); + expect(c2.signal.aborted).toBe(true); + expect(c2.signal.reason).toBeNull(); + }); + + it("throwIfAborted throws the exact reason once aborted", function () { + const controller = new AbortController(); + expect(function () { controller.signal.throwIfAborted(); }).not.toThrow(); + const reason = new Error("boom"); + controller.abort(reason); + try { + controller.signal.throwIfAborted(); + fail("expected throwIfAborted to throw"); + } catch (e) { + expect(e).toBe(reason); + } + }); + + it("listeners added after abort never fire; once listeners fire once", function () { + const controller = new AbortController(); + const signal = controller.signal; + let onceCalls = 0; + signal.addEventListener("abort", function () { onceCalls++; }, { once: true }); + controller.abort(); + expect(onceCalls).toBe(1); + + let lateCalls = 0; + signal.addEventListener("abort", function () { lateCalls++; }); + controller.abort(); + expect(lateCalls).toBe(0); + }); + + it("onabort follows event handler semantics (set / replace / clear)", function () { + const controller = new AbortController(); + const signal = controller.signal; + const calls = []; + const first = function () { calls.push("first"); }; + const second = function (event) { + calls.push("second"); + expect(this).toBe(signal); + expect(event.type).toBe("abort"); + }; + signal.onabort = first; + expect(signal.onabort).toBe(first); + signal.onabort = second; // replacement keeps a single registration + signal.onabort = 42; // primitives clear the handler + expect(signal.onabort).toBeNull(); + signal.onabort = second; + controller.abort(); + expect(calls).toEqual(["second"]); + }); + + it("a cleared onabort does not fire", function () { + const controller = new AbortController(); + let called = false; + controller.signal.onabort = function () { called = true; }; + controller.signal.onabort = null; + controller.abort(); + expect(called).toBe(false); + }); + + it("a throwing abort listener does not stop the remaining listeners", function () { + const previousHook = global.__onUncaughtError; + const uncaught = []; + global.__onUncaughtError = function (error) { uncaught.push(error); }; + try { + const controller = new AbortController(); + const seen = []; + controller.signal.addEventListener("abort", function () { + seen.push("thrower"); + throw new Error("listener boom"); + }); + controller.signal.addEventListener("abort", function () { + seen.push("survivor"); + }); + controller.abort(); + expect(seen).toEqual(["thrower", "survivor"]); + } finally { + global.__onUncaughtError = previousHook; + } + }); + + it("AbortSignal.abort() returns a pre-aborted signal", function () { + const signal = AbortSignal.abort(); + expect(signal instanceof AbortSignal).toBe(true); + expect(signal.aborted).toBe(true); + expect(signal.reason.name).toBe("AbortError"); + + const custom = new Error("custom"); + expect(AbortSignal.abort(custom).reason).toBe(custom); + }); + + it("AbortSignal.timeout aborts asynchronously with a TimeoutError", function (done) { + const signal = AbortSignal.timeout(10); + expect(signal.aborted).toBe(false); + signal.addEventListener("abort", function () { + expect(signal.aborted).toBe(true); + expect(signal.reason instanceof Error).toBe(true); + expect(signal.reason.name).toBe("TimeoutError"); + done(); + }); + }); + + it("AbortSignal.timeout validates the delay", function () { + expect(function () { AbortSignal.timeout("10"); }).toThrowError(TypeError); + expect(function () { AbortSignal.timeout(); }).toThrowError(TypeError); + expect(function () { AbortSignal.timeout(-1); }).toThrowError(RangeError); + expect(function () { AbortSignal.timeout(1.5); }).toThrowError(RangeError); + expect(function () { AbortSignal.timeout(NaN); }).toThrowError(RangeError); + expect(function () { AbortSignal.timeout(4294967296); }).toThrowError(RangeError); + }); + + it("AbortSignal.any([]) never aborts", function () { + const signal = AbortSignal.any([]); + expect(signal instanceof AbortSignal).toBe(true); + expect(signal.aborted).toBe(false); + }); + + it("AbortSignal.any rejects non-iterables and non-signals", function () { + expect(function () { AbortSignal.any(null); }).toThrowError(TypeError); + expect(function () { AbortSignal.any(undefined); }).toThrowError(TypeError); + expect(function () { AbortSignal.any(5); }).toThrowError(TypeError); + expect(function () { AbortSignal.any([{}]); }).toThrowError(TypeError); + expect(function () { AbortSignal.any([null]); }).toThrowError(TypeError); + }); + + it("AbortSignal.any returns an aborted signal when an input is already aborted", function () { + const live = new AbortController(); + const reason = new Error("already done"); + const combined = AbortSignal.any([live.signal, AbortSignal.abort(reason)]); + expect(combined.aborted).toBe(true); + expect(combined.reason).toBe(reason); + }); + + it("AbortSignal.any adopts the first aborting source's reason and fires once", function () { + const a = new AbortController(); + const b = new AbortController(); + const combined = AbortSignal.any([a.signal, b.signal]); + expect(combined.aborted).toBe(false); + let fires = 0; + combined.addEventListener("abort", function () { fires++; }); + + const reason = new Error("b first"); + b.abort(reason); + expect(combined.aborted).toBe(true); + expect(combined.reason).toBe(reason); + expect(fires).toBe(1); + + a.abort(); // the other source aborting later must not re-fire + expect(combined.reason).toBe(reason); + expect(fires).toBe(1); + }); + + it("every affected signal flips state before any abort event fires", function () { + const controller = new AbortController(); + const combined = AbortSignal.any([controller.signal]); + const observed = []; + controller.signal.addEventListener("abort", function () { + observed.push(["source", combined.aborted]); + }); + combined.addEventListener("abort", function () { + observed.push(["combined", controller.signal.aborted]); + }); + controller.abort(); + expect(observed).toEqual([["source", true], ["combined", true]]); + }); + + it("AbortSignal.any flattens composite inputs to their sources", function () { + const a = new AbortController(); + const b = new AbortController(); + const inner = AbortSignal.any([a.signal]); + const outer = AbortSignal.any([inner, b.signal]); + const reason = new Error("via inner"); + a.abort(reason); + expect(inner.aborted).toBe(true); + expect(outer.aborted).toBe(true); + expect(outer.reason).toBe(reason); + }); + + it("AbortSignal.any accepts any iterable of signals", function () { + const controller = new AbortController(); + const combined = AbortSignal.any(new Set([controller.signal])); + controller.abort(); + expect(combined.aborted).toBe(true); + }); + + it("interfaces carry WebIDL toStringTag and enumerable members", function () { + const controller = new AbortController(); + expect(Object.prototype.toString.call(controller)).toBe("[object AbortController]"); + expect(Object.prototype.toString.call(controller.signal)).toBe("[object AbortSignal]"); + expect(Object.getOwnPropertyDescriptor(AbortSignal.prototype, "aborted").enumerable).toBe(true); + expect(Object.getOwnPropertyDescriptor(AbortController.prototype, "abort").enumerable).toBe(true); + expect(Object.keys(controller.signal)).toEqual([]); + }); + + it("accessors brand-check their receiver", function () { + const abortedGetter = Object.getOwnPropertyDescriptor(AbortSignal.prototype, "aborted").get; + expect(function () { abortedGetter.call({}); }).toThrowError(TypeError); + const signalGetter = Object.getOwnPropertyDescriptor(AbortController.prototype, "signal").get; + expect(function () { signalGetter.call({}); }).toThrowError(TypeError); + }); +}); diff --git a/test-app/runtime/CMakeLists.txt b/test-app/runtime/CMakeLists.txt index 7d91ae760..0d641a1b3 100644 --- a/test-app/runtime/CMakeLists.txt +++ b/test-app/runtime/CMakeLists.txt @@ -68,6 +68,7 @@ include_directories( # drifts from the directory contents. set(RUNTIME_BUILTIN_JS_DIR ${PROJECT_SOURCE_DIR}/src/main/cpp/js) set(RUNTIME_BUILTIN_JS + ${RUNTIME_BUILTIN_JS_DIR}/abort-signal.js ${RUNTIME_BUILTIN_JS_DIR}/blob-url.js ${RUNTIME_BUILTIN_JS_DIR}/error-events.js ${RUNTIME_BUILTIN_JS_DIR}/events.js diff --git a/test-app/runtime/src/main/cpp/Events.cpp b/test-app/runtime/src/main/cpp/Events.cpp index f1f2fe60e..2ed58dc6e 100644 --- a/test-app/runtime/src/main/cpp/Events.cpp +++ b/test-app/runtime/src/main/cpp/Events.cpp @@ -22,4 +22,11 @@ void Events::Init(Local context) { } runtime->GlobalEventTarget().Reset(isolate, result.As()); + + // AbortController/AbortSignal (internal/abort-signal.js) build directly on + // the event primitives installed above; no native binding, no export. + Local abortResult; + if (!BuiltinLoader::RunBuiltin(context, BuiltinId::kAbortSignal).ToLocal(&abortResult)) { + throw NativeScriptException("Events::Init: the abort-signal bootstrap failed"); + } } diff --git a/test-app/runtime/src/main/cpp/Events.h b/test-app/runtime/src/main/cpp/Events.h index 8caea3bb9..0500643e5 100644 --- a/test-app/runtime/src/main/cpp/Events.h +++ b/test-app/runtime/src/main/cpp/Events.h @@ -10,8 +10,9 @@ class Events { /* * Installs the generic WHATWG event primitives: the Event and EventTarget * constructors on globalThis, the EventTarget methods (addEventListener / - * removeEventListener / dispatchEvent) bound onto globalThis, and the - * internal EventTarget instance backing the global. Evaluated once per + * removeEventListener / dispatchEvent) bound onto globalThis, the + * internal EventTarget instance backing the global, and — layered on top — + * the AbortController/AbortSignal interfaces. Evaluated once per * isolate during PrepareV8Runtime, before ErrorEvents::Init, for both the * main and worker isolates. Stashes the backing target in * Runtime::GlobalEventTarget() so native layers can dispatch without diff --git a/test-app/runtime/src/main/cpp/js/abort-signal.js b/test-app/runtime/src/main/cpp/js/abort-signal.js new file mode 100644 index 000000000..e485ad097 --- /dev/null +++ b/test-app/runtime/src/main/cpp/js/abort-signal.js @@ -0,0 +1,342 @@ +"use strict"; +// AbortController / AbortSignal (DOM Standard §3.2) with the AbortSignal +// abort / timeout / any statics, modeled on Node's +// internal/abort_controller.js. +// +// Deliberate deviations from Node: +// - No DOMException in this runtime: the default abort and timeout reasons +// are Error instances with `name` patched ("AbortError" / "TimeoutError"), +// the same stand-in performance.js and structured-clone.js use. +// - No WeakRef bookkeeping: a timeout() timer holds its signal strongly until +// it fires (retention is bounded by the delay, and looper timers don't +// gate process liveness the way Node's do), and any() links source -> +// dependent strongly, unlinking as soon as either side aborts. +const { + ArrayPrototypeIndexOf, + ArrayPrototypePush, + ArrayPrototypeSplice, + Error, + FunctionPrototypeCall, + NumberIsInteger, + ObjectDefineProperty, + ObjectGetOwnPropertyDescriptor, + RangeError, + SymbolIterator, + SymbolToStringTag, + TypeError, +} = primordials; +var g = globalThis; +// Init order (PrepareV8Runtime): events.js ran immediately before this builtin, +// and the timer natives are template globals present from context creation; +// captured before user code can replace them. +const EventTarget = g.EventTarget; +const Event = g.Event; +const setTimeout = g.setTimeout; +const dispatchEvent = EventTarget.prototype.dispatchEvent; +const addEventListener = EventTarget.prototype.addEventListener; +const removeEventListener = EventTarget.prototype.removeEventListener; + +// Construction token: AbortSignal instances come only from the factories in +// this module (the controller, and the abort/timeout/any statics). +const kInternal = {}; + +function abortError() { + const e = new Error("This operation was aborted"); + e.name = "AbortError"; + return e; +} + +function timeoutError() { + const e = new Error("The operation was aborted due to timeout"); + e.name = "TimeoutError"; + return e; +} + +let createAbortSignal; +let signalAbort; + +class AbortSignal extends EventTarget { + #aborted = false; + #reason = undefined; + // Event handler attribute state (HTML semantics: registered as a plain + // listener on the first non-null assignment, so its slot in the listener + // order is where it was first set; cleared assignments free the slot). + #onabort = null; + #onabortWrapper = null; + // any() linkage. #sources: the plain signals a live composite follows + // (null on plain signals and once aborted — composites never nest, any() + // flattens). #dependents: the live composites following this signal. + #composite = false; + #sources = null; + #dependents = null; + + constructor(token) { + if (token !== kInternal) { + throw new TypeError("Illegal constructor"); + } + super(); + // The EventTarget base installs `_listeners` as an own enumerable field; + // keep it out of Object.keys(signal)/JSON.stringify(signal). + ObjectDefineProperty(this, "_listeners", { + value: this._listeners, + writable: true, + enumerable: false, + configurable: true, + }); + } + + get aborted() { + return this.#aborted; + } + + get reason() { + return this.#reason; + } + + throwIfAborted() { + if (this.#aborted) { + throw this.#reason; + } + } + + get onabort() { + return this.#onabort; + } + + set onabort(handler) { + // TreatNonObjectAsNull: objects and functions are stored, any other value + // clears the handler; only a function is invoked at dispatch time. + const value = + typeof handler === "function" || + (handler !== null && typeof handler === "object") + ? handler + : null; + if (value !== null && this.#onabort === null) { + if (this.#onabortWrapper === null) { + const self = this; + this.#onabortWrapper = function (event) { + const cb = self.#onabort; + if (typeof cb === "function") { + FunctionPrototypeCall(cb, self, event); + } + }; + } + FunctionPrototypeCall( + addEventListener, + this, + "abort", + this.#onabortWrapper + ); + } else if (value === null && this.#onabort !== null) { + FunctionPrototypeCall( + removeEventListener, + this, + "abort", + this.#onabortWrapper + ); + } + this.#onabort = value; + } + + static abort(reason) { + return createAbortSignal( + true, + reason === undefined ? abortError() : reason + ); + } + + static timeout(delay) { + if (typeof delay !== "number") { + throw new TypeError('The "delay" argument must be of type number'); + } + if (!NumberIsInteger(delay) || delay < 0 || delay > 4294967295) { + throw new RangeError( + 'The value of "delay" is out of range. It must be an integer >= 0 ' + + "and <= 4294967295. Received " + + delay + ); + } + const signal = createAbortSignal(false, undefined); + setTimeout(function () { + signalAbort(signal, timeoutError()); + }, delay); + return signal; + } + + static any(signals) { + // WebIDL sequence: an iterable of AbortSignals converts + // (so a Set works and a string does not); anything else is a TypeError, + // never a silent no-op. + if ( + signals === null || + (typeof signals !== "object" && typeof signals !== "function") || + typeof signals[SymbolIterator] !== "function" + ) { + throw new TypeError("signals is not iterable"); + } + const list = []; + for (const s of signals) { + if ( + s === null || + (typeof s !== "object" && typeof s !== "function") || + !(#aborted in s) + ) { + throw new TypeError( + "signals must contain only AbortSignal instances" + ); + } + ArrayPrototypePush(list, s); + } + const result = new AbortSignal(kInternal); + result.#composite = true; + // The first aborted input wins, before any linking happens; no abort + // event fires because nothing can be listening on `result` yet. + for (let i = 0; i < list.length; i++) { + if (list[i].#aborted) { + result.#aborted = true; + result.#reason = list[i].#reason; + return result; + } + } + result.#sources = []; + for (let i = 0; i < list.length; i++) { + const s = list[i]; + if (s.#composite) { + // A live composite's sources are all live (it would have aborted with + // them otherwise); one with no sources can never abort and + // contributes nothing. + const underlying = s.#sources; + for (let j = 0; j < underlying.length; j++) { + AbortSignal.#link(result, underlying[j]); + } + } else { + AbortSignal.#link(result, s); + } + } + return result; + } + + static #link(result, source) { + if (ArrayPrototypeIndexOf(result.#sources, source) !== -1) { + return; + } + ArrayPrototypePush(result.#sources, source); + if (source.#dependents === null) { + source.#dependents = []; + } + ArrayPrototypePush(source.#dependents, result); + } + + static { + createAbortSignal = (aborted, reason) => { + const signal = new AbortSignal(kInternal); + signal.#aborted = aborted; + signal.#reason = reason; + return signal; + }; + + // Detach an aborted composite from the sources it was following so a + // long-lived source doesn't retain it (and its listeners) forever. + const unlink = (signal) => { + const sources = signal.#sources; + if (sources === null) { + return; + } + signal.#sources = null; + for (let i = 0; i < sources.length; i++) { + const dependents = sources[i].#dependents; + if (dependents !== null) { + const idx = ArrayPrototypeIndexOf(dependents, signal); + if (idx !== -1) { + ArrayPrototypeSplice(dependents, idx, 1); + } + } + } + }; + + const fireAbort = (signal) => { + // Captured dispatch: controller.abort() must keep working even if app + // code replaced signal.dispatchEvent. + FunctionPrototypeCall(dispatchEvent, signal, new Event("abort")); + }; + + // https://dom.spec.whatwg.org/#abortsignal-signal-abort — every affected + // signal (the source and its dependent composites) flips its state before + // the first abort event fires. + signalAbort = (signal, reason) => { + if (signal.#aborted) { + return; + } + signal.#aborted = true; + signal.#reason = reason; + unlink(signal); + const dependents = signal.#dependents; + signal.#dependents = null; + const toAbort = []; + if (dependents !== null) { + for (let i = 0; i < dependents.length; i++) { + const d = dependents[i]; + if (!d.#aborted) { + d.#aborted = true; + d.#reason = reason; + ArrayPrototypePush(toAbort, d); + } + } + } + fireAbort(signal); + for (let i = 0; i < toAbort.length; i++) { + unlink(toAbort[i]); + fireAbort(toAbort[i]); + } + }; + } +} + +class AbortController { + #signal = createAbortSignal(false, undefined); + + get signal() { + return this.#signal; + } + + abort(reason) { + signalAbort( + this.#signal, + reason === undefined ? abortError() : reason + ); + } +} + +// WebIDL shape: interface members are enumerable prototype properties, static +// operations are enumerable own properties of the interface object, and the +// class string is a configurable, non-writable Symbol.toStringTag; class +// syntax alone yields non-enumerable members. +function finishInterface(ctor, tag, members, staticMembers) { + const proto = ctor.prototype; + ObjectDefineProperty(proto, SymbolToStringTag, { + value: tag, + writable: false, + enumerable: false, + configurable: true, + }); + for (let i = 0; i < members.length; i++) { + const desc = ObjectGetOwnPropertyDescriptor(proto, members[i]); + desc.enumerable = true; + ObjectDefineProperty(proto, members[i], desc); + } + for (let i = 0; i < staticMembers.length; i++) { + const desc = ObjectGetOwnPropertyDescriptor(ctor, staticMembers[i]); + desc.enumerable = true; + ObjectDefineProperty(ctor, staticMembers[i], desc); + } +} +finishInterface( + AbortSignal, + "AbortSignal", + ["aborted", "reason", "onabort", "throwIfAborted"], + ["abort", "timeout", "any"] +); +finishInterface(AbortController, "AbortController", ["signal", "abort"], []); + +g.AbortController = AbortController; +g.AbortSignal = AbortSignal; diff --git a/test-app/runtime/src/main/cpp/js/primordials.js b/test-app/runtime/src/main/cpp/js/primordials.js index 559c2e37c..73dd6a6a3 100644 --- a/test-app/runtime/src/main/cpp/js/primordials.js +++ b/test-app/runtime/src/main/cpp/js/primordials.js @@ -24,6 +24,7 @@ const intrinsics = { Error, Map, Number, + RangeError, Set, String, TypeError, @@ -42,6 +43,7 @@ const intrinsics = { decodeURIComponent, JSONStringify: JSON.stringify, NumberIsFinite: Number.isFinite, + NumberIsInteger: Number.isInteger, NumberIsNaN: Number.isNaN, NumberParseFloat: Number.parseFloat, NumberParseInt: Number.parseInt, From 018a7cd7f74e4fd40a3803f57ce5d1cc60dda05e Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 24 Aug 2026 13:51:45 -0300 Subject: [PATCH 2/3] feat: GC-transparent AbortSignal (weak timers and any() links) Match Node's memory behavior: internal references never keep an unobservable signal alive and never drop an observable abort. - timeout() timers close over a WeakRef; a FinalizationRegistry cancels the pending native timer when the signal is collected. - any() links are WeakRefs in both directions with prune registries, so per-request composites never accumulate on a long-lived source and a composite whose sources all died stops being retained. - A gcPersistentSignals set strong-holds exactly the signals whose abort someone can still observe: live timeout signals and non-empty composites while they have abort listeners, plus timeout sources a composite follows until their timer fires. The listener accounting comes from a new symbol-keyed listener-mutation hook in events.js, called from every listener-list mutation path (add, remove, once-splice during dispatch) and handed to the abort builtin in a one-shot through its binding, so it cannot be bypassed via a captured EventTarget.prototype.addEventListener. Adds WeakRef/FinalizationRegistry captures to primordials and the eslint restriction lists, and 8 GC specs driven by __collect() plus a finalization-registry substrate canary. Mirrors the same commit on the iOS runtime (NativeScript/ios#447). --- docs/README.md | 5 +- docs/abort-signal.md | 37 +++- eslint.config.mjs | 2 +- .../main/assets/app/tests/testAbortSignal.js | 128 +++++++++++ test-app/runtime/src/main/cpp/Events.cpp | 6 +- .../runtime/src/main/cpp/js/abort-signal.js | 209 +++++++++++++++--- test-app/runtime/src/main/cpp/js/events.js | 28 ++- .../runtime/src/main/cpp/js/primordials.js | 5 + 8 files changed, 375 insertions(+), 45 deletions(-) diff --git a/docs/README.md b/docs/README.md index dd55fc567..21c953d23 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,8 +10,9 @@ work must share, and the documented spec deviations. - [AbortController / AbortSignal](abort-signal.md) — the DOM abort primitives (`AbortController`, `AbortSignal` with the `abort`/`timeout`/`any` statics) - layered on the runtime's `EventTarget`, and the documented deviations: no - `DOMException` (name-patched `Error` reasons) and no `WeakRef` bookkeeping. + layered on the runtime's `EventTarget`, the GC contract (weak timers and + `any()` links, listener-driven persistence), and the `DOMException` + stand-in (name-patched `Error` reasons). - [Error handling](error-handling.md) — global `error`/`unhandledrejection` events, `reportError`, catching Java exceptions in JS (`error.nativeException`), forwarding JS throws to Java callers (`interop.escapeException`), JS stacks on Java exceptions (`com.tns.JavaScriptStackTrace`), configuration flags, and crash-reporter integration. - [structuredClone](structured-clone.md) — the WHATWG `structuredClone(value, { transfer })` global: what clones, how graph identity and cycles are preserved, `ArrayBuffer` transfer, and the `DataCloneError`-named `Error` that stands in for `DOMException`. - [Implementing additional Chrome DevTools protocol Domains](extending-inspector.md) diff --git a/docs/abort-signal.md b/docs/abort-signal.md index 2a3a23b99..8abad27c4 100644 --- a/docs/abort-signal.md +++ b/docs/abort-signal.md @@ -27,6 +27,35 @@ constructor` — instances come from a controller or one of the statics. follows `a` and `b` directly. Per spec, every affected signal's `aborted`/`reason` flips before the first `abort` event fires. +## GC contract + +The implementation is GC-transparent the way Node's is: internal references +never keep an unobservable signal alive, and never let an observable abort +be dropped. + +- A `timeout()` timer closes over a `WeakRef`, so a signal nobody can + observe is collectable before it fires; a `FinalizationRegistry` cancels + the pending native timer when that happens. +- `any()` links are `WeakRef`s in both directions (source → dependent and + dependent → source), with prune registries clearing dead entries — so + per-request composites never accumulate on a long-lived source, and a + collected source leaves its composites' source lists (a composite whose + sources are all gone can never abort and stops being retained). +- Weakness alone would silently drop the abort of a signal that is + listened-to but otherwise unreachable, so a strong `gcPersistentSignals` + set holds exactly the signals whose abort someone can still observe: live + timeout signals and live non-empty composites while they have `abort` + listeners (`onabort` counts — it registers a real listener), plus timeout + sources a composite follows, until their timer fires. The listener + accounting comes from an internal symbol-keyed hook the events builtin + calls from every listener-list mutation path (add, remove, and `once` + removal during dispatch); the key is handed to the abort builtin in a + one-shot during init and never reaches app code, so the accounting cannot + be bypassed via a captured `EventTarget.prototype.addEventListener`. + +Entries leave the persistent set on abort, on the last abort-listener +removal, or when a composite loses its last source. + ## Deviations from Node / the web - **No `DOMException`.** As with [structuredClone](structured-clone.md) and @@ -34,14 +63,6 @@ constructor` — instances come from a controller or one of the statics. instances with `name` patched: `"AbortError"` (default abort) and `"TimeoutError"` (timeout). `instanceof DOMException` checks cannot work; match on `reason.name`. -- **No `WeakRef` bookkeeping.** Node wraps timeout signals and `any()` - linkage in `WeakRef`s/`FinalizationRegistry`s so an unobserved signal can - be collected early and timers never keep the process alive. Here a - `timeout()` timer holds its signal strongly until it fires — retention is - bounded by the delay, and looper timers don't gate process liveness — and - `any()` links source → dependent strongly, unlinking as soon as either - side aborts. The visible behavior is the same; only collection timing - differs. - Abort events carry no `isTrusted` flag (the runtime's `Event` doesn't model it). diff --git a/eslint.config.mjs b/eslint.config.mjs index e1d4ead32..a2dab9fad 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -32,7 +32,7 @@ const capturedStatics = [ // Captured constructors. A destructure from `primordials` shadows the global, // so these only fire on the unguarded reference. -const restrictedGlobals = ['Date', 'Map', 'Number', 'Proxy', 'RangeError', 'Set', 'String', 'TypeError'].map((name) => ({ +const restrictedGlobals = ['Date', 'FinalizationRegistry', 'Map', 'Number', 'Proxy', 'RangeError', 'Set', 'String', 'TypeError', 'WeakRef'].map((name) => ({ name, message: `Destructure ${name} from primordials — builtins must not read intrinsics off globals user code can replace.`, })); diff --git a/test-app/app/src/main/assets/app/tests/testAbortSignal.js b/test-app/app/src/main/assets/app/tests/testAbortSignal.js index 3716c8998..edcd152df 100644 --- a/test-app/app/src/main/assets/app/tests/testAbortSignal.js +++ b/test-app/app/src/main/assets/app/tests/testAbortSignal.js @@ -248,3 +248,131 @@ describe("AbortController / AbortSignal", function () { expect(function () { signalGetter.call({}); }).toThrowError(TypeError); }); }); + +describe("AbortSignal GC behavior", function () { + // WeakRef clearing and FinalizationRegistry cleanup both need turns of + // the runloop after a collection (cleanup arrives as posted V8 tasks), + // so every spec polls with a collect per turn instead of asserting + // synchronously after __collect(). + function pollGC(predicate, cb) { + let turns = 0; + (function poll() { + __collect(); + if (predicate() || turns >= 100) { + cb(); + return; + } + turns++; + setTimeout(poll, 20); + })(); + } + + it("finalization registry cleanup runs on this runtime (substrate)", function (done) { + let fired = false; + const registry = new FinalizationRegistry(function () { fired = true; }); + (function () { registry.register({}, 0); })(); + pollGC(function () { return fired; }, function () { + expect(fired).toBe(true); + done(); + }); + }); + + it("an unobserved timeout signal is collectable before its timer fires", function (done) { + const wr = (function () { + return new WeakRef(AbortSignal.timeout(60000)); + })(); + pollGC(function () { return wr.deref() === undefined; }, function () { + expect(wr.deref()).toBeUndefined(); + done(); + }); + }); + + it("a listened timeout signal survives GC and still aborts", function (done) { + let reasonName = null; + (function () { + AbortSignal.timeout(300).addEventListener("abort", function (event) { + reasonName = event.target.reason.name; + }); + })(); + __collect(); + pollGC(function () { return reasonName !== null; }, function () { + expect(reasonName).toBe("TimeoutError"); + done(); + }); + }); + + it("an unobserved composite is collectable while its source lives", function (done) { + const controller = new AbortController(); + const wr = (function () { + return new WeakRef(AbortSignal.any([controller.signal])); + })(); + pollGC(function () { return wr.deref() === undefined; }, function () { + expect(wr.deref()).toBeUndefined(); + expect(function () { controller.abort(); }).not.toThrow(); + done(); + }); + }); + + it("a listened composite with a live source survives GC and aborts", function (done) { + const controller = new AbortController(); + let got = null; + (function () { + AbortSignal.any([controller.signal]).addEventListener("abort", function (event) { + got = event.target.reason; + }); + })(); + __collect(); + setTimeout(function () { + __collect(); + const reason = new Error("late abort"); + controller.abort(reason); + expect(got).toBe(reason); + done(); + }, 50); + }); + + it("a composite keeps a dropped timeout source alive until it fires", function (done) { + let reasonName = null; + (function () { + AbortSignal.any([AbortSignal.timeout(300)]).addEventListener("abort", function (event) { + reasonName = event.target.reason.name; + }); + })(); + __collect(); + pollGC(function () { return reasonName !== null; }, function () { + expect(reasonName).toBe("TimeoutError"); + done(); + }); + }); + + it("a listened composite is released once its last source dies", function (done) { + const wr = (function () { + const controller = new AbortController(); + const composite = AbortSignal.any([controller.signal]); + composite.addEventListener("abort", function () {}); + return new WeakRef(composite); + })(); + // First the source must be collected (nothing holds it), then the + // prune callback empties the composite's sources and drops it from + // the persistent set, and only then can the composite itself go. + pollGC(function () { return wr.deref() === undefined; }, function () { + expect(wr.deref()).toBeUndefined(); + done(); + }); + }); + + it("removing the last abort listener releases a timeout signal", function (done) { + let wr = null; + (function () { + const signal = AbortSignal.timeout(60000); + const listener = function () {}; + signal.addEventListener("abort", listener); + signal.removeEventListener("abort", listener); + wr = new WeakRef(signal); + })(); + pollGC(function () { return wr.deref() === undefined; }, function () { + expect(wr.deref()).toBeUndefined(); + done(); + }); + }); +}); diff --git a/test-app/runtime/src/main/cpp/Events.cpp b/test-app/runtime/src/main/cpp/Events.cpp index 2ed58dc6e..4fc31c61e 100644 --- a/test-app/runtime/src/main/cpp/Events.cpp +++ b/test-app/runtime/src/main/cpp/Events.cpp @@ -24,9 +24,11 @@ void Events::Init(Local context) { runtime->GlobalEventTarget().Reset(isolate, result.As()); // AbortController/AbortSignal (internal/abort-signal.js) build directly on - // the event primitives installed above; no native binding, no export. + // the event primitives installed above. The events export is its binding: + // the abort builtin takes the one-shot listener-mutation hook key from it + // (_takeListenerChangedKey) for its GC-liveness accounting. Local abortResult; - if (!BuiltinLoader::RunBuiltin(context, BuiltinId::kAbortSignal).ToLocal(&abortResult)) { + if (!BuiltinLoader::RunBuiltin(context, BuiltinId::kAbortSignal, result).ToLocal(&abortResult)) { throw NativeScriptException("Events::Init: the abort-signal bootstrap failed"); } } diff --git a/test-app/runtime/src/main/cpp/js/abort-signal.js b/test-app/runtime/src/main/cpp/js/abort-signal.js index e485ad097..1db42cd2b 100644 --- a/test-app/runtime/src/main/cpp/js/abort-signal.js +++ b/test-app/runtime/src/main/cpp/js/abort-signal.js @@ -3,27 +3,44 @@ // abort / timeout / any statics, modeled on Node's // internal/abort_controller.js. // -// Deliberate deviations from Node: -// - No DOMException in this runtime: the default abort and timeout reasons -// are Error instances with `name` patched ("AbortError" / "TimeoutError"), -// the same stand-in performance.js and structured-clone.js use. -// - No WeakRef bookkeeping: a timeout() timer holds its signal strongly until -// it fires (retention is bounded by the delay, and looper timers don't -// gate process liveness the way Node's do), and any() links source -> -// dependent strongly, unlinking as soon as either side aborts. +// GC contract (Node-equivalent, see docs/abort-signal.md): +// - A timeout() timer and every any() link hold only WeakRefs, so a signal +// nobody can observe is collectable before its abort would ever fire, and +// per-request composites never accumulate on a long-lived source. +// - Weakness alone would drop aborts for signals that are listened-to but +// otherwise unreachable, so gcPersistentSignals strong-holds exactly the +// signals whose abort someone can still observe: live timeout signals and +// live non-empty composites while they have abort listeners, and timeout +// sources a composite follows until their timer fires. The listener +// accounting comes from the events builtin's kListenerChanged hook, which +// fires from every listener-list mutation path and cannot be bypassed +// from app code. +// +// Deliberate deviation from Node: no DOMException in this runtime — the +// default abort and timeout reasons are Error instances with `name` patched +// ("AbortError" / "TimeoutError"), the same stand-in performance.js and +// structured-clone.js use. const { ArrayPrototypeIndexOf, ArrayPrototypePush, ArrayPrototypeSplice, Error, + FinalizationRegistry, + FinalizationRegistryPrototypeRegister, + FinalizationRegistryPrototypeUnregister, FunctionPrototypeCall, NumberIsInteger, ObjectDefineProperty, ObjectGetOwnPropertyDescriptor, RangeError, + Set, + SetPrototypeAdd, + SetPrototypeDelete, SymbolIterator, SymbolToStringTag, TypeError, + WeakRef, + WeakRefPrototypeDeref, } = primordials; var g = globalThis; // Init order (PrepareV8Runtime): events.js ran immediately before this builtin, @@ -32,9 +49,13 @@ var g = globalThis; const EventTarget = g.EventTarget; const Event = g.Event; const setTimeout = g.setTimeout; +const clearTimeout = g.clearTimeout; const dispatchEvent = EventTarget.prototype.dispatchEvent; const addEventListener = EventTarget.prototype.addEventListener; const removeEventListener = EventTarget.prototype.removeEventListener; +// The binding is the events builtin's export; the one-shot returns the +// symbol under which EventTargetImpl looks up the listener-mutation hook. +const kListenerChanged = binding._takeListenerChangedKey(); // Construction token: AbortSignal instances come only from the factories in // this module (the controller, and the abort/timeout/any statics). @@ -52,8 +73,25 @@ function timeoutError() { return e; } +// The strong holds described in the header. Entries leave on abort, on the +// last abort-listener removal, or when a composite loses its last source +// (at which point nothing can ever abort it). +const gcPersistentSignals = new Set(); + +// Cancels the pending native timer of a collected timeout() signal. +const timerRegistry = new FinalizationRegistry(function (timerId) { + clearTimeout(timerId); +}); + let createAbortSignal; let signalAbort; +let listenerChanged; +let dependentPrune; +let sourcePrune; +// Prune callbacks arrive as posted GC-cleanup tasks; the registries are +// constructed after the static block assigns the callbacks. +let dependentPruneRegistry; +let sourcePruneRegistry; class AbortSignal extends EventTarget { #aborted = false; @@ -63,12 +101,16 @@ class AbortSignal extends EventTarget { // order is where it was first set; cleared assignments free the slot). #onabort = null; #onabortWrapper = null; - // any() linkage. #sources: the plain signals a live composite follows - // (null on plain signals and once aborted — composites never nest, any() - // flattens). #dependents: the live composites following this signal. + #isTimeout = false; + // any() linkage, all WeakRefs. #sources: the plain sources a live + // composite follows (null on plain signals and once aborted — composites + // never nest, any() flattens). #dependents: the live composites following + // this signal. #selfRef: the one WeakRef identity other signals hold for + // this one; doubles as the unregister token for the prune registries. #composite = false; #sources = null; #dependents = null; + #selfRef = null; constructor(token) { if (token !== kInternal) { @@ -157,9 +199,20 @@ class AbortSignal extends EventTarget { ); } const signal = createAbortSignal(false, undefined); - setTimeout(function () { - signalAbort(signal, timeoutError()); + signal.#isTimeout = true; + // The timer closes over a WeakRef so an unobservable signal is + // collectable before it fires; the registry cancels the native timer if + // that happens. While the signal has abort listeners the hook below + // strong-holds it, so a pending observable abort is never dropped. + const ref = new WeakRef(signal); + const timerId = setTimeout(function () { + const s = WeakRefPrototypeDeref(ref); + if (s !== undefined) { + FinalizationRegistryPrototypeUnregister(timerRegistry, s); + signalAbort(s, timeoutError()); + } }, delay); + FinalizationRegistryPrototypeRegister(timerRegistry, signal, timerId, signal); return signal; } @@ -199,32 +252,50 @@ class AbortSignal extends EventTarget { } } result.#sources = []; + const resultRef = (result.#selfRef = new WeakRef(result)); for (let i = 0; i < list.length; i++) { const s = list[i]; if (s.#composite) { - // A live composite's sources are all live (it would have aborted with - // them otherwise); one with no sources can never abort and + // A live composite's sources are all live (it would have aborted + // with them otherwise); one with no sources can never abort and // contributes nothing. const underlying = s.#sources; for (let j = 0; j < underlying.length; j++) { - AbortSignal.#link(result, underlying[j]); + const src = WeakRefPrototypeDeref(underlying[j]); + if (src !== undefined) { + AbortSignal.#link(result, resultRef, src); + } } } else { - AbortSignal.#link(result, s); + AbortSignal.#link(result, resultRef, s); } } return result; } - static #link(result, source) { - if (ArrayPrototypeIndexOf(result.#sources, source) !== -1) { + static #link(result, resultRef, source) { + let sourceRef = source.#selfRef; + if (sourceRef === null) { + sourceRef = source.#selfRef = new WeakRef(source); + } + if (ArrayPrototypeIndexOf(result.#sources, sourceRef) !== -1) { return; } - ArrayPrototypePush(result.#sources, source); + ArrayPrototypePush(result.#sources, sourceRef); if (source.#dependents === null) { source.#dependents = []; } - ArrayPrototypePush(source.#dependents, result); + ArrayPrototypePush(source.#dependents, resultRef); + // A timeout source followed only weakly would be collectable once app + // code drops it, silently never aborting the composite: hold it until + // its timer fires (retention bounded by the delay). + if (source.#isTimeout && !source.#aborted) { + SetPrototypeAdd(gcPersistentSignals, source); + } + FinalizationRegistryPrototypeRegister( + dependentPruneRegistry, result, { sourceRef, resultRef }, resultRef); + FinalizationRegistryPrototypeRegister( + sourcePruneRegistry, source, { sourceRef, resultRef }, resultRef); } static { @@ -235,20 +306,26 @@ class AbortSignal extends EventTarget { return signal; }; - // Detach an aborted composite from the sources it was following so a - // long-lived source doesn't retain it (and its listeners) forever. + // Detach an aborted composite from the sources it was following, and + // drop both prune registrations (their WeakRefs are dead weight once the + // links are gone). const unlink = (signal) => { const sources = signal.#sources; if (sources === null) { return; } signal.#sources = null; + const selfRef = signal.#selfRef; + if (selfRef !== null) { + FinalizationRegistryPrototypeUnregister(dependentPruneRegistry, selfRef); + FinalizationRegistryPrototypeUnregister(sourcePruneRegistry, selfRef); + } for (let i = 0; i < sources.length; i++) { - const dependents = sources[i].#dependents; - if (dependents !== null) { - const idx = ArrayPrototypeIndexOf(dependents, signal); + const source = WeakRefPrototypeDeref(sources[i]); + if (source !== undefined && source.#dependents !== null) { + const idx = ArrayPrototypeIndexOf(source.#dependents, selfRef); if (idx !== -1) { - ArrayPrototypeSplice(dependents, idx, 1); + ArrayPrototypeSplice(source.#dependents, idx, 1); } } } @@ -269,14 +346,15 @@ class AbortSignal extends EventTarget { } signal.#aborted = true; signal.#reason = reason; + SetPrototypeDelete(gcPersistentSignals, signal); unlink(signal); const dependents = signal.#dependents; signal.#dependents = null; const toAbort = []; if (dependents !== null) { for (let i = 0; i < dependents.length; i++) { - const d = dependents[i]; - if (!d.#aborted) { + const d = WeakRefPrototypeDeref(dependents[i]); + if (d !== undefined && !d.#aborted) { d.#aborted = true; d.#reason = reason; ArrayPrototypePush(toAbort, d); @@ -285,13 +363,82 @@ class AbortSignal extends EventTarget { } fireAbort(signal); for (let i = 0; i < toAbort.length; i++) { - unlink(toAbort[i]); - fireAbort(toAbort[i]); + const d = toAbort[i]; + SetPrototypeDelete(gcPersistentSignals, d); + unlink(d); + fireAbort(d); + } + }; + + // Listener accounting (events.js kListenerChanged hook, installed on the + // prototype below): a live timeout signal or non-empty composite is + // strong-held exactly while an abort listener could observe its abort. + listenerChanged = (signal, type, count) => { + if (type !== "abort") { + return; + } + if (signal.#aborted) { + SetPrototypeDelete(gcPersistentSignals, signal); + return; + } + const needsPersist = + signal.#isTimeout || + (signal.#composite && + signal.#sources !== null && + signal.#sources.length > 0); + if (!needsPersist) { + return; + } + if (count > 0) { + SetPrototypeAdd(gcPersistentSignals, signal); + } else { + SetPrototypeDelete(gcPersistentSignals, signal); + } + }; + + // A collected composite leaves each surviving source's dependent list, + // and its remaining pair registrations go with it. + dependentPrune = ({ sourceRef, resultRef }) => { + FinalizationRegistryPrototypeUnregister(sourcePruneRegistry, resultRef); + const source = WeakRefPrototypeDeref(sourceRef); + if (source === undefined || source.#dependents === null) { + return; + } + const idx = ArrayPrototypeIndexOf(source.#dependents, resultRef); + if (idx !== -1) { + ArrayPrototypeSplice(source.#dependents, idx, 1); + } + }; + + // A collected source leaves the composite's source list; a composite + // with no sources left can never abort, so it stops being strong-held + // even if listeners remain. + sourcePrune = ({ sourceRef, resultRef }) => { + const composite = WeakRefPrototypeDeref(resultRef); + if (composite === undefined || composite.#sources === null) { + return; + } + const idx = ArrayPrototypeIndexOf(composite.#sources, sourceRef); + if (idx !== -1) { + ArrayPrototypeSplice(composite.#sources, idx, 1); + } + if (composite.#sources.length === 0) { + SetPrototypeDelete(gcPersistentSignals, composite); } }; } } +dependentPruneRegistry = new FinalizationRegistry(dependentPrune); +sourcePruneRegistry = new FinalizationRegistry(sourcePrune); + +ObjectDefineProperty(AbortSignal.prototype, kListenerChanged, { + value: listenerChanged, + writable: false, + enumerable: false, + configurable: false, +}); + class AbortController { #signal = createAbortSignal(false, undefined); diff --git a/test-app/runtime/src/main/cpp/js/events.js b/test-app/runtime/src/main/cpp/js/events.js index 28b57fcfc..4d13052e4 100644 --- a/test-app/runtime/src/main/cpp/js/events.js +++ b/test-app/runtime/src/main/cpp/js/events.js @@ -38,6 +38,19 @@ Event.prototype.stopImmediatePropagation = function () { // user code runs); until then a thrown listener is swallowed. var reportListenerError = function (e) {}; +// Internal listener-mutation hook. A target (in practice: AbortSignal, on +// its prototype) may carry a function under this symbol; it is called with +// (target, type, newCount) from every path that changes a listener list — +// add, remove, and the once-splice inside dispatch. The key never reaches +// app code (handed to the abort-signal builtin through a one-shot below), +// so the accounting cannot be bypassed the way an overridable +// addEventListener could. +var kListenerChanged = Symbol("listenerChanged"); +function notifyListenerChanged(target, type, count) { + var hook = target[kListenerChanged]; + if (hook !== undefined) { hook(target, type, count); } +} + function EventTargetImpl() { this._listeners = ObjectCreate(null); } EventTargetImpl.prototype.addEventListener = function (type, callback, options) { if (callback === null || callback === undefined) { return; } @@ -55,6 +68,7 @@ EventTargetImpl.prototype.addEventListener = function (type, callback, options) if (list[i].callback === callback && list[i].capture === capture) { return; } } ArrayPrototypePush(list, { callback: callback, once: once, capture: capture }); + notifyListenerChanged(this, type, list.length); }; EventTargetImpl.prototype.removeEventListener = function (type, callback, options) { type = String(type); @@ -69,6 +83,7 @@ EventTargetImpl.prototype.removeEventListener = function (type, callback, option for (var i = 0; i < list.length; i++) { if (list[i].callback === callback && list[i].capture === capture) { ArrayPrototypeSplice(list, i, 1); + notifyListenerChanged(this, type, list.length); return; } } @@ -85,7 +100,10 @@ EventTargetImpl.prototype.dispatchEvent = function (event) { var entry = snapshot[i]; var idx = ArrayPrototypeIndexOf(list, entry); if (idx === -1) { continue; } // removed since snapshot - if (entry.once) { ArrayPrototypeSplice(list, idx, 1); } + if (entry.once) { + ArrayPrototypeSplice(list, idx, 1); + notifyListenerChanged(this, event.type, list.length); + } var cb = entry.callback; try { if (typeof cb === "function") { @@ -115,6 +133,14 @@ globalTarget._installListenerErrorReporter = function (fn) { reportListenerError = fn; delete globalTarget._installListenerErrorReporter; }; +// One-shot handoff of the listener-mutation hook key to the abort-signal +// builtin, which Events::Init runs next with this export as its binding. +// Same lifecycle as _installListenerErrorReporter: consumed during runtime +// init, gone before user code can reach the backing target. +globalTarget._takeListenerChangedKey = function () { + delete globalTarget._takeListenerChangedKey; + return kListenerChanged; +}; g.addEventListener = function (type, callback, options) { return globalTarget.addEventListener(type, callback, options); }; diff --git a/test-app/runtime/src/main/cpp/js/primordials.js b/test-app/runtime/src/main/cpp/js/primordials.js index 73dd6a6a3..7f60fb069 100644 --- a/test-app/runtime/src/main/cpp/js/primordials.js +++ b/test-app/runtime/src/main/cpp/js/primordials.js @@ -22,6 +22,7 @@ const intrinsics = { // Constructors. Date, Error, + FinalizationRegistry, Map, Number, RangeError, @@ -29,6 +30,7 @@ const intrinsics = { String, TypeError, URL, + WeakRef, // Well-known symbols. SymbolIterator: Symbol.iterator, @@ -66,6 +68,8 @@ const intrinsics = { DatePrototypeGetTime: uncurryThis(Date.prototype.getTime), DatePrototypeToISOString: uncurryThis(Date.prototype.toISOString), DatePrototypeToJSON: uncurryThis(Date.prototype.toJSON), + FinalizationRegistryPrototypeRegister: uncurryThis(FinalizationRegistry.prototype.register), + FinalizationRegistryPrototypeUnregister: uncurryThis(FinalizationRegistry.prototype.unregister), FunctionPrototypeCall: uncurryThis(FunctionPrototypeCall), FunctionPrototypeToString: uncurryThis(Function.prototype.toString), MapPrototypeDelete: uncurryThis(Map.prototype.delete), @@ -87,6 +91,7 @@ const intrinsics = { StringPrototypeSlice: uncurryThis(String.prototype.slice), StringPrototypeStartsWith: uncurryThis(String.prototype.startsWith), SymbolPrototypeToString: uncurryThis(Symbol.prototype.toString), + WeakRefPrototypeDeref: uncurryThis(WeakRef.prototype.deref), // Iterator-protocol escape hatches: the captured `next` of the live map/set // iterator prototypes, so entries can be walked with early exit even after From 074a8e5a4df92927543305278ec47adf38785f84 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 24 Aug 2026 13:53:23 -0300 Subject: [PATCH 3/3] refactor: shared internals parameter for cross-builtin capabilities Add a sixth fixed wrapper parameter, `internals`: one plain per-isolate object (stored in the BuiltinRealm per-runtime state) handed identically to every builtin and reachable from nowhere else. Producers publish during their init, consumers read during theirs, so the PrepareV8Runtime ordering is the dependency graph and a missing key fails loudly at init. Both existing ad-hoc channels migrate onto it: events.js publishes the kListenerChanged hook key (read by abort-signal.js, previously a one-shot relayed through the abort builtin's binding) and setListenerErrorReporter (called by error-events.js, previously the _installListenerErrorReporter one-shot on the app-reachable global target). No capability ever sits on an app-reachable object anymore, even transiently. Documented in the js README as an interim mechanism: if cross-builtin needs outgrow one shared object, migrate to a Node-style private internal-module tier (require("internal/...") resolved for builtins only) and fold internals into it. Mirrors the same commit on the iOS runtime (NativeScript/ios#447). --- docs/abort-signal.md | 7 ++- eslint.config.mjs | 3 +- .../runtime/src/main/cpp/BuiltinLoader.cpp | 60 +++++++++++++++---- test-app/runtime/src/main/cpp/BuiltinLoader.h | 15 +++-- test-app/runtime/src/main/cpp/Events.cpp | 8 +-- test-app/runtime/src/main/cpp/js/README.md | 16 ++++- .../runtime/src/main/cpp/js/abort-signal.js | 6 +- .../runtime/src/main/cpp/js/error-events.js | 2 +- test-app/runtime/src/main/cpp/js/events.js | 31 +++------- 9 files changed, 94 insertions(+), 54 deletions(-) diff --git a/docs/abort-signal.md b/docs/abort-signal.md index 8abad27c4..1ed543e94 100644 --- a/docs/abort-signal.md +++ b/docs/abort-signal.md @@ -49,9 +49,10 @@ be dropped. sources a composite follows, until their timer fires. The listener accounting comes from an internal symbol-keyed hook the events builtin calls from every listener-list mutation path (add, remove, and `once` - removal during dispatch); the key is handed to the abort builtin in a - one-shot during init and never reaches app code, so the accounting cannot - be bypassed via a captured `EventTarget.prototype.addEventListener`. + removal during dispatch); the key travels only through the builtin-only + `internals` object (see `test-app/runtime/src/main/cpp/js/README.md`) and + never reaches app code, so the accounting cannot be bypassed via a + captured `EventTarget.prototype.addEventListener`. Entries leave the persistent set on abort, on the last abort-listener removal, or when a composite loses its last source. diff --git a/eslint.config.mjs b/eslint.config.mjs index a2dab9fad..ab05c8e6f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,7 +1,7 @@ // Lint setup for the runtime's builtin JavaScript // (test-app/runtime/src/main/cpp/js). Each file is compiled by BuiltinLoader // as a FUNCTION BODY with the fixed parameters `exports`, `require`, `module`, -// `binding` and `primordials` (see that directory's README.md), which are +// `binding`, `primordials` and `internals` (see that directory's README.md), which are // declared as globals here. no-undef is the typo net for binding-bag destructures and // native-global usage alike; no-restricted-properties keeps the captured // intrinsics from being read off the live globals again. @@ -56,6 +56,7 @@ export default [ module: 'readonly', binding: 'readonly', primordials: 'readonly', + internals: 'readonly', global: 'readonly', console: 'readonly', URL: 'readonly', diff --git a/test-app/runtime/src/main/cpp/BuiltinLoader.cpp b/test-app/runtime/src/main/cpp/BuiltinLoader.cpp index c02a15f41..6abecf60d 100644 --- a/test-app/runtime/src/main/cpp/BuiltinLoader.cpp +++ b/test-app/runtime/src/main/cpp/BuiltinLoader.cpp @@ -26,26 +26,53 @@ std::vector builtinCache[static_cast(BuiltinId::kCount)]; * parameters, mirroring Node's module wrapper: a file exports through * `module.exports`/`exports`, reaches sibling builtin modules through * `require`, natives arrive as properties of the `binding` bag (Node's - * internalBinding idiom) and intrinsics as properties of `primordials`; each - * file destructures what it needs. + * internalBinding idiom), intrinsics as properties of `primordials` and + * cross-builtin capabilities as properties of `internals`; each file + * destructures what it needs. */ constexpr const char* kExportsParamName = "exports"; constexpr const char* kRequireParamName = "require"; constexpr const char* kModuleParamName = "module"; constexpr const char* kBindingParamName = "binding"; constexpr const char* kPrimordialsParamName = "primordials"; -constexpr size_t kParamCount = 5; +constexpr const char* kInternalsParamName = "internals"; +constexpr size_t kParamCount = 6; /* - * This runtime's intrinsics snapshot and builtin require. Per-runtime state - * rather than an isolate-keyed shared map, so reaching it needs no lock and it - * is released with the runtime, while the isolate is still alive. + * This runtime's intrinsics snapshot, builtin require and shared internals + * object. Per-runtime state rather than an isolate-keyed shared map, so + * reaching it needs no lock and it is released with the runtime, while the + * isolate is still alive. */ struct BuiltinRealm { v8::Global primordials; v8::Global builtinRequire; + v8::Global internals; }; +/* + * Per-isolate `internals` object handed to every builtin: the private channel + * for cross-builtin capabilities (hook keys, setters) that must never reach + * app code. Producers publish during their init, consumers read during + * theirs, so PrepareV8Runtime's ordering is the dependency graph. + */ +MaybeLocal GetInternals(Local context) { + Isolate* isolate = v8::Isolate::GetCurrent(); + + auto* realm = RuntimeState::For(isolate); + if (realm == nullptr) { + return MaybeLocal(); + } + + if (!realm->internals.IsEmpty()) { + return realm->internals.Get(isolate); + } + + Local internals = Object::New(isolate); + realm->internals.Reset(isolate, internals); + return internals; +} + /* * The `require` every builtin receives: builtin specifiers only, so a builtin * can never reach application code or the filesystem. @@ -113,7 +140,8 @@ MaybeLocal CompileBuiltin(Local context, BuiltinId id) { ArgConverter::ConvertToV8String(isolate, kRequireParamName), ArgConverter::ConvertToV8String(isolate, kModuleParamName), ArgConverter::ConvertToV8String(isolate, kBindingParamName), - ArgConverter::ConvertToV8String(isolate, kPrimordialsParamName)}; + ArgConverter::ConvertToV8String(isolate, kPrimordialsParamName), + ArgConverter::ConvertToV8String(isolate, kInternalsParamName)}; Local fn; if (!blob.empty()) { @@ -152,7 +180,7 @@ MaybeLocal CompileBuiltin(Local context, BuiltinId id) { } MaybeLocal CallBuiltin(Local context, BuiltinId id, Local binding, - Local primordials) { + Local primordials, Local internals) { Isolate* isolate = v8::Isolate::GetCurrent(); Local fn; @@ -174,7 +202,7 @@ MaybeLocal CallBuiltin(Local context, BuiltinId id, Local Local args[] = {exportsObj, require, moduleObj, binding.IsEmpty() ? Undefined(isolate).As() : binding, - primordials}; + primordials, internals}; if (fn->Call(context, Undefined(isolate), static_cast(kParamCount), args).IsEmpty()) { return MaybeLocal(); } @@ -188,7 +216,7 @@ MaybeLocal CallBuiltin(Local context, BuiltinId id, Local * Builtins compiled later in the isolate's life get the same pristine * snapshot. */ -MaybeLocal GetPrimordials(Local context) { +MaybeLocal GetPrimordials(Local context, Local internals) { Isolate* isolate = v8::Isolate::GetCurrent(); auto* realm = RuntimeState::For(isolate); @@ -201,7 +229,8 @@ MaybeLocal GetPrimordials(Local context) { } Local result; - if (!CallBuiltin(context, BuiltinId::kPrimordials, Local(), Undefined(isolate)) + if (!CallBuiltin(context, BuiltinId::kPrimordials, Local(), Undefined(isolate), + internals) .ToLocal(&result) || !result->IsObject()) { return MaybeLocal(); @@ -216,12 +245,17 @@ MaybeLocal GetPrimordials(Local context) { MaybeLocal BuiltinLoader::RunBuiltin(Local context, BuiltinId id, Local binding) { + Local internals; + if (!GetInternals(context).ToLocal(&internals)) { + return MaybeLocal(); + } + Local primordials; - if (!GetPrimordials(context).ToLocal(&primordials)) { + if (!GetPrimordials(context, internals).ToLocal(&primordials)) { return MaybeLocal(); } - return CallBuiltin(context, id, binding, primordials); + return CallBuiltin(context, id, binding, primordials, internals); } } // namespace tns diff --git a/test-app/runtime/src/main/cpp/BuiltinLoader.h b/test-app/runtime/src/main/cpp/BuiltinLoader.h index 013e4ab65..7078dbc7b 100644 --- a/test-app/runtime/src/main/cpp/BuiltinLoader.h +++ b/test-app/runtime/src/main/cpp/BuiltinLoader.h @@ -11,11 +11,16 @@ class BuiltinLoader { /* * Compiles the builtin identified by id as a function body with the fixed * parameters `exports`, `require`, `module`, `binding` (Node's module - * wrapper plus its internalBinding idiom) and `primordials`, calls it with - * the given bag of natives (or undefined when omitted) plus this isolate's - * frozen intrinsics snapshot, and returns the resulting `module.exports`. - * `require` reaches the builtin modules (NsBuiltinModules) and nothing - * else. The snapshot is produced by the kPrimordials builtin on first use + * wrapper plus its internalBinding idiom), `primordials` and `internals`, + * calls it with the given bag of natives (or undefined when omitted), this + * isolate's frozen intrinsics snapshot, and the isolate's shared internals + * object, and returns the resulting `module.exports`. `require` reaches + * the builtin modules (NsBuiltinModules) and nothing else. `internals` is + * one plain object per isolate handed identically to every builtin and + * never exposed anywhere app code can reach: the channel for + * cross-builtin capabilities (see the js README; interim until a + * Node-style private internal-module tier exists). + * The snapshot is produced by the kPrimordials builtin on first use * and cached per isolate, so it is taken before any user code can replace * a global. * Scripts carry an "internal/.js" origin so runtime frames are diff --git a/test-app/runtime/src/main/cpp/Events.cpp b/test-app/runtime/src/main/cpp/Events.cpp index 4fc31c61e..4602b2e3e 100644 --- a/test-app/runtime/src/main/cpp/Events.cpp +++ b/test-app/runtime/src/main/cpp/Events.cpp @@ -24,11 +24,11 @@ void Events::Init(Local context) { runtime->GlobalEventTarget().Reset(isolate, result.As()); // AbortController/AbortSignal (internal/abort-signal.js) build directly on - // the event primitives installed above. The events export is its binding: - // the abort builtin takes the one-shot listener-mutation hook key from it - // (_takeListenerChangedKey) for its GC-liveness accounting. + // the event primitives installed above; the listener-mutation hook key for + // its GC-liveness accounting arrives through the shared `internals` + // parameter, published by the events builtin. Local abortResult; - if (!BuiltinLoader::RunBuiltin(context, BuiltinId::kAbortSignal, result).ToLocal(&abortResult)) { + if (!BuiltinLoader::RunBuiltin(context, BuiltinId::kAbortSignal).ToLocal(&abortResult)) { throw NativeScriptException("Events::Init: the abort-signal bootstrap failed"); } } diff --git a/test-app/runtime/src/main/cpp/js/README.md b/test-app/runtime/src/main/cpp/js/README.md index 16a7fea53..4cc45e04d 100644 --- a/test-app/runtime/src/main/cpp/js/README.md +++ b/test-app/runtime/src/main/cpp/js/README.md @@ -9,8 +9,8 @@ build time a CMake custom command runs `tools/js2c.mjs`, which embeds them into ## Contract (Node's module wrapper + internalBinding idiom) Every file is compiled as a **function body** via `v8::ScriptCompiler::CompileFunction` -with the fixed parameters `exports`, `require`, `module`, `binding` and -`primordials`: +with the fixed parameters `exports`, `require`, `module`, `binding`, +`primordials` and `internals`: ```js const { someNative, anotherNative } = binding; @@ -29,6 +29,18 @@ module.exports = somethingTheCallSiteNeeds; Requiring a module that is still loading throws rather than recursing. - `primordials` is the frozen intrinsics snapshot built by `primordials.js` (see below), the same object for every builtin in an isolate. +- `internals` is one plain per-isolate object handed identically to every + builtin and reachable from nowhere else — the private channel for + cross-builtin capabilities that must never leak to app code (the + `kListenerChanged` hook key events.js publishes for abort-signal.js, the + `setListenerErrorReporter` setter error-events.js calls). Producers + publish during their init, consumers read during theirs, so the + `PrepareV8Runtime` ordering is the dependency graph; a missing key fails + loudly at init, not at first use. **Interim mechanism**: if cross-builtin + needs outgrow one shared object (many producers, lazy consumers), migrate + to a Node-style private internal-module tier — `require("internal/…")` + resolved for builtins only, never through the public `ns:`/`node:` + registry — and fold `internals` into it. - **`module.exports` is the export channel** — whatever it holds when the file finishes is what `RunBuiltin` hands back to C++ (used for factory functions and init results). Both CommonJS styles work: replace the whole export with diff --git a/test-app/runtime/src/main/cpp/js/abort-signal.js b/test-app/runtime/src/main/cpp/js/abort-signal.js index 1db42cd2b..bc31851b3 100644 --- a/test-app/runtime/src/main/cpp/js/abort-signal.js +++ b/test-app/runtime/src/main/cpp/js/abort-signal.js @@ -53,9 +53,9 @@ const clearTimeout = g.clearTimeout; const dispatchEvent = EventTarget.prototype.dispatchEvent; const addEventListener = EventTarget.prototype.addEventListener; const removeEventListener = EventTarget.prototype.removeEventListener; -// The binding is the events builtin's export; the one-shot returns the -// symbol under which EventTargetImpl looks up the listener-mutation hook. -const kListenerChanged = binding._takeListenerChangedKey(); +// Published by events.js: the symbol under which EventTargetImpl looks up +// the listener-mutation hook. +const kListenerChanged = internals.kListenerChanged; // Construction token: AbortSignal instances come only from the factories in // this module (the controller, and the abort/timeout/any statics). diff --git a/test-app/runtime/src/main/cpp/js/error-events.js b/test-app/runtime/src/main/cpp/js/error-events.js index 02788ef22..6f4e3e844 100644 --- a/test-app/runtime/src/main/cpp/js/error-events.js +++ b/test-app/runtime/src/main/cpp/js/error-events.js @@ -29,7 +29,7 @@ PromiseRejectionEvent.prototype.constructor = PromiseRejectionEvent; // A listener that throws must not stop other listeners: route the thrown // value to the native fatal tail instead of ever recursively dispatching // another `error` event from inside dispatch. -globalTarget._installListenerErrorReporter(function (e) { +internals.setListenerErrorReporter(function (e) { try { nativeReportFatal(e, (e && e.stack) || ""); } catch (ignored) {} }); diff --git a/test-app/runtime/src/main/cpp/js/events.js b/test-app/runtime/src/main/cpp/js/events.js index 4d13052e4..4430b2656 100644 --- a/test-app/runtime/src/main/cpp/js/events.js +++ b/test-app/runtime/src/main/cpp/js/events.js @@ -34,18 +34,21 @@ Event.prototype.stopImmediatePropagation = function () { // A listener that throws must not stop other listeners: route the thrown // value to the native fatal tail instead of ever recursively dispatching // another `error` event from inside dispatch. The error-events layer -// installs the real reporter via _installListenerErrorReporter (before any -// user code runs); until then a thrown listener is swallowed. +// installs the real reporter via internals.setListenerErrorReporter (before +// any user code runs); until then a thrown listener is swallowed. var reportListenerError = function (e) {}; +internals.setListenerErrorReporter = function (fn) { + reportListenerError = fn; +}; // Internal listener-mutation hook. A target (in practice: AbortSignal, on // its prototype) may carry a function under this symbol; it is called with // (target, type, newCount) from every path that changes a listener list — -// add, remove, and the once-splice inside dispatch. The key never reaches -// app code (handed to the abort-signal builtin through a one-shot below), -// so the accounting cannot be bypassed the way an overridable -// addEventListener could. +// add, remove, and the once-splice inside dispatch. The key travels only +// through `internals`, so the accounting cannot be bypassed the way an +// overridable addEventListener could. var kListenerChanged = Symbol("listenerChanged"); +internals.kListenerChanged = kListenerChanged; function notifyListenerChanged(target, type, count) { var hook = target[kListenerChanged]; if (hook !== undefined) { hook(target, type, count); } @@ -125,22 +128,6 @@ EventTargetImpl.prototype.dispatchEvent = function (event) { // is intentionally NOT made an EventTarget; only the three methods are // bound onto it. var globalTarget = new EventTargetImpl(); -// Called by the error-events layer to install the native listener-error -// reporter into this closure. One-shot: the backing target leaks to app -// code via event.target, so the hook removes itself after the install -// (which happens during runtime init, before any user code runs). -globalTarget._installListenerErrorReporter = function (fn) { - reportListenerError = fn; - delete globalTarget._installListenerErrorReporter; -}; -// One-shot handoff of the listener-mutation hook key to the abort-signal -// builtin, which Events::Init runs next with this export as its binding. -// Same lifecycle as _installListenerErrorReporter: consumed during runtime -// init, gone before user code can reach the backing target. -globalTarget._takeListenerChangedKey = function () { - delete globalTarget._takeListenerChangedKey; - return kListenerChanged; -}; g.addEventListener = function (type, callback, options) { return globalTarget.addEventListener(type, callback, options); };