From fe9ff019a9670096a721e5fc69088447b2bde7d2 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Fri, 23 Oct 2026 05:06:02 +0100 Subject: [PATCH 1/2] rc: a temporary a finally reads is released after the finally `const v = mv(); try { if (c) continue; } finally { s += v.get(); }`: a folded const (a call's result named by a const) is released where its block ends, and in front of each break, continue or return that leaves the block early. That release ran before the finally the jump goes through, and the finally then read a freed block. #600 fixed the same for locals in slots; a folded const is released by releaseDiscardedTemporaries instead. When a finally between the jump and the temporary's block reads it, the release now goes at the end of the outermost such finally, in a ts.If tagged with the jump (JUMP_ONLY_ATTR_NAME, RETURN_ONLY_ATTR_NAME), so TryOpLowering keeps it in that jump's copy of the finally alone - the scheme MLIRGen uses for the scopes the jump leaves. Jumps with one key share one copy and get one release. Fuzzer (#646) R6, rc. Co-Authored-By: Claude Opus 5.5 --- .../TypeScript/OwnedReturnConsumptionPass.cpp | 85 +++++++++- tslang/test/tester/CMakeLists.txt | 3 + .../tests/00temporary_read_in_finally.ts | 150 ++++++++++++++++++ 3 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 tslang/test/tester/tests/00temporary_read_in_finally.ts diff --git a/tslang/lib/TypeScript/OwnedReturnConsumptionPass.cpp b/tslang/lib/TypeScript/OwnedReturnConsumptionPass.cpp index a2ad4ea4b..7ba5685a6 100644 --- a/tslang/lib/TypeScript/OwnedReturnConsumptionPass.cpp +++ b/tslang/lib/TypeScript/OwnedReturnConsumptionPass.cpp @@ -17,6 +17,9 @@ #include "llvm/ADT/TypeSwitch.h" #include "llvm/Support/Debug.h" +#include +#include + #define DEBUG_TYPE "pass" namespace mlir_ts = mlir::typescript; @@ -1315,6 +1318,7 @@ class OwnedReturnConsumptionPass }); mlir::OpBuilder builder(&getContext()); + std::set> placedAfterFinally; for (auto *op : discarded) { if (!allUsesReleasableInOwnBlock(op)) @@ -1368,7 +1372,7 @@ class OwnedReturnConsumptionPass auto leavesBlock = mlir::isa(nested) || (mlir::isa(nested) && jumpLeavesBlock(nested, block)); - if (leavesBlock) + if (leavesBlock && !releaseAfterFinally(builder, op, nested, block, placedAfterFinally)) { builder.setInsertionPoint(nested); builder.create(op->getLoc(), op->getResult(0)); @@ -1400,6 +1404,85 @@ class OwnedReturnConsumptionPass } } + // A jump out of a `try` body or catch clause runs the `finally` first, and the finally may read + // the temporary: `const v = mv(); try { if (c) continue; } finally { s += v.get(); }` gave `v` + // back at the `continue`, before the finally read it. When a finally between the jump and the + // temporary's block reads it, the release goes at the end of the outermost such finally + // instead - the last one the jump runs - inside a `ts.If` tagged with the jump, as MLIRGen does + // for the scopes the jump leaves (mlirGenScopeExitAfterFinally), so that TryOpLowering keeps it + // in the copy of the finally that jump runs and in no other. False when no finally reads it. + // + // Every jump with one key runs one copy, so the release goes in once for each (`placed`). + static bool releaseAfterFinally(mlir::OpBuilder &builder, mlir::Operation *op, mlir::Operation *jump, + mlir::Block *block, + std::set> &placed) + { + auto value = op->getResult(0); + mlir_ts::TryOp lastTry; + auto readInFinally = false; + for (auto *child = jump; child->getBlock() != block; child = child->getParentOp()) + { + auto tryOp = mlir::dyn_cast(child->getParentOp()); + if (!tryOp || child->getParentRegion() == &tryOp.getFinally() || + child->getParentRegion() == &tryOp.getCleanup()) + { + continue; + } + + auto &finally = tryOp.getFinally(); + if (finally.empty() || &finally.front().front() == finally.front().getTerminator()) + { + continue; + } + + lastTry = tryOp; + readInFinally |= llvm::any_of(value.getUsers(), [&](mlir::Operation *user) { + return finally.isAncestor(user->getParentRegion()); + }); + } + + if (!lastTry || !readInFinally) + { + return false; + } + + auto isReturn = mlir::isa(jump); + std::string key; + if (!isReturn) + { + auto isBreak = mlir::isa(jump); + auto label = isBreak ? mlir::cast(jump).getLabelAttr() + : mlir::cast(jump).getLabelAttr(); + key = std::string(isBreak ? JUMP_ONLY_BREAK_KEY : JUMP_ONLY_CONTINUE_KEY) + + (label ? label.getValue().str() : std::string()); + } + + if (!placed.insert({op, lastTry.getOperation(), isReturn ? std::string(RETURN_ONLY_ATTR_NAME) : key}).second) + { + return true; + } + + auto location = op->getLoc(); + auto &finallyEnd = lastTry.getFinally().back(); + builder.setInsertionPoint(finallyEnd.getTerminator()); + auto alwaysTrue = builder.create(location, mlir_ts::BooleanType::get(builder.getContext()), + builder.getBoolAttr(true)); + auto ifOp = builder.create(location, alwaysTrue, /*withElseRegion=*/false); + if (isReturn) + { + ifOp->setAttr(RETURN_ONLY_ATTR_NAME, builder.getUnitAttr()); + } + else + { + ifOp->setAttr(JUMP_ONLY_ATTR_NAME, builder.getStringAttr(key)); + } + + mlir::OpBuilder::InsertionGuard guard(builder); + builder.setInsertionPointToStart(&ifOp.getThenRegion().front()); + builder.create(location, value); + return true; + } + // Does `op` catch this jump - is it the loop, switch or labelled statement the jump names? // // An unlabelled `break` is caught by the nearest enclosing loop or `switch`; an unlabelled diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 9661bd9c4..9b93af709 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -405,6 +405,7 @@ tslang_add_test(NAME test-compile-00-const-element-read COMMAND test-runner "${P tslang_add_test(NAME test-compile-00-const-global-read COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00const_global_read.ts") tslang_add_test(NAME test-compile-00-global-argument-reassigned COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00global_argument_reassigned.ts") tslang_add_test(NAME test-compile-00-global-argument-view COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00global_argument_view.ts") +tslang_add_test(NAME test-compile-00-temporary-read-in-finally COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00temporary_read_in_finally.ts") tslang_add_test(NAME test-compile-00-array-data-string-alias COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00array_data_string_alias.ts") tslang_add_test(NAME test-compile-00-arrays7 COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00array7.ts") tslang_add_test(NAME test-compile-00-arrays8-tuple-spread COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00array8_tuple_spread.ts") @@ -984,6 +985,7 @@ tslang_add_test(NAME test-jit-00-const-element-read COMMAND test-runner -jit "${ tslang_add_test(NAME test-jit-00-const-global-read COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00const_global_read.ts") tslang_add_test(NAME test-jit-00-global-argument-reassigned COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00global_argument_reassigned.ts") tslang_add_test(NAME test-jit-00-global-argument-view COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00global_argument_view.ts") +tslang_add_test(NAME test-jit-00-temporary-read-in-finally COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00temporary_read_in_finally.ts") tslang_add_test(NAME test-jit-00-array-data-string-alias COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00array_data_string_alias.ts") tslang_add_test(NAME test-jit-00-arrays7 COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00array7.ts") tslang_add_test(NAME test-jit-00-arrays8-tuple-spread COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00array8_tuple_spread.ts") @@ -1953,6 +1955,7 @@ set(TSLANG_CORPUS 00const_global_read.ts 00global_argument_reassigned.ts 00global_argument_view.ts + 00temporary_read_in_finally.ts 00array_data_string_alias.ts 00array7.ts 00array8_tuple_spread.ts diff --git a/tslang/test/tester/tests/00temporary_read_in_finally.ts b/tslang/test/tester/tests/00temporary_read_in_finally.ts new file mode 100644 index 000000000..f83a2bbfd --- /dev/null +++ b/tslang/test/tester/tests/00temporary_read_in_finally.ts @@ -0,0 +1,150 @@ +// A `const` holding a call's result (a folded const, released where its block ends) stays alive +// when a `break`, `continue` or `return` leaves its block through a `try` whose `finally` reads +// it. Under rc the release went in front of the jump, and the finally then ran on a freed block. +// Each read follows allocations of the same size, so a freed block is reused. +class V { + tag = ""; + constructor(public x: number) { + this.tag = "v" + x; + } + + get() { + return this.x; + } +} + +function mv(x: number) { + return new V(x); +} + +function churn() { + const junk: V[] = []; + for (let i = 0; i < 64; i++) { + junk.push(new V(999)); + } +} + +function viaContinue() { + let s: number = 0; + for (let i = 0; i < 4; i++) { + const v = mv(i); + try { + churn(); + if (i % 2 == 0) { + continue; + } + } finally { + churn(); + s += v.get(); + } + } + + return s; +} + +function viaBreak() { + let s: number = 0; + for (let i = 0; i < 4; i++) { + const v = mv(i + 10); + try { + if (i == 1) { + break; + } + } finally { + churn(); + s += v.x; + } + } + + return s; +} + +function viaTwoJumps() { + let s: number = 0; + for (let i = 0; i < 6; i++) { + const v = mv(i); + try { + if (i % 3 == 0) { + continue; + } + + if (i % 3 == 1) { + continue; + } + } finally { + churn(); + s += v.get(); + } + } + + return s; +} + +function viaLabelledContinue() { + let s: number = 0; + outer: for (let i = 0; i < 3; i++) { + const v = mv(i + 1); + for (let j = 0; j < 3; j++) { + try { + try { + if (j == 1) { + continue outer; + } + } finally { + churn(); + s += v.get(); + } + } finally { + churn(); + s += v.x * 100; + } + } + } + + return s; +} + +function viaReturn(x: number) { + const v = mv(x); + try { + if (x > 0) { + return x; + } + } finally { + churn(); + assert(v.x == x && v.tag == "v" + x, "read in the finally of a return"); + } + + return -1; +} + +function callInFinally(v: V) { + churn(); + return v.get(); +} + +function viaCallInFinally() { + let s: number = 0; + for (let i = 0; i < 3; i++) { + const v = mv(i + 5); + try { + if (v.x > 2) { + continue; + } + } finally { + s += callInFinally(v); + } + } + + return s; +} + +function main() { + assert(viaContinue() == 6, "continue through a finally"); + assert(viaBreak() == 21, "break through a finally"); + assert(viaTwoJumps() == 15, "two continues through one finally"); + assert(viaLabelledContinue() == 1212, "a labelled continue through two finallys"); + assert(viaReturn(3) == 3, "return through a finally"); + assert(viaCallInFinally() == 18, "a call in the finally"); + print("done."); +} From 4d07298f2d5392df96f008a58a344ecabfc5b53f Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Fri, 23 Oct 2026 06:39:33 +0100 Subject: [PATCH 2/2] rc: a jump through a finally that jumps itself releases a temporary once `for (..) { const v = mv(); try { try { continue; } finally { break; } } finally {} }`: the temporary was released in front of the `continue`, and again in front of the `break` its finally then took. The release for a jump that passes through a finally now always goes at the end of the outermost such finally, tagged with the jump, not only when the finally reads the temporary: a finally that leaves by a jump of its own never reaches it, and the jump's own release is then the only one. Fuzzer (#646), found past the first 1000 programs. Co-Authored-By: Claude Opus 5.5 --- .../TypeScript/OwnedReturnConsumptionPass.cpp | 22 +++++++------- .../tests/00temporary_read_in_finally.ts | 29 ++++++++++++++++++- 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/tslang/lib/TypeScript/OwnedReturnConsumptionPass.cpp b/tslang/lib/TypeScript/OwnedReturnConsumptionPass.cpp index 7ba5685a6..fabc61e67 100644 --- a/tslang/lib/TypeScript/OwnedReturnConsumptionPass.cpp +++ b/tslang/lib/TypeScript/OwnedReturnConsumptionPass.cpp @@ -1406,20 +1406,21 @@ class OwnedReturnConsumptionPass // A jump out of a `try` body or catch clause runs the `finally` first, and the finally may read // the temporary: `const v = mv(); try { if (c) continue; } finally { s += v.get(); }` gave `v` - // back at the `continue`, before the finally read it. When a finally between the jump and the - // temporary's block reads it, the release goes at the end of the outermost such finally - // instead - the last one the jump runs - inside a `ts.If` tagged with the jump, as MLIRGen does - // for the scopes the jump leaves (mlirGenScopeExitAfterFinally), so that TryOpLowering keeps it - // in the copy of the finally that jump runs and in no other. False when no finally reads it. + // back at the `continue`, before the finally read it. Or the finally may leave by a jump of its + // own, which releases the temporary in front of it too: `try { continue; } finally { break; }` + // gave it back twice. When a jump passes through a finally between it and the temporary's block, + // the release goes at the end of the outermost such finally instead - the last one the jump + // runs - inside a `ts.If` tagged with the jump, as MLIRGen does for the scopes the jump leaves + // (mlirGenScopeExitAfterFinally), so that TryOpLowering keeps it in the copy of the finally that + // jump runs and in no other. A finally that leaves on its own never reaches it. False when the + // jump passes through no finally. // // Every jump with one key runs one copy, so the release goes in once for each (`placed`). static bool releaseAfterFinally(mlir::OpBuilder &builder, mlir::Operation *op, mlir::Operation *jump, mlir::Block *block, std::set> &placed) { - auto value = op->getResult(0); mlir_ts::TryOp lastTry; - auto readInFinally = false; for (auto *child = jump; child->getBlock() != block; child = child->getParentOp()) { auto tryOp = mlir::dyn_cast(child->getParentOp()); @@ -1436,16 +1437,15 @@ class OwnedReturnConsumptionPass } lastTry = tryOp; - readInFinally |= llvm::any_of(value.getUsers(), [&](mlir::Operation *user) { - return finally.isAncestor(user->getParentRegion()); - }); } - if (!lastTry || !readInFinally) + if (!lastTry) { return false; } + auto value = op->getResult(0); + auto isReturn = mlir::isa(jump); std::string key; if (!isReturn) diff --git a/tslang/test/tester/tests/00temporary_read_in_finally.ts b/tslang/test/tester/tests/00temporary_read_in_finally.ts index f83a2bbfd..5c4c13732 100644 --- a/tslang/test/tester/tests/00temporary_read_in_finally.ts +++ b/tslang/test/tester/tests/00temporary_read_in_finally.ts @@ -1,6 +1,7 @@ // A `const` holding a call's result (a folded const, released where its block ends) stays alive // when a `break`, `continue` or `return` leaves its block through a `try` whose `finally` reads -// it. Under rc the release went in front of the jump, and the finally then ran on a freed block. +// it, or leaves by a jump of its own. Under rc the release went in front of the jump, and the +// finally then ran on a freed block, or released it a second time in front of its own jump. // Each read follows allocations of the same size, so a freed block is reused. class V { tag = ""; @@ -118,6 +119,31 @@ function viaReturn(x: number) { return -1; } +function jumpInFinally() { + let s: number = 0; + for (let i = 0; i < 4; i++) { + const v = mv(i); + try { + try { + if (i % 2 == 0) { + continue; + } + } finally { + churn(); + if (i == 2) { + break; + } + } + } finally { + } + + s += v.x; + } + + churn(); + return s; +} + function callInFinally(v: V) { churn(); return v.get(); @@ -146,5 +172,6 @@ function main() { assert(viaLabelledContinue() == 1212, "a labelled continue through two finallys"); assert(viaReturn(3) == 3, "return through a finally"); assert(viaCallInFinally() == 18, "a call in the finally"); + assert(jumpInFinally() == 1, "a continue through a finally that breaks"); print("done."); }