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..f04a24c0a
--- /dev/null
+++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsForAwaitTest.kt
@@ -0,0 +1,159 @@
+/*
+ * 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 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 })
+ 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 }
+ 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 })
+ }
+ }
+ }
+
+}
diff --git a/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts b/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts
index de5fa67a6..a8f7b61b7 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";
@@ -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;
@@ -365,27 +367,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,37 +389,199 @@ 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 },
- }));
- this.inBreakable({ kind: "loop", breakTarget: exitLabel, continueTarget: headLabel, label }, () => {
- this.lowerStatement(node.statement);
- });
- cfg.goto(headLabel);
+ 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);
+ }
+ 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;
+ 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
@@ -591,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);
}
@@ -729,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.
@@ -746,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
new file mode 100644
index 000000000..ab5225246
--- /dev/null
+++ b/jacodb-ets/ts-frontend/test/async-iterator.spec.ts
@@ -0,0 +1,563 @@
+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();
+ 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;
+ 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 (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");
+ 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);
+ });
+});
+
+
+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 6d4e7b4fc..9d24f2812 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(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);
+
+ 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];