Skip to content

feat(runtime): add AbortController and AbortSignal - #447

Merged
NathanWalker merged 3 commits into
mainfrom
feat/abort-signal
Aug 24, 2026
Merged

feat(runtime): add AbortController and AbortSignal#447
NathanWalker merged 3 commits into
mainfrom
feat/abort-signal

Conversation

@edusperoni

@edusperoni edusperoni commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

What

Installs the DOM Standard's abort primitives as globals in every isolate (main and workers), modeled on Node's internal/abort_controller.js:

  • AbortControllercontroller.signal (stable identity) and controller.abort(reason?).
  • AbortSignal extending the runtime's EventTarget, with aborted, reason, throwIfAborted(), the abort event, and onabort with HTML event-handler semantics. new AbortSignal() throws TypeError: Illegal constructor.
  • Statics: AbortSignal.abort(reason?), AbortSignal.timeout(delay) (Node's delay validation), and AbortSignal.any(signals) (composite flattening, first-aborted-reason-wins, spec-ordered state flips before events fire).
  • WebIDL-shaped interfaces: enumerable members, Symbol.toStringTag, brand-checked accessors (via private fields).

How

The builtin (NativeScript/runtime/js/abort-signal.js) runs from Events::Init immediately after the Event/EventTarget builtin it is layered on.

This PR also introduces a sixth fixed wrapper parameter, internals: one plain per-isolate object (a Caches::StateFor slot) handed identically to every builtin and reachable from nowhere app code can see — the private channel for cross-builtin capabilities. Both previously ad-hoc channels now ride it: the kListenerChanged hook key (events → abort-signal) and setListenerErrorReporter (error-events → events), replacing the _installListenerErrorReporter one-shot that transiently sat on the app-reachable global target. Documented in the js README as an interim mechanism, with the intended end-state being a Node-style private internal-module tier (require("internal/…") for builtins only). RangeError, NumberIsInteger, WeakRef, and FinalizationRegistry are added to primordials (and the eslint restriction lists).

GC contract (Node-equivalent, documented in docs/abort-signal.md)

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 if the signal is collected first.
  • 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 add, remove, and the once-splice during dispatch), published on the builtin-only internals channel — so it cannot be bypassed via a captured EventTarget.prototype.addEventListener.

Deviation from Node

No DOMException in this runtime: default reasons are Error instances with name patched to "AbortError" / "TimeoutError", the same stand-in performance.js and structured-clone.js use.

Tests

  • 20 behavioral specs: illegal constructor, state-flips-before-event ordering, reason identity (including null), throwing listeners not stopping dispatch, once listeners, onabort set/replace/clear, delay validation, any() flattening/dedup/no-double-fire, arbitrary iterables, toStringTag, brand checks.
  • 8 GC specs driven by __collect(): a finalization-registry substrate canary, collectability of unobserved timeout signals and composites, survival (and delivery) for listened ones, a composite keeping a dropped timeout source alive until it fires, release of a listened composite once its last source dies, and release on last-listener removal.

Full iOS suite passes with 0 failures.

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.
@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 60b0333c-963b-4f32-b17b-71ba2ced859e

📥 Commits

Reviewing files that changed from the base of the PR and between 9399899 and 495f751.

📒 Files selected for processing (10)
  • NativeScript/runtime/Events.cpp
  • NativeScript/runtime/Events.h
  • NativeScript/runtime/js/abort-signal.js
  • NativeScript/runtime/js/primordials.js
  • TestRunner/app/tests/AbortSignalTests.js
  • TestRunner/app/tests/index.js
  • docs/README.md
  • docs/abort-signal.md
  • eslint.config.mjs
  • tools/js2c-inputs.xcfilelist

Included review availability: Your plan provides up to 2 included reviews per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The runtime adds global AbortController and AbortSignal implementations with abort, timeout, composite-signal, event, validation, and documentation support. Runtime initialization and js2c build inputs now include the new builtin.

Changes

AbortSignal runtime support

Layer / File(s) Summary
Signal state and abort transitions
NativeScript/runtime/js/primordials.js, NativeScript/runtime/js/abort-signal.js
Adds signal state, abort reasons, event handling, receiver checks, and dependent-signal propagation.
Static signal creation and composition
NativeScript/runtime/js/abort-signal.js
Adds abort, timeout, and any, plus AbortController, validation, composite flattening, and global exports.
Runtime loading and build integration
NativeScript/runtime/Events.cpp, NativeScript/runtime/Events.h, eslint.config.mjs, tools/js2c-inputs.xcfilelist
Loads the abort-signal builtin after event primitives and registers its runtime source and primordial requirements.
Behavior validation and API documentation
TestRunner/app/tests/AbortSignalTests.js, TestRunner/app/tests/index.js, docs/README.md, docs/abort-signal.md
Adds coverage and documentation for controller, signal, timeout, composite, event, branding, and enumeration behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk: ⚪ Minimal · up to 495f7

The PR adds AbortController and AbortSignal support with targeted tests and documentation; no actionable merge-blocking risk remains, so it is merge-ready after normal checks and review.

Sequence Diagram(s)

sequenceDiagram
  participant EventsInit
  participant BuiltinLoader
  participant AbortSignalBuiltin
  participant GlobalThis
  EventsInit->>BuiltinLoader: RunBuiltin(kAbortSignal)
  BuiltinLoader->>AbortSignalBuiltin: load internal/abort-signal.js
  AbortSignalBuiltin->>GlobalThis: expose AbortController and AbortSignal
Loading

Suggested reviewers: nathanwalker

Poem

🐇 A rabbit hops through signals bright,
Abort and timeout set things right.
Composite paths now weave and flow,
Tests guard each state they know.
Docs mark every trail in sight.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring check was indeterminate for this PR — some files could not be analyzed in time. Not blocking.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: adding AbortController and AbortSignal to the runtime.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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.
…ilities

Add a sixth fixed wrapper parameter, `internals`: one plain per-isolate
object (stored via Caches::StateFor) handed identically to every builtin
and reachable from nowhere else. Producers publish during their init,
consumers read during theirs, so the Runtime::Init 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.
@NathanWalker
NathanWalker merged commit 292a9e3 into main Aug 24, 2026
9 checks passed
@NathanWalker
NathanWalker deleted the feat/abort-signal branch August 24, 2026 18:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants