I'm playing with Promise Extensions for JavaScript (prex) and I want to extend the standard Promise class with cancellation support using prex.CancellationToken, complete code here.
Unexpectedly, I'm seeing the constructor of my custom class CancellablePromise
being called twice. To simplify things, I've now stripped down all the cancellation logic and left just a bare minimum required to repro the issue:
class CancellablePromise extends Promise {
constructor(executor) {
console.log("CancellablePromise::constructor");
super(executor);
}
}
function delayWithCancellation(timeoutMs, token) {
// TODO: we've stripped all cancellation logic for now
console.log("delayWithCancellation");
return new CancellablePromise(resolve => {
setTimeout(resolve, timeoutMs);
}, token);
}
async function main() {
await delayWithCancellation(2000, null);
console.log("successfully delayed.");
}
main().catch(e => console.log(e));
Running it with node simple-test.js
, I'm getting this:
delayWithCancellation CancellablePromise::constructor CancellablePromise::constructor successfully delayed.
Why are there two invocations of CancellablePromise::constructor
?
I tried setting breakpoints with VSCode. The stack trace for the second hit shows it's called from runMicrotasks
, which itself is called from _tickCallback
somewhere inside Node.
Updated, Google now have "await under the hood" blog post which is a good read to understand this behavior and some other async/await implementation specifics in V8.
First Update:
I first thought
.catch( callback)
after 'main' would return a new, pending promise of the extended Promise class, but this is incorrect - calling an async function returns aPromise
promise.Cutting the code down further, to only produce a pending promise:
shows the extended constructor being called twice in Firefox, Chrome and Node.
Now
await
callsPromise.resolve
on its operand. (Edit: or it probably did in early JS engine's versions of async/await not strictly implemented to standard)If the operand is a promise whose constructor is Promise,
Promise.resolve
returns the operand unchanged.If the operand is a thenable whose constructor is not
Promise
,Promise.resolve
calls the operand's then method with both onfulfilled and onRejected handlers so as to be notified of the operand's settled state. The promise created and returned by this call tothen
is of the extended class, and accounts for the second call to CancellablePromise.prototype.constructor.Supporting evidence
new CancellablePromise().constructor
isCancellablePromise
CancellablePromise.prototype.constructor
toPromise
for testing purposes causes only one call toCancellablePromise
(becauseawait
is fooled into returning its operand) :Second Update (with huge thanks to links provided by the OP)
Conforming Implementations
Per the
await
specificationawait
creates an anonymous, intermediate Promise promise with onFulilled and onRejected handlers to either resume execution after theawait
operator or throw an error from it, depending on which settled state the intermediate promise achieves.It (
await
) also callsthen
on the operand promise to fulfill or reject the intermediate promise. This particularthen
call returns a promise of classoperandPromise.constructor
. Although thethen
returned promise is never used, logging within an extended class constructor reveals the call.If the
constructor
value of an extended promise is changed back toPromise
for experimental purposes, the abovethen
call will silently return a Promise class promise.Appendix: Decyphering the
await
specificationCreates a new jQuery-like deferred object with
promise
,resolve
andreject
properties, calling it a "PromiseCapability Record" instead. The deferred'spromise
object is of the (global) base Promise constructor class.Resolve the deferred promise with the right operand of
await
. The resolution process either calls thethen
method of the operand if it is a "thenable", or fulfills the deferred promise if the operand is some other, non-promise, value.Create an onfulfilled handler to resume the
await
operation, inside theasync
function it was called in, by returning the fulfilled value of the operand passed as argument to the handler.Create an onrejected handler to resume the
await
operation, inside theasync
function it was called in, by throwing a promise rejection reason passed to the handler as its argument.Call
then
on the deferred promise with these two handlers so thatawait
can respond to its operand being settled.This call using three parameters is an optimisation that effectively means
then
has been called internally and won't be creating or returning a promise from the call. Hence settlement of the deferred will dispatch calling one of its settlement handlers to the promise job queue for execution, but has no additional side effects.Store where to resume after a successful
await
and return to the event loop or micro task queue manager.