Skip to content

A finally that returns, breaks or holds a try works on the exceptional path on Windows - #599

Merged
ASDAlexander77 merged 2 commits into
mainfrom
fix-win32-finally-funclet
Oct 17, 2026
Merged

ASDAlexander77 merged 2 commits into
mainfrom
fix-win32-finally-funclet

Conversation

@ASDAlexander77

Copy link
Copy Markdown
Owner

Fixes two shapes that crashed tslang --emit=jit with 0xC0000005 in function(ts::Win32ExceptionPass) under every -mm:

function f(pre: string) { let s = pre + "1"; try { h(); } finally { return s; } }   // now prints p1
function g(pre: string) { let s = pre + "2"; try { h(); } finally { try { h(); } catch { print("in " + s); } finally { print("inner " + s); } } return s; }
// main: try { g("p"); } catch { print("c"); }   → in p2 / inner p2 / c

Cause

On Windows, the copy of a finally that runs on the exceptional path is a cleanup funclet, and a cleanup funclet can't do what these finallys need:

  • It can't return to normal flow. The return branched out of the cleanup region, so the pass never found a resume to end it. The region's end stayed null, and the pass dereferenced it.
  • It can't hold pads of its own. TryOpLowering tagged the inner try's pads as top-level, so the scan closed the cleanup region early, again with no end. Nesting them properly would not have been enough either: under the MSVC personality, LLVM gives no EH state to a pad inside a cleanuppad. The inner throw would then terminate the process (CRT abort dialog).

Fix

  • TryOpLowering: when a finally holds a return, a break/continue out of the try, or a try, its exceptional-path copy is entered through a catch-all pad instead.
    • A return or jump in that copy ends the catch first, which gives a catchret, and the exception is swallowed.
    • A copy that runs to its end finishes the way a cleanup does: end-catch, then resume.
    • The finally's pad now gets an id. Tries in that copy, and only that copy, are tagged as nested in it.
  • Win32ExceptionPass:
    • A catch region that ends in resume becomes a rethrow from inside the funclet. It goes to the handler the region unwinds to, or to the caller.
    • A region with no end is now reported with report_fatal_error instead of crashing on a null dereference.

Tests

New 00try_finally_funclet.ts (compile + JIT + TSLANG_CORPUS gc/rc/none) covers:

  • a return in a finally, with and without a throw
  • a try in a finally whose exception carries on out
  • a try in a finally inside an enclosing try that catches
  • a return in a finally inside a try that catches
  • a break in a finally

All of these also pass with --opt=false and as AOT executables. Full ctest -C Release -j 8 --timeout 300 was green on the branch base, 0b6859c (4138/4138).

main has since gained #596 and #597. They merge cleanly with this branch, but I haven't run the merged result locally, so CI covers that.

The try-in-finally test passes the string as a parameter instead of an owned local. An owned local in a function that an exception leaves through a try/finally was a known rc leak (still open after #595). The ownership-verifier sweep would flag it, and #596 may have closed it.

Not changed

A region that leaves on only some paths, like finally { if (c) return s; } or catch { if (c) return s; ... } (the latter is already broken on main), still breaks. The pass's scan assumes each region has a single exit. That needs CFG-based region membership, so it's left for a follow-up.

🤖 Generated with Claude Code

ASDAlexander77 and others added 2 commits October 17, 2026 21:06
…l path on Windows

Two shapes crashed tslang in Win32ExceptionPass with 0xC0000005 under every memory model:

    function f(pre: string) { let s = pre + "1"; try { h(); } finally { return s; } }

    function g(pre: string) {
        let s = pre + "2";
        try { h(); } finally { try { h(); } catch { print("in " + s); } finally { print("inner " + s); } }
        return s;
    }

On Windows the copy of a finally that runs while an exception unwinds is a cleanup funclet,
and a cleanup funclet can do neither thing these finallys need:

- It cannot return to normal flow, only carry on unwinding. The `return` branched out of the
  cleanup region, so the pass found no `resume` to end it, left the region's `end` null and
  dereferenced it.
- It cannot hold pads of its own. TryOpLowering tagged the inner try's pads as top-level, so
  the pass closed the cleanup region on reaching them, again with no end. Nesting them inside
  the cleanup would not have been enough: LLVM gives a pad in a cleanup funclet no EH state
  under the MSVC personality, so the inner throw would not be caught there and the process
  would terminate.

TryOpLowering now enters the finally's exceptional-path copy through a catch-all pad when that
finally holds a `return`, a break/continue out of the try, or a try. A `return` (or jump) in
that copy ends the catch first, as one in a catch clause does, so it becomes a catchret and the
exception is swallowed. A copy that runs to its end finishes as a cleanup does (end-catch, then
`resume`), and Win32ExceptionPass turns a catch region that ends in `resume` into a rethrow from
inside the funclet, to the handler the region unwinds to or to the caller. The finally's pad
gets an id now, and a try in that copy of the finally (only that copy: the normal-exit, return
and jump copies run outside the funclet) names it as the funclet it is nested in.

Win32ExceptionPass also reports a region it found no end for with report_fatal_error instead
of dereferencing null.

New test 00try_finally_funclet.ts: return in finally with and without a throw, a try in finally
whose exception carries on out, the same inside an enclosing try that catches, a return in a
finally inside a try that catches, and a break in a finally. Compile + JIT, and TSLANG_CORPUS
(gc, rc, none). The try-in-finally case passes the string as a parameter rather than holding an
owned local: an owned local in a function an exception leaves through a try/finally is a known
rc leak (still open from #595), which the ownership-verifier sweep would report.

Not changed here: a finally (or catch) that leaves only on some paths - `finally { if (c)
return s; }` - still breaks, because the pass's scan assumes a region has one exit. The same
holds for `catch { if (c) return s; ... }` before this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…m path too

00try_finally_funclet.ts failed on Linux with "terminate called after throwing an instance of
'int'" for `finally { return s; }` and `finally { break; }` when no try further out catches.
There the finally's pad is a cleanup, and an exception with only cleanups to run is never
unwound: the search phase finds no handler and the program terminates before the finally runs,
so its return never gets the chance to swallow the exception.

The finally of a try with no enclosing handler in the function is now entered through a
catch-all landing pad when it can leave (the shape the enclosingHandler case already has): a
`return` or jump in that copy ends the catch first, and a run to the end rethrows. The end-catch
marking of those escaping ops now applies on both paths, as the pad is a catch-all on both.

Verified on Linux (WSL): the new test under compile, JIT and the rc/none corpus, and the 261
try/catch/finally/using/throw/ownership tests; on Windows the same subset (267 tests).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ASDAlexander77
ASDAlexander77 merged commit 7d6a7cf into main Oct 17, 2026
2 checks passed
@ASDAlexander77
ASDAlexander77 deleted the fix-win32-finally-funclet branch October 17, 2026 20:50
ASDAlexander77 added a commit that referenced this pull request Oct 17, 2026
…very path out of it

A `break` or `continue` out of a catch clause crashed with 0x80000003 on Windows (JIT and AOT,
every memory model, with or without a finally), and so did a `return` out of one whenever the
clause could also fall off its end. Two defects:

- TryOpLowering only marked a break/continue out of a catch as leaving the catch when the try had
  a finally, so without one the jump ended no catch at all. A jump out of a catch nested in another
  catch ended only one of the two. TSContext::catchesLeft now counts the catch clauses a return,
  break or continue leaves, and their lowering ends that many. The return or jump in the copy of a
  finally entered through a catch-all pad (#599) counts that pad as one more.

- Win32ExceptionPass gave each catch region a single end: its scan follows the layout and closed
  the region at the first end-of-catch marker. Every other way out of the clause was left as a
  bare branch out of the catchpad, with no funclet bundle on its calls, which WinEHPrepare turns
  into unreachable code. findOtherCatchExits now walks each catch clause by its control flow from
  the pad (a nested catch first, continuing after its ends), collects every end-of-catch marker
  and the calls on the way to them, and each extra end gets its own catchret.

  The same goes for the copy of a finally entered through a catch-all pad (#599) that leaves on
  some paths only (`finally { if (c) return s; ... }`): its run-to-the-end exit, which rethrows,
  is now always the region's main end and its `return` the other, where the layout could put the
  `return` first and give the rethrow path a catchret into a `resume` (SimplifyCFG crashed on it).
  An invoked end-of-catch marker is also recorded among the region's invokes, so that rethrow goes
  to the enclosing handler rather than to the caller.

00try_jump_from_catch.ts covers break, continue and return out of a catch with and without a
finally, a finally reading a local of the block around the try, a typed catch, a catch nested in
another, a loop inside a catch, a labelled continue, and a finally that returns or breaks on some
paths only, with and without a try around it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ASDAlexander77 added a commit that referenced this pull request Oct 17, 2026
…very path out of it (#601)

A `break` or `continue` out of a catch clause crashed with 0x80000003 on Windows (JIT and AOT,
every memory model, with or without a finally), and so did a `return` out of one whenever the
clause could also fall off its end. Two defects:

- TryOpLowering only marked a break/continue out of a catch as leaving the catch when the try had
  a finally, so without one the jump ended no catch at all. A jump out of a catch nested in another
  catch ended only one of the two. TSContext::catchesLeft now counts the catch clauses a return,
  break or continue leaves, and their lowering ends that many. The return or jump in the copy of a
  finally entered through a catch-all pad (#599) counts that pad as one more.

- Win32ExceptionPass gave each catch region a single end: its scan follows the layout and closed
  the region at the first end-of-catch marker. Every other way out of the clause was left as a
  bare branch out of the catchpad, with no funclet bundle on its calls, which WinEHPrepare turns
  into unreachable code. findOtherCatchExits now walks each catch clause by its control flow from
  the pad (a nested catch first, continuing after its ends), collects every end-of-catch marker
  and the calls on the way to them, and each extra end gets its own catchret.

  The same goes for the copy of a finally entered through a catch-all pad (#599) that leaves on
  some paths only (`finally { if (c) return s; ... }`): its run-to-the-end exit, which rethrows,
  is now always the region's main end and its `return` the other, where the layout could put the
  `return` first and give the rethrow path a catchret into a `resume` (SimplifyCFG crashed on it).
  An invoked end-of-catch marker is also recorded among the region's invokes, so that rethrow goes
  to the enclosing handler rather than to the caller.

00try_jump_from_catch.ts covers break, continue and return out of a catch with and without a
finally, a finally reading a local of the block around the try, a typed catch, a catch nested in
another, a loop inside a catch, a labelled continue, and a finally that returns or breaks on some
paths only, with and without a try around it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant