1 of 29

revisiting async generator yielding

Domenic Denicola�May 2017 TC39 meeting

2 of 29

Stage 3 in action!

  • We thought we had reviewed this carefully and it was perfect
  • We put it in front of real users and implementers
  • They found tough edge cases
  • Let's fix them!

https://github.com/tc39/proposal-async-iteration/issues/93�(originally about something else, but it root-causes to the larger issue here)

3 of 29

Remember: no "promises for promises"

async function* f() {� yield 1;� yield Promise.resolve(2);� yield 3;�}

We don't want the second iteration over the returned async generator to return a promise for { value: promise for 2, done: false }.

We want to unwrap it, returning a promise for { value: 2, done: false }.

Analogous to Promise.resolve(Promise.resolve(2)).then(x => /* x is 2 */)

4 of 29

The problem code

async function* f() {� yield 1;� yield Promise.reject(2);� yield 3;�}

Consider:

  • Consumption via for-await-of
  • Consumption via yield*
  • Consumption via manual .next()

5 of 29

The current design

  • Previous agreement was:
    • yield p is not equivalent to yield await p; it does not "unwrap"
    • Execution of the generator body continues
    • The promise is stored in the iterator, and calling .next() unwraps it later
  • Thus for f():
    • for-await-of will see 1, exception
    • But next() will give you�fulfilled: { value: 1, done: false }�rejected: <exception: 2>�fulfilled: { value: 3, done: false }�fulfilled: { value: undefined, done: true }
    • yield* could be specced either way
  • This breaks with sync generators, where producing an exception means the sequence has ended.

6 of 29

Another way of looking at it

async function* f() {� yield 1;� yield Promise.resolve(2);� yield 3;�}

These will act the same to all consumers.

async function* g() {� yield 1;� yield 2;� yield 3;�}

7 of 29

Another way of looking at it

async function* f() {� yield 1;� yield Promise.reject(2);� yield 3;�}

These will be different; f() will not terminate, whereas g() will.��Comparing with the previous slide, this is quite surprising.��Worse, there is no way of knowing on the second next() which situation you are in.

async function* g() {� yield 1;� throw 2;� yield 3;�}

8 of 29

No way of knowing

A consumer of f(), upon seeing next() reject, doesn't know that f is "still running", and that further next()s could give useful data the generator has computed. This could be a resource leak.

A consumer of g(), upon seeing next() reject, no longer knows that g is "done"; it has to call next() again to be sure.

for-await-of will assume rejects are g-like and rethrow, leaving f-like async iterators "still running" forever.

The (async) iteration protocol is no longer generally reliable. Any combinator library now needs to make a policy decision on how to handle sequences which contain (possibly multiple) errors in the middle, not just at the end.

9 of 29

Resource leak, expanded

async function* readLines(filename) {� const fd = await fs.open(filename);� try {� while (fd.position !== fs.EOF) {� yield fd.readLine();� }� } finally {� await fs.close(fd);� }�}

10 of 29

Resource leak, expanded

for await (line of readLines()) {� console.log(line);�}

Assume the second fs.readLine(fd) inside the body of readLines() rejects.

for-await-of will rethrow the exception, but the body of readLines() will stay paused, so the finally block will never be reached, and the file descriptor will never be closed.

11 of 29

This is inherent in the design

Any design which delays unwrapping the yielded value cannot tell what the consumer is ultimately going to see until next() is called.

Thus any such design cannot know whether to terminate the generator body and the produced sequence.

12 of 29

Possible solutions

Unwrap more, or unwrap less, basically

  1. Don't unwrap rejections
  2. Only unwrap in for-await-of (and yield*?)
  3. Make yield implicitly unwrap

(Or option 0, do nothing.)

I strongly favor option 3. Let's investigate each.

13 of 29

  1. don't unwrap rejections

(credit: @erights)

14 of 29

When unwrapping a yielded promise, "re-wrap" it back up if we find out it was rejected.��

async function* f() {� yield Promise.resolve(1);� yield Promise.reject(2);� yield 3;�}

async function* g() {� yield 1;� throw 2;� yield 3;�}

15 of 29

f()

next() gives:

  • fulfilled promise for { value: 1, done: false }
  • fulfilled promise for { value: rejected promise for 2, done: false }
  • fulfilled promise for { value: 3, done: false }
  • fulfilled promise for { value: undefined, done: true }

for-await-of: 1, rejected promise for 2, 3

yield*: yields 1, yields rejected promise for 2, yields 3

16 of 29

g()

next() gives:

  • fulfilled promise for { value: 1, done: false }
  • rejected promise for 2

for-await-of: 1, throws 2

yield*: yields 1, throws 2

17 of 29

I'm not a fan

"Promises for promises" is just as bad when the inner promise is rejected. Promise.resolve(Promise.reject(2)) turns into a rejected promise for 2, not a wrapper around the rejected promise. Why treat rejected promises specially here, and not there?

Now consumers have to figure out what the value being a rejected promise means (but don't have to figure out what the value being a fulfilled promise means).��Hint: it probably means that the library author thought rejections were unwrapped, like fulfillments, but never tested their library's failure paths.

18 of 29

2. only unwrap in for-await-of

(credit: @zenparsing)

19 of 29

yielding always stores the raw value. Manual next() consumers need to deal, but for-await-of unwraps for you.��

async function* f() {� yield Promise.resolve(1);� yield Promise.reject(2);� yield 3;�}

async function* g() {� yield 1;� throw 2;� yield 3;�}

20 of 29

f()

next() gives:

  • fulfilled promise for { value: fulfilled promise for 1, done: false }
  • fulfilled promise for { value: rejected promise for 2, done: false }
  • fulfilled promise for { value: 3, done: false }
  • fulfilled promise for { value: undefined, done: true }

for-await-of: 1, throws 2

yield*: yields fulfilled promise for 1, yields rejected promise for 2, yields 3 (?)

21 of 29

g()

next() gives:

  • fulfilled promise for { value: 1, done: false }
  • rejected promise for 2

for-await-of: 1, throws 2

yield*: yields 1, throws 2

22 of 29

I'm not a fan

This pushes the problem onto library authors

Manual consumption via next() now is arduous to get reasonable semantics

for-await-of still causes resource leaks (due to still-running async generator bodies)

  • (or we could special-case async iteration so that it catches .next() failures and calls .return(), unlike sync iterators; but that seems like a bad asymmetry to introduce as a band-aid.)

yield* isn't easily explained in terms of for-await-of anymore

23 of 29

3. make yield implicitly unwrap

24 of 29

yield ↔ yield await. That's it.�

async function* f() {� yield Promise.resolve(1);� yield Promise.reject(2);� yield 3;�}

async function* g() {� yield 1;� throw 2;� yield 3;�}

25 of 29

f() and g()

next() gives:

  • fulfilled promise for { value: 1, done: false }
  • rejected promise for 2

for-await-of: 1, throws 2

yield*: yields 1, throws 2

26 of 29

I'm a fan!

Gives the same behavior for f() and g()

Easy to explain yield* in terms of for-await-of

Consumers never see a value field that is a promise

Consumers know exactly what a rejected next() or throwing for-await-of means; simple API contract

Producers know that yielding a rejected promise is telling their consumers about a failure

Inside async function*, yield and await both work on promises and non-promises, just like inside async function, await works on promises and non-promises

27 of 29

Notes

Unwrapping happens entirely on the producer side; for-await-of/yield* don't peek inside the promises returned from next()

Thus, you could manually assemble an async iterator whose next() returns promises whose value is a promise, and we wouldn't unwrap them, and consumers would see the inner promise. That's fine; it's a contract violation.

28 of 29

Notes

If you want the flexibility of not blocking on the async operation before continuing the async generator body, then just don't yield the value yet.

It's analogous to async functions, where you can continue the body by just not awaiting the promise.

29 of 29

discuss