Read an iterator's next once, and await return()'s value in for await

JavaScript reads an iterator's `next` when the iterator opens and keeps
calling that function, so a `next` the iterator assigns while it runs is
not called. The for-of loop and the spread drain over an object-literal
iterator read the field on every step; they now read it once after the
open (classIteratorNextRead) and call the function they read. The
checked-dynamic spread drain (%iterator.pack.checked) does the same,
using the stepping the for-of and Array.from drains already use.

`for await` over a synchronous iterator closes it on an early exit the
way the async-from-sync iterator does: return()'s result is read for done
and value, and the value is awaited, so a pending promise settles before
the loop continues and a rejected one rejects the loop (after a throw from
the body, the body's error still wins).
This commit is contained in:
Chris Tate
2026-10-24 20:24:11 -05:00
parent ba80dc254d
commit 120c65bfbb
9 changed files with 306 additions and 36 deletions
@@ -29,9 +29,10 @@ import { lowerCollectionSpread } from "../containers/collection-methods.js";
import { strCharsCall } from "../containers/array-construction.js";
import { generatorDrain } from "../iterator-adapters.js";
import { isSearchParamsIterable } from "../builtins/url.js";
import { varRef } from "../../../ir/build.js";
import { dynUndefinedExpr, varRef } from "../../../ir/build.js";
import { arrayValueStore } from "../array-values.js";
import { classGeneratorIterable } from "../class-iterables.js";
import { iteratorCanStep } from "../iterator-consumption.js";
/** JavaScript: whether `expr` is or holds an existing checked-dynamic
* object — a binding stored as a dyn value whose checker type is an
@@ -717,13 +718,17 @@ function lowerTupleSpreadLiteral(
return { kind: "seqExpr", stmts, result: { kind: "recordLit", fields, type, loc }, type, loc };
}
/** Drain using checked dispatch so native class and generator iterators keep their methods.
* `spell` is the TypeError message for a source that is not iterable
* (notIterableSpelling). */
/** Drain a checked-dynamic iterable: `next` is read once when the iterator
* opens, as JS keeps it in the iterator record, so a `next` the iterator
* assigns while it runs is not called. Runtime iterators step natively, and
* other iterators call the `next` read (class methods keep their emitted
* dispatch). `spell` is the TypeError message for a source that is not
* iterable (notIterableSpelling). */
function checkedIteratorPack(lowerer: Lowerer, source: IrExpr, spell: string, loc: SrcLoc): IrExpr {
const name = "%iterator.pack.checked";
if (!lowerer.liftedFns.some((fn) => fn.name === name)) {
const ref = (id: string): IrExpr => ({ kind: "varRef", localId: id, type: DYN, loc });
const flag = (id: string): IrExpr => ({ kind: "varRef", localId: id, type: BOOL, loc });
const property = (key: string): IrExpr => ({
kind: "dynKeyGet",
value: ref("step"),
@@ -731,6 +736,7 @@ function checkedIteratorPack(lowerer: Lowerer, source: IrExpr, spell: string, lo
type: DYN,
loc,
});
const finished: IrStmt = { kind: "return", value: ref("result"), loc };
lowerer.liftedFns.push({
name,
params: [
@@ -739,12 +745,13 @@ function checkedIteratorPack(lowerer: Lowerer, source: IrExpr, spell: string, lo
],
returnType: DYN,
locals: [
...["source", "iterator", "step", "result"].map((id) => ({
...["source", "iterator", "next", "step", "value", "result"].map((id) => ({
id,
name: id,
type: DYN,
mutable: id !== "source",
})),
{ id: "fast", name: "fast", type: BOOL, mutable: false },
{ id: "spell", name: "spell", type: STRING, mutable: false },
],
body: [
@@ -760,6 +767,24 @@ function checkedIteratorPack(lowerer: Lowerer, source: IrExpr, spell: string, lo
},
loc,
},
{
kind: "varDecl",
localId: "next",
init: {
kind: "dynKeyGet",
value: ref("iterator"),
key: { kind: "strLit", value: "next", type: STRING, loc },
type: DYN,
loc,
},
loc,
},
{
kind: "varDecl",
localId: "fast",
init: iteratorCanStep(ref("iterator"), ref("next"), loc),
loc,
},
{
kind: "varDecl",
localId: "result",
@@ -770,25 +795,77 @@ function checkedIteratorPack(lowerer: Lowerer, source: IrExpr, spell: string, lo
kind: "while",
cond: { kind: "boolLit", value: true, type: BOOL, loc },
body: [
{
kind: "varDecl",
localId: "step",
init: {
kind: "dynInvoke",
recv: ref("iterator"),
method: "next",
calleeName: "iterator.next",
args: [],
type: DYN,
loc,
},
loc,
},
// Declared before the branches, which each assign it: a local
// declared in a branch is released when the branch ends.
{ kind: "varDecl", localId: "value", init: dynUndefinedExpr(loc), loc },
{
kind: "if",
cond: { kind: "dynTest", test: "truthy", value: property("done"), type: BOOL, loc },
then: [{ kind: "return", value: ref("result"), loc }],
else_: null,
cond: flag("fast"),
then: [
{
kind: "assign",
localId: "value",
value: {
kind: "libCall",
fn: "dyn.iteratorStep",
args: [ref("iterator")],
type: DYN,
loc,
},
loc,
},
{
kind: "if",
cond: {
kind: "libCall",
fn: "dyn.iteratorStepDone",
args: [ref("iterator")],
type: BOOL,
loc,
},
then: [finished],
else_: null,
loc,
},
],
else_: [
{
kind: "varDecl",
localId: "step",
init: {
kind: "libCall",
fn: "dyn.iteratorResult",
args: [
{
kind: "dynCall",
callee: ref("next"),
receiver: ref("iterator"),
calleeName: "iterator.next",
args: [],
type: DYN,
loc,
},
],
type: DYN,
loc,
},
loc,
},
{
kind: "if",
cond: {
kind: "dynTest",
test: "truthy",
value: property("done"),
type: BOOL,
loc,
},
then: [finished],
else_: null,
loc,
},
{ kind: "assign", localId: "value", value: property("value"), loc },
],
loc,
},
{
@@ -796,7 +873,7 @@ function checkedIteratorPack(lowerer: Lowerer, source: IrExpr, spell: string, lo
expr: {
kind: "libCall",
fn: "dyn.packPush",
args: [ref("result"), property("value")],
args: [ref("result"), ref("value")],
type: VOID,
loc,
},
@@ -8016,15 +8016,42 @@ export function classIteratorOf(
};
}
/** The `it.next()` step of a class iterator as an ordinary (possibly
* virtual) method call. */
export function classIteratorNextCall(
/** The `next` an object-literal iterator's protocol calls, read once when
* the iterator opens: JS keeps the `next` it reads then in the iterator
* record, so a `next` the iterator assigns while it runs is not called.
* Null for an iterator class, whose `next` is a method. */
export function classIteratorNextRead(
lowerer: Lowerer,
cit: ClassIteratorInfo,
itRef: IrExpr,
loc: SrcLoc,
): (IrExpr & { type: IrType & { kind: "func" } }) | null {
const iterT = cit.iterT;
if (iterT.kind === "object") return null;
const field = lowerer.shapes.get(iterT.shapeId)!.fields.find((f) => f.name === "next")!;
return {
kind: "recordGet",
obj: itRef,
shapeId: iterT.shapeId,
field: "next",
type: field.type as IrType & { kind: "func" },
loc,
};
}
/** The `it.next()` step of a class iterator: an ordinary (possibly
* virtual) method call, or the call of `next`, the object-literal
* iterator's `next` that classIteratorNextRead read when it opened. */
export function classIteratorNextCall(
lowerer: Lowerer,
cit: ClassIteratorInfo,
itRef: IrExpr,
next: IrExpr | null,
loc: SrcLoc,
): IrExpr {
return classIteratorMethodCall(lowerer, cit, "next", itRef, loc);
if (next === null || next.type.kind !== "func")
return classIteratorMethodCall(lowerer, cit, "next", itRef, loc);
return { kind: "callValue", callee: next, args: [], type: next.type.ret, loc };
}
/** `it.next()` or `it.return()` of a class iterator: a (possibly virtual)
@@ -8108,6 +8135,8 @@ export function classIteratorDrainCall(
const recvT: IrType = { kind: "object", className: cit.className };
const recvRef: IrExpr = { kind: "varRef", localId: "r.0", type: recvT, loc };
const itRef: IrExpr = { kind: "varRef", localId: "it.0", type: cit.iterT, loc };
const nextRead = classIteratorNextRead(lowerer, cit, itRef, loc);
const nextRef: IrExpr | null = nextRead ? varRef("next.0", nextRead.type, loc) : null;
const outRef: IrExpr = { kind: "varRef", localId: "out.0", type: outT, loc };
const resRef: IrExpr = { kind: "varRef", localId: "res.0", type: cit.resultT, loc };
const valueRead: IrExpr = {
@@ -8122,7 +8151,7 @@ export function classIteratorDrainCall(
{
kind: "varDecl",
localId: "res.0",
init: classIteratorNextCall(lowerer, cit, itRef, loc),
init: classIteratorNextCall(lowerer, cit, itRef, nextRef, loc),
loc,
},
...(cit.hasDone
@@ -8163,6 +8192,7 @@ export function classIteratorDrainCall(
locals: [
{ id: "r.0", name: "r", type: recvT, mutable: false },
{ id: "it.0", name: "it", type: cit.iterT, mutable: false },
...(nextRead ? [{ id: "next.0", name: "next", type: nextRead.type, mutable: false }] : []),
{ id: "out.0", name: "out", type: outT, mutable: false },
{ id: "res.0", name: "res", type: cit.resultT, mutable: true },
],
@@ -8179,6 +8209,9 @@ export function classIteratorDrainCall(
init: classIteratorOpenCall(lowerer, cit, recvRef, loc),
loc,
},
...(nextRead
? [{ kind: "varDecl", localId: "next.0", init: nextRead, loc } satisfies IrStmt]
: []),
{
kind: "while",
cond: { kind: "boolLit", value: true, type: BOOL, loc },
@@ -8226,7 +8259,7 @@ export function classIteratorRestDrainCall(
{
kind: "varDecl",
localId: "res.0",
init: classIteratorNextCall(lowerer, cit, itRef, loc),
init: classIteratorNextCall(lowerer, cit, itRef, null, loc),
loc,
},
...(cit.hasDone
@@ -2578,7 +2578,7 @@ export function lowerBindingPattern(
const itRef = (): IrExpr => ({ kind: "varRef", localId: it.id, type: citPat.iterT, loc });
for (const el of pattern.elements.nodes) {
const stepLoc = locOf(el);
const step = lowerer.classIteratorNextCall(citPat, itRef(), stepLoc);
const step = lowerer.classIteratorNextCall(citPat, itRef(), null, stepLoc);
if (ts.isOmittedExpression(el) || el.name === undefined) {
out.push({ kind: "exprStmt", expr: step, loc: stepLoc });
continue;
@@ -10726,6 +10726,8 @@ function lowerForOfClassIterator(
const r = lowerer.declareHiddenLocal("%cir", cit.resultT);
const recvRef: IrExpr = { kind: "varRef", localId: recv.id, type: iterable.type, loc };
const itRef = (): IrExpr => ({ kind: "varRef", localId: it.id, type: cit.iterT, loc });
const nextRead = lowerer.classIteratorNextRead(cit, itRef(), loc);
const next = nextRead ? lowerer.declareHiddenLocal("%cin", nextRead.type) : null;
const rRef = (): IrExpr => ({ kind: "varRef", localId: r.id, type: cit.resultT, loc });
const done = cit.hasReturn ? lowerer.declareHiddenLocal("%cid", BOOL) : null;
if (done) done.mutable = true;
@@ -10756,7 +10758,12 @@ function lowerForOfClassIterator(
{
kind: "varDecl",
localId: r.id,
init: lowerer.classIteratorNextCall(cit, itRef(), loc),
init: lowerer.classIteratorNextCall(
cit,
itRef(),
next ? varRef(next.id, next.type, loc) : null,
loc,
),
loc,
},
...(cit.hasDone
@@ -10820,6 +10827,9 @@ function lowerForOfClassIterator(
init: lowerer.classIteratorOpenCall(cit, recvRef, loc),
loc,
},
...(next && nextRead
? [{ kind: "varDecl", localId: next.id, init: nextRead, loc } satisfies IrStmt]
: []),
...(done
? [
{
@@ -10887,6 +10897,7 @@ function lowerForOfDyn(
done.mutable = true;
const value = lowerer.declareHiddenLocal("%dofValue", DYN);
const returned = lowerer.declareHiddenLocal("%dofReturn", DYN);
const closed = lowerer.declareHiddenLocal("%dofClosed", DYN);
const needsClose = lowerer.declareHiddenLocal("%dofNeedsClose", BOOL);
needsClose.mutable = true;
const error = lowerer.declareHiddenLocal("%dofError", CAUGHT);
@@ -10952,9 +10963,10 @@ function lowerForOfDyn(
},
then: [
{
kind: "exprStmt",
kind: "varDecl",
localId: closed.id,
// return()'s (awaited) result must be an object, as for next().
expr: asyncLoop
init: asyncLoop
? check(
awaitDyn({
kind: "dynCall",
@@ -10969,6 +10981,27 @@ function lowerForOfDyn(
: call(varRef(returned.id, DYN, loc)),
loc,
},
// A synchronous iterator's return() result is read as the
// async-from-sync iterator reads it: done, then the value, which is
// awaited, so a rejected promise there rejects the loop.
...(syncIterator
? [
{
kind: "if",
cond: varRef(syncIterator.id, BOOL, loc),
then: [
{ kind: "exprStmt", expr: get(varRef(closed.id, DYN, loc), "done"), loc },
{
kind: "exprStmt",
expr: awaitDyn(get(varRef(closed.id, DYN, loc), "value")),
loc,
},
],
else_: null,
loc,
} satisfies IrStmt,
]
: []),
],
else_: null,
loc,
@@ -1982,7 +1982,7 @@ function classIteratorObject(
{
kind: "varDecl",
localId: r.id,
init: classIteratorNextCall(lowerer, cit, varRef(it.id, cit.iterT, loc), loc),
init: classIteratorNextCall(lowerer, cit, varRef(it.id, cit.iterT, loc), null, loc),
loc,
},
{
@@ -362,6 +362,7 @@ import {
classIteratorDrainCall,
classIteratorMethodCall,
classIteratorNextCall,
classIteratorNextRead,
classIteratorOf,
classIteratorOpenCall,
classIteratorRestDrainCall,
@@ -13063,8 +13064,17 @@ export class Lowerer {
return classIteratorOpenCall(this, cit, recv, loc);
}
classIteratorNextCall(cit: ClassIteratorInfo, itRef: IrExpr, loc: SrcLoc): IrExpr {
return classIteratorNextCall(this, cit, itRef, loc);
classIteratorNextRead(cit: ClassIteratorInfo, itRef: IrExpr, loc: SrcLoc): IrExpr | null {
return classIteratorNextRead(this, cit, itRef, loc);
}
classIteratorNextCall(
cit: ClassIteratorInfo,
itRef: IrExpr,
next: IrExpr | null,
loc: SrcLoc,
): IrExpr {
return classIteratorNextCall(this, cit, itRef, next, loc);
}
classIteratorDrainCall(src: IrExpr, loc: SrcLoc, elemT?: IrType): IrExpr | null {
@@ -91,3 +91,22 @@ class Range {
let total = 0;
for (const v of new Range(4)) total += v;
console.log(total, [...new Range(3)].join());
// An object-literal iterator that replaces its next() while it runs: the
// loop and the spread keep calling the next() they read when it opened.
class Swapping {
[Symbol.iterator]() {
let i = 0;
const it = {
next(): { value: number; done: boolean } {
i++;
if (i === 2) it.next = () => ({ value: -1, done: true });
return { value: i, done: i > 4 };
},
};
return it;
}
}
const swapped: number[] = [];
for (const v of new Swapping()) swapped.push(v);
console.log(swapped.join(), [...new Swapping()].join());
@@ -76,3 +76,22 @@ try {
} catch (e) {
console.log("throw:", e.message);
}
// An object-literal iterator that replaces its next() while it runs: the
// loop and the spread keep calling the next() they read when it opened.
class Swapping {
[Symbol.iterator]() {
let i = 0;
const it = {
next() {
i++;
if (i === 2) it.next = () => ({ value: -1, done: true });
return { value: i, done: i > 4 };
},
};
return it;
}
}
const swapped = [];
for (const v of new Swapping()) swapped.push(v);
console.log(swapped.join(), [...new Swapping()].join());
@@ -56,5 +56,45 @@ async function main(): Promise<void> {
console.log("found", await find());
for await (const p of new Pages()) console.log(p);
for await (const l of new Letters()) console.log("letter", l);
// return()'s value is awaited when the loop leaves early: a pending
// promise settles first, and a rejection rejects the loop after a break
// (after a throw from the body, the body's error wins).
for await (const v of new Closing((resolve) => setTimeout(() => resolve(1), 5)))
if (v === 1) break;
console.log("after break");
try {
for await (const v of new Closing((_, reject) => reject(new Error("closing failed"))))
if (v === 1) break;
} catch (e) {
console.log("break:", (e as Error).message);
}
try {
for await (const v of new Closing((_, reject) => reject(new Error("closing failed"))))
if (v === 1) throw new Error("body failed");
} catch (e) {
console.log("throw:", (e as Error).message);
}
}
class Closing {
readonly settle: (resolve: (v: number) => void, reject: (e: Error) => void) => void;
constructor(settle: (resolve: (v: number) => void, reject: (e: Error) => void) => void) {
this.settle = settle;
}
[Symbol.iterator]() {
let i = 0;
const settle = this.settle;
return {
next(): IteratorResult<number> {
return { value: i++, done: false };
},
return(): IteratorResult<number> {
console.log("return called");
const value = new Promise<number>((resolve, reject) =>
settle((v) => (console.log("value settled"), resolve(v)), reject),
);
return { value: value as unknown as number, done: true };
},
};
}
}
main();
@@ -56,5 +56,44 @@ async function main() {
console.log("found", await find());
for await (const p of new Pages()) console.log(p);
for await (const l of new Letters()) console.log("letter", l);
// return()'s value is awaited when the loop leaves early: a pending
// promise settles first, and a rejection rejects the loop after a break
// (after a throw from the body, the body's error wins).
for await (const v of new Closing((resolve) => setTimeout(() => resolve(1), 5)))
if (v === 1) break;
console.log("after break");
try {
for await (const v of new Closing((_, reject) => reject(new Error("closing failed"))))
if (v === 1) break;
} catch (e) {
console.log("break:", e.message);
}
try {
for await (const v of new Closing((_, reject) => reject(new Error("closing failed"))))
if (v === 1) throw new Error("body failed");
} catch (e) {
console.log("throw:", e.message);
}
}
class Closing {
constructor(settle) {
this.settle = settle;
}
[Symbol.iterator]() {
let i = 0;
const settle = this.settle;
return {
next() {
return { value: i++, done: false };
},
return() {
console.log("return called");
const value = new Promise((resolve, reject) =>
settle((v) => (console.log("value settled"), resolve(v)), reject),
);
return { value, done: true };
},
};
}
}
main();