From a935daddbe159ac91550f5428a0ffc649aa4af62 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 4 Oct 2026 23:17:21 +0300 Subject: [PATCH 1/3] fix(ts): preserve async and sync iterator semantics in for-await (#385) --- .../org/jacodb/ets/test/EtsTsForAwaitTest.kt | 99 +++++++ .../ts-frontend/src/lowering/stmtLowering.ts | 163 ++++++++--- .../ts-frontend/test/async-iterator.spec.ts | 276 ++++++++++++++++++ jacodb-ets/ts-frontend/test/cfg.spec.ts | 27 ++ 4 files changed, 527 insertions(+), 38 deletions(-) create mode 100644 jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsForAwaitTest.kt create mode 100644 jacodb-ets/ts-frontend/test/async-iterator.spec.ts diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsForAwaitTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsForAwaitTest.kt new file mode 100644 index 000000000..9436f6133 --- /dev/null +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsForAwaitTest.kt @@ -0,0 +1,99 @@ +/* + * Copyright 2022 UnitTestBot contributors (utbot.org) + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jacodb.ets.test + +import kotlin.io.path.createTempDirectory +import kotlin.io.path.exists +import kotlin.io.path.readText +import kotlin.io.path.writeText +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.jacodb.ets.dto.EtsFileDto +import org.jacodb.ets.dto.dtoModule +import org.jacodb.ets.dto.toEtsFile +import org.jacodb.ets.utils.DEFAULT_ARK_CLASS_NAME +import org.jacodb.ets.utils.EtsIrProvider +import org.jacodb.ets.utils.generateEtsIR +import org.junit.jupiter.api.Test +import org.jacodb.ets.dto.AssignStmtDto +import org.jacodb.ets.dto.PtrCallExprDto +import org.jacodb.ets.dto.AwaitExprDto +import org.jacodb.ets.dto.RawValueDto +import org.jacodb.ets.model.EtsAssignStmt +import org.jacodb.ets.model.EtsAwaitExpr + +class EtsTsForAwaitTest { + companion object { + /** Run the PRODUCTION integration path: generateEtsIR + EtsFileDto.loadFromJson. */ + private fun runFrontend(source: String, fileName: String = "test.ts"): EtsFileDto { + val dir = createTempDirectory("ts-frontend-test") + val inputPath = dir.resolve(fileName) + inputPath.writeText(source) + + val outputPath = generateEtsIR( + inputPath, + isProject = false, + timeout = null, + provider = EtsIrProvider.TS_FRONTEND, + ) + assertTrue(outputPath.exists(), "ts-frontend did not produce output: $outputPath") + + return EtsFileDto.loadFromJson(outputPath.readText()) + } + } + + @Test + fun `for await preserves async acquisition and sync value unwrapping`() { + val frontendDto = runFrontend( + source = """ + export async function first(input: AsyncIterable): Promise { + for await (const value of input) return value; + return -1; + } + export async function fromArray(input: number[]): Promise { + for await (const value of input) return value; + return -1; + } + """.trimIndent(), + ) + val roundTripped = EtsFileDto.loadFromJson( + Json { serializersModule = dtoModule }.encodeToString(frontendDto), + ) + for (name in listOf("first", "fromArray")) { + val methodDto = roundTripped.classes.single { it.signature.name == DEFAULT_ARK_CLASS_NAME } + .methods.single { it.signature.name == name } + val assignments = methodDto.body!!.cfg.blocks.flatMap { it.stmts }.filterIsInstance() + val calls = assignments.mapNotNull { it.right as? PtrCallExprDto } + val awaits = assignments.mapNotNull { it.right as? AwaitExprDto } + val next = assignments.single { (it.right as? PtrCallExprDto)?.method?.name == "next" } + + assertTrue(calls.any { it.method.name == "Symbol.asyncIterator" && it.receiver != null }) + assertTrue(calls.any { it.method.name == "Symbol.iterator" && it.receiver != null }) + assertEquals(2, awaits.size) + assertTrue(awaits.any { it.arg == next.left }) + assertTrue(assignments.none { it.right is RawValueDto }) + + val modelMethod = roundTripped.toEtsFile().classes.single { it.name == DEFAULT_ARK_CLASS_NAME } + .methods.single { it.name == name } + assertEquals(2, modelMethod.cfg.stmts.filterIsInstance() + .count { it.rhv is EtsAwaitExpr }) + } + } + +} diff --git a/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts b/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts index de5fa67a6..9865b98fa 100644 --- a/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts @@ -23,12 +23,12 @@ import * as ts from "typescript"; import { MethodSignatureDto, UNKNOWN_CLASS_SIGNATURE, UNKNOWN_FILE_SIGNATURE } from "../dto/signatures"; -import { BOOLEAN_TYPE, NUMBER_TYPE, STRING_TYPE, TypeDto, UNDEFINED_TYPE, UNKNOWN_TYPE } from "../dto/types"; +import { BOOLEAN_TYPE, NUMBER_TYPE, STRING_TYPE, SYMBOL_TYPE, TypeDto, UNDEFINED_TYPE, UNKNOWN_TYPE } from "../dto/types"; import { ImmediateDto, LValueDto, LocalDto, ValueDto } from "../dto/values"; import { exportAssignmentSupport } from "./astUtils"; import type { BuiltParameters } from "./astUtils"; import { Label } from "./cfg"; -import { IteratorLowerer } from "./iteratorLowering"; +import { IteratorLowerer, throwRuntimeError } from "./iteratorLowering"; import { unsupportedStmt } from "./diagnostics"; import { ExprLowerer, LoweringError, arrayElementType, constant } from "./exprLowering"; import { MethodContext } from "./methodBuilder"; @@ -365,27 +365,20 @@ export class StmtLowerer { cfg.placeLabel(exitLabel); } - /** - * `for (const v of iterable)` — iterator protocol, ArkAnalyzer-shaped: - * %it := iterable.Symbol.iterator() - * head: %res := %it.next(); %done := %res.done - * if (%done == true) exit else body - * body: v := %res.value; ... - */ + /** Lower iterator reads, including Async-from-Sync value unwrapping for `for await`. */ private lowerForOf(node: ts.ForOfStatement, label: string | undefined): void { const cfg = this.m.cfg; - const iterable = this.expr.lowerToLocal(node.expression); - - const iterator = this.m.newTemp(UNKNOWN_TYPE); - cfg.emit({ - _: "AssignStmt", - left: iterator, - right: { - _: "InstanceCallExpr", - instance: iterable, - method: unknownMethod("Symbol.iterator"), - args: [], - }, + const source = this.expr.lowerToLocal(node.expression); + const isAsync = node.awaitModifier !== undefined; + // GetMethod may invoke a getter that reassigns the source binding. + const iterable = isAsync ? this.m.snapshotToLocal(source, source.type) : source; + const asyncIterator = isAsync ? this.lowerAsyncIterator(iterable) : undefined; + + const iterator = asyncIterator?.iterator ?? this.expr.materialize({ + _: "InstanceCallExpr", + instance: iterable, + method: unknownMethod("Symbol.iterator"), + args: [], }); const headLabel = cfg.newLabel(); @@ -394,30 +387,64 @@ export class StmtLowerer { cfg.placeLabel(headLabel); const result = this.m.newTemp(UNKNOWN_TYPE); + const nextResult = isAsync ? this.m.newTemp(UNKNOWN_TYPE) : result; cfg.emit({ _: "AssignStmt", - left: result, - right: { _: "InstanceCallExpr", instance: iterator, method: unknownMethod("next"), args: [] }, - }); - const done = this.m.newTemp(BOOLEAN_TYPE); - cfg.emit({ - _: "AssignStmt", - left: done, - right: { - _: "InstanceFieldRef", - instance: result, - field: { declaringClass: UNKNOWN_CLASS_SIGNATURE, name: "done", type: BOOLEAN_TYPE }, - }, + left: nextResult, + right: asyncIterator === undefined + ? { _: "InstanceCallExpr", instance: iterator, method: unknownMethod("next"), args: [] } + : { _: "PtrCallExpr", ptr: asyncIterator.next, receiver: iterator, method: unknownMethod("next"), args: [] }, }); + if (asyncIterator !== undefined) { + const syncResult = cfg.newLabel(); + const asyncResult = cfg.newLabel(); + const resultReady = cfg.newLabel(); + cfg.branch(asyncIterator.fromSync, syncResult, asyncResult); + + cfg.placeLabel(syncResult); + cfg.emit({ _: "AssignStmt", left: result, right: nextResult }); + cfg.goto(resultReady); + + cfg.placeLabel(asyncResult); + cfg.emit({ _: "AssignStmt", left: result, right: { _: "AwaitExpr", arg: nextResult } }); + cfg.placeLabel(resultReady); + this.requireIteratorObject(result); + } + + const doneType = asyncIterator === undefined ? BOOLEAN_TYPE : UNKNOWN_TYPE; + const rawDone = this.expr.materialize(this.iteratorField(result, "done", doneType), doneType); + // IteratorComplete uses ToBoolean, including non-boolean truthy done values. + const notDone = asyncIterator === undefined ? undefined + : this.expr.materialize({ _: "UnopExpr", op: "!", arg: rawDone }, BOOLEAN_TYPE); + const done = notDone === undefined ? rawDone + : this.expr.materialize({ _: "UnopExpr", op: "!", arg: notDone }, BOOLEAN_TYPE); + + const value = asyncIterator === undefined ? undefined : this.m.newTemp(UNKNOWN_TYPE); + if (asyncIterator !== undefined && value !== undefined) { + const unwrap = cfg.newLabel(); + const valueReady = cfg.newLabel(); + cfg.branch(asyncIterator.fromSync, unwrap, valueReady); + cfg.placeLabel(unwrap); + // Async-from-Sync reads done and value before awaiting value, even when done is true. + // Awaiting the result object would incorrectly assimilate a user-defined then method. + cfg.emit({ _: "AssignStmt", left: value, right: this.iteratorField(result, "value") }); + cfg.emit({ _: "AssignStmt", left: value, right: { _: "AwaitExpr", arg: value } }); + cfg.placeLabel(valueReady); + } + // if (done == true) -> exit, else -> body cfg.branch(this.expr.relation("==", done, constant("true", BOOLEAN_TYPE)), exitLabel, bodyLabel); cfg.placeLabel(bodyLabel); - this.emitLoopBinding(node.initializer, (binding) => ({ - _: "InstanceFieldRef", - instance: result, - field: { declaringClass: UNKNOWN_CLASS_SIGNATURE, name: "value", type: binding.type }, - })); + if (asyncIterator !== undefined && value !== undefined) { + const asyncValue = cfg.newLabel(); + const valueReady = cfg.newLabel(); + cfg.branch(asyncIterator.fromSync, valueReady, asyncValue); + cfg.placeLabel(asyncValue); + cfg.emit({ _: "AssignStmt", left: value, right: this.iteratorField(result, "value") }); + cfg.placeLabel(valueReady); + } + this.emitLoopBinding(node.initializer, (binding) => value ?? this.iteratorField(result, "value", binding.type)); this.inBreakable({ kind: "loop", breakTarget: exitLabel, continueTarget: headLabel, label }, () => { this.lowerStatement(node.statement); }); @@ -425,6 +452,66 @@ export class StmtLowerer { cfg.placeLabel(exitLabel); } + /** GetAsyncIterator chooses the async method first and caches the chosen iterator's next. */ + private lowerAsyncIterator(iterable: LocalDto): { iterator: LocalDto; next: LocalDto; fromSync: LocalDto } { + const cfg = this.m.cfg; + const method = this.iteratorMethod(iterable, "asyncIterator"); + const iterator = this.m.newTemp(UNKNOWN_TYPE); + const fromSync = this.m.newTemp(BOOLEAN_TYPE); + const sync = cfg.newLabel(); + const async = cfg.newLabel(); + const acquired = cfg.newLabel(); + cfg.branch(this.expr.relation("==", method, constant("null", { _: "NullType" })), sync, async); + + cfg.placeLabel(sync); + const syncMethod = this.iteratorMethod(iterable, "iterator"); + cfg.emit({ _: "AssignStmt", left: iterator, right: { + _: "PtrCallExpr", ptr: syncMethod, receiver: iterable, method: unknownMethod("Symbol.iterator"), args: [], + } }); + cfg.emit({ _: "AssignStmt", left: fromSync, right: constant("true", BOOLEAN_TYPE) }); + cfg.goto(acquired); + + cfg.placeLabel(async); + // A present non-callable async method throws at the call; it must never select the fallback. + cfg.emit({ _: "AssignStmt", left: iterator, right: { + _: "PtrCallExpr", ptr: method, receiver: iterable, method: unknownMethod("Symbol.asyncIterator"), args: [], + } }); + cfg.emit({ _: "AssignStmt", left: fromSync, right: constant("false", BOOLEAN_TYPE) }); + cfg.placeLabel(acquired); + this.requireIteratorObject(iterator); + const next = this.expr.materialize(this.iteratorField(iterator, "next")); + return { iterator, next, fromSync }; + } + + private iteratorMethod(iterable: LocalDto, name: "iterator" | "asyncIterator"): LocalDto { + const key = this.expr.materialize({ + _: "StaticFieldRef", + field: { declaringClass: { name: "Symbol", declaringFile: UNKNOWN_FILE_SIGNATURE }, name, type: SYMBOL_TYPE }, + }, SYMBOL_TYPE); + return this.expr.materialize({ _: "PropertyRef", instance: iterable, key, type: UNKNOWN_TYPE }); + } + + private iteratorField(instance: LocalDto, name: string, type: TypeDto = UNKNOWN_TYPE): ValueDto { + return { _: "InstanceFieldRef", instance, field: { declaringClass: UNKNOWN_CLASS_SIGNATURE, name, type } }; + } + + private requireIteratorObject(value: LocalDto): void { + const cfg = this.m.cfg; + const kind = this.expr.materialize({ _: "TypeOfExpr", arg: value }, STRING_TYPE); + const object = cfg.newLabel(); + const functionCheck = cfg.newLabel(); + const valid = cfg.newLabel(); + const invalid = cfg.newLabel(); + cfg.branch(this.expr.relation("===", kind, constant("object", STRING_TYPE)), object, functionCheck); + cfg.placeLabel(object); + cfg.branch(this.expr.relation("===", value, constant("null", { _: "NullType" })), invalid, valid); + cfg.placeLabel(functionCheck); + cfg.branch(this.expr.relation("===", kind, constant("function", STRING_TYPE)), valid, invalid); + cfg.placeLabel(invalid); + throwRuntimeError(this.m, "TypeError", "Iterator protocol requires an object"); + cfg.placeLabel(valid); + } + /** * `for (const k in obj)` — index loop over `Object.keys(obj)`: * %keys := Object.keys(obj); %i := 0 diff --git a/jacodb-ets/ts-frontend/test/async-iterator.spec.ts b/jacodb-ets/ts-frontend/test/async-iterator.spec.ts new file mode 100644 index 000000000..0f0a77442 --- /dev/null +++ b/jacodb-ets/ts-frontend/test/async-iterator.spec.ts @@ -0,0 +1,276 @@ +import { describe, expect, it } from "vitest"; +import { MethodDto } from "../src/dto/model"; +import { ValueDto } from "../src/dto/values"; +import { serializeEtsFile } from "../src/serialize"; +import { validateEtsFile } from "../src/validate"; +import { lower, methodByName } from "./util"; + +/** Executes only the protocol nodes used here. Only AwaitExpr assimilates a Promise; + * ordinary property reads and calls retain their raw values. This is a concrete DTO + * regression oracle, not a USVM execution test. + */ +async function execute(method: MethodDto, input: unknown): Promise { + const body = method.body!; + const locals = new Map(); + const read = (value: ValueDto): any => { + switch (value._) { + case "Local": return locals.get(value.name); + case "ParameterRef": return input; + case "ThisRef": return undefined; + case "Constant": + switch (value.type._) { + case "UndefinedType": return undefined; + case "NullType": return null; + case "NumberType": return Number(value.value); + case "BooleanType": return value.value === "true"; + default: return value.value; + } + case "StaticFieldRef": + if (value.field.declaringClass.name !== "Symbol") throw new Error("Unexpected static field"); + return (Symbol as any)[value.field.name]; + case "InstanceFieldRef": return read(value.instance)[value.field.name]; + case "PropertyRef": return read(value.instance)[read(value.key)]; + case "TypeOfExpr": return typeof read(value.arg); + case "UnopExpr": + if (value.op !== "!") throw new Error(`Unexpected unary op ${value.op}`); + return !read(value.arg); + case "BinopExpr": + case "ConditionExpr": { + const left = read(value.left); + const right = read(value.right); + switch (value.op) { + case "+": return left + right; + case "==": return left == right; + case "===": return left === right; + case "!==": return left !== right; + default: throw new Error(`Unexpected binary op ${value.op}`); + } + } + case "PtrCallExpr": return Reflect.apply(read(value.ptr), read(value.receiver!), value.args.map(read)); + case "NewExpr": + if (value.classType._ !== "ClassType") throw new Error("Unexpected allocation type"); + return { constructorName: value.classType.signature.name }; + case "InstanceCallExpr": { + const instance = read(value.instance); + if (value.method.name === "constructor") { + const constructor = (globalThis as any)[instance.constructorName]; + return Reflect.construct(constructor, value.args.map(read)); + } + const key = value.method.name === "Symbol.asyncIterator" ? Symbol.asyncIterator + : value.method.name === "Symbol.iterator" ? Symbol.iterator : value.method.name; + return Reflect.apply(instance[key], instance, value.args.map(read)); + } + default: throw new Error(`Unexpected protocol value ${value._}`); + } + }; + + let blockId = 0; + for (let steps = 0; steps < 10_000; steps++) { + const block = body.cfg.blocks[blockId]; + let next = block.successors[0]; + for (const statement of block.stmts) { + switch (statement._) { + case "AssignStmt": + if (statement.left._ !== "Local") throw new Error("Unexpected assignment target"); + locals.set(statement.left.name, statement.right._ === "AwaitExpr" + ? await read(statement.right.arg) : read(statement.right)); + break; + case "IfStmt": next = block.successors[read(statement.condition) ? 1 : 0]; break; + case "ReturnStmt": return read(statement.arg); + case "ReturnVoidStmt": return undefined; + case "ThrowStmt": throw read(statement.arg); + case "NopStmt": break; + default: throw new Error(`Unexpected protocol statement ${statement._}`); + } + } + if (next === undefined) throw new Error("Missing successor"); + blockId = next; + } + throw new Error("Protocol oracle step limit"); +} + +function program(source: string) { + const { file, diagnostics } = lower(source); + const roundTripped = JSON.parse(serializeEtsFile(file)); + expect(diagnostics.messages).toEqual([]); + expect(validateEtsFile(roundTripped)).toEqual([]); + + const method = methodByName(roundTripped, "run"); + return { + ir: (input: unknown) => execute(method, input), + native: new Function("input", `${source}; return run(input);`) as (input: unknown) => Promise, + }; +} + +const sumSource = ` + async function run(input) { + let total = 0; + for await (const value of input) total += value; + return total; + } +`; + +describe("for-await iterator acquisition and Async-from-Sync values", () => { + it("snapshots the iterable before an async getter reassigns its source binding", async () => { + const iteratorMethod = `function() { + const value = this.id; + return { next: function() { return { value, done: false }; } }; + }`; + for (const asyncMethod of [iteratorMethod, "null"]) { + const source = ` + async function run() { + let input; + input = { + id: 3, + get [Symbol.asyncIterator]() { input = { id: 9 }; return ${asyncMethod}; }, + get [Symbol.iterator]() { return ${iteratorMethod}; }, + }; + for await (const value of input) return value; + return 0; + } + `; + const { file, diagnostics } = lower(source); + const roundTripped = JSON.parse(serializeEtsFile(file)); + const statements = methodByName(roundTripped, "run").body!.cfg.blocks.flatMap((block) => block.stmts); + const snapshotIndex = statements.findIndex((statement) => statement._ === "AssignStmt" + && statement.right._ === "Local" && statement.right.name === "input"); + const snapshot = statements[snapshotIndex]; + const lookups = statements.filter((statement) => statement._ === "AssignStmt" + && statement.right._ === "PropertyRef"); + const calls = statements.filter((statement) => statement._ === "AssignStmt" + && statement.right._ === "PtrCallExpr" && statement.right.method.name.startsWith("Symbol.")); + + expect(diagnostics.messages).toEqual([]); + expect(validateEtsFile(roundTripped)).toEqual([]); + expect(snapshot).toMatchObject({ _: "AssignStmt", left: { _: "Local" }, right: { _: "Local", name: "input" } }); + expect(snapshot.left.name).not.toBe("input"); + expect(lookups).toHaveLength(2); + expect(calls).toHaveLength(2); + expect(lookups.every((statement) => statement.right.instance.name === snapshot.left.name)).toBe(true); + expect(calls.every((statement) => statement.right.receiver?.name === snapshot.left.name)).toBe(true); + expect(lookups.every((statement) => statements.indexOf(statement) > snapshotIndex)).toBe(true); + expect(await new Function(`${source}; return run();`)()).toBe(3); + } + }); + + it("accepts arrays and synchronous generators yielding Promises", async () => { + const run = program(sumSource); + const inputs = [ + () => [3, 4], + () => (function* () { yield Promise.resolve(3); yield Promise.resolve(4); })(), + ]; + + for (const input of inputs) { + expect(await run.ir(input())).toBe(await run.native(input())); + expect(await run.ir(input())).toBe(7); + } + }); + + it("selects the async method and does not additionally await an async iterator's value", async () => { + const run = program(` + async function run(input) { + let kind = "empty"; + for await (const value of input) kind = typeof value; + return kind; + } + `); + const input = () => { + let step = 0; + return { + [Symbol.asyncIterator]() { + return { next: () => Promise.resolve(step++ === 0 + ? { value: Promise.resolve(7), done: false } : { done: true }) }; + }, + [Symbol.iterator]() { throw new Error("sync fallback must not run"); }, + }; + }; + + expect(await run.ir(input())).toBe("object"); + expect(await run.ir(input())).toBe(await run.native(input())); + }); + + it("falls back for null and undefined, but rejects a present non-callable async method", async () => { + const run = program(sumSource); + for (const asyncMethod of [null, undefined]) { + const input = () => ({ + [Symbol.asyncIterator]: asyncMethod, + *[Symbol.iterator]() { yield 7; }, + }); + + expect(await run.ir(input())).toBe(await run.native(input())); + } + + let syncReads = 0; + const invalid = { + [Symbol.asyncIterator]: 1, + get [Symbol.iterator]() { syncReads++; return [][Symbol.iterator]; }, + }; + + await expect(run.ir(invalid)).rejects.toBeInstanceOf(TypeError); + await expect(run.native(invalid)).rejects.toBeInstanceOf(TypeError); + expect(syncReads).toBe(0); + }); + + it("caches next, reads done before value and awaits the final sync value without awaiting its result", async () => { + const run = program(sumSource); + const input = (events: string[]) => { + let step = 0; + const iterator = { + get next() { + events.push("next lookup"); + return function () { + expect(this).toBe(iterator); + const index = step++; + return { + get done() { events.push(`done ${index}`); return index === 0 ? "" : "finished"; }, + get value() { + events.push(`value ${index}`); + return { then(resolve: (value: number) => void) { + events.push(`await ${index}`); + resolve(index === 0 ? 7 : 0); + } }; + }, + then() { throw new Error("must not await the IteratorResult"); }, + }; + }; + }, + }; + return { [Symbol.iterator]() { return iterator; } }; + }; + const irEvents: string[] = []; + const nativeEvents: string[] = []; + + expect(await run.ir(input(irEvents))).toBe(7); + expect(await run.native(input(nativeEvents))).toBe(7); + expect(irEvents).toEqual(nativeEvents); + expect(irEvents).toEqual(["next lookup", "done 0", "value 0", "await 0", "done 1", "value 1", "await 1"]); + }); + + it("propagates getter failures and rejects primitive iterators and results", async () => { + const run = program(sumSource); + const failure = new Error("async lookup failed"); + const failingLookup = { + get [Symbol.asyncIterator]() { throw failure; }, + [Symbol.iterator]() { throw new Error("must not select fallback"); }, + }; + + await expect(run.ir(failingLookup)).rejects.toBe(failure); + await expect(run.native(failingLookup)).rejects.toBe(failure); + + for (const key of [Symbol.iterator, Symbol.asyncIterator]) { + await expect(run.ir({ [key]: () => 1 })).rejects.toBeInstanceOf(TypeError); + await expect(run.native({ [key]: () => 1 })).rejects.toBeInstanceOf(TypeError); + await expect(run.ir({ [key]: () => ({ next: () => 1 }) })).rejects.toBeInstanceOf(TypeError); + await expect(run.native({ [key]: () => ({ next: () => 1 }) })).rejects.toBeInstanceOf(TypeError); + } + }); + + it("propagates rejection of a Promise yielded by a synchronous iterator", async () => { + const run = program(sumSource); + const failure = new Error("yielded value rejected"); + const input = () => (function* () { yield Promise.reject(failure); })(); + + await expect(run.ir(input())).rejects.toBe(failure); + await expect(run.native(input())).rejects.toBe(failure); + }); +}); diff --git a/jacodb-ets/ts-frontend/test/cfg.spec.ts b/jacodb-ets/ts-frontend/test/cfg.spec.ts index 6d4e7b4fc..c863bea4e 100644 --- a/jacodb-ets/ts-frontend/test/cfg.spec.ts +++ b/jacodb-ets/ts-frontend/test/cfg.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { serializeEtsFile } from "../src/serialize"; import { BasicBlockDto, MethodDto } from "../src/dto/model"; import { IfStmtDto, StmtDto } from "../src/dto/stmts"; import { defaultMethod, lower, methodByName } from "./util"; @@ -278,6 +279,32 @@ describe("control flow lowering", () => { expect(method.body!.locals.some((l) => l.name === "v")).toBe(true); }); + it("preserves async iteration and awaits each next result through JSON", async () => { + const source = ` + async function first(input) { + for await (const value of input) return value; + return -1; + } + `; + const { file, diagnostics } = lower(source); + const roundTripped = JSON.parse(serializeEtsFile(file)); + const statements = allStmts(methodByName(roundTripped, "first")); + const calls = statements.filter((statement) => statement._ === "AssignStmt" + && statement.right._ === "PtrCallExpr"); + const awaits = statements.filter((statement) => statement._ === "AssignStmt" + && statement.right._ === "AwaitExpr"); + + expect(diagnostics.messages).toEqual([]); + expect(calls.map((statement) => statement.right.method.name)).toContain("Symbol.asyncIterator"); + expect(calls.map((statement) => statement.right.method.name)).toContain("Symbol.iterator"); + expect(awaits).toHaveLength(2); + expect(awaits.map((statement) => statement.right.arg)) + .toContainEqual(calls.find((statement) => statement.right.method.name === "next")?.left); + + const concrete = await new Function(`${source}; return first({ async *[Symbol.asyncIterator]() { yield 7; } });`)(); + expect(concrete).toBe(7); + }); + it("lowers for-in via Object.keys indexing", () => { const { file } = lower(` let obj = [1]; From 53f9d94be4677ce48b55528570bbeba306154d2a Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 8 Oct 2026 00:23:12 +0300 Subject: [PATCH 2/3] fix(ets): close for-await iterators on abrupt completion --- .../ts-frontend/src/lowering/stmtLowering.ts | 96 +++++- .../ts-frontend/test/async-iterator.spec.ts | 313 +++++++++++++++++- jacodb-ets/ts-frontend/test/cfg.spec.ts | 2 +- 3 files changed, 386 insertions(+), 25 deletions(-) diff --git a/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts b/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts index 9865b98fa..a8f7b61b7 100644 --- a/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts @@ -49,6 +49,8 @@ interface BreakableContext { label?: string; /** finallyScopes depth at the moment this breakable was entered. */ finallyDepth?: number; + /** Iterator cleanup also runs on break from this loop, but not on continue. */ + breakFinallyDepth?: number; } interface CatchContext { @@ -59,8 +61,8 @@ interface CatchContext { export class StmtLowerer { readonly expr: ExprLowerer; private readonly breakables: BreakableContext[] = []; - /** finally blocks of the enclosing try statements (outermost first). */ - private readonly finallyScopes: ts.Block[] = []; + /** Finally blocks and iterator cleanup of enclosing statements (outermost first). */ + private readonly finallyScopes: (ts.Block | (() => void))[] = []; private readonly finallyExceptionTargets: Label[] = []; private readonly catchScopes: CatchContext[] = []; private pendingLabel: string | undefined; @@ -444,14 +446,82 @@ export class StmtLowerer { cfg.emit({ _: "AssignStmt", left: value, right: this.iteratorField(result, "value") }); cfg.placeLabel(valueReady); } - this.emitLoopBinding(node.initializer, (binding) => value ?? this.iteratorField(result, "value", binding.type)); - this.inBreakable({ kind: "loop", breakTarget: exitLabel, continueTarget: headLabel, label }, () => { - this.lowerStatement(node.statement); - }); - cfg.goto(headLabel); + const lowerBody = (breakFinallyDepth?: number) => { + this.emitLoopBinding(node.initializer, (binding) => value ?? this.iteratorField(result, "value", binding.type)); + this.inBreakable({ kind: "loop", breakTarget: exitLabel, continueTarget: headLabel, label, breakFinallyDepth }, () => { + this.lowerStatement(node.statement); + }); + }; + if (asyncIterator === undefined) { + lowerBody(); + cfg.goto(headLabel); + } else { + const closeOnThrow = cfg.newLabel(); + const rethrow = cfg.newLabel(); + const outerFinallyDepth = this.finallyScopes.length; + this.finallyScopes.push(() => this.lowerAsyncIteratorClose(asyncIterator)); + this.finallyExceptionTargets.push(closeOnThrow); + this.catchScopes.push({ target: closeOnThrow, finallyDepth: this.finallyScopes.length }); + try { + cfg.withExceptionTarget(closeOnThrow, () => lowerBody(outerFinallyDepth)); + } finally { + this.catchScopes.pop(); + this.finallyScopes.pop(); + this.finallyExceptionTargets.pop(); + } + cfg.goto(headLabel); + + cfg.placeLabel(closeOnThrow); + const original = this.m.newTemp(UNKNOWN_TYPE); + cfg.emit({ _: "AssignStmt", left: original, right: { _: "CaughtExceptionRef", type: UNKNOWN_TYPE } }); + // AsyncIteratorClose preserves an existing throw over any cleanup failure. + cfg.withExceptionTarget(rethrow, () => this.lowerAsyncIteratorClose(asyncIterator)); + cfg.goto(rethrow); + cfg.placeLabel(rethrow); + cfg.throwValue(original); + } cfg.placeLabel(exitLabel); } + private lowerAsyncIteratorClose(record: { iterator: LocalDto; fromSync: LocalDto }): void { + const cfg = this.m.cfg; + const method = this.expr.materialize(this.iteratorField(record.iterator, "return")); + const missing = cfg.newLabel(); + const invoke = cfg.newLabel(); + const sync = cfg.newLabel(); + const async = cfg.newLabel(); + const done = cfg.newLabel(); + cfg.branch(this.expr.relation("==", method, constant("null", { _: "NullType" })), missing, invoke); + + cfg.placeLabel(missing); + const missingSync = cfg.newLabel(); + cfg.branch(record.fromSync, missingSync, done); + cfg.placeLabel(missingSync); + // Async-from-Sync still returns a resolved Promise when its return is absent. + const unused = this.m.newTemp(UNKNOWN_TYPE); + cfg.emit({ _: "AssignStmt", left: unused, right: { _: "AwaitExpr", arg: constant("undefined", UNDEFINED_TYPE) } }); + cfg.goto(done); + + cfg.placeLabel(invoke); + const result = this.expr.materialize({ + _: "PtrCallExpr", ptr: method, receiver: record.iterator, method: unknownMethod("return"), args: [], + }); + cfg.branch(record.fromSync, sync, async); + + cfg.placeLabel(sync); + // The wrapper validates the raw result and reads done before awaiting value. + this.requireIteratorObject(result); + this.expr.materialize(this.iteratorField(result, "done")); + const value = this.expr.materialize(this.iteratorField(result, "value")); + cfg.emit({ _: "AssignStmt", left: unused, right: { _: "AwaitExpr", arg: value } }); + cfg.goto(done); + + cfg.placeLabel(async); + cfg.emit({ _: "AssignStmt", left: result, right: { _: "AwaitExpr", arg: result } }); + this.requireIteratorObject(result); + cfg.placeLabel(done); + } + /** GetAsyncIterator chooses the async method first and caches the chosen iterator's next. */ private lowerAsyncIterator(iterable: LocalDto): { iterator: LocalDto; next: LocalDto; fromSync: LocalDto } { const cfg = this.m.cfg; @@ -678,8 +748,8 @@ export class StmtLowerer { if (target === undefined) { throw new LoweringError(`break outside of a breakable context`); } - // Run finally blocks of try statements entered INSIDE the target breakable. - this.emitFinallies(target.finallyDepth ?? 0); + // Run nested finally blocks, plus this loop's iterator cleanup when present. + this.emitFinallies(target.breakFinallyDepth ?? target.finallyDepth ?? 0); this.m.cfg.goto(target.breakTarget); } @@ -816,7 +886,7 @@ export class StmtLowerer { } /** - * Emit copies of the enclosing finally blocks with stack depth > `downTo`, + * Emit enclosing finally blocks and cleanup callbacks with stack depth >= `downTo`, * innermost first — used before abrupt exits (return/throw/break/continue). * While a finally body is being emitted, its own scope (and deeper ones) is * masked so a nested abrupt exit only re-runs the OUTER finallies. @@ -833,7 +903,11 @@ export class StmtLowerer { const target = outerCatch !== undefined && outerCatch.finallyDepth >= i ? outerCatch.target : outerFinally ?? outerCatch?.target; - this.m.cfg.withExceptionTarget(target, () => this.lowerStatement(masked[0])); + this.m.cfg.withExceptionTarget(target, () => { + const scope = masked[0]; + if (typeof scope === "function") scope(); + else this.lowerStatement(scope); + }); } finally { this.finallyScopes.push(...masked); this.finallyExceptionTargets.push(...maskedTargets); diff --git a/jacodb-ets/ts-frontend/test/async-iterator.spec.ts b/jacodb-ets/ts-frontend/test/async-iterator.spec.ts index 0f0a77442..ab5225246 100644 --- a/jacodb-ets/ts-frontend/test/async-iterator.spec.ts +++ b/jacodb-ets/ts-frontend/test/async-iterator.spec.ts @@ -12,11 +12,18 @@ import { lower, methodByName } from "./util"; async function execute(method: MethodDto, input: unknown): Promise { const body = method.body!; const locals = new Map(); + let caught: unknown; const read = (value: ValueDto): any => { switch (value._) { case "Local": return locals.get(value.name); case "ParameterRef": return input; case "ThisRef": return undefined; + case "CaughtExceptionRef": return caught; + case "RequireObjectCoercibleExpr": { + const source = read(value.arg); + if (source === null || source === undefined) throw new TypeError("nullish binding"); + return source; + } case "Constant": switch (value.type._) { case "UndefinedType": return undefined; @@ -68,19 +75,29 @@ async function execute(method: MethodDto, input: unknown): Promise { for (let steps = 0; steps < 10_000; steps++) { const block = body.cfg.blocks[blockId]; let next = block.successors[0]; - for (const statement of block.stmts) { - switch (statement._) { - case "AssignStmt": - if (statement.left._ !== "Local") throw new Error("Unexpected assignment target"); - locals.set(statement.left.name, statement.right._ === "AwaitExpr" - ? await read(statement.right.arg) : read(statement.right)); - break; - case "IfStmt": next = block.successors[read(statement.condition) ? 1 : 0]; break; - case "ReturnStmt": return read(statement.arg); - case "ReturnVoidStmt": return undefined; - case "ThrowStmt": throw read(statement.arg); - case "NopStmt": break; - default: throw new Error(`Unexpected protocol statement ${statement._}`); + for (let index = 0; index < block.stmts.length; index++) { + const statement = block.stmts[index]; + try { + switch (statement._) { + case "AssignStmt": + if (statement.left._ !== "Local") throw new Error("Unexpected assignment target"); + locals.set(statement.left.name, statement.right._ === "AwaitExpr" + ? await read(statement.right.arg) : read(statement.right)); + break; + case "CallStmt": read(statement.expr); break; + case "IfStmt": next = block.successors[read(statement.condition) ? 1 : 0]; break; + case "ReturnStmt": return read(statement.arg); + case "ReturnVoidStmt": return undefined; + case "ThrowStmt": throw read(statement.arg); + case "NopStmt": break; + default: throw new Error(`Unexpected protocol statement ${statement._}`); + } + } catch (error) { + const handler = block.exceptionalSuccessors?.find((edge) => edge.stmtIndex === index); + if (handler === undefined) throw error; + caught = error; + next = handler.target; + break; } } if (next === undefined) throw new Error("Missing successor"); @@ -274,3 +291,273 @@ describe("for-await iterator acquisition and Async-from-Sync values", () => { await expect(run.native(input())).rejects.toBe(failure); }); }); + + +const breakSource = "async function run(input) { for await (const value of input) break; return 9; }"; + +function closingInput(events: string[], options: { + sync?: boolean; + value?: unknown; + close?: () => unknown; + getterFailure?: unknown; +} = {}) { + const iterator = { + next: () => ({ value: Object.prototype.hasOwnProperty.call(options, "value") ? options.value : 1, done: false }), + get return() { + events.push("return lookup"); + if (options.getterFailure !== undefined) throw options.getterFailure; + return function () { + expect(this).toBe(iterator); + events.push("return call"); + return options.close === undefined ? { done: true } : options.close(); + }; + }, + }; + return { [options.sync ? Symbol.iterator : Symbol.asyncIterator]: () => iterator }; +} + +describe("for-await abrupt completion closes its iterator", () => { + for (const completion of ["break;", "return value;", "throw value;"]) { + it(`awaits async iterator cleanup for ${completion}`, async () => { + const run = program(`async function run(input) { for await (const value of input) { ${completion} } return 9; }`); + const failure = new Error("body failure"); + const outcomes: unknown[] = []; + const logs: string[][] = []; + + for (const consume of [run.native, run.ir]) { + const events: string[] = []; + const input = closingInput(events, { + value: completion.startsWith("throw") ? failure : 7, + close: () => ({ then(resolve: (result: object) => void) { + events.push("return awaited"); + resolve({ done: true }); + } }), + }); + + try { outcomes.push(await consume(input)); } catch (error) { outcomes.push(error); } + logs.push(events); + } + + expect(outcomes[1]).toBe(outcomes[0]); + expect(logs[1]).toEqual(logs[0]); + expect(logs[1]).toEqual(["return lookup", "return call", "return awaited"]); + }); + } + + it("closes on a runtime body failure and a binding initialization failure", async () => { + const failure = new Error("body call failure"); + const cases = [ + { source: "async function run(input) { for await (const value of input) value.fail(); }", value: { fail() { throw failure; } } }, + { source: "async function run(input) { for await (const {field} of input) return field; }", value: null }, + ]; + + for (const scenario of cases) { + const run = program(scenario.source); + for (const consume of [run.native, run.ir]) { + const events: string[] = []; + const input = closingInput(events, { value: scenario.value }); + + if (scenario.value === null) await expect(consume(input)).rejects.toBeInstanceOf(TypeError); + else await expect(consume(input)).rejects.toBe(failure); + expect(events).toEqual(["return lookup", "return call"]); + } + } + }); + + it("preserves a body throw over return getter, call, await and result failures", async () => { + const bodyFailure = new Error("body failure"); + const closeFailure = new Error("close failure"); + const run = program("async function run(input) { for await (const value of input) throw value; }"); + const closes = [ + { getterFailure: closeFailure }, + { close: () => { throw closeFailure; } }, + { close: () => Promise.reject(closeFailure) }, + { close: () => 1 }, + ]; + + for (const options of closes) { + for (const consume of [run.native, run.ir]) { + await expect(consume(closingInput([], { ...options, value: bodyFailure }))).rejects.toBe(bodyFailure); + } + } + }); + + it("propagates cleanup failures for break and return", async () => { + const closeFailure = new Error("close failure"); + for (const completion of ["break;", "return value;"]) { + const run = program(`async function run(input) { for await (const value of input) { ${completion} } }`); + for (const consume of [run.native, run.ir]) { + await expect(consume(closingInput([], { getterFailure: closeFailure }))).rejects.toBe(closeFailure); + await expect(consume(closingInput([], { close: () => Promise.reject(closeFailure) }))).rejects.toBe(closeFailure); + await expect(consume(closingInput([], { close: () => 1 }))).rejects.toBeInstanceOf(TypeError); + } + } + }); + + it("closes the sync fallback without assimilating its IteratorResult", async () => { + const run = program(breakSource); + const logs: string[][] = []; + for (const consume of [run.native, run.ir]) { + const events: string[] = []; + const input = closingInput(events, { sync: true, close: () => ({ + get done() { events.push("close done"); return true; }, + get value() { events.push("close value"); return { then(resolve: (value: number) => void) { + events.push("close value awaited"); resolve(0); + } }; }, + then() { throw new Error("must not await sync IteratorResult"); }, + }) }); + + expect(await consume(input)).toBe(9); + logs.push(events); + } + + expect(logs[1]).toEqual(logs[0]); + expect(logs[1]).toEqual(["return lookup", "return call", "close done", "close value", "close value awaited"]); + }); + + it("does not close on exhaustion, same-loop continue or next rejection", async () => { + const run = program("async function run(input) { for await (const value of input) continue; return 9; }"); + const failure = new Error("next failure"); + for (const rejectNext of [false, true]) { + for (const consume of [run.native, run.ir]) { + const events: string[] = []; + let step = 0; + const input = closingInput(events); + input[Symbol.asyncIterator]().next = () => { + if (rejectNext) throw failure; + return { value: 1, done: step++ > 0 }; + }; + + if (rejectNext) await expect(consume(input)).rejects.toBe(failure); + else expect(await consume(input)).toBe(9); + expect(events).toEqual([]); + } + } + }); + + it("runs inner finally before iterator cleanup and outer finally after cleanup", async () => { + const run = program(`async function run(input) { + try { for await (const value of input) { + try { return value; } finally { input.inner(); } + } } finally { input.outer(); } + }`); + const logs: string[][] = []; + for (const consume of [run.native, run.ir]) { + const events: string[] = []; + const input = Object.assign(closingInput(events), { + inner: () => events.push("inner finally"), + outer: () => events.push("outer finally"), + }); + + expect(await consume(input)).toBe(1); + logs.push(events); + } + + expect(logs[1]).toEqual(logs[0]); + expect(logs[1]).toEqual(["inner finally", "return lookup", "return call", "outer finally"]); + }); + + it("closes nested iterators in order on a labeled outer continue", async () => { + const run = program(`async function run(input) { + outer: for await (const value of input) { + for await (const other of input) continue outer; + } + return 9; + }`); + const logs: string[][] = []; + for (const consume of [run.native, run.ir]) { + const events: string[] = []; + let acquired = 0; + const input = { [Symbol.asyncIterator]() { + const id = acquired++; + let step = 0; + return { + next: () => ({ value: 1, done: id === 0 && step++ > 0 }), + return() { events.push(`close ${id}`); return { done: true }; }, + }; + } }; + + expect(await consume(input)).toBe(9); + logs.push(events); + } + + expect(logs[1]).toEqual(logs[0]); + expect(logs[1]).toEqual(["close 1"]); + }); + + it("closes both iterators from inner to outer on a labeled break", async () => { + const run = program(`async function run(input) { + outer: for await (const value of input) { + for await (const other of input) break outer; + } + return 9; + }`); + for (const consume of [run.native, run.ir]) { + const events: number[] = []; + let acquired = 0; + const input = { [Symbol.asyncIterator]() { + const id = acquired++; + return { + next: () => ({ value: 1, done: false }), + return() { events.push(id); return { done: true }; }, + }; + } }; + + expect(await consume(input)).toBe(9); + expect(events).toEqual([1, 0]); + } + }); + + it("routes a cleanup failure to the enclosing catch without closing twice", async () => { + const run = program(`async function run(input) { + try { for await (const value of input) break; } + catch (failure) { return failure; } + }`); + const failure = new Error("close failure"); + for (const consume of [run.native, run.ir]) { + const events: string[] = []; + const input = closingInput(events, { close: () => { throw failure; } }); + + expect(await consume(input)).toBe(failure); + expect(events).toEqual(["return lookup", "return call"]); + } + }); + + it("captures the return expression before cleanup changes its source", async () => { + const run = program("async function run(input) { for await (const value of input) return input.marker; }"); + for (const consume of [run.native, run.ir]) { + const input = Object.assign(closingInput([], { close: () => { + input.marker = 2; + return { done: true }; + } }), { marker: 1 }); + + expect(await consume(input)).toBe(1); + expect(input.marker).toBe(2); + } + }); + + it("accepts a missing return method on async and synchronous iterators", async () => { + const run = program(breakSource); + for (const key of [Symbol.iterator, Symbol.asyncIterator]) { + for (const consume of [run.native, run.ir]) { + expect(await consume({ [key]: () => ({ next: () => ({ value: 1, done: false }) }) })).toBe(9); + } + } + }); + + it("does not close when reading the async iterator value fails before binding", async () => { + const run = program(breakSource); + const failure = new Error("value getter failure"); + for (const consume of [run.native, run.ir]) { + let closes = 0; + const input = { [Symbol.asyncIterator]: () => ({ + next: () => ({ done: false, get value() { throw failure; } }), + return() { closes++; return { done: true }; }, + }) }; + + await expect(consume(input)).rejects.toBe(failure); + expect(closes).toBe(0); + } + }); + +}); diff --git a/jacodb-ets/ts-frontend/test/cfg.spec.ts b/jacodb-ets/ts-frontend/test/cfg.spec.ts index c863bea4e..9d24f2812 100644 --- a/jacodb-ets/ts-frontend/test/cfg.spec.ts +++ b/jacodb-ets/ts-frontend/test/cfg.spec.ts @@ -297,7 +297,7 @@ describe("control flow lowering", () => { expect(diagnostics.messages).toEqual([]); expect(calls.map((statement) => statement.right.method.name)).toContain("Symbol.asyncIterator"); expect(calls.map((statement) => statement.right.method.name)).toContain("Symbol.iterator"); - expect(awaits).toHaveLength(2); + expect(calls.map((statement) => statement.right.method.name)).toContain("return"); expect(awaits.map((statement) => statement.right.arg)) .toContainEqual(calls.find((statement) => statement.right.method.name === "next")?.left); From fa87a577e188144162cba79208133ea111f95e47 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sat, 10 Oct 2026 20:39:38 +0300 Subject: [PATCH 3/3] Align for-await integration assertions with iterator closing --- .../org/jacodb/ets/test/EtsTsForAwaitTest.kt | 70 +++++++++++++++++-- 1 file changed, 65 insertions(+), 5 deletions(-) diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsForAwaitTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsForAwaitTest.kt index 9436f6133..f04a24c0a 100644 --- a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsForAwaitTest.kt +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsForAwaitTest.kt @@ -80,19 +80,79 @@ class EtsTsForAwaitTest { .methods.single { it.signature.name == name } val assignments = methodDto.body!!.cfg.blocks.flatMap { it.stmts }.filterIsInstance() val calls = assignments.mapNotNull { it.right as? PtrCallExprDto } - val awaits = assignments.mapNotNull { it.right as? AwaitExprDto } + val awaitAssignments = assignments.filter { it.right is AwaitExprDto } val next = assignments.single { (it.right as? PtrCallExprDto)?.method?.name == "next" } + val iterator = (next.right as PtrCallExprDto).receiver + val nextAwait = awaitAssignments.single { (it.right as AwaitExprDto).arg == next.left } + val nextValueReads = assignments.filter { + (it.right as? org.jacodb.ets.dto.InstanceFieldRefDto)?.let { field -> + field.field.name == "value" && field.instance == nextAwait.left + } == true + } + val syncValueAwait = awaitAssignments.single { + (it.right as AwaitExprDto).arg == nextValueReads.first().left + } + val returnLookup = assignments.single { + (it.right as? org.jacodb.ets.dto.InstanceFieldRefDto)?.let { field -> + field.field.name == "return" && field.instance == iterator + } == true + } + val close = assignments.single { (it.right as? PtrCallExprDto)?.method?.name == "return" } + val closeCall = close.right as PtrCallExprDto + val closeValueRead = assignments.single { + (it.right as? org.jacodb.ets.dto.InstanceFieldRefDto)?.let { field -> + field.field.name == "value" && field.instance == close.left + } == true + } + val asyncCloseAwait = awaitAssignments.single { (it.right as AwaitExprDto).arg == close.left } + val syncCloseAwait = awaitAssignments.single { (it.right as AwaitExprDto).arg == closeValueRead.left } + val missingReturnAwait = awaitAssignments.single { + ((it.right as AwaitExprDto).arg as? org.jacodb.ets.dto.ConstantDto)?.value == "undefined" + } assertTrue(calls.any { it.method.name == "Symbol.asyncIterator" && it.receiver != null }) assertTrue(calls.any { it.method.name == "Symbol.iterator" && it.receiver != null }) - assertEquals(2, awaits.size) - assertTrue(awaits.any { it.arg == next.left }) + assertTrue(iterator != null) + assertEquals(2, nextValueReads.size) + assertTrue(nextValueReads.all { it.left == syncValueAwait.left }) + assertEquals(returnLookup.left, closeCall.ptr) + assertEquals(iterator, closeCall.receiver) + assertTrue(assignments.any { + (it.right as? org.jacodb.ets.dto.InstanceFieldRefDto)?.let { field -> + field.field.name == "done" && field.instance == close.left + } == true + }) + assertEquals( + setOf(nextAwait, syncValueAwait, asyncCloseAwait, syncCloseAwait, missingReturnAwait), + awaitAssignments.toSet(), + ) + assertEquals(5, awaitAssignments.size) assertTrue(assignments.none { it.right is RawValueDto }) val modelMethod = roundTripped.toEtsFile().classes.single { it.name == DEFAULT_ARK_CLASS_NAME } .methods.single { it.name == name } - assertEquals(2, modelMethod.cfg.stmts.filterIsInstance() - .count { it.rhv is EtsAwaitExpr }) + val modelAssignments = modelMethod.cfg.stmts.filterIsInstance() + val modelAwaits = modelAssignments.mapNotNull { it.rhv as? EtsAwaitExpr } + val modelNext = modelAssignments.single { + (it.rhv as? org.jacodb.ets.model.EtsPtrCallExpr)?.callee?.name == "next" + } + val modelClose = modelAssignments.single { + (it.rhv as? org.jacodb.ets.model.EtsPtrCallExpr)?.callee?.name == "return" + } + + assertEquals(awaitAssignments.size, modelAwaits.size) + assertEquals( + (iterator as org.jacodb.ets.dto.LocalDto).name, + (modelNext.rhv as org.jacodb.ets.model.EtsPtrCallExpr).receiver?.name, + ) + assertEquals( + (modelNext.rhv as org.jacodb.ets.model.EtsPtrCallExpr).receiver, + (modelClose.rhv as org.jacodb.ets.model.EtsPtrCallExpr).receiver, + ) + for (dtoAwait in listOf(nextAwait, syncValueAwait, asyncCloseAwait, syncCloseAwait)) { + val argument = (dtoAwait.right as AwaitExprDto).arg as org.jacodb.ets.dto.LocalDto + assertTrue(modelAwaits.any { it.arg.name == argument.name }) + } } }