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 SafeThenable #36326

Closed
wants to merge 1 commit into from
Closed
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
19 changes: 8 additions & 11 deletions lib/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ const {
const {
validateAbortSignal
} = require('internal/validators');
let SafeThenable;

const kCapture = Symbol('kCapture');
const kErrorMonitor = Symbol('events.errorMonitor');
Expand Down Expand Up @@ -218,18 +219,14 @@ function addCatch(that, promise, type, args) {
return;
}

// Handle Promises/A+ spec, then could be a getter
// that throws on second use.
try {
const then = promise.then;

if (typeof then === 'function') {
then.call(promise, undefined, function(err) {
// The callback is called with nextTick to avoid a follow-up
// rejection from this promise.
process.nextTick(emitUnhandledRejectionOrErr, that, err, type, args);
});
}
if (!SafeThenable)
SafeThenable = require('internal/per_context/safethenable');
new SafeThenable(promise).catch(function(err) {
// The callback is called with nextTick to avoid a follow-up
// rejection from this promise.
process.nextTick(emitUnhandledRejectionOrErr, that, err, type, args);
});
Copy link
Member

Choose a reason for hiding this comment

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

We should run benchmarks on this before landing

} catch (err) {
that.emit('error', err);
}
Expand Down
16 changes: 8 additions & 8 deletions lib/internal/event_target.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const {
SymbolToStringTag,
SafeWeakSet,
} = primordials;
let SafeThenable;

const {
codes: {
Expand Down Expand Up @@ -564,14 +565,13 @@ function isEventTarget(obj) {
}

function addCatch(that, promise, event) {
const then = promise.then;
if (typeof then === 'function') {
then.call(promise, undefined, function(err) {
// The callback is called with nextTick to avoid a follow-up
// rejection from this promise.
process.nextTick(emitUnhandledRejectionOrErr, that, err, event);
});
}
if (!SafeThenable)
SafeThenable = require('internal/per_context/safethenable');
new SafeThenable(promise).catch(function(err) {
Copy link
Member

Choose a reason for hiding this comment

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

What advantage does this have over { await thenable } catch (err) { ...?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is not awaiting the thenable at all – it attaches a catch handler in case the event handler returns a thenable. I'm pretty sure that's in the spec, although I haven't checked.

// The callback is called with nextTick to avoid a follow-up
// rejection from this promise.
process.nextTick(emitUnhandledRejectionOrErr, that, err, event);
});
}

function emitUnhandledRejectionOrErr(that, err, event) {
Expand Down
71 changes: 71 additions & 0 deletions lib/internal/per_context/safethenable.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
'use strict';

const {
PromisePrototypeCatch,
PromisePrototypeThen,
ReflectApply,
SafeWeakMap,
} = primordials;

const { isPromise } = require('internal/util/types');

const cache = new SafeWeakMap();

// `SafeThenable` should be used when dealing with user provided `Promise`-like
// instances. It provides two methods `.then` and `.catch`.
// Wrapping uses of `SafeThenable` in `try`/`catch` may be useful if the
// accessing `.then` property of the provided object throws.
class SafeThenable {
#is_promise;
#makeUnsafeCalls;
#target;
#cachedThen;
#hasAlreadyAccessedThen;
Copy link
Member

Choose a reason for hiding this comment

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

there are still a number of performance concerns around using private fields (unfortunately). These should likely be non-exported Symbols instead.


constructor(thenable, makeUnsafeCalls = false) {
Copy link
Member

Choose a reason for hiding this comment

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

A comment here describing the intent of makeUnsafeCalls would be good.

Also, I generally prefer avoiding boolean arguments in favor of named flags or an options object. That is, either new SafeThenable(promise, kMakeUnsafeCalls) where kMakeUnsafeCalls === 1 (allows additional flags to be added later using xor) or new SafeThenable(promise, { unsafeCalls: true }).

this.#target = thenable;
this.#is_promise = isPromise(thenable);
this.#makeUnsafeCalls = makeUnsafeCalls;
}

get #then() {
Copy link
Member

Choose a reason for hiding this comment

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

While I'm fine with using this syntax in general, we should benchmark this extensively before landing. Last I checked, v8 was not yet optimizing private accessors that well and since we're using this in EventEmitter (one of the most performance sensitive bits of code we have in core) it's good to be careful here.

// Handle Promises/A+ spec, `then` could be a getter
Copy link
Member

Choose a reason for hiding this comment

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

Why do we care about the Promises/A+ spec in the first place? The only spec that we care about is the JavaScript spec - if we got a native promise this should never happen right?

The "double getteR" thing is an issue (thanks jQuery!) with assimilation mostly.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, it is not intended to be used with genuine Promise object, it's just for API that have to deal with user-provided thenable objects (E.G.: util.callbackify).

// that throws on second access.
if (this.#hasAlreadyAccessedThen === undefined) {
this.#hasAlreadyAccessedThen = cache.has(this.#target);
if (this.#hasAlreadyAccessedThen) {
this.#cachedThen = cache.get(this.#target);
}
}
if (!this.#hasAlreadyAccessedThen) {
this.#cachedThen = this.#target?.then;
this.#hasAlreadyAccessedThen = true;
if (typeof this.#target === 'object' && this.#target !== null) {
cache.set(this.#target, this.#cachedThen);
}
}

return this.#cachedThen;
}

get isThenable() {
return this.#is_promise || typeof this.#then === 'function';
}

catch(onError) {
return this.#is_promise ?
PromisePrototypeCatch(this.#target, onError) :
this.then(undefined, onError);
}

then(...args) {
Copy link
Member

Choose a reason for hiding this comment

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

This is terrifying, even if we ignore things like "the returned thenable is missing finally" or "if you do `.constructor you don't get all the promise statics. This is still terrifying ^^

I am -0.5, if others feel strongly then maybe

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is intended to be used with objects for which only .then method is called. It's just a wrapper for the pattern that shows several times in core:

const then = obj?.then;
if(typeof then === function) {
  FunctionPrototypeCall(then, obj, onSuccess, onError);
}

I think .finally is a bit off-topic, we should always prefer PromisePrototypeFinally anyway.

if (this.#is_promise) {
return PromisePrototypeThen(this.#target, ...args);
} else if (this.#makeUnsafeCalls || this.isThenable) {
return ReflectApply(this.#then, this.#target, args);
}
}

}

module.exports = SafeThenable;
19 changes: 9 additions & 10 deletions lib/internal/streams/destroy.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const {
ERR_MULTIPLE_CALLBACK
} = require('internal/errors').codes;
const { Symbol } = primordials;
let SafeThenable; // lazy loaded

const kDestroy = Symbol('kDestroy');
const kConstruct = Symbol('kConstruct');
Expand Down Expand Up @@ -91,10 +92,10 @@ function _destroy(self, err, cb) {
});
if (result !== undefined && result !== null) {
try {
const then = result.then;
if (typeof then === 'function') {
then.call(
result,
if (!SafeThenable)
SafeThenable = require('internal/per_context/safethenable');
new SafeThenable(result)
Copy link
Member

Choose a reason for hiding this comment

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

Should wrap this in a lazySafeThenable() function to avoid the code duplication

.then(
function() {
if (called)
return;
Expand Down Expand Up @@ -142,7 +143,6 @@ function _destroy(self, err, cb) {

process.nextTick(emitErrorCloseNT, self, err);
});
}
} catch (err) {
process.nextTick(emitErrorNT, self, err);
}
Expand Down Expand Up @@ -309,10 +309,10 @@ function constructNT(stream) {
});
if (result !== undefined && result !== null) {
try {
const then = result.then;
if (typeof then === 'function') {
then.call(
result,
if (!SafeThenable)
SafeThenable = require('internal/per_context/safethenable');
new SafeThenable(result)
.then(
function() {
// If the callback was invoked, do nothing further.
if (called)
Expand Down Expand Up @@ -343,7 +343,6 @@ function constructNT(stream) {
process.nextTick(errorOrDestroy, stream, err);
}
});
}
} catch (err) {
process.nextTick(emitErrorNT, stream, err);
}
Expand Down
14 changes: 7 additions & 7 deletions lib/internal/streams/pipeline.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@

const {
ArrayIsArray,
ReflectApply,
SymbolAsyncIterator,
SymbolIterator,
} = primordials;
let SafeThenable;

let eos;

Expand Down Expand Up @@ -224,18 +224,18 @@ function pipeline(...streams) {
objectMode: true
});

// Handle Promises/A+ spec, `then` could be a getter that throws on
// second use.
const then = ret?.then;
if (typeof then === 'function') {
ReflectApply(then, ret, [
if (!SafeThenable)
SafeThenable = require('internal/per_context/safethenable');
const maybeThenable = new SafeThenable(ret);
if (maybeThenable.isThenable) {
maybeThenable.then(
(val) => {
value = val;
pt.end(val);
}, (err) => {
pt.destroy(err);
}
]);
);
} else if (isIterable(ret, true)) {
finishCount++;
pump(ret, pt, finish);
Expand Down
19 changes: 9 additions & 10 deletions lib/internal/streams/transform.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ const {
ObjectSetPrototypeOf,
Symbol
} = primordials;
let SafeThenable;

module.exports = Transform;
const {
Expand Down Expand Up @@ -130,10 +131,10 @@ function final(cb) {
});
if (result !== undefined && result !== null) {
try {
const then = result.then;
if (typeof then === 'function') {
then.call(
result,
if (!SafeThenable)
SafeThenable = require('internal/per_context/safethenable');
new SafeThenable(result)
.then(
(data) => {
if (called)
return;
Expand All @@ -150,7 +151,6 @@ function final(cb) {
process.nextTick(() => this.destroy(err));
}
});
}
} catch (err) {
process.nextTick(() => this.destroy(err));
}
Expand Down Expand Up @@ -205,10 +205,10 @@ Transform.prototype._write = function(chunk, encoding, callback) {
});
if (result !== undefined && result != null) {
try {
const then = result.then;
if (typeof then === 'function') {
then.call(
result,
if (!SafeThenable)
SafeThenable = require('internal/per_context/safethenable');
new SafeThenable(result)
.then(
(val) => {
if (called)
return;
Expand All @@ -230,7 +230,6 @@ Transform.prototype._write = function(chunk, encoding, callback) {
(err) => {
process.nextTick(callback, err);
});
}
} catch (err) {
process.nextTick(callback, err);
}
Expand Down
10 changes: 5 additions & 5 deletions lib/internal/streams/writable.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const {
Symbol,
SymbolHasInstance,
} = primordials;
let SafeThenable;

module.exports = Writable;
Writable.WritableState = WritableState;
Expand Down Expand Up @@ -667,10 +668,10 @@ function callFinal(stream, state) {
});
if (result !== undefined && result !== null) {
try {
const then = result.then;
if (typeof then === 'function') {
then.call(
result,
if (!SafeThenable)
SafeThenable = require('internal/per_context/safethenable');
new SafeThenable(result)
.then(
function() {
if (state.prefinished)
return;
Expand All @@ -685,7 +686,6 @@ function callFinal(stream, state) {
}
process.nextTick(errorOrDestroy, stream, err, state.sync);
});
}
} catch (err) {
process.nextTick(errorOrDestroy, stream, err, state.sync);
}
Expand Down
5 changes: 4 additions & 1 deletion lib/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const {
ObjectSetPrototypeOf,
ReflectApply,
} = primordials;
let SafeThenable;

const {
codes: {
Expand Down Expand Up @@ -208,7 +209,9 @@ function callbackify(original) {
const cb = (...args) => { ReflectApply(maybeCb, this, args); };
// In true node style we process the callback on `nextTick` with all the
// implications (stack, `uncaughtException`, `async_hooks`)
ReflectApply(original, this, args)
if (!SafeThenable)
SafeThenable = require('internal/per_context/safethenable');
new SafeThenable(ReflectApply(original, this, args), true)
.then((ret) => process.nextTick(cb, null, ret),
(rej) => process.nextTick(callbackifyOnRejected, rej, cb));
}
Expand Down
1 change: 1 addition & 0 deletions node.gyp
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
'lib/internal/per_context/primordials.js',
'lib/internal/per_context/domexception.js',
'lib/internal/per_context/messageport.js',
'lib/internal/per_context/safethenable.js',
'lib/async_hooks.js',
'lib/assert.js',
'lib/assert/strict.js',
Expand Down
Loading