Skip to content
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

Open
wants to merge 2 commits into
base: main
Choose a base branch
from

Conversation

mhofman
Copy link
Member

@mhofman mhofman commented Dec 17, 2021

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 the yield* 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 equivalent for..of loop awaiting the result would. After this change, the iterator would be closed in both cases. Closes #1849

This change plumbs the sync iterator into AsyncFromSyncIteratorContinuation with instructions to close it on rejection, but not for return 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 the value given to throw. This deviates from the yield * behavior in 2 ways: the wrapped iterator is not closed, and the rejection value not a TypeError to indicate the contract was broken. This updates fixes both differences by closing the iterator, and throwing a new TypeError instance instead of the value provided to throw.

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 calling yield * with a sync iterator from an async generator, and explicitly call throw on that async iterator.

@mhofman mhofman added needs consensus This needs committee consensus before it can be eligible to be merged. normative change Affects behavior required to correctly evaluate some ECMAScript source text labels Dec 17, 2021
@mhofman mhofman force-pushed the fix-async-from-sync-close branch 2 times, most recently from e5c1f81 to 92262e3 Compare December 17, 2021 03:06
@bakkot bakkot added the needs test262 tests The proposal should specify how to test an implementation. Ideally via github.com/tc39/test262 label Dec 17, 2021
@mhofman mhofman marked this pull request as ready for review December 17, 2021 03:43
spec.html Outdated Show resolved Hide resolved
Copy link
Member

@jridgewell jridgewell left a 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*, &laquo; a newly created *TypeError* object &raquo;).
1. Return _promiseCapability_.[[Promise]].
1. Return ! AsyncFromSyncIteratorContinuation(_result_, _promiseCapability_).
1. Return ! AsyncFromSyncIteratorContinuation(_result_, _promiseCapability_, _syncIteratorRecord_, *false*).
Copy link
Member

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?

Copy link
Contributor

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 breaks, 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.

Copy link
Member Author

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.)

Copy link
Contributor

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.)

Copy link
Member Author

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.

Copy link
Member Author

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 called return, 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?

Copy link
Contributor

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.

Copy link
Member Author

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.

Copy link
Contributor

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 lacks throw, fall back to return
  • if throw is called on the outer iterator and the inner iterator has throw and it returns { done: false, value: Promise.reject() }, call return on the inner iterator.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bakkot's suggestion SGTM.

@mhofman
Copy link
Member Author

mhofman commented Dec 17, 2021

The iterator is returning a value, which may throw when we try to coerce into a native promise.

In principle I'd say yes, but this will complicate things quite a bit, and arguably we have an iterator yielding bogus values

@bakkot
Copy link
Contributor

bakkot commented Dec 17, 2021

In principle I'd say yes, but this will complicate things quite a bit

Is it not sufficient to add 1. If _valueWrapper_ is an abrupt completion, set _valueWrapper_ to IteratorClose(_syncIteratorRecord_, _valueWrapper_) before step 6? That's not very much complexity.

@mhofman
Copy link
Member Author

mhofman commented Dec 17, 2021

But only if closeIteratorOnRejection and not done, right ?

@bakkot
Copy link
Contributor

bakkot commented Dec 17, 2021

Sorry, right.

spec.html Outdated Show resolved Hide resolved
@mhofman
Copy link
Member Author

mhofman commented Dec 17, 2021

PTAL @bakkot @jridgewell .

I decided to ignore the operation type when closing the iterator and solely rely on the done value.
If the sync iterator decides it isn't actually done on return, it will get another closing attempt from IteratorClose, but that's fine by me since it's a buggy behavior in the first place on behalf of the sync iterator.

@mhofman mhofman force-pushed the fix-async-from-sync-close branch from 1400ee6 to f3c4d29 Compare December 21, 2021 18:56
@mhofman mhofman marked this pull request as draft January 13, 2022 17:52
@mhofman mhofman force-pushed the fix-async-from-sync-close branch from f3c4d29 to 4dcbf3d Compare January 22, 2022 23:55
@mhofman mhofman marked this pull request as ready for review January 23, 2022 00:40
@mhofman
Copy link
Member Author

mhofman commented Jan 23, 2022

@bakkot @jridgewell, PTAL

I believe this now specifies the behavior we discussed in #2600 (comment)

We could consider rejecting with TypeError for missing throw like yield *.

This is yet another discrepancy I found, but I'm less sure if we should fix it.

Copy link
Contributor

@bakkot bakkot left a 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.

spec.html Outdated Show resolved Hide resolved
spec.html Outdated Show resolved Hide resolved
spec.html Outdated Show resolved Hide resolved
@jmdyck
Copy link
Collaborator

jmdyck commented Jan 25, 2022

Looks okay to me.

Note that AsyncFromSyncIteratorContinuation's new _syncIteratorRecord_ parameter would be an Iterator Record in PR #2591, so whichever lands second should update.

@ljharb ljharb added has consensus This has committee consensus. es2022 and removed needs consensus This needs committee consensus before it can be eligible to be merged. labels Jan 25, 2022
@mhofman mhofman force-pushed the fix-async-from-sync-close branch from c85d807 to da31741 Compare January 25, 2022 21:08
ptomato pushed a commit to tc39/test262 that referenced this pull request Jan 23, 2024
…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>
@ptomato
Copy link
Contributor

ptomato commented Jan 23, 2024

The tests have landed now in test262.

@ptomato ptomato added has test262 tests and removed needs test262 tests The proposal should specify how to test an implementation. Ideally via github.com/tc39/test262 labels Jan 23, 2024
@ljharb ljharb requested a review from jmdyck January 24, 2024 04:55
@ljharb ljharb force-pushed the fix-async-from-sync-close branch from 6602674 to f42aea0 Compare January 24, 2024 04:55
@bakkot
Copy link
Contributor

bakkot commented Jan 24, 2024

This maybe shouldn't land until there's implementations. Hopefully that'll happen soon now there's tests.

Copy link
Collaborator

@jmdyck jmdyck left a 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.

@bakkot
Copy link
Contributor

bakkot commented Apr 17, 2024

V8 issue: https://bugs.chromium.org/p/v8/issues/detail?id=12594
SpiderMonkey issue: https://bugzilla.mozilla.org/show_bug.cgi?id=1877862 (at least this was the best one I could find)

hubot pushed a commit to v8/v8 that referenced this pull request Apr 18, 2024
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}
sosukesuzuki added a commit to sosukesuzuki/WebKit that referenced this pull request May 28, 2024
…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):
@sosukesuzuki
Copy link

sosukesuzuki commented Jun 26, 2024

It has not been merged in JSC yet?

@syg
Copy link
Contributor

syg commented Jun 26, 2024

It has not been merged in JSC yet?

Oh! Apologies, I misread the auto-linked stuff in the PR.

@vadzim
Copy link

vadzim commented Jul 15, 2024

spidermonkey issue
https://bugzilla.mozilla.org/show_bug.cgi?id=1610315

copybara-service bot pushed a commit to google/closure-compiler that referenced this pull request Jul 17, 2024
…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
Hans-Halverson added a commit to Hans-Halverson/brimstone that referenced this pull request Oct 15, 2024
… 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.
@vadzim
Copy link

vadzim commented Dec 13, 2024

@mhofman

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.

@mhofman
Copy link
Member Author

mhofman commented Dec 14, 2024

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 catch is triggered because the yield does an implicit await of the yielded value. The spec currently does not ever invoke throw on its own on an iterator: it only passes it through in the case of yield * or when a async-from-sync iterator has its throw called.

I do not recall if this was explicitly discussed in plenary, but technically we could attempt to invoke the throw of the sync iterator if it exists in the onRejected handler. This would be another normative change.

@vadzim
Copy link

vadzim commented Dec 16, 2024

technically we could attempt to invoke the throw of the sync iterator if it exists in the onRejected handler

yep, sync-to-async wrapper already awaits the sync generator's yielded values simulating the implicit awaits within an async generator.
just it does not simulate throwing an exception.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
has consensus This has committee consensus. has test262 tests normative change Affects behavior required to correctly evaluate some ECMAScript source text
Projects
None yet
Development

Successfully merging this pull request may close these issues.

finally is not called when asynchronously iterating over synchronous generator which yields rejected promise