Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

lib: implement AbortSignal.any() #47821

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions doc/api/globals.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,18 @@ added:

Returns a new `AbortSignal` which will be aborted in `delay` milliseconds.

#### Static method: `AbortSignal.any(signals)`

<!-- YAML
added: REPLACEME
-->

* `signals` {AbortSignal\[]} The `AbortSignal`s of which to compose a new `AbortSignal`.

Returns a new `AbortSignal` which will be aborted if any of the provided
signals are aborted. Its [`abortSignal.reason`][] will be set to whichever
one of the `signals` caused it to be aborted.

#### Event: `'abort'`

<!-- YAML
Expand Down Expand Up @@ -1026,6 +1038,7 @@ A browser-compatible implementation of [`WritableStreamDefaultWriter`][].
[`WritableStream`]: webstreams.md#class-writablestream
[`__dirname`]: modules.md#__dirname
[`__filename`]: modules.md#__filename
[`abortSignal.reason`]: #abortsignalreason
[`buffer.atob()`]: buffer.md#bufferatobdata
[`buffer.btoa()`]: buffer.md#bufferbtoadata
[`clearImmediate`]: timers.md#clearimmediateimmediate
Expand Down
71 changes: 63 additions & 8 deletions lib/internal/abort_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const {

const {
validateAbortSignal,
validateAbortSignalArray,
validateObject,
validateUint32,
} = require('internal/validators');
Expand All @@ -54,6 +55,7 @@ const {
clearTimeout,
setTimeout,
} = require('timers');
const assert = require('internal/assert');

const {
messaging_deserialize_symbol: kDeserialize,
Expand All @@ -80,13 +82,16 @@ function lazyMakeTransferable(obj) {
}

const clearTimeoutRegistry = new SafeFinalizationRegistry(clearTimeout);
const timeOutSignals = new SafeSet();
const gcPersistentSignals = new SafeSet();

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 kSourceSignals = Symbol('kSourceSignals');
const kDependantSignals = Symbol('kDependantSignals');

function customInspect(self, obj, depth, options) {
if (depth < 0)
Expand Down Expand Up @@ -116,7 +121,7 @@ function setWeakAbortSignalTimeout(weakRef, delay) {
const timeout = setTimeout(() => {
const signal = weakRef.deref();
if (signal !== undefined) {
timeOutSignals.delete(signal);
gcPersistentSignals.delete(signal);
abortSignal(
signal,
new DOMException(
Expand Down Expand Up @@ -185,25 +190,68 @@ class AbortSignal extends EventTarget {
return signal;
}

/**
* @param {AbortSignal[]} signals
* @returns {AbortSignal}
*/
static any(signals) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this can be simplified to:

function any(signals) {
  const ac = new AbortController();
  validateAbortSignalArray(signals, 'signals');
  for(const signal of signals) {
    if (signal.aborted) {
      ac.abort(signal);
      return ac.signal;
    }
  }
  for(const signal of signals) {
    signal.addEventListener("abort", (e) => {
      ac.signal.abort(e);
    }, { once: true, [kWeakListener]: ac.signal });
  }
  return ac.signal;
}

Since we already support weak listeners on event handlers inside core. You've effectively reimplemented parts of this in this PR and we can reuse the existing machinery.

Copy link
Member Author

@atlowChemi atlowChemi May 2, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@benjamingr The implementation currently is spec compliant. do we want to implement it differently than the spec?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@atlowChemi the spec talks about behavior not about how we implement the spec behavior. We can be spec compliant even if we don't follow the spec machinery word for word as long as we have the same external API and the same behavior.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@benjamingr Got it, thanks for the explanation 🙂
That said, I tried implementing based on your suggestion, but it causes the execution order to change (which is breaking the tests - as it doesn't comply with spec). I was unable to figure this out, any suggestions?

[UNEXPECTED_FAILURE][FAIL] Abort events for AbortSignal.any() signals fire in the right order (using AbortController)
assert_equals: expected "01234" but got "41230"
    at Test.<anonymous> (/Users/chemiatlow/Documents/node/test/fixtures/wpt/dom/abort/resources/abort-signal-any-tests.js:183:5)
    at Test.step (/Users/chemiatlow/Documents/node/test/fixtures/wpt/resources/testharness.js:2595:25)
    at test (/Users/chemiatlow/Documents/node/test/fixtures/wpt/resources/testharness.js:628:30)
    at abortSignalAnyTests (/Users/chemiatlow/Documents/node/test/fixtures/wpt/dom/abort/resources/abort-signal-any-tests.js:164:3)
    at /Users/chemiatlow/Documents/node/test/fixtures/wpt/dom/abort/abort-signal-any.any.js:4:1
Command: /Users/chemiatlow/Documents/node/out/Release/node  /Users/chemiatlow/Documents/node/test/wpt/test-abort.js 'abort-signal-any.any.js'

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's interesting and really surprising behavior for the signal "abort" to not be in "listener added order. We can work around it but I wonder if there is context regarding why they did this.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worst case this is just event listener order and not hard to work around but I wanna understand why

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See spec discussion in whatwg/dom#1152 (comment)

Just for completeness the way to work around it with my suggestion implementation without absorbing the additional complexity is to add the ability to tag event listeners as "run first" perhaps in an EventTarget subclass.

I generally think it's better to fix it in the spec but to be absolutely clear I feel strongly our behavior should be aligned with the spec as that was a big part of the motivation to implement AbortSignal and web standard more generally and diverging would defeat the purpose.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@benjamingr I am not fully sure I follow the discussion in whatwg... As far as I understood, concerns were raised regarding using addEventListener related to user-land being able to call stopImmediatePropagation.
Were does this suggestion stand currently?
Should it be implemented?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We patiently wait (or engage in the discussion) until @shaseley and @annevk decide and then follow what they decide. I've raised my concerns there but I feel strongly we should follow whatever consensus that PR concludes with and the correct spec.

validateAbortSignalArray(signals, 'signals');
const resultSignal = createAbortSignal({ composite: true });
const resultSignalWeakRef = new WeakRef(resultSignal);
resultSignal[kSourceSignals] = new SafeSet();
for (let i = 0; i < signals.length; i++) {
const signal = signals[i];
if (signal.aborted) {
abortSignal(resultSignal, signal.reason);
return resultSignal;
}
signal[kDependantSignals] ??= new SafeSet();
atlowChemi marked this conversation as resolved.
Show resolved Hide resolved
if (!signal[kComposite]) {
resultSignal[kSourceSignals].add(new WeakRef(signal));
signal[kDependantSignals].add(resultSignalWeakRef);
} else if (!signal[kSourceSignals]) {
continue;
} else {
for (const sourceSignal of signal[kSourceSignals]) {
const sourceSignalRef = sourceSignal.deref();
if (!sourceSignalRef) {
continue;
}
assert(!sourceSignalRef.aborted);
assert(!sourceSignalRef[kComposite]);

if (resultSignal[kSourceSignals].has(sourceSignal)) {
continue;
}
resultSignal[kSourceSignals].add(sourceSignal);
sourceSignalRef[kDependantSignals].add(resultSignalWeakRef);
}
}
}
return resultSignal;
}

[kNewListener](size, type, listener, once, capture, passive, weak) {
super[kNewListener](size, type, listener, once, capture, passive, weak);
if (this[kTimeout] &&
const isTimeoutOrNonEmptyCompositeSignal = this[kTimeout] || (this[kComposite] && this[kSourceSignals]?.size);
if (isTimeoutOrNonEmptyCompositeSignal &&
type === 'abort' &&
!this.aborted &&
!weak &&
size === 1) {
// If this is a timeout signal, and we're adding a non-weak abort
// 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.
timeOutSignals.add(this);
gcPersistentSignals.add(this);
}
}

[kRemoveListener](size, type, listener, capture) {
super[kRemoveListener](size, type, listener, capture);
if (this[kTimeout] && type === 'abort' && size === 0) {
timeOutSignals.delete(this);
const isTimeoutOrNonEmptyCompositeSignal = this[kTimeout] || (this[kComposite] && this[kSourceSignals]?.size);
if (isTimeoutOrNonEmptyCompositeSignal && type === 'abort' && size === 0) {
gcPersistentSignals.delete(this);
}
}

Expand Down Expand Up @@ -287,7 +335,8 @@ defineEventHandler(AbortSignal.prototype, 'abort');
* @param {{
* aborted? : boolean,
* reason? : any,
* transferable? : boolean
* transferable? : boolean,
* composite? : boolean,
* }} [init]
* @returns {AbortSignal}
*/
Expand All @@ -296,11 +345,13 @@ function createAbortSignal(init = kEmptyObject) {
aborted = false,
reason = undefined,
transferable = false,
composite = false,
} = init;
const signal = new EventTarget();
ObjectSetPrototypeOf(signal, AbortSignal.prototype);
signal[kAborted] = aborted;
signal[kReason] = reason;
signal[kComposite] = composite;
return transferable ? lazyMakeTransferable(signal) : signal;
}

Expand All @@ -312,6 +363,10 @@ function abortSignal(signal, reason) {
[kTrustEvent]: true,
});
signal.dispatchEvent(event);
signal[kDependantSignals]?.forEach((s) => {
atlowChemi marked this conversation as resolved.
Show resolved Hide resolved
const signalRef = s.deref();
if (signalRef) abortSignal(signalRef, reason);
});
}

class AbortController {
Expand Down
21 changes: 21 additions & 0 deletions lib/internal/validators.js
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,26 @@ function validateBooleanArray(value, name) {
}
}

/**
* @callback validateAbortSignalArray
* @param {*} value
* @param {string} name
* @returns {asserts value is AbortSignal[]}
*/

/** @type {validateAbortSignalArray} */
function validateAbortSignalArray(value, name) {
validateArray(value, name);
for (let i = 0; i < value.length; i++) {
const signal = value[i];
const indexedName = `${name}[${i}]`;
if (signal == null) {
throw new ERR_INVALID_ARG_TYPE(indexedName, 'AbortSignal', signal);
}
validateAbortSignal(signal, indexedName);
}
}

/**
* @param {*} signal
* @param {string} [name='signal']
Expand Down Expand Up @@ -534,6 +554,7 @@ module.exports = {
validateArray,
validateStringArray,
validateBooleanArray,
validateAbortSignalArray,
validateBoolean,
validateBuffer,
validateDictionary,
Expand Down
8 changes: 5 additions & 3 deletions test/common/wpt.js
Original file line number Diff line number Diff line change
Expand Up @@ -680,7 +680,7 @@ class WPTRunner {

process.on('exit', () => {
for (const spec of this.inProgress) {
this.fail(spec, { name: 'Unknown' }, kIncomplete);
this.fail(spec, { name: 'Incomplete' }, kIncomplete);
}
inspect.defaultOptions.depth = Infinity;
// Sorts the rules to have consistent output
Expand Down Expand Up @@ -796,9 +796,11 @@ class WPTRunner {
* @param {object} harnessStatus - The status object returned by WPT harness.
*/
completionCallback(spec, harnessStatus) {
const status = this.getTestStatus(harnessStatus.status);

// Treat it like a test case failure
if (harnessStatus.status === 2) {
this.resultCallback(spec, { status: 2, name: 'Unknown' });
if (status === kTimeout) {
this.fail(spec, { name: 'WPT testharness timeout' }, kTimeout);
}
this.inProgress.delete(spec);
// Always force termination of the worker. Some tests allocate resources
Expand Down
9 changes: 9 additions & 0 deletions test/common/wpt/worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,17 @@ add_result_callback((result) => {
});
});

// Keep the event loop alive
const timeout = setTimeout(() => {
parentPort.postMessage({
type: 'completion',
status: { status: 2 },
});
}, 2 ** 31 - 1); // Max timeout is 2^31-1, when overflown the timeout is set to 1.

// eslint-disable-next-line no-undef
add_completion_callback((_, status) => {
clearTimeout(timeout);
parentPort.postMessage({
type: 'completion',
status,
Expand Down
2 changes: 1 addition & 1 deletion test/fixtures/wpt/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Last update:

- common: https://github.com/web-platform-tests/wpt/tree/dbd648158d/common
- console: https://github.com/web-platform-tests/wpt/tree/767ae35464/console
- dom/abort: https://github.com/web-platform-tests/wpt/tree/8fadb38120/dom/abort
- dom/abort: https://github.com/web-platform-tests/wpt/tree/d1f1ecbd52/dom/abort
- dom/events: https://github.com/web-platform-tests/wpt/tree/ab8999891c/dom/events
- encoding: https://github.com/web-platform-tests/wpt/tree/0c1b9d1622/encoding
- fetch/data-urls/resources: https://github.com/web-platform-tests/wpt/tree/7c79d998ff/fetch/data-urls/resources
Expand Down
4 changes: 4 additions & 0 deletions test/fixtures/wpt/dom/abort/abort-signal-any.any.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// META: script=./resources/abort-signal-any-tests.js

abortSignalAnySignalOnlyTests(AbortSignal);
abortSignalAnyTests(AbortSignal, AbortController);
Loading