Normative: Close sync iterator when async wrapper yields rejection - #2600
Conversation
e5c1f81 to
92262e3
Compare
jridgewell
left a comment
There was a problem hiding this comment.
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.
| 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.
Wouldn't you need to close here? Can you recover from a recovery?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
Which begs the question, should
returnbe 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.
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.
except to forward on explicit calls to
throwfrom 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
returnto 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.
I don't have much intuition for how throw is supposed to work, to be honest.
There was a problem hiding this comment.
I don't have much intuition for how
throwis 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.
Yeah, I guess the analogy to yield* is compelling. I'm on board with both
- if
throwis called on the outer iterator and the inner iterator lacksthrow, fall back toreturn - if
throwis called on the outer iterator and the inner iterator hasthrowand it returns{ done: false, value: Promise.reject() }, callreturnon the inner iterator.
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. |
|
Looks okay to me. Note that AsyncFromSyncIteratorContinuation's new |
c85d807 to
da31741
Compare
|
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 2and 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 2In 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. |
…row` is null or undefined https://bugs.webkit.org/show_bug.cgi?id=286885 Reviewed by Yusuke Suzuki. The normative change for `AsyncFromSyncIterator`[1] includes the following two changes: 1. AsyncFromSyncIterator now closes the sync iterator when `throw` is called on the wrapper but is not implemented on the sync iterator. In that case, it updates the rejection value to a `TypeError` to reflect the contract violation. 2. AsyncFromSyncIterator closes its sync iterator when the sync iterator yields a rejected promise as its value. This normative change has already been implemented in V8[2]. This patch changes to implement only the first change. [1]: tc39/ecma262#2600 [2]: v8/v8@6c3f7aa#diff-c18e5d5743326f6ba544801f6ce25154b785f759885651f98924d5b56dc1c2e6 * JSTests/stress/async-from-sync-iterator-prototype-throw-close-sync-iter.js: Added. (shouldBe): (shouldThrowAsync): (throw.new.Error.const.syncIterator.get throw): (throw.new.Error.const.asyncIterator): (throw.new.Error.async OurError): (throw.new.Error): (const.syncIterator.get throw): (throw.new.Error.async drainMicrotasks): (const.asyncIterator): (async OurError): (OurError): (NotOurError): (const.syncIterator.get return): (async drainMicrotasks): (async asyncIterator): * JSTests/test262/expectations.yaml: * Source/JavaScriptCore/builtins/AsyncFromSyncIteratorPrototype.js: (throw): Canonical link: https://commits.webkit.org/289726@main
…xt`/`throw` yields rejected promise https://bugs.webkit.org/show_bug.cgi?id=273768 Reviewed by Yusuke Suzuki. The normative change for `AsyncFromSyncIterator`[1] includes the following two changes: 1. AsyncFromSyncIterator now closes the sync iterator when `throw` is called on the wrapper but is not implemented on the sync iterator. In that case, it updates the rejection value to a `TypeError` to reflect the contract violation. 2. AsyncFromSyncIterator closes its sync iterator when the sync iterator yields a rejected promise as its value. This normative change has already been implemented in V8[2]. This patch changes to implement only the second change. [1]: tc39/ecma262#2600 [2]: v8/v8@6c3f7aa#diff-c18e5d5743326f6ba544801f6ce25154b785f759885651f98924d5b56dc1c2e6 * JSTests/stress/async-from-sync-iterator-prototype-next-rejected-close-sync-iter.js: Added. (shouldBe): (shouldThrowAsync): (throw.new.Error.OurError): (throw.new.Error.iterator): (OurError): (throw.new.Error.async drainMicrotasks): (iterator): (async asyncIterator): (async drainMicrotasks): * JSTests/stress/async-from-sync-iterator-prototype-throw-rejected-close-sync-iter.js: Added. (shouldBe): (shouldThrowAsync): (throw.new.Error.OurError): (throw.new.Error.async asyncIterator): * JSTests/test262/expectations.yaml: * Source/JavaScriptCore/builtins/AsyncFromSyncIteratorPrototype.js: (linkTimeConstant.asyncFromSyncIteratorOnRejected): (linkTimeConstant.asyncFromSyncIteratorOnFulfilledContinue): (linkTimeConstant.asyncFromSyncIteratorOnFulfilledDone): (return): (throw): * Source/JavaScriptCore/builtins/BuiltinNames.h: Canonical link: https://commits.webkit.org/290137@main
f42aea0 to
ff129b1
Compare
|
Sorry, guys |
|
@vadzim No, no one has proposed to change that behavior (and I would be opposed to changing it - that looks like the right behavior to me). |
It does not. According to #1849 (comment), there are opinions to not have the synthetic throw behavior you propose. You could open a new issue, but that change would be harder to justify. I am personally ambivalent, but it's not really clear what the behavior should be if |
Adds built-ins/Array/fromAsync/sync-iterable-with-rejecting-thenable-closes.js. This is related to tc39/ecma262#1849 and tc39/ecma262#2600. This closes tc39/proposal-array-from-async#49. Renames built-ins/Array/fromAsync/sync-iterable-with-thenable-element-rejects.js to built-ins/Array/fromAsync/sync-iterable-with-rejecting-thenable-closes.js for consistency with the new test.
This PR contains the following updates: | Package | Change | Age | Adoption | Passing | Confidence | |---|---|---|---|---|---| | [core-js](https://redirect.github.com/zloirock/core-js) ([source](https://redirect.github.com/zloirock/core-js/tree/HEAD/packages/core-js)) | [`3.41.0` -> `3.42.0`](https://renovatebot.com/diffs/npm/core-js/3.41.0/3.42.0) | [](https://docs.renovatebot.com/merge-confidence/) | [](https://docs.renovatebot.com/merge-confidence/) | [](https://docs.renovatebot.com/merge-confidence/) | [](https://docs.renovatebot.com/merge-confidence/) | --- ### Release Notes <details> <summary>zloirock/core-js (core-js)</summary> ### [`v3.42.0`](https://redirect.github.com/zloirock/core-js/blob/HEAD/CHANGELOG.md#3420---20250430) [Compare Source](https://redirect.github.com/zloirock/core-js/compare/v3.41.0...v3.42.0) - Changes [v3.41.0...v3.42.0](https://redirect.github.com/zloirock/core-js/compare/v3.41.0...v3.42.0) (142 commits) - [`Map` upsert proposal](https://redirect.github.com/tc39/proposal-upsert): - Moved to stage 2.7, [April 2025 TC39 meeting](https://x.com/robpalmer2/status/1911882240109261148) - Validation order of `WeakMap.prototype.getOrInsertComputed` updated following [tc39/proposal-upsert#79](https://redirect.github.com/tc39/proposal-upsert/pull/79) - Built-ins: - `Map.prototype.getOrInsert` - `Map.prototype.getOrInsertComputed` - `WeakMap.prototype.getOrInsert` - `WeakMap.prototype.getOrInsertComputed` - Don't call well-known `Symbol` methods for `RegExp` on primitive values following [tc39/ecma262#3009](https://redirect.github.com/tc39/ecma262/pull/3009): - For avoid performance regression, temporarily, only in own `core-js` implementations - Built-ins: - `String.prototype.matchAll` - `String.prototype.match` - `String.prototype.replaceAll` - `String.prototype.replace` - `String.prototype.search` - `String.prototype.split` - Added workaround for the [`Uint8Array.prototype.setFromBase64`](https://redirect.github.com/tc39/proposal-arraybuffer-base64) [bug](https://bugs.webkit.org/show_bug.cgi?id=290829) in some of Linux builds of WebKit - Implemented early-error iterator closing following [tc39/ecma262#3467](https://redirect.github.com/tc39/ecma262/pull/3467), including fix of [a WebKit bug](https://bugs.webkit.org/show_bug.cgi?id=291195), in the following methods: - `Iterator.prototype.drop` - `Iterator.prototype.every` - `Iterator.prototype.filter` - `Iterator.prototype.find` - `Iterator.prototype.flatMap` - `Iterator.prototype.forEach` - `Iterator.prototype.map` - `Iterator.prototype.reduce` - `Iterator.prototype.some` - `Iterator.prototype.take` - Fixed missing forced replacement of [`AsyncIterator` helpers](https://redirect.github.com/tc39/proposal-async-iterator-helpers) - Added closing of sync iterator when async wrapper yields a rejection following [tc39/ecma262#2600](https://redirect.github.com/tc39/ecma262/pull/2600). Affected methods: - [`Array.fromAsync`](https://redirect.github.com/tc39/proposal-array-from-async) (due to the lack of async feature detection capability - temporarily, only in own `core-js` implementation) - [`AsyncIterator.from`](https://redirect.github.com/tc39/proposal-async-iterator-helpers) - [`Iterator.prototype.toAsync`](https://redirect.github.com/tc39/proposal-async-iterator-helpers) - Added detection for throwing on `undefined` initial parameter in `Iterator.prototype.reduce` (see [WebKit bug](https://bugs.webkit.org/show_bug.cgi?id=291651)) - `core-js-compat` and `core-js-builder` API: - Added `'intersect'` support for `targets.esmodules` (Babel 7 behavior) - Fixed handling of `targets.esmodules: true` (Babel 7 behavior) - Compat data improvements: - [Explicit Resource Management](https://redirect.github.com/tc39/proposal-explicit-resource-management) features disabled (again) in V8 ~ Chromium 135 and re-added in 136 - [`RegExp.escape`](https://redirect.github.com/tc39/proposal-regex-escaping) marked as [shipped from V8 ~ Chromium 136](https://issues.chromium.org/issues/353856236#comment17) - [`Error.isError`](https://redirect.github.com/tc39/proposal-is-error) marked as [shipped from FF138](https://bugzilla.mozilla.org/show_bug.cgi?id=1952249) - [Explicit Resource Management](https://redirect.github.com/tc39/proposal-explicit-resource-management) features re-enabled in [Deno 2.2.10](https://redirect.github.com/denoland/deno/releases/tag/v2.2.10) - [`Iterator` helpers proposal](https://redirect.github.com/tc39/proposal-iterator-helpers) features marked as supported from Deno 1.38.1 since it seems they were disabled in 1.38.0 - `Iterator.prototype.{ drop, reduce, take }` methods marked as fixed in Bun 1.2.11 - Added [NodeJS 24.0](https://redirect.github.com/nodejs/node/pull/57609) compat data mapping - Updated Electron 36 and added Electron 37 compat data mapping - Added Opera Android [88](https://forums.opera.com/topic/83800/opera-for-android-88) and [89](https://forums.opera.com/topic/84437/opera-for-android-89) compat data mapping - Added Oculus Quest Browser 37 compat data mapping </details> --- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/infonl/podiumd-office-plugin). <!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzOS4yNTcuMyIsInVwZGF0ZWRJblZlciI6IjM5LjI1Ny4zIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119--> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
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
throwis 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..ofloop would not trigger the iterator's close when a rejected promise is yielded, but the equivalentfor..ofloop awaiting the result would. After this change, the iterator would be closed in both cases. Closes #1849This change plumbs the sync iterator into
AsyncFromSyncIteratorContinuationwith instructions to close it on rejection, but not forreturncalls (as the iterator was already instructed to close), or if the iterator closed on its own (done === true).Close on missing
throwIf
throwis missing on the sync iterator, the async wrapper currently simply rejects with thevaluegiven to throw. This deviates from theyield *behavior in 2 ways: the wrapped iterator is not closed, and the rejection value not aTypeErrorto indicate the contract was broken. This updates fixes both differences by closing the iterator, and throwing a newTypeErrorinstance instead of the value provided tothrow.Since the spec never calls
throwon 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 callthrowon that async iterator.