4

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; } to the CancellablePromise class solves the problem.

noseratio
  • 59,932
  • 34
  • 208
  • 486
  • 3
    after `console.log("CancellablePromise::constructor");` add `console.log(executor+'')` - and all will be revealed – Bravo Nov 04 '18 at 23:42
  • @Bravo, it's a different version of `executor`, not mine: `function () { [native code] }` – noseratio Nov 04 '18 at 23:43
  • what does `await` do? – Bravo Nov 04 '18 at 23:52
  • @Bravo thanks for pointing that out. I suspect it will be the implicit `Promise.resolve()` call made by `await` but will need to check it out before getting back. – traktor Nov 04 '18 at 23:54
  • 1
    it's more an implicit `.then` - but the internals of `async`/`await` are not important - remove the `await` **(but keep the `async`)** and while now the code isn't behaving correctly, you'll see only a single `CancellablePromise::constructor` call – Bravo Nov 04 '18 at 23:57
  • @Bravo, indeed looks like implicit `then` it is. I wonder if it would do the same behind the scene if I didn't subclass `Promise`. – noseratio Nov 05 '18 at 00:03
  • yes, because `.then` returns a new Promise - I guess subclassing Promise means it returns a new subclassed Promise :p – Bravo Nov 05 '18 at 00:04
  • @Bravo, makes sense. To handle `await`, I imagine they have to attach via `then` to support all the 3rd party `Promise` implementations like Bluebird. Feel free to post it as answer. – noseratio Nov 05 '18 at 00:07
  • 1
    It's probably more useful to use something like `console.trace("Trace")` in the constructor to see the stack when it's called. You will see the first call made in `main`, but the next in `Promise.then()` suggesting that await is calling the constructor. – Mark Nov 05 '18 at 00:07
  • @noseratio - I don't really have anything I would class as an *answer*. There are far more knowledgeable people on stackoverflow when it comes to the inner workings of Promises that will hopefully come across this question and explain it in a meaningful way (rather than my *hey, chuck a console.log here, remove this, and see how this happens*) it's not really a definitive answer in my opinion – Bravo Nov 05 '18 at 00:13

1 Answers1

3

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.

traktor
  • 17,588
  • 4
  • 32
  • 53
  • 1
    it's not the `.catch` - remove it, and you still call constructor twice – Bravo Nov 04 '18 at 23:46
  • Spot on! How interesting.. I'd expect it to create a standard `Promise` rather than another instance of my `CancellablePromise`. – noseratio Nov 04 '18 at 23:48
  • @noseratio why? It would render your custom class kinda worthless if it did... if you really want it to return normal Promises [you can](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol/species) – Jared Smith Nov 04 '18 at 23:48
  • it's still not correct - remove `.catch` - you wont believe what happens next – Bravo Nov 04 '18 at 23:49
  • @JaredSmith I agree, but @Bravo is also right. I removed `catch` and I suspect Node is still creating another promise somehow to chain to mine. – noseratio Nov 04 '18 at 23:50
  • @Bravo thanks for pointing that out. I will look into the use of `Promise,resolvel` by `await`and get back. – traktor Nov 04 '18 at 23:58
  • @traktor53, so now we know, while `catch` does create a new `Promise`, it is not an instance of `CancellablePromise` here. If `main` returned `CancellablePromise`, then yes, it would be. – noseratio Nov 05 '18 at 00:24
  • @traktor53, *Now await calls Promise.resolve on its operand.* - did you rather mean *Now await calls **Promise.then** on its operand.*? – noseratio Nov 05 '18 at 01:03
  • 1
    @noseratio no I did not. `await` is always asynchronous and calls `Promise resolve` to convert non-promise operands into Promise promises. This conversion is applied to extended Promise class instances as well. – traktor Nov 05 '18 at 01:10
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/183099/discussion-between-traktor53-and-noseratio). – traktor Nov 05 '18 at 01:21
  • It appears you're right, I've found this [TC39's `Async Functions`](https://tc39.github.io/ecmascript-asyncawait): `Promise.resolve(next.value).then(function(v) ...` I let this q/a hang out for a while to get more attention. I hope some of V8 or TC39 team members might come across this. Like @RonBuckton who seems to be deeply involved in this stuff. Thanks! – noseratio Nov 05 '18 at 01:37
  • 1
    @noseratio That link's gold. No so good the explanation - deconstructing the standard worked better for me to show why `await` uses two turns in the Promise Job Queue. Thanks! – traktor Dec 19 '18 at 04:32