Skip to content

Fix iterator closing around for-await-of and async-from-sync - #1649

Open
andreasrosdal wants to merge 4 commits into
quickjs-ng:masterfrom
nordstjernen-web:fix-for-await-of-iterator-close
Open

Fix iterator closing around for-await-of and async-from-sync#1649
andreasrosdal wants to merge 4 commits into
quickjs-ng:masterfrom
nordstjernen-web:fix-for-await-of-iterator-close

Conversation

@andreasrosdal

Copy link
Copy Markdown
Contributor

Two halves of the same story: who closes the sync iterator when a for await step rejects. Today the loop closes it (it should not) and the async-from-sync wrapper does not (it should). Each commit alone moves a test in the wrong direction, so they are sent together.

1. for-await-of must not close on an abrupt next()

ForIn/OfBodyEvaluation calls next(), awaits the result, and on an abrupt completion sets iteratorRecord.[[Done]] and returns it without closing the iterator — an iterator that failed to produce a result is not closed.

The for-await-of bytecode keeps the iterator live in the slot the loop's catch offset unwinds to, so a next() that throws, or an awaited result that rejects, runs the trailing OP_iterator_close and calls return():

let returnCalled = 0;
const it = { [Symbol.asyncIterator]: () => ({
  next() { return Promise.reject(new Error('boom')); },
  return() { returnCalled++; return Promise.resolve({done: true}); },
})};
try { for await (const v of it) {} } catch (e) {}
returnCalled   // 1; V8: 0

Two opcodes bracket the next()+await sequence. OP_for_await_of_dup moves the iterator out of the slot the handler reads and parks it above the catch offset, so an abrupt result unwinds to an undefined iterator and the close is skipped; OP_for_await_of_restore puts it back once a result has been obtained. They are appended at the end of the opcode list so no opcode already embedded in a precompiled bytecode blob is renumbered.

2. The async-from-sync wrapper must close on a rejected value

AsyncFromSyncIteratorContinuation takes a closeOnRejection flag — true for %AsyncFromSyncIteratorPrototype%.next and .throw, false for .return. When it is set and the sync result is not done, the wrapped value promise gets an onRejected that performs IteratorClose(syncIteratorRecord, ThrowCompletion(error)) before the rejection reaches the consumer:

13. Else,
  a. Let closeIterator be a new Abstract Closure ... performs
     IteratorClose(syncIteratorRecord, ThrowCompletion(error)).
  b. Let onRejected be CreateBuiltinFunction(closeIterator, 1, "", « »).
14. Perform PerformPromiseThen(valueWrapper, onFulfilled, onRejected,
    promiseCapability).

js_async_from_sync_iterator_do() always passes JS_UNDEFINED as onRejected, so a sync iterator that yields a rejected promise is never closed. The same applies when PromiseResolve on the value itself throws.

Testing

suite before after
built-ins/AsyncFromSyncIteratorPrototype 6/38 0/38
language/statements/for-await-of 0/1234 0/1234
language/statements/for-of 0/751 0/751
language/expressions/async-generator 0/623 0/623

The six AsyncFromSyncIteratorPrototype entries are dropped from test262_errors.txt.

🤖 Generated with Claude Code

https://claude.ai/code/session_014mv33YvfHz7t9mmkituBnn


Generated by Claude Code

claude and others added 4 commits August 6, 2026 17:40
ForIn/OfBodyEvaluation calls next(), awaits the result, and on an abrupt
completion sets iteratorRecord.[[Done]] and returns it without closing the
iterator: an iterator that failed to produce a result is not closed.

The for-await-of bytecode keeps the iterator live in the slot the loop's
catch offset unwinds to, so a next() that throws, or an awaited result that
rejects, runs the trailing OP_iterator_close and calls return():

    let returnCalled = 0;
    const it = { [Symbol.asyncIterator]: () => ({
      next() { return Promise.reject(new Error('boom')); },
      return() { returnCalled++; return Promise.resolve({done: true}); },
    })};
    try { for await (const v of it) {} } catch (e) {}
    returnCalled   // 1, V8: 0

Add two opcodes around the next()+await sequence. OP_for_await_of_dup moves
the iterator out of the slot the handler reads and parks it above the catch
offset, so an abrupt result unwinds to an undefined iterator and the close is
skipped. OP_for_await_of_restore puts it back once a result has been obtained.
They are appended at the end of the opcode list so no opcode already embedded
in a precompiled bytecode blob is renumbered.

js_iterator_get_value_done() clears the parked copy at sp[-2] instead of the
live slot at sp[-4], since the restore now decides what the live slot holds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014mv33YvfHz7t9mmkituBnn
AsyncFromSyncIteratorContinuation takes a closeOnRejection flag, true for
%AsyncFromSyncIteratorPrototype%.next and .throw and false for .return.
When it is set and the sync iterator's result is not done, the promise the
value is wrapped in gets an onRejected handler that performs
IteratorClose(syncIteratorRecord, ThrowCompletion(error)) before the
rejection reaches the consumer:

    13. Else,
      a. Let closeIterator be a new Abstract Closure ... performs
         IteratorClose(syncIteratorRecord, ThrowCompletion(error)).
      b. Let onRejected be CreateBuiltinFunction(closeIterator, 1, "", « »).
    14. Perform PerformPromiseThen(valueWrapper, onFulfilled, onRejected,
        promiseCapability).

js_async_from_sync_iterator_do() always passes JS_UNDEFINED as onRejected, so
a sync iterator that yields a rejected promise is never closed. The same
applies when PromiseResolve on the value itself throws.

Install a close-then-rethrow handler for .next and .throw when the result is
not done. Together with the previous commit
test/built-ins/AsyncFromSyncIteratorPrototype goes from 6/38 failures to 0/38;
the six entries are dropped from test262_errors.txt. for-await-of (1234),
for-of (751) and async-generator (623) are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014mv33YvfHz7t9mmkituBnn
Covers both halves of the change. ForIn/OfBodyEvaluation reaches the next
result through plain '?' steps, so a throwing next(), a rejecting result, a
non-object result and throwing done/value getters all propagate without
closing the iterator, while an abrupt loop body still closes it.
AsyncFromSyncIteratorContinuation is the opposite: a sync iterator whose
yielded value is a rejecting promise is closed with a throw completion,
including through yield* delegation, but an abrupt IteratorValue is not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6eRbuuuCujKgQkrHgvMrc
OP_for_await_of_dup and OP_for_await_of_restore add a slot to the loop's
stack for the duration of next(), so anything that leaves the loop from
somewhere other than its normal end, or that suspends inside it, has to
find the stack it expects. Added:

  - continue, and a labelled break and continue that cross a loop boundary
  - try/finally around and inside the loop, and a return out of a try with
    a finally
  - await in the body, and the whole loop inside an async generator that
    yields, including abandoning that generator mid-iteration
  - a destructuring head and a head that assigns to an existing lvalue
  - five nested loops, which an understated stack effect would overrun
  - a rejected next() inside a try, which must still leave the iterator
    open
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants