Skip to content

Commit 0142cd5

Browse files
An exception out of a try/finally, a nested block in a try, or a throw releases the scopes it leaves (#596)
Under -mm=rc, --verify-ownership still reported real leaks of an owned local on three unwind shapes that #595 left open, plus a plain `throw`: - `let s = a + b; try { h(); } finally { ... } return s;` - after the finally rethrows, `s` was never given back. The release cannot go in the cleanup region, which runs before the finally and so before it may read `s`. MLIRGen now puts the walk of #595 (mlirGenReleaseOwnedOnUnwind) at the end of the finally, with each release marked UNWIND_ONLY_ATTR_NAME. TryOpLowering keeps them in the copy of the finally the landing pad runs, and erases them from the copies normal completion, `return` and `break` run (through each copy's mapping: the rewriter defers an erasure, so an op erased from the region is still cloned into later copies). - `try { { let s = a + b; h(); } } catch {}` - the TryOp's cleanup releases its body's scope and no other. A block that declares a let/const inside a TryOp body (GenContext::isUnwindCleanupScope somewhere up the chain) is now wrapped like a `using` block, so its cleanup releases it and the scopes up to that body. - `try { h(); } catch { throw 2; }` and `let s = a + b; throw 1;` - a throw statement now releases the scopes it leaves up to the nearest TryOp body, as a `return` does, after taking a reference for the thrown value (mlirGenRetainCaptured at last use) so `throw s` is not freed with `s`. The thrown value is never given back, so it leaks rather than dangles. A catch clause of a try with a finally stops that walk, since the finally's unwind copy releases those scopes. New test 00try_unwind_outer_locals.ts (compile + JIT + TSLANG_CORPUS, so gc/rc/none) covers the finally on throw/complete/return/break, a throw from a catch with and without a finally, a try in a try body, nested scopes and a loop body in a try, and throws from a function body and of a local. The compiler before this change reports 9 slots in it; the verifier sweep covers it. Still open: a call that throws out of a catch with no finally, a typed catch that does not match with no finally, and a throwing call with no try around it - none has a landing pad to release from. Found on the way, all pre-existing: an empty `catch { }` does not catch; under rc a `return` through a finally releases the frame before the finally runs; `return` inside a finally, and a try/catch/finally nested in a finally, crash Win32ExceptionPass. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 0b6859c commit 0142cd5

6 files changed

Lines changed: 458 additions & 11 deletions

File tree

‎tslang/include/TypeScript/Defines.h‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,15 @@
6161
// MLIROwnedReference.h.
6262
#define OWNED_RESULT_NAMED_ATTR_NAME "__owned_result_named"
6363

64+
// Marks a release MLIRGen put at the end of a hand-written `finally` for the exceptional leg only:
65+
// what the scopes around the `try` owe once the exception carries on past it. The finally region
66+
// is one region in MLIRGen but several copies after TryOpLowering - the copy the landing pad runs
67+
// before resuming, and the copies normal completion, a `return` and a `break` run - and only the
68+
// first leaves the function's scopes behind; on every other copy they are still live, and their
69+
// own exits release them. TryOpLowering keeps these in the unwind copy and erases them from the
70+
// rest.
71+
#define UNWIND_ONLY_ATTR_NAME "__unwind_only"
72+
6473
// Marks a `ts.CreateBoundFunction` whose `this` is a capture box built for it a moment earlier,
6574
// rather than a receiver that belongs to somebody else. Only such a closure owns its `this`, and
6675
// only it gets the type tag at CLOSURE_TYPE_INDEX - a bound method must not take ownership of

‎tslang/lib/TypeScript/LowerToAffineLoops.cpp‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1618,13 +1618,44 @@ struct TryOpLowering : public TsPattern<mlir_ts::TryOp>
16181618
tsContext->finallyBlockOf[tryOp.getOperation()] = finallyBlock;
16191619
finallyBlockLast = continuation->getPrevNode();
16201620

1621+
// That copy is the one the landing pad runs before the exception carries on, and the
1622+
// only one whose way out leaves the scopes around the try: the releases MLIRGen put at
1623+
// the end of the finally for them stay in it and go from every later copy. Not from a
1624+
// try nested in the finally - its own lowering sorts its own.
1625+
//
1626+
// Erased from each later copy through its mapping, and from the region itself only once
1627+
// the last copy is made: the rewriter defers an erasure, so an op erased from the
1628+
// region still goes into every copy cloned from it afterwards.
1629+
SmallVector<Operation *> unwindOnlyOps;
1630+
tryOp.getFinally().walk<mlir::WalkOrder::PreOrder>([&](Operation *op) {
1631+
if (isa<mlir_ts::TryOp>(op))
1632+
{
1633+
return WalkResult::skip();
1634+
}
1635+
1636+
if (op->hasAttr(UNWIND_ONLY_ATTR_NAME))
1637+
{
1638+
unwindOnlyOps.push_back(op);
1639+
}
1640+
1641+
return WalkResult::advance();
1642+
});
1643+
1644+
auto eraseUnwindOnlyOps = [&](const mlir::IRMapping &mapping) {
1645+
for (auto *op : unwindOnlyOps)
1646+
{
1647+
rewriter.eraseOp(mapping.lookup(op));
1648+
}
1649+
};
1650+
16211651
// add clone for 'return'
16221652
if (returns.size() > 0)
16231653
{
16241654
mlir::IRMapping returnFinallyMapping;
16251655
rewriter.cloneRegionBefore(tryOp.getFinally(), *continuation->getParent(), continuation->getIterator(),
16261656
returnFinallyMapping);
16271657
propagateTsContextEntries(returnFinallyMapping);
1658+
eraseUnwindOnlyOps(returnFinallyMapping);
16281659
auto returnFinallyBlockLast = continuation->getPrevNode();
16291660
rewriter.setInsertionPoint(returnFinallyBlockLast->getTerminator());
16301661
auto resultOpOfReturnFinallyBlock = cast<mlir_ts::ResultOp>(returnFinallyBlockLast->getTerminator());
@@ -1655,6 +1686,7 @@ struct TryOpLowering : public TsPattern<mlir_ts::TryOp>
16551686
rewriter.cloneRegionBefore(tryOp.getFinally(), *continuation->getParent(),
16561687
continuation->getIterator(), jumpFinallyMapping);
16571688
propagateTsContextEntries(jumpFinallyMapping);
1689+
eraseUnwindOnlyOps(jumpFinallyMapping);
16581690
auto jumpFinallyBlockLast = continuation->getPrevNode();
16591691
rewriter.setInsertionPoint(jumpFinallyBlockLast->getTerminator());
16601692
auto resultOpOfJumpFinallyBlock = cast<mlir_ts::ResultOp>(jumpFinallyBlockLast->getTerminator());
@@ -1679,6 +1711,11 @@ struct TryOpLowering : public TsPattern<mlir_ts::TryOp>
16791711
}
16801712
}
16811713

1714+
for (auto *op : unwindOnlyOps)
1715+
{
1716+
rewriter.eraseOp(op);
1717+
}
1718+
16821719
rewriter.inlineRegionBefore(tryOp.getFinally(), continuation);
16831720
exitBlockLast = continuation->getPrevNode();
16841721
}

‎tslang/lib/TypeScript/MLIRGenImpl.h‎

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,29 @@ class MLIRGenImpl
502502
return false;
503503
}
504504

505+
// Whether this block declares a `let` or `const` of its own - a local that may own a
506+
// reference, and that the TryOp around it would not release if an exception left the block
507+
// (see mlirGen(Block)). Over-approximates: the type that decides it is not known yet.
508+
bool blockDeclaresLocals(ts::Block blockAST, int skipStatements = 0)
509+
{
510+
auto index = 0;
511+
for (auto statement : blockAST->statements)
512+
{
513+
if (index++ < skipStatements || (SyntaxKind)statement != SyntaxKind::VariableStatement)
514+
{
515+
continue;
516+
}
517+
518+
auto flags = statement.as<VariableStatement>()->declarationList->flags;
519+
if ((flags & NodeFlags::Let) == NodeFlags::Let || (flags & NodeFlags::Const) == NodeFlags::Const)
520+
{
521+
return true;
522+
}
523+
}
524+
525+
return false;
526+
}
527+
505528
// Whether every top-level `using` this block declares initializes directly from
506529
// `new SomeClass(...)`.
507530
//
@@ -836,7 +859,8 @@ class MLIRGenImpl
836859
return mlir::success();
837860
}
838861

839-
void releaseScopeOwnedVars(mlir::Location location, const SmallVector<mlir::Value> &ownedVars)
862+
void releaseScopeOwnedVars(mlir::Location location, const SmallVector<mlir::Value> &ownedVars,
863+
bool unwindOnly = false)
840864
{
841865
// reverse declaration order, the order a scope is unwound in: a later local may
842866
// hold the only other reference to what an earlier one points at
@@ -846,22 +870,33 @@ class MLIRGenImpl
846870
// released as a cell whatever its contents are owned by - the block has to go
847871
// back regardless. A slot that is neither is here only because it might have
848872
// turned into a cell and did not; see trackPossibleCell.
873+
mlir::Operation *releaseOp = nullptr;
849874
if (isCapturedVariableCell(storage))
850875
{
851-
builder.create<mlir_ts::ReleaseCellOp>(location, storage);
876+
releaseOp = builder.create<mlir_ts::ReleaseCellOp>(location, storage);
852877
}
853878
else if (isOwnedLocalSlot(storage))
854879
{
855-
builder.create<mlir_ts::ReleaseSlotOp>(location, storage);
880+
releaseOp = builder.create<mlir_ts::ReleaseSlotOp>(location, storage);
881+
}
882+
883+
if (releaseOp && unwindOnly)
884+
{
885+
releaseOp->setAttr(UNWIND_ONLY_ATTR_NAME, builder.getUnitAttr());
856886
}
857887
}
858888
}
859889

860890
// The rest of the unwind leg of a catch-less TryOp's cleanup region - the one wrapped around
861-
// a block or a switch for its `using`. Once the region has given back its own scope, the
862-
// exception keeps unwinding, and the scopes around it that have no cleanup region of their
863-
// own get no other chance: a function's body holding `let s = a + b` and, further in,
864-
// `{ using r = new R(); ... }` leaked `s` on every exception through the inner block.
891+
// a block or a switch for its `using`, or around a block with locals inside a try body. Once
892+
// the region has given back its own scope, the exception keeps unwinding, and the scopes
893+
// around it that have no cleanup region of their own get no other chance: a function's body
894+
// holding `let s = a + b` and, further in, `{ using r = new R(); ... }` leaked `s` on every
895+
// exception through the inner block.
896+
//
897+
// The same walk serves the two other places an exception leaves a scope: a `throw` statement,
898+
// which raises it there, and the copy of a hand-written `finally` that runs on the unwind leg
899+
// and then resumes it (`unwindOnly`, see UNWIND_ONLY_ATTR_NAME).
865900
//
866901
// Walks outwards from the scope around the wrapped one, releasing as it goes, and stops at
867902
// the first scope that is itself a TryOp's body (isUnwindCleanupScope) without touching its
@@ -876,17 +911,32 @@ class MLIRGenImpl
876911
// locals declared ahead of the wrapped scope are in the lists yet, so every slot released
877912
// here has had its store - or, hoisted in front of a switch whose case did not run, is
878913
// null, which a release does nothing with (VariableOpLowering).
879-
void mlirGenReleaseOwnedOnUnwind(mlir::Location location, const GenContext *genContext)
914+
void mlirGenReleaseOwnedOnUnwind(mlir::Location location, const GenContext *genContext, bool unwindOnly = false)
880915
{
881916
for (; genContext != nullptr && !genContext->isUnwindCleanupScope; genContext = genContext->parentBlockContext)
882917
{
883918
if (genContext->ownedVars != nullptr)
884919
{
885-
releaseScopeOwnedVars(location, *genContext->ownedVars);
920+
releaseScopeOwnedVars(location, *genContext->ownedVars, unwindOnly);
886921
}
887922
}
888923
}
889924

925+
// Whether an exception out of a scope opened in this context lands on a TryOp's landing pad
926+
// in this function: some scope between here and the function is a TryOp's body.
927+
static bool isInsideUnwindCleanupScope(const GenContext &genContext)
928+
{
929+
for (auto *context = &genContext; context != nullptr; context = context->parentBlockContext)
930+
{
931+
if (context->isUnwindCleanupScope)
932+
{
933+
return true;
934+
}
935+
}
936+
937+
return false;
938+
}
939+
890940
// Does this reference address a captured variable's cell - a heap block the frame shares
891941
// with the closures that captured it - rather than ordinary frame storage?
892942
//

‎tslang/lib/TypeScript/MLIRGenStatements.cpp‎

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,12 @@ namespace mlirgen
157157
// that is an exit the scope's owned locals have to be released before - see
158158
// blockHasTypedCatch. Same wrapping, same cleanup region, different reason to want it.
159159
//
160+
// So are a block's own locals, once the block sits inside a TryOp's body: the TryOp's
161+
// cleanup releases its body's scope and no other, so an exception out of a nested block
162+
// - `try { { let s = a + b; h(); } } catch {}` - left `s` behind. The wrap's cleanup
163+
// gives back the block's scope and the scopes between it and that body
164+
// (mlirGenReleaseOwnedOnUnwind). Outside any TryOp nothing would run it.
165+
//
160166
// The two remaining conditions each guard against a real, still-open bug the wrapping
161167
// would otherwise hit (see their comments). blockUsingInitializersAreAllNewExpr is
162168
// about disposing a `using`, so it only has a say when there is one - but it keeps its
@@ -167,7 +173,8 @@ namespace mlirgen
167173
auto declaresUsing = blockDeclaresUsing(blockAST, skipStatements);
168174
auto usingIsDisposable = !declaresUsing || blockUsingInitializersAreAllNewExpr(blockAST, skipStatements);
169175
auto rethrowNeedsCleanup = !compileOptions.isWindows && blockHasTypedCatch(blockAST, skipStatements);
170-
if (usingIsDisposable && (declaresUsing || rethrowNeedsCleanup) && !blockIsInsideCatchOrFinally())
176+
auto localsNeedCleanup = isInsideUnwindCleanupScope(genContext) && blockDeclaresLocals(blockAST, skipStatements);
177+
if (usingIsDisposable && (declaresUsing || rethrowNeedsCleanup || localsNeedCleanup) && !blockIsInsideCatchOrFinally())
171178
{
172179
return mlirGenBlockWithUnwindCleanup(blockAST, genContext, skipStatements);
173180
}
@@ -1395,6 +1402,16 @@ namespace mlirgen
13951402
EXIT_IF_FAILED_OR_NO_VALUE(result)
13961403
auto exception = V(result);
13971404

1405+
// The exception leaves every scope from here out to the nearest TryOp body, and those
1406+
// scopes have no landing pad of their own to release their locals from - the function's
1407+
// body, a `catch` clause's enclosing scopes. So the throw gives them back itself, as a
1408+
// `return` would, and first takes a reference for the exception, as a `return` does for
1409+
// its caller: `throw s` must not be freed by the release of `s`. Nothing gives that
1410+
// reference back - a caught value is not owned (takeOwnershipOfLocal) - so a thrown heap
1411+
// value leaks rather than dangles.
1412+
mlirGenRetainCaptured(location, mlir::ValueRange{exception}, /*atLastUse=*/true);
1413+
mlirGenReleaseOwnedOnUnwind(location, &genContext);
1414+
13981415
auto throwOp = builder.create<mlir_ts::ThrowOp>(location, exception);
13991416

14001417
if (!genContext.allowPartialResolve)
@@ -1527,7 +1544,16 @@ namespace mlirgen
15271544
}
15281545
}
15291546

1530-
auto result = mlirGen(tryStatementAST->catchClause->block, tryGenContext);
1547+
// With a finally, an exception out of the catch clause unwinds into the finally's
1548+
// unwind copy, which releases the scopes around the `try` (below) - so a `throw` in
1549+
// the clause must stop short of them, as it does at a TryOp's body
1550+
GenContext catchGenContext(tryGenContext);
1551+
if (tryStatementAST->finallyBlock)
1552+
{
1553+
catchGenContext.isUnwindCleanupScope = true;
1554+
}
1555+
1556+
auto result = mlirGen(tryStatementAST->catchClause->block, catchGenContext);
15311557
EXIT_IF_FAILED(result)
15321558
}
15331559

@@ -1540,6 +1566,14 @@ namespace mlirgen
15401566
{
15411567
auto result = mlirGen(tryStatementAST->finallyBlock, tryGenContext);
15421568
EXIT_IF_FAILED(result)
1569+
1570+
// The copy of the finally that runs on the unwind leg resumes the exception past the
1571+
// `try`, so it is the last chance the scopes around it get, up to the nearest TryOp
1572+
// body, as in a catch-less TryOp's cleanup. It cannot be the cleanup region itself:
1573+
// that runs before the finally, which may still read them. Marked unwind-only, so
1574+
// TryOpLowering drops them from the copies normal completion, `return` and `break`
1575+
// run, where those scopes are still live.
1576+
mlirGenReleaseOwnedOnUnwind(location, &tryGenContext, /*unwindOnly=*/true);
15431577
}
15441578

15451579
// terminator

‎tslang/test/tester/CMakeLists.txt‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,7 @@ tslang_add_test(NAME test-compile-00-labelled-scope-exit COMMAND test-runner "${
337337
tslang_add_test(NAME test-compile-00-switch-case-scope-exit COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00switch_case_scope_exit.ts")
338338
tslang_add_test(NAME test-compile-00-using-dispose-order COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00using_dispose_order.ts")
339339
tslang_add_test(NAME test-compile-00-using-unwind-outer-locals COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00using_unwind_outer_locals.ts")
340+
tslang_add_test(NAME test-compile-00-try-unwind-outer-locals COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00try_unwind_outer_locals.ts")
340341
tslang_add_test(NAME test-compile-00-vars COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00vars.ts")
341342
tslang_add_test(NAME test-compile-00-var-bindings COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00var_bindings.ts")
342343
tslang_add_test(NAME test-compile-00-globals COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00globals.ts")
@@ -880,6 +881,7 @@ tslang_add_test(NAME test-jit-00-labelled-scope-exit COMMAND test-runner -jit "$
880881
tslang_add_test(NAME test-jit-00-switch-case-scope-exit COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00switch_case_scope_exit.ts")
881882
tslang_add_test(NAME test-jit-00-using-dispose-order COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00using_dispose_order.ts")
882883
tslang_add_test(NAME test-jit-00-using-unwind-outer-locals COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00using_unwind_outer_locals.ts")
884+
tslang_add_test(NAME test-jit-00-try-unwind-outer-locals COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00try_unwind_outer_locals.ts")
883885
tslang_add_test(NAME test-jit-00-vars COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00vars.ts")
884886
tslang_add_test(NAME test-jit-00-var-bindings COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00var_bindings.ts")
885887
tslang_add_test(NAME test-jit-00-globals COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00globals.ts")
@@ -1834,6 +1836,7 @@ set(TSLANG_CORPUS
18341836
00switch_case_scope_exit.ts
18351837
00using_dispose_order.ts
18361838
00using_unwind_outer_locals.ts
1839+
00try_unwind_outer_locals.ts
18371840
00capture_in_new_arguments.ts
18381841
00cast_to_f32.ts
18391842
00catch_untyped_value.ts

0 commit comments

Comments
 (0)