Skip to content

Commit 6ebb265

Browse files
authored
feat: add AbortController and AbortSignal (#2025)
1 parent d1fc925 commit 6ebb265

15 files changed

Lines changed: 1066 additions & 36 deletions

File tree

docs/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@
88
timing, performance timeline with `PerformanceObserver`), per-isolate time
99
origins for workers, the native clock hook that future `requestAnimationFrame`
1010
work must share, and the documented spec deviations.
11+
- [AbortController / AbortSignal](abort-signal.md) — the DOM abort primitives
12+
(`AbortController`, `AbortSignal` with the `abort`/`timeout`/`any` statics)
13+
layered on the runtime's `EventTarget`, the GC contract (weak timers and
14+
`any()` links, listener-driven persistence), and the `DOMException`
15+
stand-in (name-patched `Error` reasons).
1116
- [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.
1217
- [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`.
1318
- [Implementing additional Chrome DevTools protocol Domains](extending-inspector.md)

docs/abort-signal.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# AbortController / AbortSignal
2+
3+
The runtime installs the DOM Standard's abort primitives as globals in every
4+
isolate (main and workers): `AbortController`, and `AbortSignal` with the
5+
`abort`, `timeout` and `any` statics. The implementation is
6+
`test-app/runtime/src/main/cpp/js/abort-signal.js`, evaluated during `Events::Init`
7+
right after the `Event`/`EventTarget` builtin it is layered on, so the
8+
interfaces exist before any user code runs. `AbortSignal` extends the
9+
runtime's `EventTarget`; `new AbortSignal()` throws `TypeError: Illegal
10+
constructor` — instances come from a controller or one of the statics.
11+
12+
## Surface
13+
14+
- `new AbortController()``controller.signal` (stable identity) and
15+
`controller.abort(reason?)`.
16+
- `signal.aborted`, `signal.reason`, `signal.throwIfAborted()`, and the
17+
`abort` event (`addEventListener("abort", …)` or the `onabort` handler
18+
attribute with HTML event-handler semantics).
19+
- `AbortSignal.abort(reason?)` — an already-aborted signal; no event fires.
20+
- `AbortSignal.timeout(delay)` — aborts with a `TimeoutError`-named reason
21+
after `delay` ms. `delay` must be an integer in `[0, 2^32 − 1]`
22+
(`TypeError` for non-numbers, `RangeError` otherwise), matching Node's
23+
validation.
24+
- `AbortSignal.any(signals)` — a composite signal that aborts with the first
25+
source's reason. Accepts any iterable whose members are all `AbortSignal`s
26+
(`TypeError` otherwise). Composites are flattened: `any([any([a]), b])`
27+
follows `a` and `b` directly. Per spec, every affected signal's
28+
`aborted`/`reason` flips before the first `abort` event fires.
29+
30+
## GC contract
31+
32+
The implementation is GC-transparent the way Node's is: internal references
33+
never keep an unobservable signal alive, and never let an observable abort
34+
be dropped.
35+
36+
- A `timeout()` timer closes over a `WeakRef`, so a signal nobody can
37+
observe is collectable before it fires; a `FinalizationRegistry` cancels
38+
the pending native timer when that happens.
39+
- `any()` links are `WeakRef`s in both directions (source → dependent and
40+
dependent → source), with prune registries clearing dead entries — so
41+
per-request composites never accumulate on a long-lived source, and a
42+
collected source leaves its composites' source lists (a composite whose
43+
sources are all gone can never abort and stops being retained).
44+
- Weakness alone would silently drop the abort of a signal that is
45+
listened-to but otherwise unreachable, so a strong `gcPersistentSignals`
46+
set holds exactly the signals whose abort someone can still observe: live
47+
timeout signals and live non-empty composites while they have `abort`
48+
listeners (`onabort` counts — it registers a real listener), plus timeout
49+
sources a composite follows, until their timer fires. The listener
50+
accounting comes from an internal symbol-keyed hook the events builtin
51+
calls from every listener-list mutation path (add, remove, and `once`
52+
removal during dispatch); the key travels only through the builtin-only
53+
`internals` object (see `test-app/runtime/src/main/cpp/js/README.md`) and
54+
never reaches app code, so the accounting cannot be bypassed via a
55+
captured `EventTarget.prototype.addEventListener`.
56+
57+
Entries leave the persistent set on abort, on the last abort-listener
58+
removal, or when a composite loses its last source.
59+
60+
## Deviations from Node / the web
61+
62+
- **No `DOMException`.** As with [structuredClone](structured-clone.md) and
63+
the [Performance API](performance.md), default reasons are `Error`
64+
instances with `name` patched: `"AbortError"` (default abort) and
65+
`"TimeoutError"` (timeout). `instanceof DOMException` checks cannot work;
66+
match on `reason.name`.
67+
- Abort events carry no `isTrusted` flag (the runtime's `Event` doesn't
68+
model it).
69+
70+
Listener errors during the abort dispatch go through the runtime's standard
71+
listener-error pipeline (see [error handling](error-handling.md)); a throwing
72+
listener never prevents the remaining listeners from running.

eslint.config.mjs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Lint setup for the runtime's builtin JavaScript
22
// (test-app/runtime/src/main/cpp/js). Each file is compiled by BuiltinLoader
33
// as a FUNCTION BODY with the fixed parameters `exports`, `require`, `module`,
4-
// `binding` and `primordials` (see that directory's README.md), which are
4+
// `binding`, `primordials` and `internals` (see that directory's README.md), which are
55
// declared as globals here. no-undef is the typo net for binding-bag destructures and
66
// native-global usage alike; no-restricted-properties keeps the captured
77
// intrinsics from being read off the live globals again.
@@ -16,6 +16,7 @@ const capturedStatics = [
1616
['ArrayBuffer', 'isView', 'ArrayBufferIsView'],
1717
['JSON', 'stringify', 'JSONStringify'],
1818
['Number', 'isFinite', 'NumberIsFinite'],
19+
['Number', 'isInteger', 'NumberIsInteger'],
1920
['Number', 'isNaN', 'NumberIsNaN'],
2021
['Number', 'parseFloat', 'NumberParseFloat'],
2122
['Number', 'parseInt', 'NumberParseInt'],
@@ -31,7 +32,7 @@ const capturedStatics = [
3132

3233
// Captured constructors. A destructure from `primordials` shadows the global,
3334
// so these only fire on the unguarded reference.
34-
const restrictedGlobals = ['Date', 'Map', 'Number', 'Proxy', 'Set', 'String', 'TypeError'].map((name) => ({
35+
const restrictedGlobals = ['Date', 'FinalizationRegistry', 'Map', 'Number', 'Proxy', 'RangeError', 'Set', 'String', 'TypeError', 'WeakRef'].map((name) => ({
3536
name,
3637
message: `Destructure ${name} from primordials — builtins must not read intrinsics off globals user code can replace.`,
3738
}));
@@ -55,6 +56,7 @@ export default [
5556
module: 'readonly',
5657
binding: 'readonly',
5758
primordials: 'readonly',
59+
internals: 'readonly',
5860
global: 'readonly',
5961
console: 'readonly',
6062
URL: 'readonly',

test-app/app/src/main/assets/app/mainpage.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ require('./tests/testURLSearchParamsImpl.js');
8080
require('./tests/testQueueMicrotask');
8181
require('./tests/testErrorEvents');
8282
require('./tests/testUnhandledRejections');
83+
// AbortController/AbortSignal (abort/timeout/any) on top of EventTarget
84+
require('./tests/testAbortSignal');
8385
require('./tests/testEscapeException');
8486
require('./tests/testUncaughtErrorPolicy');
8587
// Runtime builtins keep working when app code replaces the intrinsics they use

0 commit comments

Comments
 (0)