diff --git a/quickjs-opcode.h b/quickjs-opcode.h index ec2a5ad91..07e1a0974 100644 --- a/quickjs-opcode.h +++ b/quickjs-opcode.h @@ -372,6 +372,11 @@ DEF( is_null, 1, 1, 1, none) DEF(typeof_is_undefined, 1, 1, 1, none) DEF( typeof_is_function, 1, 1, 1, none) +/* appended at the end so as not to renumber any opcode embedded in the + precompiled bytecode blobs (builtin-array-fromasync.h and friends) */ +DEF(for_await_of_dup, 1, 5, 6, none) /* iter_obj next catch iter_obj_c next_c -> undefined next catch iter_obj iter_obj_c next_c */ +DEF(for_await_of_restore, 1, 6, 5, none) /* undefined next catch iter_obj value done -> iter_obj next catch value done */ + #undef DEF #undef def #endif /* DEF */ diff --git a/quickjs.c b/quickjs.c index bbac00c33..49789d6d6 100644 --- a/quickjs.c +++ b/quickjs.c @@ -17258,14 +17258,13 @@ static __exception int js_iterator_get_value_done(JSContext *ctx, JSValue *sp) sp[-1] = value; sp[0] = js_bool(done); if (done) { - /* Iterator exhausted via {done:true}: drop the iterator object (stack - layout at the for-await `next` call is iter_obj,next,catch_offset,result - so iter_obj is sp[-4]) so the trailing OP_iterator_close skips calling - return(). Mirrors js_for_of_next, which nulls the iterator on done. - Per spec AsyncIteratorClose must NOT run on normal completion. This op - is emitted only by the for-await-of loop, so the layout is fixed. */ - JS_FreeValue(ctx, sp[-4]); - sp[-4] = JS_UNDEFINED; + /* Iterator exhausted via {done:true}: drop the saved iterator object + so that OP_for_await_of_restore makes the trailing + OP_iterator_close skip return(). Mirrors js_for_of_next, which + nulls the iterator on done. Per spec AsyncIteratorClose must NOT + run on normal completion. */ + JS_FreeValue(ctx, sp[-2]); + sp[-2] = JS_UNDEFINED; } return 0; } @@ -19344,6 +19343,43 @@ static JSValue JS_CallInternal(JSContext *caller_ctx, JSValueConst func_obj, goto exception; sp += 1; BREAK; + CASE(OP_for_await_of_dup): + { + /* stack: iter_obj next catch iter_obj_c next_c -> + undefined next catch iter_obj iter_obj_c next_c + Clearing the live iter_obj slot for the duration of the + next()+await means that if it throws (the awaited value + rejects, say), unwinding to the loop's catch offset sees an + undefined iterator and skips the close: per + ForIn/OfBodyEvaluation an abrupt next() result must not + close the iterator. */ + JSValue iter_obj = sp[-5]; + JSValue iter_obj_c = sp[-2]; + JSValue next_c = sp[-1]; + sp[-5] = JS_UNDEFINED; + sp[-2] = iter_obj; + sp[-1] = iter_obj_c; + sp[0] = next_c; + sp += 1; + } + BREAK; + CASE(OP_for_await_of_restore): + { + /* stack: undefined next catch iter_obj value done -> + iter_obj next catch value done + Reached only once next()'s result has been obtained without + throwing: restore the live iterator so the loop body and any + later close see it again. */ + JSValue iter_obj = sp[-3]; + JSValue value = sp[-2]; + JSValue done = sp[-1]; + JS_FreeValue(ctx, sp[-6]); + sp[-6] = iter_obj; + sp[-3] = value; + sp[-2] = done; + sp -= 1; + } + BREAK; CASE(OP_check_object): if (unlikely(!JS_IsObject(sp[-1]))) { JS_ThrowTypeErrorNotAnObject(ctx); @@ -29340,12 +29376,17 @@ static __exception int js_parse_for_in_of(JSParseState *s, int label_name, /* stack: iter_obj next catch_offset */ emit_op(s, OP_dup3); emit_op(s, OP_drop); + /* clear the live iter_obj while next() is pending: an abrupt + next()/await must not close the iterator */ + emit_op(s, OP_for_await_of_dup); emit_op(s, OP_call_method); emit_u16(s, 0); /* get the result of the promise */ emit_op(s, OP_await); /* unwrap the value and done values */ emit_op(s, OP_iterator_get_value_done); + /* next() succeeded: restore the live iter_obj */ + emit_op(s, OP_for_await_of_restore); } else { emit_op(s, OP_for_of_next); emit_u8(s, 0); @@ -56618,6 +56659,27 @@ static JSValue js_async_from_sync_iterator_unwrap_func_create(JSContext *ctx, 1, 0, 1, func_data); } +static JSValue js_async_from_sync_iterator_close_on_reject( + JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv, + int magic, JSValueConst *func_data) +{ + /* IteratorClose(syncIterator, ThrowCompletion(reason)): close the sync + iterator, then reject with the original reason whatever return() does */ + JS_Throw(ctx, js_dup(argv[0])); + JS_IteratorClose(ctx, func_data[0], true); + return JS_EXCEPTION; +} + +static JSValue js_async_from_sync_iterator_close_func_create(JSContext *ctx, + JSValueConst sync_iter) +{ + JSValueConst func_data[1]; + + func_data[0] = sync_iter; + return JS_NewCFunctionData(ctx, js_async_from_sync_iterator_close_on_reject, + 1, 0, 1, func_data); +} + /* AsyncIteratorPrototype */ static const JSCFunctionListEntry js_async_iterator_proto_funcs[] = { @@ -56757,6 +56819,10 @@ static JSValue js_async_from_sync_iterator_next(JSContext *ctx, JSValueConst thi 1, vc(&value), 0); if (JS_IsException(value_wrapper_promise)) { JS_FreeValue(ctx, value); + /* an abrupt PromiseResolve closes the sync iterator too when + closeOnRejection is true and the result is not done */ + if (magic != GEN_MAGIC_RETURN && !done) + JS_IteratorClose(ctx, s->sync_iter, true); goto reject; } @@ -56766,13 +56832,27 @@ static JSValue js_async_from_sync_iterator_next(JSContext *ctx, JSValueConst thi JS_FreeValue(ctx, value_wrapper_promise); goto fail; } + /* closeOnRejection is true for .next and .throw and false for + .return: when the result is not done, a rejected value promise + has to close the sync iterator */ + if (magic != GEN_MAGIC_RETURN && !done) { + resolve_reject[1] = + js_async_from_sync_iterator_close_func_create(ctx, s->sync_iter); + if (JS_IsException(resolve_reject[1])) { + JS_FreeValue(ctx, resolve_reject[0]); + JS_FreeValue(ctx, value_wrapper_promise); + goto fail; + } + } else { + resolve_reject[1] = JS_UNDEFINED; + } JS_FreeValue(ctx, value); - resolve_reject[1] = JS_UNDEFINED; res = perform_promise_then(ctx, value_wrapper_promise, vc(resolve_reject), vc(resolving_funcs)); JS_FreeValue(ctx, resolve_reject[0]); + JS_FreeValue(ctx, resolve_reject[1]); JS_FreeValue(ctx, value_wrapper_promise); JS_FreeValue(ctx, resolving_funcs[0]); JS_FreeValue(ctx, resolving_funcs[1]); diff --git a/test262_errors.txt b/test262_errors.txt index 082fb016a..d95d9c72e 100644 --- a/test262_errors.txt +++ b/test262_errors.txt @@ -5,18 +5,6 @@ test262/test/annexB/language/expressions/assignmenttargettype/callexpression-in- test262/test/annexB/language/expressions/assignmenttargettype/callexpression-in-prefix-update.js:27: SyntaxError: invalid increment/decrement operand test262/test/annexB/language/expressions/assignmenttargettype/callexpression.js:33: SyntaxError: invalid assignment left-hand side test262/test/annexB/language/expressions/assignmenttargettype/cover-callexpression-and-asyncarrowhead.js:20: SyntaxError: invalid assignment left-hand side -test262/test/built-ins/AsyncFromSyncIteratorPrototype/next/iterator-result-poisoned-wrapper.js:64: TypeError: $DONE() not called -test262/test/built-ins/AsyncFromSyncIteratorPrototype/next/iterator-result-poisoned-wrapper.js:64: strict mode: TypeError: $DONE() not called -test262/test/built-ins/AsyncFromSyncIteratorPrototype/next/next-result-poisoned-wrapper.js:69: TypeError: $DONE() not called -test262/test/built-ins/AsyncFromSyncIteratorPrototype/next/next-result-poisoned-wrapper.js:69: strict mode: TypeError: $DONE() not called -test262/test/built-ins/AsyncFromSyncIteratorPrototype/next/yield-iterator-next-rejected-promise-close.js:59: TypeError: $DONE() not called -test262/test/built-ins/AsyncFromSyncIteratorPrototype/next/yield-iterator-next-rejected-promise-close.js:59: strict mode: TypeError: $DONE() not called -test262/test/built-ins/AsyncFromSyncIteratorPrototype/next/yield-next-rejected-promise-close.js:64: TypeError: $DONE() not called -test262/test/built-ins/AsyncFromSyncIteratorPrototype/next/yield-next-rejected-promise-close.js:64: strict mode: TypeError: $DONE() not called -test262/test/built-ins/AsyncFromSyncIteratorPrototype/throw/iterator-result-rejected-promise-close.js:74: TypeError: $DONE() not called -test262/test/built-ins/AsyncFromSyncIteratorPrototype/throw/iterator-result-rejected-promise-close.js:74: strict mode: TypeError: $DONE() not called -test262/test/built-ins/AsyncFromSyncIteratorPrototype/throw/throw-result-poisoned-wrapper.js:81: TypeError: $DONE() not called -test262/test/built-ins/AsyncFromSyncIteratorPrototype/throw/throw-result-poisoned-wrapper.js:81: strict mode: TypeError: $DONE() not called test262/test/language/destructuring/binding/keyed-destructuring-property-reference-target-evaluation-order-with-bindings.js:73: Test262Error: Actual [binding::source, binding::sourceKey, sourceKey, get source, binding::defaultValue, binding::varTarget] and expected [binding::source, binding::sourceKey, sourceKey, binding::varTarget, get source, binding::defaultValue] should have the same contents. test262/test/language/expressions/assignment/destructuring/iterator-destructuring-property-reference-target-evaluation-order.js:42: Test262Error: Actual [source, iterator, target, target-key, target-key-tostring, iterator-step, iterator-done, set] and expected [source, iterator, target, target-key, iterator-step, iterator-done, target-key-tostring, set] should have the same contents. test262/test/language/expressions/assignment/destructuring/iterator-destructuring-property-reference-target-evaluation-order.js:42: strict mode: Test262Error: Actual [source, iterator, target, target-key, target-key-tostring, iterator-step, iterator-done, set] and expected [source, iterator, target, target-key, iterator-step, iterator-done, target-key-tostring, set] should have the same contents. diff --git a/tests/for-await-of-iterator-close.js b/tests/for-await-of-iterator-close.js new file mode 100644 index 000000000..351bb7335 --- /dev/null +++ b/tests/for-await-of-iterator-close.js @@ -0,0 +1,411 @@ +import { assert } from "./assert.js"; + +/* Two rules meet in for-await-of. + + ForIn/OfBodyEvaluation gets the next result with plain `?` steps: if + next() throws, if the awaited result rejects, if it is not an object, or + if the `done`/`value` getters throw, the loop propagates the failure + *without* closing the iterator. Only an abrupt completion of the loop body + (throw, break, return) closes it. + + AsyncFromSyncIteratorContinuation is the other way around: when the value + a *sync* iterator yields is a promise that rejects, the sync iterator is + closed with a throw completion before the rejection is handed on. */ + +function trace() { + const log = []; + return { + log, + push(x) { log.push(x); return x; }, + get text() { return log.join(","); }, + }; +} + +async function collect(t, body) { + try { + await body(); + t.push("no-throw"); + } catch (e) { + t.push("caught:" + e.message); + } + return t.text; +} + +/* an async iterator that fails in a configurable way */ +function asyncIter(t, fail) { + return { + [Symbol.asyncIterator]() { + return { + next() { + t.push("next"); + return fail(); + }, + return() { + t.push("return"); + return Promise.resolve({ done: true }); + }, + }; + }, + }; +} + +/* --- an abrupt next() must not close the iterator --------------------- */ +{ + const t = trace(); + assert(await collect(t, async () => { + for await (const x of asyncIter(t, () => { throw new Error("nx"); })) + t.push("body"); + }), "next,caught:nx"); +} +{ + const t = trace(); + assert(await collect(t, async () => { + for await (const x of asyncIter(t, () => Promise.reject(new Error("nr")))) + t.push("body"); + }), "next,caught:nr"); +} +{ + /* the result is not an object */ + const t = trace(); + const got = await collect(t, async () => { + for await (const x of asyncIter(t, () => 1)) + t.push("body"); + }); + assert(got.startsWith("next,caught:"), true, got); + assert(t.log.indexOf("return"), -1, got); + assert(t.log.length, 2, got); +} +{ + /* the awaited result resolves, but its getters throw */ + const t = trace(); + assert(await collect(t, async () => { + for await (const x of asyncIter(t, () => Promise.resolve({ + get done() { t.push("done"); throw new Error("dn"); }, + value: 1, + }))) t.push("body"); + }), "next,done,caught:dn"); +} +{ + const t = trace(); + assert(await collect(t, async () => { + for await (const x of asyncIter(t, () => Promise.resolve({ + done: false, + get value() { t.push("value"); throw new Error("vl"); }, + }))) t.push("body"); + }), "next,value,caught:vl"); +} +/* --- an abrupt body must close it ------------------------------------- */ +{ + const ok = () => Promise.resolve({ done: false, value: 1 }); + + const t1 = trace(); + assert(await collect(t1, async () => { + for await (const x of asyncIter(t1, ok)) { + t1.push("body"); + throw new Error("bd"); + } + }), "next,body,return,caught:bd"); + + const t2 = trace(); + assert(await collect(t2, async () => { + for await (const x of asyncIter(t2, ok)) { + t2.push("body"); + break; + } + }), "next,body,return,no-throw"); + + const t3 = trace(); + assert(await collect(t3, async () => { + await (async () => { + for await (const x of asyncIter(t3, ok)) { + t3.push("body"); + return; + } + })(); + }), "next,body,return,no-throw"); + + /* a normal finish never closes */ + const t4 = trace(); + let i = 0; + assert(await collect(t4, async () => { + for await (const x of asyncIter(t4, () => + Promise.resolve(i++ < 1 ? { done: false, value: 1 } : { done: true }))) + t4.push("body"); + }), "next,body,next,no-throw"); +} + +/* --- the sync iterator behind an async-from-sync wrapper -------------- */ +function syncIter(t, next) { + return { + [Symbol.iterator]() { + return { + next() { t.push("next"); return next(); }, + return() { t.push("return"); return { done: true }; }, + }; + }, + }; +} + +{ + /* a yielded promise that rejects closes the sync iterator */ + const t = trace(); + assert(await collect(t, async () => { + for await (const x of syncIter(t, () => ({ + done: false, + value: Promise.reject(new Error("rp")), + }))) t.push("body"); + }), "next,return,caught:rp"); +} +{ + /* ... including through yield* delegation in an async generator */ + const t = trace(); + const src = syncIter(t, () => ({ + done: false, + value: Promise.reject(new Error("yp")), + })); + async function* g() { yield* src; } + assert(await collect(t, async () => { + for await (const x of g()) t.push("body"); + }), "next,return,caught:yp"); +} +{ + /* a value that is a thenable whose then() throws is the same shape */ + const t = trace(); + assert(await collect(t, async () => { + for await (const x of syncIter(t, () => ({ + done: false, + value: { then(res, rej) { t.push("then"); rej(new Error("th")); } }, + }))) t.push("body"); + }), "next,then,return,caught:th"); +} +{ + /* a done result still has its value awaited, so a rejection propagates, + but there is nothing left to close */ + const t = trace(); + assert(await collect(t, async () => { + for await (const x of syncIter(t, () => ({ + done: true, + value: Promise.reject(new Error("dr")), + }))) t.push("body"); + }), "next,caught:dr"); + assert(t.log.indexOf("return"), -1); +} +{ + /* but an abrupt IteratorValue rejects without closing */ + const t = trace(); + assert(await collect(t, async () => { + for await (const x of syncIter(t, () => ({ + done: false, + get value() { t.push("value"); throw new Error("sv"); }, + }))) t.push("body"); + }), "next,value,caught:sv"); +} +{ + /* and so does an abrupt next() on the sync iterator */ + const t = trace(); + assert(await collect(t, async () => { + for await (const x of syncIter(t, () => { throw new Error("sn"); })) + t.push("body"); + }), "next,caught:sn"); +} +{ + /* a plain sync iteration still runs to completion */ + const t = trace(); + let i = 0; + assert(await collect(t, async () => { + for await (const x of syncIter(t, () => + i++ < 2 ? { done: false, value: i } : { done: true })) + t.push("body" + x); + }), "next,body1,next,body2,next,no-throw"); +} + +/* --- the loop still yields the right values -------------------------- */ +{ + const seen = []; + for await (const x of [1, Promise.resolve(2), 3]) + seen.push(x); + assert(seen.join(","), "1,2,3"); + + async function* gen() { yield 1; yield 2; } + const seen2 = []; + for await (const x of gen()) + seen2.push(x); + assert(seen2.join(","), "1,2"); + + /* nested loops keep their own iterators straight */ + const pairs = []; + for await (const a of [1, 2]) + for await (const b of ["x", "y"]) + pairs.push(a + b); + assert(pairs.join(","), "1x,1y,2x,2y"); + + /* a labelled break closes only the inner iterator */ + const t = trace(); + const inner = asyncIter(t, () => Promise.resolve({ done: false, value: 1 })); + for await (const a of [1]) { + for await (const b of inner) { + t.push("body"); + break; + } + } + assert(t.text, "next,body,return"); +} + +/* --- the control flow that moves the loop's stack around -------------- */ +{ + const upto = (t, n) => { + let i = 0; + return asyncIter(t, () => Promise.resolve( + i++ < n ? { done: false, value: i } : { done: true })); + }; + + /* continue takes the back edge, which has to find the same stack it + started with */ + { + const t = trace(); + const seen = []; + for await (const x of upto(t, 4)) { + if (x % 2) continue; + seen.push(x); + } + assert(seen.join(","), "2,4"); + assert(t.text, "next,next,next,next,next"); + } + + /* a labelled break and a labelled continue that cross a loop boundary + close every iterator they leave, innermost first */ + { + const t = trace(); + outer: + for await (const a of upto(t, 3)) { + for await (const b of upto(t, 3)) { + if (a === 2) break outer; + continue outer; + } + } + assert(t.text, "next,next,return,next,next,return,return"); + } + + /* try/finally around and inside the loop: the finally blocks run in + order and the iterator is still closed exactly once */ + { + const t = trace(); + try { + for await (const x of upto(t, 3)) { + try { + throw new Error("boom"); + } finally { + t.push("inner-finally"); + } + } + } catch (e) { + t.push("caught:" + e.message); + } finally { + t.push("outer-finally"); + } + assert(t.text, "next,inner-finally,return,caught:boom,outer-finally"); + } + + /* a return out of a try with a finally, from inside the loop */ + { + const t = trace(); + const r = await (async () => { + for await (const x of upto(t, 3)) { + try { + return "returned"; + } finally { + t.push("finally"); + } + } + })(); + assert(r, "returned"); + assert(t.text, "next,finally,return"); + } + + /* awaiting in the body suspends the function with the loop's stack live */ + { + const t = trace(); + const seen = []; + for await (const x of upto(t, 3)) { + await null; + seen.push(await Promise.resolve(x * 10)); + await null; + } + assert(seen.join(","), "10,20,30"); + } + + /* the same, in an async generator, where yield suspends it as well */ + { + async function* relay(t) { + for await (const x of upto(t, 3)) + yield x * 2; + } + const t = trace(); + const seen = []; + for await (const x of relay(t)) + seen.push(x); + assert(seen.join(","), "2,4,6"); + + /* abandoning the outer loop closes the generator, which closes the + inner iterator it was driving */ + const t2 = trace(); + for await (const x of relay(t2)) + break; + assert(t2.text, "next,return"); + } + + /* a destructuring head, and a head that assigns to an existing lvalue, + both run after the iterator has been restored */ + { + const t = trace(); + let i = 0; + const pairs = { + [Symbol.asyncIterator]: () => ({ + next: () => Promise.resolve( + i++ < 2 ? { done: false, value: { a: i, b: [i, i + 1] } } + : { done: true }), + }), + }; + const seen = []; + for await (const { a, b: [b0, b1] } of pairs) + seen.push(a + ":" + b0 + ":" + b1); + assert(seen.join(","), "1:1:2,2:2:3"); + + const obj = {}; + let last; + i = 0; + for await (obj.k of pairs) last = obj.k.a; + assert(last, 2); + i = 0; + for await (last of pairs) ; + assert(last.a, 2); + } + + /* nesting deeply enough that an understated stack effect would run off + the end of the frame */ + { + const t = trace(); + let n = 0; + for await (const a of upto(t, 2)) + for await (const b of upto(t, 2)) + for await (const c of upto(t, 2)) + for await (const d of upto(t, 2)) + for await (const e of upto(t, 2)) + n += a + b + c + d + e; + assert(n, 240); + } + + /* a rejected next() leaves the iterator open even from inside a try */ + { + const t = trace(); + let caught = null; + try { + for await (const x of asyncIter(t, () => Promise.reject(new Error("nx")))) + t.push("body"); + } catch (e) { + caught = e.message; + } + assert(caught, "nx"); + assert(t.text, "next"); + } +}