简体   繁体   中英

Constructor of a custom promise class is called twice (extending standard Promise)

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.

Updated , as I keep coming back to this, adding static get [Symbol.species]() { return Promise; } static get [Symbol.species]() { return Promise; } to the CancellablePromise class solves the problem .

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 a Promise promise.

Cutting the code down further, to only produce a pending promise:

 class CancellablePromise extends Promise { constructor(executor) { console.log("CancellablePromise::constructor"); super(executor); } } async function test() { await new CancellablePromise( ()=>null); } test();

shows the extended constructor being called twice in Firefox, Chrome and Node.

Now await calls Promise.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 to then is of the extended class, and accounts for the second call to CancellablePromise.prototype.constructor.

Supporting evidence

  1. new CancellablePromise().constructor is CancellablePromise

 class CancellablePromise extends Promise { constructor(executor) { super(executor); } } console.log ( new CancellablePromise( ()=>null).constructor.name);

  1. Changing CancellablePromise.prototype.constructor to Promise for testing purposes causes only one call to CancellablePromise (because await is fooled into returning its operand) :

 class CancellablePromise extends Promise { constructor(executor) { console.log("CancellablePromise::constructor"); super(executor); } } CancellablePromise.prototype.constructor = Promise; // TESTING ONLY async function test() { await new CancellablePromise( ()=>null); } test();


Second Update (with huge thanks to links provided by the OP)

Conforming Implementations

Per the await specification

await creates an anonymous, intermediate Promise promise with onFulilled and onRejected handlers to either resume execution after the await operator or throw an error from it, depending on which settled state the intermediate promise achieves.

It ( await ) also calls then on the operand promise to fulfill or reject the intermediate promise. This particular then call returns a promise of class operandPromise.constructor . Although the then 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 to Promise for experimental purposes, the above then call will silently return a Promise class promise.


Appendix: Decyphering the await specification

  1. Let asyncContext be the running execution context.

  2. Let promiseCapability be ! NewPromiseCapability(%Promise%).

Creates a new jQuery-like deferred object with promise , resolve and reject properties, calling it a "PromiseCapability Record" instead. The deferred's promise object is of the (global) base Promise constructor class.

  1. Perform ! Call(promiseCapability.[[Resolve]], undefined, « promise »).

Resolve the deferred promise with the right operand of await . The resolution process either calls the then method of the operand if it is a "thenable", or fulfills the deferred promise if the operand is some other, non-promise, value.

  1. Let stepsFulfilled be the algorithm steps defined in Await Fulfilled Functions.

  2. Let onFulfilled be CreateBuiltinFunction(stepsFulfilled, « [[AsyncContext]] »).

  3. Set onFulfilled.[[AsyncContext]] to asyncContext.

Create an onfulfilled handler to resume the await operation, inside the async function it was called in, by returning the fulfilled value of the operand passed as argument to the handler.

  1. Let stepsRejected be the algorithm steps defined in Await Rejected Functions.

  2. Let onRejected be CreateBuiltinFunction(stepsRejected, « [[AsyncContext]] »).

  3. Set onRejected.[[AsyncContext]] to asyncContext.

Create an onrejected handler to resume the await operation, inside the async function it was called in, by throwing a promise rejection reason passed to the handler as its argument.

  1. Perform ! PerformPromiseThen(promiseCapability.[[Promise]], onFulfilled, onRejected).

Call then on the deferred promise with these two handlers so that await 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.

  1. Remove asyncContext from the execution context stack and restore the execution context that is at the top of the execution context stack as the running execution context.

  2. Set the code evaluation state of asyncContext such that when evaluation is resumed with a Completion completion, the following steps of the algorithm that invoked Await will be performed, with completion available.

Store where to resume after a successful await and return to the event loop or micro task queue manager.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM