-
Notifications
You must be signed in to change notification settings - Fork 44
Clarification of execution of next() and throw() with await #93
Comments
What implementation are you testing? Can you edit your test code to be runnable? For example it references the undefined value |
@domenic Oh, Sorry for code with error! Fixed. I'm implementing this spec for JavaScriptCore |
Thanks @gskachkov! Unfortunately I wasn't able to get to this by the end of my workday, but I'll definitely dig in on Monday. (Thoughts from @arai-a and @caitp / @GeorgNeis also welcome.) |
fwiw, I also get the following with WIP patch for SpiderMonkey:
I'll check what's happening. |
here's the list of jobs queued after await (so, executing the continuation of async generator)
so, iiuc, the out-of-order happens because of Async Iterator Value Unwrap Functions |
There is one more example without await and I think it also related to the 'Async Iterator Value Unwrap Functions' async function *foo() {
yield '#1';
};
const f = foo();
f.return('bar').then(({value})=> print('return#1', value));
f.throw(new Error('some error')).then(({value})=>print('fulfilled throw#2', value), error=>print('reject#2', error));
f.next().then(({value})=>print('fulfilled next#3', value), error=>print('reject#3', error)); I received:
But without knowing internals I would expect this in following order:
@arai-a Could you please double check this snipped on your WIP patch, to be sure that is not my wrong implementation? |
i get this
|
@arai-a Thanks! Sorry, I did not follow spec from start and missed discussions and possible ask already asked question, but why do we wrap fulfilled result in Unwrap function, but did not do this for reject? |
I agree that the behavior is unexpected. If I understand the current spec correctly, AsyncGeneratorResolve calls AsyncGeneratorResumeNext without waiting for the yielded value to be unwrapped. It seems to me that we should not dequeue any further items off of AsyncGeneratorQueue until the current value has been unwrapped. Originally I think we discussed the unwrapping happening "within" the |
@zenparsing Hmm, IMHO cause of the difference in the behavior, is difference in AsyncGeneratorResolve and AsyncGeneratorReject For instance in my example #93 (comment)
When return is called, it invoked AsyncGeneratorResolve, but dequeue does not happened, because queue is empty until call throw is called. The result of return will be visible later to throw because promise callback for return will called later for one cycle/microtask later in comparison to promise callback for throw: |
Yes, but when Forcing an unnecessary unwrapping in AsyncGeneratorReject would solve the timing problem in this thread, but I'm worried that there might be a bigger problem here. What does your implementation print for this example? async function* foo() {
print('1');
yield Promise.resolve('a').then(x => {
print('2');
return x;
});
print('3');
}
let iter = foo();
iter.next();
iter.next(); With value unwrapping, I would naively expect this output:
What would you expect? What does your implementation output? Further: what does this do? async function* foo() {
yield Promise.reject(1);
yield 2;
}
let iter = foo();
iter.next().then(print);
iter.next().then(print); If it prints this:
then we have broken an important generator invariant: that a |
Because I know that the Promise isn't waited for if there isn't an "await", even though it is unwrapped, I would expect
First, Second, Finally, AsyncGeneratorResumeNext is invoked at the end of AsyncGeneratorResolve. Since the generator has not seen an error yet, and
|
I think your read that the generator invariant is broken is incorrect, because the generator has not actually seen an error. The request's Promise is rejected, but as mentioned, this is entirely separate from flow control within the generator. No exception is thrown as far as the generator can see, and no rejection has been awaited. |
For me, it's not a question of what the generator sees, but what the consumer of the generator sees when manually calling |
Mmm, I still don't see this as comparable to sync generators. There is no equivalent sync-generator case that sees this kind of control flow, this is true --- but so what? Promises aren't a 1:1 abstraction for sync control flow. Now, if you wanted to make this equivalent to the sync generator case, you could simply change the generator code: async function* foo() {
yield await Promise.reject(1);
yield 2;
} But without that |
It's a fundamental change to the iteration protocol and has an impact on code which relies on that protocol. For instance, any combinator library over this proposal will have to decide what to do with interstitial errors, whereas combinators over sync iterators can always assume the "terminating error" protocol. |
Well, you want to be able to continue iteration before a Promise has been settled, that's one of the benefits of async iteration. Short of making all "yields" implicitly await, I don't see another way to do it, and I don't think that would be beneficial. |
It's a design challenge, for sure. Another worry I have is that, even if we're okay with the current semantics, the caller of let iter = asyncGenFunction();
iter.next().catch(err => {
// Is the iterator at end-of-stream now or not? I can't tell.
}); The only two options I can see for solving that problem are:
|
What would a |
According to the current spec: async function* foo() {
yield Promise.reject(1);
yield 2;
}
let iter = foo();
iter.next().catch(err => {
// This is a "not done" error, because the next call
// to "next" will result in { value: 2, done: false }
}); |
Oh weird. Unwrapping promises but not using What happens when you If it continues to the next If there's not implicit await, I like the idea of being able to have an async iterator of promises over unwrapping. |
One of the problems with this is with user-defined async iterators (not generators or async-from-sync iterators), because then it's impossible to enforce. |
I'm not sure I understand the concern there. Is the issue that user-defined async iterators might return promises that throw errors without cleaning up the iterator? Or is it something else that I'm not getting? If people are throwing errors from iterators and expecting iteration to continue, I'm pretty sure they're doing it wrong, and I'm not sure I see the benefit in accounting for that |
Yes. If the expectation is that rejections are final, there is no way to require that of user-defined iterators. It's not like with sync-iterators where you can just assert that they return an iterator result object from .next(). |
Well with sync iterators, if there's an error, it'll just be thrown when you invoke I think the same thing could happen here: if the async generator's Anything that applies to user-defined async iterators not performing cleanup and error handling properly should be exactly the same for sync iterators, and if stopping iteration on throwing is good enough for sync iterators, it should be good enough for async iterators. |
This isn't actually possible. async function* foo() {
yield Promise.resolve().then(function() {
// At the time AsyncGeneratorResolve is performed, we have no idea that this Promise
// will be rejected
throw "blah";
});
yield 2;
} |
(reading relevant parts of the spec so I can be more informed) |
I thought I had it for a bit, and wrote up a paragraph of what I thought I understood, and I think I'm lost again. Sorry! If I understand correctly, it's impossible because |
I agree we need to revisit. I am no longer convinced of my previous position. |
A related issue I see with the current spec is that it appears that async function f() {
let asyncIter = {
[Symbol.asyncIterator]() { return this; },
next() {
return Promise.resolve({ value: Promise.resolve(1), done: false });
},
};
for await (let value of asyncIter) {
console.log(value); // Promise<1>?
break;
}
} Am I reading the spec correctly? If so, I think it violates the user's expectation that when we see keyword "await", we assume full unwrapping, regardless of what is passed in. In my mind, this is further evidence that the best way forward is to perform the value component unwrapping in |
@zenparsing Hmm, interesting case. |
@zenparsing good eye, I didn't notice this before. It's strange to me that the iterator result is awaited, but not the value component. On the other hand, you don't see this with async-from-sync iterators, so arguably the iterator is just implemented wrong. I dunno. I think the expectation is more like this: async function unwrap(value, done) {
return { value: await value, done };
}
async function f() {
let asyncIter = {
[Symbol.asyncIterator]() { return this; },
next() {
return unwrap(Promise.resolve(1), false);
},
};
for await (let value of asyncIter) {
console.log(value); // 1
break;
}
} I'm not sure how you could make this automatic or enforce it, though, other than adding an extra await (and there are probably enough of those already) |
Just a consequence I realized of value field unwrapping is that it makes the done value available later than it would've otherwise been. Without value unwrapping this is safe to do with an async generator (and is currently how babel works so you can try it): async function* concurrent(seq, max=8) {
const iter = seq[Symbol.asyncIterator]()
const promises = []
let final
let done = false
for (let i=0 ; i < max ; i++) {
// If a promise/value is received it'll still happen concurrently for an async generator
// whereas if value unwrapping occurs this will block until the value is ready and thus won't
// be concurrent
const { value, done: itemDone } = await iter.next()
if (itemDone) {
done = true
final = value
break
}
promises.push(value)
}
while (!done) {
yield await promises.shift()
const { value, done: itemDone } = await iter.next()
if (itemDone) {
done = true
final = value
} else {
promises.push(value)
}
}
// Consume anything left over
for (const promise of promises) {
yield await promise
}
return final
} With value unwrapping I think it's impossible to avoid over-consuming the iterator (although it's not really a big deal with this operator). e.g. the operator would look like this (and in fact this operator works regardless of whether or not value unwrapping happens at the generator level). async function* concurrent(seq, max=8) {
const iter = seq[Symbol.asyncIterator]()
const promises = []
for (let i=0 ; i < max ; i++) {
// If value unwrapping happens then we can't inspect if we're done
// or not until after the value is available so we might over-consume the iterator here
promises.push(iter.next())
}
while (true) {
const { value, done } = await promises.shift()
if (done) {
return value
}
yield value
// Here we'll *always* over-consume the iterator (if its finite) because a done could already
// be waiting in the promises
promises.push(iter.next())
}
} So I'm not sure how I feel now about value unwrapping at the async generator level (still totally support value unwrapping at the |
The decision was that manually-constructed async iterators can be error prone and that we shouldn't try to patch over them in for-await (just like we don't patch over them in next() somehow). In general we don't want to treat for-await and yield* as special if at all possible. |
Again, I would say that if the user sees I don't see it as a case of treating I'm a little unclear on how |
This is intentional. You indeed can't know if you're done until the promise settles, as it might reject. |
That is almost exactly the same example as the one given above, and the issue is the same: you can't know if the promise will reject or not. There are easy alternate implementation strategies that will allow you to speculate that maybe the async operation will succeed, and move onward then. You can modify your code to make such speculation if you want. If your intent is literally to yield promises for some reason, you can wrap them in an object, e.g. |
(I removed my comments since this is a rather large thread already, and they were not adding anything useful to the conversation) |
To sum up, this thread lists three possible semantics: A: B: C All of these seem like plausible designs to me. Differences: Parallelism
Type of
What would user intuitions be about these two properties of the semantics? |
To clarify, I don't really want this. I think the current behaviour is good enough. I can live with yielding rejected Promises not affecting generator flow control. |
I was hoping to have time to put together a more comprehensive slide deck and summary of this issue myself in advance of the next meeting. For now I just want to say that the comment above does not cover all aspects of the issue, so that people don't get confused and re-start the discussion without full information. E.g. it doesn't deal with the confusion between the two types of rejected promises that (A) and (B) create and the way different parts of the system treat them. |
I've put together a summary slide deck of the issue, discussing the three potential solutions I've seen under discussion so far. This will be used at next week's TC39 meeting, but I'd be happy for people to read it and send comments ahead of time, especially about things that could be clearer or things I haven't considered. In any case, I'm planning that we have a resolution one way or another for the larger issue by the end of the meeting. Of course then we'll be able to address the OP's smaller issue. And in the end we'll write extensive test262 tests for whatever is decided on. |
I kept reading the slides thinking "I sure hope option 3 makes |
I was initially confused by yield* in option 2 not throwing, but it made sense since it'd behave the same for manual consumers and would still throw for fot-await. Option 3 would be the least astonishing for people to use IMO. |
With regards to option 3 allowing for async iterators that will make for-await see values that are promises, I doubt that any producers violating the contract like that will be used in the wild since it's a pretty mistake to make. A person making a custom producer might make the mistake once and catch it when doing tests and never make it again. |
Resolution was option 3 |
I'm fine with this resolution. It was already unreasonably difficult to write async generators which respected the concurrency of the original iterator, I never figured out how to write a Now that unwrapping isn't happening inside async generators, Table 2 should probably be changed from saying
to just mentioning that no special meaning is assigned to promises in the value field and that a promise in the value field may be a mistake. |
Would this conclusion be equivalent to reverting ca6942b , or does option 3 differ from what came before this patch somehow? |
This is reverting some of ca6942b, per the May 2017 TC39 meeting agreement to make `yield` automatically await. This also simplifies yield* to be more simply a for-await-of that yields; the only modifications it requires from the original yield* spec text are those needed to parallel for-await-of, and those needed to call AsyncGeneratorYield instead of GeneratorYield. Fixes #93: both the original issue posted there, and the much larger issue it evolved into.
This is reverting some of ca6942b, per the May 2017 TC39 meeting agreement to make `yield` automatically await. Making those changes also revealed that we needed similar changes for `return`, which were made more complicated than those for `yield` due to the special behavior of the `asyncGen.return()` method. In order to make that behave the same as a `return` statement, additional changes were necessary in a few places. This also simplifies yield* to be more simply a for-await-of that yields; the only modifications it requires from the original yield* spec text are those needed to parallel for-await-of, and those needed to call AsyncGeneratorYield instead of GeneratorYield. Fixes #93: both the original issue posted there, and the much larger issue it evolved into.
This is reverting some of ca6942b, per the May 2017 TC39 meeting agreement to make `yield` automatically await. Making those changes also revealed that we needed similar changes for `return`, which were made more complicated than those for `yield` due to the special behavior of the `asyncGen.return()` method. In order to make that behave the same as a `return` statement, additional changes were necessary in a few places. This also simplifies yield* to be just a for-await-of that yields; the only modifications it requires from the original yield* spec text are those needed to parallel for-await-of, and those needed to call AsyncGeneratorYield instead of GeneratorYield. Fixes #93: both the original issue posted there, and the much larger issue it evolved into.
I've read the slides, but it is still not clear for me, sorry. The slides don't mention why option 0 (no unwrap in Why async function a() {
Promise.reject(1)
return 1
}
async function b() {
await Promise.reject(1)
return 1
} in a - it is just a value, in b - it is an exception, while, yes, if we return the value after it will be handled in next |
During testing async iteration I faced with unexpected result:
I expect following print:
As I can understand this happened because we do not directly resolve promiseCapabilty but use valueWrapperCapability:
6.4.3.3 AsyncGeneratorResolve
....
6.4.3.3.10. Perform ! PerformPromiseThen(valueWrapperCapability.[[Promise]], onFulfilled, undefined, promiseCapability)
6.4.3.3.11.Perform ! AsyncGeneratorResumeNext(generator).
and in case of throw() we print result earlier because in 6.4.3.4 AsyncGeneratorReject we reject promiseCapabilty without any wrapper.
Could you please suggest is this something wrong in my implementation or it works as expected?
Best regards,
Oleksandr
The text was updated successfully, but these errors were encountered: