revisiting async generator yielding
Domenic Denicola�May 2017 TC39 meeting
Stage 3 in action!
https://github.com/tc39/proposal-async-iteration/issues/93�(originally about something else, but it root-causes to the larger issue here)
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 */)
The problem code
async function* f() {� yield 1;� yield Promise.reject(2);� yield 3;�}
Consider:
The current design
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;�}
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;�}
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.
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);� }�}
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.
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.
Possible solutions
Unwrap more, or unwrap less, basically
(Or option 0, do nothing.)
I strongly favor option 3. Let's investigate each.
(credit: @erights)
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;�}
f()
next() gives:
for-await-of: 1, rejected promise for 2, 3
yield*: yields 1, yields rejected promise for 2, yields 3
g()
next() gives:
for-await-of: 1, throws 2
yield*: yields 1, throws 2
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.
2. only unwrap in for-await-of
(credit: @zenparsing)
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;�}
f()
next() gives:
for-await-of: 1, throws 2
yield*: yields fulfilled promise for 1, yields rejected promise for 2, yields 3 (?)
g()
next() gives:
for-await-of: 1, throws 2
yield*: yields 1, throws 2
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)
yield* isn't easily explained in terms of for-await-of anymore
3. make yield implicitly unwrap
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;�}
f() and g()
next() gives:
for-await-of: 1, throws 2
yield*: yields 1, throws 2
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
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.
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.
discuss