Skip to content

feat: add AbortController and AbortSignal - #2025

Merged
NathanWalker merged 3 commits into
mainfrom
feat/abortsignal
Aug 24, 2026
Merged

feat: add AbortController and AbortSignal#2025
NathanWalker merged 3 commits into
mainfrom
feat/abortsignal

Conversation

@edusperoni

@edusperoni edusperoni commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Mirrors NativeScript/ios#447 on Android (same three commits, same file shapes).

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 (test-app/runtime/src/main/cpp/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 BuiltinRealm slot in the per-runtime state) 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.

The spec files are byte-identical to the iOS suite; the only source divergence from iOS is one comment line naming the Android init path (PrepareV8Runtime).

Summary by CodeRabbit

  • New Features

    • Added global AbortController and AbortSignal APIs.
    • Supports abort events, custom reasons, throwIfAborted(), timeouts, and combining multiple signals.
    • Added validation and WebIDL-compatible interface behavior.
  • Bug Fixes

    • Improved cleanup of unused timeout and composite signals to reduce unnecessary resource retention.
  • Documentation

    • Added comprehensive API documentation, including behavior, error handling, and garbage-collection considerations.
  • Tests

    • Added extensive coverage for lifecycle, propagation, validation, events, errors, and cleanup behavior.

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).
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).
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).
@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: 409e9f79-88df-46fe-923f-9a4fcb54bc07

📥 Commits

Reviewing files that changed from the base of the PR and between d1fc925 and 074a8e5.

📒 Files selected for processing (15)
  • docs/README.md
  • docs/abort-signal.md
  • eslint.config.mjs
  • test-app/app/src/main/assets/app/mainpage.js
  • test-app/app/src/main/assets/app/tests/testAbortSignal.js
  • test-app/runtime/CMakeLists.txt
  • test-app/runtime/src/main/cpp/BuiltinLoader.cpp
  • test-app/runtime/src/main/cpp/BuiltinLoader.h
  • test-app/runtime/src/main/cpp/Events.cpp
  • test-app/runtime/src/main/cpp/Events.h
  • test-app/runtime/src/main/cpp/js/README.md
  • test-app/runtime/src/main/cpp/js/abort-signal.js
  • test-app/runtime/src/main/cpp/js/error-events.js
  • test-app/runtime/src/main/cpp/js/events.js
  • test-app/runtime/src/main/cpp/js/primordials.js

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


📝 Walkthrough

Walkthrough

Changes

The runtime adds global AbortController and AbortSignal implementations. It integrates them with EventTarget, per-isolate builtin internals, weak-reference GC handling, runtime initialization, documentation, and functional and asynchronous tests.

AbortController and AbortSignal

Layer / File(s) Summary
Builtin internals channel
test-app/runtime/src/main/cpp/BuiltinLoader.*, test-app/runtime/src/main/cpp/js/README.md, eslint.config.mjs
Builtin wrappers receive a shared per-isolate internals object. Documentation and lint rules describe the new parameter and channel.
Event hooks and captured intrinsics
test-app/runtime/src/main/cpp/js/events.js, test-app/runtime/src/main/cpp/js/error-events.js, test-app/runtime/src/main/cpp/js/primordials.js, eslint.config.mjs
Events publishes listener-mutation and error-reporting hooks. Primordials captures WeakRef, FinalizationRegistry, RangeError, and Number.isInteger.
AbortSignal implementation and installation
test-app/runtime/src/main/cpp/js/abort-signal.js, test-app/runtime/CMakeLists.txt, test-app/runtime/src/main/cpp/Events.*
The runtime implements controller and signal APIs, timeout and composite signals, abort propagation, event delivery, WebIDL metadata, and GC lifetime rules. Events::Init installs the builtin.
Behavior and GC validation
test-app/app/src/main/assets/app/mainpage.js, test-app/app/src/main/assets/app/tests/testAbortSignal.js
The test suite covers API behavior, validation, event handling, propagation, brand checks, and asynchronous garbage-collection behavior.
AbortSignal documentation
docs/README.md, docs/abort-signal.md
Documentation describes the public APIs, installation, GC contract, listener accounting, and runtime deviations.

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

Merge Risk: ⚪ Minimal · up to 074a8

The PR adds AbortController and AbortSignal runtime support without any identified concrete correctness, security, availability, or integration issue; no actionable merge-blocking risk remains beyond normal checks and review.

Sequence Diagram(s)

sequenceDiagram
  participant App
  participant AbortController
  participant AbortSignal
  participant EventTarget
  App->>AbortController: create controller
  AbortController->>AbortSignal: expose stable signal
  App->>AbortController: abort(reason)
  AbortController->>AbortSignal: set aborted state and reason
  AbortSignal->>EventTarget: dispatch abort event
  EventTarget-->>App: invoke abort listeners
Loading

Suggested reviewers: nathanwalker

Poem

A rabbit taps abort with care,
The signal hops through event air.
Weak links fade and timers rest,
Tests watch each lifecycle quest.
GC nods, then fields the call.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely identifies the primary change: adding AbortController and AbortSignal.
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.
✨ 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.

@NathanWalker
NathanWalker merged commit 6ebb265 into main Aug 24, 2026
8 checks passed
@NathanWalker
NathanWalker deleted the feat/abortsignal 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