Files
node/lib/internal/abort_controller.js
Robert Nagy 6dea43a0f8 lib: speed up AbortSignal
Speed up creating and aborting signals, adding and removing abort
listeners, AbortSignal.any() and AbortSignal.timeout(), and stop the
default abort reason from retaining the aborting caller.

- Only allocate and dispatch the abort event when the signal has abort
  listeners, and only allocate the dependent signal list when there
  are dependent signals.
- Track whether an event is trusted with a private field instead of a
  WeakSet.
- Flatten addEventListener() options into locals instead of
  allocating a normalized options object, only validate the signal
  option when one is passed, and use the listener flags directly.
- Call the EventTarget kNewListener and kRemoveListener
  implementations directly. V8 does not optimize keyed super property
  access such as super[kNewListener]().
- Initialize the internal slots that AbortSignal.any() adds to its
  sources up front, and the composite-only slots for composites, so
  live signals don't change shape.
- Give every signal a single WeakRef that is shared by all the
  composites that follow it, and register each composite and each
  source once with their cleanup registries instead of registering
  every (source, composite) pair with an unregister token. Registering
  with an unregister token is several times more expensive.
- Register AbortSignal.timeout() timers through a holder that is
  emptied when the timer fires, instead of using the signal as an
  unregister token.
- Take positional arguments in AbortSignal's internal constructor
  instead of destructuring an options object that defaults to
  kEmptyObject. kEmptyObject is a null-prototype object, which V8 keeps
  in dictionary mode, so each of those reads was an uncached dictionary
  miss that cost more than the rest of the constructor.
- Create the default abort reason with Error.stackTraceLimit set to 0.
  Until a captured stack is formatted, V8 keeps the call sites, which
  retain the receivers and functions of the aborting caller's frames,
  so a retained signal kept the aborted work alive. Formatting the
  stack eagerly would fix that but costs several microseconds per
  abort(). The default AbortError no longer shows where abort() was
  called; callers that want that can pass their own reason.
- Add benchmarks for aborting, adding and removing abort listeners,
  and AbortSignal.any().

Refs: https://github.com/nodejs/node/issues/66192
Refs: https://github.com/nodejs/node/pull/66265
Assisted-by: claude:opus-5.5
Signed-off-by: Robert Nagy <ronagy@icloud.com>
PR-URL: https://github.com/nodejs/node/pull/66789
Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com>
2026-10-24 19:17:24 +00:00

721 lines
21 KiB
JavaScript

'use strict';
// Modeled very closely on the AbortController implementation
// in https://github.com/mysticatea/abort-controller (MIT license)
const {
ArrayPrototypePush,
FunctionPrototypeCall,
ObjectAssign,
ObjectDefineProperties,
ObjectDefineProperty,
ObjectFreeze,
ObjectSetPrototypeOf,
PromiseResolve,
PromiseWithResolvers,
SafeFinalizationRegistry,
SafeSet,
SafeWeakRef,
Symbol,
SymbolToStringTag,
} = primordials;
const {
defineEventHandler,
EventTarget,
Event,
kEvents,
kTrustEvent,
kNewListener,
kRemoveListener,
kResistStopPropagation,
kWeakHandler,
} = require('internal/event_target');
const { kMaxEventTargetListeners } = require('events');
const {
customInspectSymbol,
kEnumerableProperty,
} = require('internal/util');
const { inspect } = require('internal/util/inspect');
const {
codes: {
ERR_ILLEGAL_CONSTRUCTOR,
ERR_INVALID_ARG_TYPE,
ERR_INVALID_THIS,
},
isErrorStackTraceLimitWritable,
} = require('internal/errors');
const {
converters,
createInterfaceConverter,
createSequenceConverter,
} = require('internal/webidl');
const {
validateObject,
validateUint32,
kValidateObjectAllowObjects,
} = require('internal/validators');
const {
DOMException,
} = internalBinding('messaging');
const {
clearTimeout,
setTimeout,
} = require('timers');
const assert = require('internal/assert');
const {
kDeserialize,
kTransfer,
kTransferList,
markTransferMode,
} = require('internal/worker/js_transferable');
let _MessageChannel;
const kDontThrowSymbol = Symbol('kDontThrowSymbol');
const kTrustedEventInit = ObjectFreeze({ [kTrustEvent]: true });
// V8 doesn't optimize keyed super property access such as
// super[kNewListener](), so call the EventTarget implementations directly.
const {
[kNewListener]: eventTargetNewListener,
[kRemoveListener]: eventTargetRemoveListener,
} = EventTarget.prototype;
// Loading the MessageChannel and markTransferable have to be done lazily
// because otherwise we'll end up with a require cycle that ends up with
// an incomplete initialization of abort_controller.
function lazyMessageChannel() {
_MessageChannel ??= require('internal/worker/io').MessageChannel;
return new _MessageChannel();
}
const clearTimeoutRegistry = new SafeFinalizationRegistry((holder) => {
if (holder.timeout !== undefined) {
clearTimeout(holder.timeout);
}
});
// Each followed composite signal is registered once. When it is collected,
// remove it from the dependant signals of its remaining sources.
const dependantSignalsCleanupRegistry = new SafeFinalizationRegistry(
({ sourceSignals, dependantSignalRef }) => {
for (const sourceSignalRef of sourceSignals) {
sourceSignalRef.deref()?.[kDependantSignals]?.delete(dependantSignalRef);
}
});
const gcPersistentSignals = new SafeSet();
// Each source signal is registered once, when it gets its first dependant.
// When it is collected, remove it from the source signals of its remaining
// dependants. Registering per source rather than per (source, dependant) pair
// avoids unregister tokens, which are expensive to register.
const sourceSignalsCleanupRegistry = new SafeFinalizationRegistry(({ sourceSignalRef, dependantSignals }) => {
for (const composedSignalRef of dependantSignals) {
const composedSignal = composedSignalRef.deref();
if (composedSignal !== undefined) {
composedSignal[kSourceSignals].delete(sourceSignalRef);
if (composedSignal[kSourceSignals].size === 0) {
// This signal will no longer abort. There's no need to keep it in the gcPersistentSignals set.
gcPersistentSignals.delete(composedSignal);
}
}
}
});
const kAborted = Symbol('kAborted');
const kReason = Symbol('kReason');
const kCloneData = Symbol('kCloneData');
const kTimeout = Symbol('kTimeout');
const kMakeTransferable = Symbol('kMakeTransferable');
const kComposite = Symbol('kComposite');
const kFollowing = Symbol('kFollowing');
const kWeakRef = Symbol('kWeakRef');
const kSourceSignals = Symbol('kSourceSignals');
const kDependantSignals = Symbol('kDependantSignals');
// A signal's own WeakRef, shared by every composite that follows it so that
// a long-lived source doesn't allocate a new WeakRef per AbortSignal.any().
function getWeakRef(signal) {
return signal[kWeakRef] ??= new SafeWeakRef(signal);
}
function customInspect(self, obj, depth, options) {
if (depth < 0)
return self;
const opts = ObjectAssign({}, options, {
depth: options.depth === null ? null : options.depth - 1,
});
return `${self.constructor.name} ${inspect(obj, opts)}`;
}
function validateThisAbortSignal(obj) {
if (obj?.[kAborted] === undefined)
throw new ERR_INVALID_THIS('AbortSignal');
}
function refreshCompositeSignal(signal) {
if (!signal[kComposite] || signal[kAborted] || !signal[kSourceSignals]?.size) {
return;
}
for (const sourceSignalWeakRef of signal[kSourceSignals]) {
const sourceSignal = sourceSignalWeakRef.deref();
if (sourceSignal === undefined) {
signal[kSourceSignals].delete(sourceSignalWeakRef);
continue;
}
if (sourceSignal[kAborted]) {
abortSignal(signal, sourceSignal[kReason]);
return;
}
}
}
function followCompositeSignal(signal) {
if (signal[kFollowing] || signal[kAborted] || !signal[kSourceSignals]?.size) {
return;
}
const resultSignalWeakRef = getWeakRef(signal);
const sourceSignals = signal[kSourceSignals];
dependantSignalsCleanupRegistry.register(signal, {
sourceSignals,
dependantSignalRef: resultSignalWeakRef,
});
for (const sourceSignalWeakRef of sourceSignals) {
const sourceSignal = sourceSignalWeakRef.deref();
if (sourceSignal === undefined) {
sourceSignals.delete(sourceSignalWeakRef);
continue;
}
if (sourceSignal[kAborted]) {
abortSignal(signal, sourceSignal[kReason]);
return;
}
let dependantSignals = sourceSignal[kDependantSignals];
if (dependantSignals === undefined) {
dependantSignals = sourceSignal[kDependantSignals] = new SafeSet();
sourceSignalsCleanupRegistry.register(sourceSignal, {
sourceSignalRef: sourceSignalWeakRef,
dependantSignals,
});
}
dependantSignals.add(resultSignalWeakRef);
}
signal[kFollowing] = true;
}
// Because the AbortSignal timeout cannot be canceled, we don't want the
// presence of the timer alone to keep the AbortSignal from being garbage
// collected if it otherwise no longer accessible. We also don't want the
// timer to keep the Node.js process open on it's own. Therefore, we wrap
// the AbortSignal in a WeakRef and have the setTimeout callback close
// over the WeakRef rather than directly over the AbortSignal, and we unref
// the created timer object. Separately, we add the signal to a
// FinalizerRegistry that will clear the timeout when the signal is gc'd.
// The registry holds the timeout through a holder that is emptied once the
// timer fires, which releases the fired timer without having to unregister
// the signal. Registering with an unregister token is several times more
// expensive than registering without one.
function setWeakAbortSignalTimeout(weakRef, delay) {
const holder = { timeout: undefined };
holder.timeout = setTimeout(() => {
holder.timeout = undefined;
const signal = weakRef.deref();
if (signal !== undefined) {
gcPersistentSignals.delete(signal);
abortSignal(
signal,
new DOMException(
'The operation was aborted due to timeout',
'TimeoutError'));
}
}, delay);
holder.timeout.unref();
return holder;
}
// The default reason captures no stack. A captured stack keeps the receivers
// and functions of the aborting caller's frames alive until it is formatted,
// so a retained signal would keep the aborted work alive, and formatting it
// eagerly costs several microseconds per abort. Callers that want the stack
// can pass their own reason.
// Refs: https://github.com/nodejs/node/issues/66192
function createDefaultAbortReason() {
if (!isErrorStackTraceLimitWritable()) {
return new DOMException('This operation was aborted', 'AbortError');
}
const limit = Error.stackTraceLimit;
Error.stackTraceLimit = 0;
const reason = new DOMException('This operation was aborted', 'AbortError');
Error.stackTraceLimit = limit;
return reason;
}
class AbortSignal extends EventTarget {
#brand;
static {
converters.AbortSignal = createInterfaceConverter(
'AbortSignal',
(value) => typeof value === 'object' && value !== null && #brand in value,
);
}
/**
* Takes positional arguments rather than an options object: destructuring
* the default kEmptyObject, which V8 keeps in dictionary mode, cost more
* than the rest of the constructor.
* @param {symbol | undefined} dontThrowSymbol
* @param {boolean} [aborted]
* @param {any} [reason]
* @param {boolean} [transferable]
* @param {boolean} [composite]
* @private
*/
constructor(dontThrowSymbol = undefined, aborted = false, reason = undefined,
transferable = false, composite = false) {
if (dontThrowSymbol !== kDontThrowSymbol) {
throw new ERR_ILLEGAL_CONSTRUCTOR();
}
super();
this[kMaxEventTargetListeners] = 0;
this[kAborted] = aborted;
this[kReason] = reason;
this[kComposite] = composite;
// Signals that become sources of AbortSignal.any() get these slots later.
// Initialize them up front so that adding them doesn't change the shape
// of live signals and make the call sites that use them polymorphic.
this[kWeakRef] = undefined;
this[kDependantSignals] = undefined;
if (composite) {
this[kTimeout] = false;
this[kFollowing] = false;
this[kSourceSignals] = undefined;
}
if (transferable) {
markTransferMode(this, false, true);
}
}
/**
* @type {boolean}
*/
get aborted() {
validateThisAbortSignal(this);
refreshCompositeSignal(this);
return !!this[kAborted];
}
/**
* @type {any}
*/
get reason() {
validateThisAbortSignal(this);
refreshCompositeSignal(this);
return this[kReason];
}
throwIfAborted() {
validateThisAbortSignal(this);
refreshCompositeSignal(this);
if (this[kAborted]) {
throw this[kReason];
}
}
[customInspectSymbol](depth, options) {
return customInspect(this, {
aborted: this.aborted,
}, depth, options);
}
/**
* @param {any} [reason]
* @returns {AbortSignal}
*/
static abort(reason = createDefaultAbortReason()) {
return new AbortSignal(kDontThrowSymbol, true, reason);
}
/**
* @param {number} delay
* @returns {AbortSignal}
*/
static timeout(delay) {
validateUint32(delay, 'delay', false);
const signal = new AbortSignal(kDontThrowSymbol);
signal[kTimeout] = true;
clearTimeoutRegistry.register(
signal,
setWeakAbortSignalTimeout(getWeakRef(signal), delay));
return signal;
}
/**
* @param {AbortSignal[]} signals
* @returns {AbortSignal}
*/
static any(signals) {
const signalsArray = converters['sequence<AbortSignal>'](
signals,
{ __proto__: null, context: 'signals' },
);
const resultSignal = new AbortSignal(kDontThrowSymbol, false, undefined, false, true);
if (!signalsArray.length) {
return resultSignal;
}
resultSignal[kSourceSignals] = new SafeSet();
// Track if we have any timeout signals
let hasTimeoutSignals = false;
for (let i = 0; i < signalsArray.length; i++) {
const signal = signalsArray[i];
// Check if this is a timeout signal
if (signal[kTimeout]) {
hasTimeoutSignals = true;
// Add the timeout signal to gcPersistentSignals to keep it alive
// This is what the kNewListener method would do when adding abort listeners
gcPersistentSignals.add(signal);
}
refreshCompositeSignal(signal);
if (signal[kAborted]) {
abortSignal(resultSignal, signal[kReason]);
return resultSignal;
}
if (!signal[kComposite]) {
resultSignal[kSourceSignals].add(getWeakRef(signal));
} else if (!signal[kSourceSignals]) {
continue;
} else {
for (const sourceSignalWeakRef of signal[kSourceSignals]) {
const sourceSignal = sourceSignalWeakRef.deref();
if (!sourceSignal) {
continue;
}
assert(!sourceSignal[kComposite]);
if (sourceSignal[kAborted]) {
abortSignal(resultSignal, sourceSignal[kReason]);
return resultSignal;
}
if (resultSignal[kSourceSignals].has(sourceSignalWeakRef)) {
continue;
}
resultSignal[kSourceSignals].add(sourceSignalWeakRef);
}
}
}
if (hasTimeoutSignals && resultSignal[kSourceSignals].size > 0) {
resultSignal[kTimeout] = true;
}
// Preserve abort state even if sources are collected before this signal is
// observed. Following uses weak references so unused signals can be collected.
followCompositeSignal(resultSignal);
return resultSignal;
}
[kNewListener](size, type, listener, once, capture, passive, weak) {
FunctionPrototypeCall(eventTargetNewListener, this, size, type, listener, once, capture, passive, weak);
if (this[kComposite] && type === 'abort' && !this.aborted && size === 1) {
followCompositeSignal(this);
}
const isTimeoutOrNonEmptyCompositeSignal = this[kTimeout] || (this[kComposite] && this[kSourceSignals]?.size);
if (isTimeoutOrNonEmptyCompositeSignal &&
type === 'abort' &&
!this.aborted &&
!weak &&
size === 1) {
// If this is a timeout signal, or a non-empty composite signal, and we're adding a non-weak abort
// listener, then we don't want it to be gc'd while the listener
// is attached and the timer still hasn't fired. So, we retain a
// strong ref that is held for as long as the listener is registered.
gcPersistentSignals.add(this);
}
}
[kRemoveListener](size, type, listener, capture) {
FunctionPrototypeCall(eventTargetRemoveListener, this, size, type, listener, capture);
const isTimeoutOrNonEmptyCompositeSignal = this[kTimeout] || (this[kComposite] && this[kSourceSignals]?.size);
if (isTimeoutOrNonEmptyCompositeSignal && type === 'abort' && size === 0) {
gcPersistentSignals.delete(this);
}
}
[kTransfer]() {
validateThisAbortSignal(this);
const aborted = this.aborted;
if (aborted) {
const reason = this.reason;
return {
data: { aborted, reason },
deserializeInfo: 'internal/abort_controller:ClonedAbortSignal',
};
}
const { port1, port2 } = this[kCloneData];
this[kCloneData] = undefined;
this.addEventListener('abort', () => {
port1.postMessage(this.reason);
port1.close();
}, { once: true });
return {
data: { port: port2 },
deserializeInfo: 'internal/abort_controller:ClonedAbortSignal',
};
}
[kTransferList]() {
if (!this.aborted) {
const { port1, port2 } = lazyMessageChannel();
port1.unref();
port2.unref();
this[kCloneData] = {
port1,
port2,
};
return [port2];
}
return [];
}
[kDeserialize]({ aborted, reason, port }) {
if (aborted) {
this[kAborted] = aborted;
this[kReason] = reason;
return;
}
port.onmessage = ({ data }) => {
abortSignal(this, data);
port.close();
port.onmessage = undefined;
};
// The receiving port, by itself, should never keep the event loop open.
// The unref() has to be called *after* setting the onmessage handler.
port.unref();
}
}
converters['sequence<AbortSignal>'] = createSequenceConverter(converters.AbortSignal);
function ClonedAbortSignal() {
return new AbortSignal(kDontThrowSymbol, false, undefined, true);
}
ClonedAbortSignal.prototype[kDeserialize] = () => {};
ObjectDefineProperties(AbortSignal.prototype, {
aborted: kEnumerableProperty,
});
ObjectDefineProperty(AbortSignal.prototype, SymbolToStringTag, {
__proto__: null,
writable: false,
enumerable: false,
configurable: true,
value: 'AbortSignal',
});
defineEventHandler(AbortSignal.prototype, 'abort');
// https://dom.spec.whatwg.org/#dom-abortsignal-abort
function abortSignal(signal, reason) {
// 1. If signal is aborted, then return.
if (signal[kAborted]) return;
// 2. Set signal's abort reason to reason if it is given;
// otherwise to a new "AbortError" DOMException.
signal[kAborted] = true;
signal[kReason] = reason;
// 3. Let dependentSignalsToAbort be a new list.
// The list is only allocated when there are dependent signals.
let dependentSignalsToAbort;
// 4. For each dependentSignal of signal's dependent signals:
const dependantSignals = signal[kDependantSignals];
if (dependantSignals !== undefined && dependantSignals.size > 0) {
dependentSignalsToAbort = ObjectSetPrototypeOf([], null);
dependantSignals.forEach((s) => {
const dependentSignal = s.deref();
// 1. If dependentSignal is not aborted, then:
if (dependentSignal && !dependentSignal[kAborted]) {
// 1. Set dependentSignal's abort reason to signal's abort reason.
dependentSignal[kReason] = reason;
dependentSignal[kAborted] = true;
// 2. Append dependentSignal to dependentSignalsToAbort.
ArrayPrototypePush(dependentSignalsToAbort, dependentSignal);
}
});
}
// 5. Run the abort steps for signal
runAbort(signal);
// 6. For each dependentSignal of dependentSignalsToAbort,
// run the abort steps for dependentSignal.
if (dependentSignalsToAbort !== undefined) {
for (let i = 0; i < dependentSignalsToAbort.length; i++) {
const dependentSignal = dependentSignalsToAbort[i];
runAbort(dependentSignal);
// A transitively-aborted dependent signal can never abort again, so there
// is no longer any reason to keep it alive. Dropping it from
// gcPersistentSignals lets it be collected, which in turn prunes its
// WeakRef from its sources' kDependantSignals sets. Otherwise, an observed
// composite that follows a long-lived source stays retained forever and
// its entry accumulates in that source's kDependantSignals.
gcPersistentSignals.delete(dependentSignal);
}
}
// Clean up the signal from gcPersistentSignals
gcPersistentSignals.delete(signal);
// If this is a composite signal, also remove all of its source signals from gcPersistentSignals
// when they get dereferenced from the signal's kSourceSignals set
if (signal[kComposite] && signal[kSourceSignals]) {
signal[kSourceSignals].forEach((sourceWeakRef) => {
const sourceSignal = sourceWeakRef.deref();
if (sourceSignal) {
gcPersistentSignals.delete(sourceSignal);
}
});
}
}
// To run the abort steps for an AbortSignal signal
function runAbort(signal) {
// Firing an event at a signal without abort listeners has no observable
// effect, so skip allocating and dispatching it.
const root = signal[kEvents]?.get('abort');
if (root === undefined || root.next === undefined) {
return;
}
const event = new Event('abort', kTrustedEventInit);
signal.dispatchEvent(event);
}
class AbortController {
#signal;
/**
* @type {AbortSignal}
*/
get signal() {
this.#signal ??= new AbortSignal(kDontThrowSymbol);
return this.#signal;
}
/**
* @param {any} [reason]
*/
abort(reason = createDefaultAbortReason()) {
abortSignal(this.#signal ??= new AbortSignal(kDontThrowSymbol), reason);
}
[customInspectSymbol](depth, options) {
return customInspect(this, {
signal: this.signal,
}, depth, options);
}
static [kMakeTransferable]() {
const controller = new AbortController();
controller.#signal = new AbortSignal(kDontThrowSymbol, false, undefined, true);
return controller;
}
}
/**
* Enables the AbortSignal to be transferable using structuredClone/postMessage.
* @param {AbortSignal} signal
* @returns {AbortSignal}
*/
function transferableAbortSignal(signal) {
if (signal?.[kAborted] === undefined)
throw new ERR_INVALID_ARG_TYPE('signal', 'AbortSignal', signal);
markTransferMode(signal, false, true);
return signal;
}
/**
* Creates an AbortController with a transferable AbortSignal
* @returns {AbortController}
*/
function transferableAbortController() {
return AbortController[kMakeTransferable]();
}
/**
* @param {AbortSignal} signal
* @param {any} resource
* @returns {Promise<void>}
*/
async function aborted(signal, resource) {
converters.AbortSignal(signal, { __proto__: null, context: 'signal' });
validateObject(resource, 'resource', kValidateObjectAllowObjects);
if (signal.aborted)
return PromiseResolve();
const abortPromise = PromiseWithResolvers();
const opts = { __proto__: null, [kWeakHandler]: resource, once: true, [kResistStopPropagation]: true };
signal.addEventListener('abort', abortPromise.resolve, opts);
return abortPromise.promise;
}
ObjectDefineProperties(AbortController.prototype, {
signal: kEnumerableProperty,
abort: kEnumerableProperty,
});
ObjectDefineProperty(AbortController.prototype, SymbolToStringTag, {
__proto__: null,
writable: false,
enumerable: false,
configurable: true,
value: 'AbortController',
});
module.exports = {
AbortController,
AbortSignal,
ClonedAbortSignal,
abortSignal,
aborted,
transferableAbortSignal,
transferableAbortController,
};