Skip to content

yield * in an async iterator should not call .throw when the inner iterator produces a rejected promise #2813

@bakkot

Description

@bakkot

Consider the following horrible program:

'use strict';
var console = console || { log: print };

let inner = {
  [Symbol.asyncIterator]: () => ({
    next() {
      return Promise.resolve({ done: false, value: Promise.reject('rejection') });
    },
    throw(e) {
      console.log('throw called with', e);
      throw e;
    },
    return(e) {
      console.log('return called with', e);
      return {};
    },
  }),
};

async function* outer() {
  yield* inner;
}

(async () => {
  for await (let x of outer()) {
    console.log(x);
  }
})().catch(e => {
  console.log('threw', e);
});

Per spec, this will print "throw called with 'rejection'". Specifically, the evaluation semantics for yield* step 7.a.vi will invoke AsyncGeneratorYield passing Promise.reject('rejection'), which will then be Await'd, which will throw, causing the received alias to hold a throw completion. The following machinery then acts as if .throw had been explicitly called on outer, which is the only other way that received can hold a throw completion - the behavior in that case being to invoke .throw on inner.

I think this possibility was simply overlooked during the spec'ing of async generators. This is the only place in the entire spec that .throw is called without a user explicitly invoking it at some point. (Previously I'd thought there were no such places.) And the aliases certainly imply that the algorithm is assuming that a throw completion must have come from the user, rather than from the iterator itself.

This behavior seems wrong to me. Normally when consuming an iterator the spec will either a.) determine that the iterator itself is broken (e.g. it threw), in which case it is assumed to have done its own cleanup and the spec will not make further calls to any of its methods, or b.) determine that the iterator is fine but for some reason the consumer needs to exit early, in which case the spec will call .return (not .throw) to give the iterator a chance to clean up.


I am inclined to treat this as in (b). Nothing in the spec prevents custom async iterators from returning promises - see the snippet in this comment. So I think we ought to treat the await iterResult.value as being something the outer iterator is doing, rather than a violation of the iteration protocol, so that if this await happens to throw then the inner iterator should be closed with .return.

I could probably be persuaded to treat this as in (a), i.e. to say that this is a violation of the iteration protocol and so the spec need not clean up the inner iterator.

The current behavior seems bad, though.

For comparison,

for await (let item of inner) {
  yield item;
}

will call .return on inner, in the above example. I would like yield* to match this case.


For the snippet above, JavaScriptCore and V8 implement the spec (they print "throw called with rejection" and then "threw rejection"). SpiderMonkey, Graal, and XS only print "threw rejection" (they also don't close the inner iterator).

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs consensusThis needs committee consensus before it can be eligible to be merged.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions