-
Notifications
You must be signed in to change notification settings - Fork 1.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Normative: Close sync iterator when async wrapper yields rejection #2600
base: main
Are you sure you want to change the base?
Conversation
e5c1f81
to
92262e3
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think there's one more case with step 5 and 6:
5. Let valueWrapper be PromiseResolve(%Promise%, value).
6. IfAbruptRejectPromise(valueWrapper, promiseCapability).
The iterator is returning a value, which may throw when we try to coerce into a native promise. Eg,
function* gen() {
try {
const p = Promise.resolve('FAIL');
Object.defineProperty(p, 'constructor', {
get() {
throw new Error('foo');
}
});
yield p;
} finally {
console.log('PASS');
}
}
(async () => {
for await (const v of gen()) {
console.log(v);
}
})()
This should call the finally, but doesn't.
spec.html
Outdated
@@ -43009,7 +43009,7 @@ <h1>%AsyncFromSyncIteratorPrototype%.throw ( [ _value_ ] )</h1> | |||
1. If Type(_result_) is not Object, then | |||
1. Perform ! Call(_promiseCapability_.[[Reject]], *undefined*, « a newly created *TypeError* object »). | |||
1. Return _promiseCapability_.[[Promise]]. | |||
1. Return ! AsyncFromSyncIteratorContinuation(_result_, _promiseCapability_). | |||
1. Return ! AsyncFromSyncIteratorContinuation(_result_, _promiseCapability_, _syncIteratorRecord_, *false*). |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wouldn't you need to close here? Can you recover from a recovery?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good question. I find it hard to know what the "right" answer is for throw
, since it doesn't come up as much (the user has to call it explicitly), but I think you're right. Compare, for example,
function* count() {
try {
for (let i = 0; i < 10; ++i) {
try {
yield i;
} catch (e) {
console.log("caught and suppressed", e);
}
}
} finally {
console.log("time to clean up");
}
}
function* wrap(inner) {
yield* inner;
}
let iterable = wrap(count());
for (let item of iterable) {
console.log(item);
console.log("throw returned", iterable.throw("throwing here")); // note that this advances the iterator
if (item >= 4) break;
}
This goes through three iterations of the loop, printing "caught and suppressed throwing here" in each one, and then on the last iteration, which break
s, also prints "time to clean up".
(The call to wrap
in this example has no observable effects, vs just iterable = count()
which is my point - calling .throw
on this wrapper delegates it to the inner iterator, which suppresses the exception, so the loop continues and can later call .return
.)
I'm not at all clear on when you'd ever want to do call throw
- I think it was copied from Python, which uses specific kinds of exceptions for more general signals in a way that JS does not, but this was before my time and I haven't reviewed all the relevant notes. But if we assume the analogy above is reasonable, then I agree with @jridgewell.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch, I totally forgot about throw
being able to recover. And I agree, letting the sync iterator decide is the right approach. Good thing is, since we now check the done
value before installing the close on rejection logic, just switching the boolean to true
here should be sufficient.
Which begs the question, should return
be handled similarly? It seems that the result of return
can similarly continue a yield *
from my reading of the steps (7.c.viii.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Which begs the question, should
return
be handled similarly?
Huh. I guess so, looking at it, though I think this matters less - as these slides say, failure to stop iterating when return
is called is probably a bug in the contract between the iterator and the loop.
(It would allow dropping the closeIteratorOnRejection
parameter entirely, which is nice.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Based on
- In short: any abrupt completion of the loop.
- Normal completion should not call the method; in
that case the iterator itself decided to close
I'm willing to say, let's close if the sync iterator doesn't believe it's done, but the consumer of the wrapper considers the async iterator is buggy, regardless of what caused the iterator result to be yielded.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
except to forward on explicit calls to
throw
from the user
Yeah, and this is actually another inconsistency of this wrapper. If sync iterator doesn't have a throw
method, the wrapper doesn't fallback to .return
like yield *
would.
Here we would be calling it twice. That seems like it would violate the expectation that it gets called exactly once. It's also not clear what benefit it would have: the language calls
return
to say "I'm done with this", but we've already done that the first time we calledreturn
, and it's not like we're going to be any more done just because the promise rejected.
Yeah I think I agree.
@bakkot, what do you think of still calling .return
if .throw
returns a rejected promise as value with done: false
, but not calling .return
a second time if .return
was already called, regardless if it returned done: false
or not. In that case I can reintroduce the boolean flag and name it something like "returnAlreadyCalled".
Also, do you think we should add a .return
fallback in the wrapper's .throw
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't have much intuition for how throw
is supposed to work, to be honest.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't have much intuition for how
throw
is supposed to work, to be honest.
Step 7.b.3
of yield *
falls back to calling .return
if throw
is undefined
on the iterator. The problem is that the wrapper has a throw
on its prototype regardless of the shape of the sync iterator, so yield *
would call the wrapper's .throw
, which would simply reject because it can't find a .throw
on the sync iterator, and not call .return
like yield *
would have if the wrapper lacked a .throw
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I guess the analogy to yield*
is compelling. I'm on board with both
- if
throw
is called on the outer iterator and the inner iterator lacksthrow
, fall back toreturn
- if
throw
is called on the outer iterator and the inner iterator hasthrow
and it returns{ done: false, value: Promise.reject() }
, callreturn
on the inner iterator.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@bakkot's suggestion SGTM.
In principle I'd say yes, but this will complicate things quite a bit, and arguably we have an iterator yielding bogus values |
Is it not sufficient to add |
But only if |
Sorry, right. |
PTAL @bakkot @jridgewell . I decided to ignore the operation type when closing the iterator and solely rely on the |
1400ee6
to
f3c4d29
Compare
f3c4d29
to
4dcbf3d
Compare
@bakkot @jridgewell, PTAL I believe this now specifies the behavior we discussed in #2600 (comment)
This is yet another discrepancy I found, but I'm less sure if we should fix it. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM other than comment.
Looks okay to me. Note that AsyncFromSyncIteratorContinuation's new |
c85d807
to
da31741
Compare
…brupt completes. (#3977) * Test closing async-from-sync iterator when resolving result promise abrupt completes. These test new steps 6-6.a of AsyncFromSyncIteratorContinuation as per normative changes of ecma626 PR 2600 tc39/ecma262#2600 * Apply suggestions from code review Co-authored-by: Kevin Gibbons <bakkot@gmail.com> * Apply suggestions from code review Co-authored-by: Nicolò Ribaudo <hello@nicr.dev> * Refactoring tests to use the Async Helpers. --------- Co-authored-by: Kevin Gibbons <bakkot@gmail.com> Co-authored-by: Nicolò Ribaudo <hello@nicr.dev>
The tests have landed now in test262. |
6602674
to
f42aea0
Compare
This maybe shouldn't land until there's implementations. Hopefully that'll happen soon now there's tests. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks okay to me.
V8 issue: https://bugs.chromium.org/p/v8/issues/detail?id=12594 |
This implements the normative change in tc39/ecma262#2600 There is also a drive-by fix that changes the methods from throwing when encountering a non-AsyncFromSyncIterator receiver to CHECK()ing instead, because AsyncFromSyncIterator is not exposed to user code and cannot be called with an incorrect receiver. Bug: v8:12594 Change-Id: Id4f9348cc2baf3e484feed8b93a9eb6bb04bd832 Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/5460446 Reviewed-by: Eric Leese <leese@chromium.org> Reviewed-by: Michael Lippautz <mlippautz@chromium.org> Reviewed-by: Rezvan Mahdavi Hezaveh <rezvan@chromium.org> Commit-Queue: Shu-yu Guo <syg@chromium.org> Cr-Commit-Position: refs/heads/main@{#93445}
…ncFromSyncIterator https://bugs.webkit.org/show_bug.cgi?id=273768 Reviewed by NOBODY (OOPS!). This patch implements the new normative change[1]. When the AsyncFromSyncIterator yields a rejected promise, it calls the return method of the underlying syncIterator and closes it. [1]: tc39/ecma262#2600 * JSTests/test262/expectations.yaml: * Source/JavaScriptCore/builtins/AsyncFromSyncIteratorPrototype.js: (linkTimeConstant.asyncFromSyncIteratorOnRejected): (return): (throw): (linkTimeConstant.AsyncFromSyncIterator):
It has not been merged in JSC yet? |
Oh! Apologies, I misread the auto-linked stuff in the PR. |
spidermonkey issue |
…s for when the wrapped sync iterator is missing a `throw` method. Previously, the async iterator would also elide the `throw` method, but github.com/tc39/ecma262/pull/2600 specifies that it should instead provide a `throw` method that (1) closes the underlying sync iterator (via `return`), and (2) throws a new `TypeError` indicating that the wrapped sync iterator was non-conformant. PiperOrigin-RevId: 653268505
… rejecting (#72) ## Summary In tc39/ecma262#2600 AsyncFromSyncIterator was changed to close the underlying sync iterator before rejecting in some cases. In addition if in `AsyncFromSyncIterator.prototype.throw` the underlying sync iterator does not have a `throw` method, threw a new TypeError. ## Tests Fixes all failing AsyncFromSyncIteratorPrototype test262 tests.
First of all thank you a lot for your work. Recent Chrome and Node already have this fix. I've just realized that just closing sync iterator causes different behavior with try/catch within async and sync generators like void async function () {
try {
for await (const num of function*() {
try {
yield 1;
yield Promise.reject(2);
yield 3;
} catch (e) {
console.log("called catch", e);
throw e;
} finally {
console.log("called finally");
}
}()) {
console.log(num);
}
} catch (e) {
console.log("caught", e);
}
}()
// 1
// called finally
// caught 2 and void async function () {
try {
for await (const num of /*->*/async/*<- the only difference*/ function*() {
try {
yield 1;
yield Promise.reject(2);
yield 3;
} catch (e) {
console.log("called catch", e);
throw e;
} finally {
console.log("called finally");
}
}()) {
console.log(num);
}
} catch (e) {
console.log("caught", e);
}
}()
// 1
// called catch 2
// called finally
// caught 2 In particular that means that async iterator can catch the error, handle it and continue to run while sync iterator cannot. I would personally prefer that AsyncFromSyncIteratorContinuation makes sync iterator behavior exactly the same as async one and not to explain my grandchild why even recent JS features are so damn strange. But I'm not really sure if it's worth to spend someone's time to fix that. |
This is a potentially more complicated problem. In the case of the async generator, the I do not recall if this was explicitly discussed in plenary, but technically we could attempt to invoke the |
yep, sync-to-async wrapper already awaits the sync generator's yielded values simulating the implicit awaits within an async generator. |
This updates Async-from-Sync Iterator Objects so that the async wrapper closes its sync iterator when the sync iterator yields a rejected promise as value. This also updates the async wrapper to close the sync iterator when
throw
is called on the wrapper but is missing on the sync iterator, and updates the rejection value in that case to a TypeError to reflect the contract violation. This aligns the async wrapper behavior to theyield*
semantics.Slides presented at TC39 plenary in January 2020.
Close on rejection
A rejected promise as value is transformed by the async wrapper into a rejection, which is considered by consumers of async iterators as a fatal failure of the iterator, and the consumer will not close the iterator in those cases. However, yielding a rejected promise as value is entirely valid for a sync iterator. The wrapper should adapt both expectations and explicitly close the sync iterator it holds when this situation arise.
Currently a sync iterator consumed by a
for..await..of
loop would not trigger the iterator's close when a rejected promise is yielded, but the equivalentfor..of
loop awaiting the result would. After this change, the iterator would be closed in both cases. Closes #1849This change plumbs the sync iterator into
AsyncFromSyncIteratorContinuation
with instructions to close it on rejection, but not forreturn
calls (as the iterator was already instructed to close), or if the iterator closed on its own (done === true
).Close on missing
throw
If
throw
is missing on the sync iterator, the async wrapper currently simply rejects with thevalue
given to throw. This deviates from theyield *
behavior in 2 ways: the wrapped iterator is not closed, and the rejection value not aTypeError
to indicate the contract was broken. This updates fixes both differences by closing the iterator, and throwing a newTypeError
instance instead of the value provided tothrow
.Since the spec never calls
throw
on an iterator on its own (it only ever forwards it), and that the async wrapper is never exposed to the program, the only way to observe this async wrapper behavior is through a program callingyield *
with a sync iterator from an async generator, and explicitly callthrow
on that async iterator.