From 0760b6d45329df08b08530a98ce9d1b5181c3dd2 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Thu, 23 Jul 2026 14:12:14 -0500 Subject: [PATCH 1/3] Fix wasm R2R codegen trap on catch-resumption normal-completion branch An async `try { ...; return; } catch {}` MoveNext compiled to WebAssembly R2R (crossgen) produced a valid module that trapped at runtime (RuntimeError: unreachable) on the normal-completion path. In Compiler::fgWasmControlFlow(), a non-contiguous forward branch that exits a try/catch region started its enclosing Block at the innermost try's begin. When that branch also escapes an outer catch-try sharing the same end cursor, the Block ended up nested inside the outer try, so the `br` resolved to depth 0 and landed on the outer try's trailing validation `unreachable` (emitted after its `end`) instead of the continuation. Walk the enclosing try regions outward and start the Block at the OUTERMOST escaped catch-try's begin, so the Block encloses those trys' ends and the branch resolves to the continuation. For a single-level try/catch the outermost escaped region is also the innermost, so behavior is unchanged there. Adds a JitBlue regression test that forces the browser leg through crossgen wasm R2R via AlwaysUseCrossGen2. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 964b4f25-1c41-44d6-81fb-a6a75ee7d3e4 --- src/coreclr/jit/fgwasm.cpp | 42 ++++++++++++++-- .../JitBlue/Runtime_NNNNN/Runtime_NNNNN.cs | 50 +++++++++++++++++++ .../Runtime_NNNNN/Runtime_NNNNN.csproj | 16 ++++++ 3 files changed, 104 insertions(+), 4 deletions(-) create mode 100644 src/tests/JIT/Regression/JitBlue/Runtime_NNNNN/Runtime_NNNNN.cs create mode 100644 src/tests/JIT/Regression/JitBlue/Runtime_NNNNN/Runtime_NNNNN.csproj diff --git a/src/coreclr/jit/fgwasm.cpp b/src/coreclr/jit/fgwasm.cpp index f2a029f2816f1b..092f89470d2ffd 100644 --- a/src/coreclr/jit/fgwasm.cpp +++ b/src/coreclr/jit/fgwasm.cpp @@ -1458,11 +1458,45 @@ PhaseStatus Compiler::fgWasmControlFlow() continue; } - // Non-contiguous, non-subsumed forward branch. Start the Block at the try - // header when crossing a try-catch exit so it encloses the wrapper. + // Non-contiguous, non-subsumed forward branch. When this branch exits one + // or more enclosing try/catch regions, start the Block at the OUTERMOST + // such region's try-begin so the Block encloses those trys' ends. Each TRY + // interval emits a trailing validation `unreachable` right after its `end` + // (codegenwasm.cpp genEmitStartBlock), and for equal extents the interval + // sort nests a Block just outside a Try. Starting the Block only at the + // innermost try (the previous behavior) leaves it nested inside an outer try + // that shares this branch's target as its end cursor; the branch then lands + // on that outer try's trailing `unreachable` instead of the continuation. // - BasicBlock* const blockStart = isCrossingTryCatchExit ? blockTryDsc->ebdTryBeg : block; - WasmInterval* const branch = WasmInterval::NewBlock(this, blockStart, initialLayout[succNum]); + // This matters for wasm catch resumption (resume-after-catch), where the + // paired ExnRefWrapper is stretched past the try-end to cover the catch- + // resumption dispatcher, so the normal-completion continuation sits exactly + // at the outer try's end cursor. For a single-level try/catch the outermost + // escaped region is also the innermost, so this reproduces the previous + // behavior. + // + BasicBlock* blockStart = block; + for (EHblkDsc* tryDsc = ehGetBlockTryDsc(block); tryDsc != nullptr;) + { + // Once succ is contained in an enclosing try, all further-out trys + // contain it too, so the branch does not exit them. + // + if (bbInTryRegions(ehGetIndex(tryDsc), succ)) + { + break; + } + + if (tryDsc->HasCatchHandler()) + { + blockStart = tryDsc->ebdTryBeg; + } + + tryDsc = (tryDsc->ebdEnclosingTryIndex == EHblkDsc::NO_ENCLOSING_INDEX) + ? nullptr + : ehGetDsc(tryDsc->ebdEnclosingTryIndex); + } + + WasmInterval* const branch = WasmInterval::NewBlock(this, blockStart, initialLayout[succNum]); fgWasmIntervals->push_back(branch); // Remember an interval end here diff --git a/src/tests/JIT/Regression/JitBlue/Runtime_NNNNN/Runtime_NNNNN.cs b/src/tests/JIT/Regression/JitBlue/Runtime_NNNNN/Runtime_NNNNN.cs new file mode 100644 index 00000000000000..738f5b0f3a5cad --- /dev/null +++ b/src/tests/JIT/Regression/JitBlue/Runtime_NNNNN/Runtime_NNNNN.cs @@ -0,0 +1,50 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// Regression test for a WebAssembly R2R (crossgen) codegen bug in async +// exception-handling lowering. When an async method's try block completes +// normally by branching out early (a `return` after an `await`), the +// normal-completion branch was bound to a Block that ended at the try's own +// end cursor -- nested inside the exception-ref wrapper funclet -- so the +// branch landed on the wrapper's trailing validation `unreachable` and +// trapped (RuntimeError: unreachable) instead of resuming. +// +// The defect is in codegen, independent of whether the await actually +// suspends: `await Task.CompletedTask` still generates the state-machine +// normal-completion branch that traps. A completed await is used deliberately +// so the synchronous .GetAwaiter().GetResult() does not block the single wasm +// thread (blocking on incomplete work traps elsewhere in corelib on wasm). +// +// Reproduces only under crossgen wasm R2R (TargetOS=browser), where the +// unfixed JIT aborts the module (exit != 100 => test fails). Passes trivially +// on all other targets. + +using System; +using System.Threading.Tasks; +using Xunit; + +public class Runtime_NNNNN +{ + [Fact] + public static void TestEntryPoint() + { + // Sync [Fact]: the merged runner invokes this via a bare call, so drive + // the async method to completion synchronously here. RunAsync's MoveNext + // takes the normal-completion `return` path -- the edge that trapped + // under the unfixed wasm R2R JIT. + RunAsync().GetAwaiter().GetResult(); + } + + private static async Task RunAsync() + { + try + { + await Task.CompletedTask; + return; + } + catch (Exception ex) + { + GC.KeepAlive(ex); + } + } +} diff --git a/src/tests/JIT/Regression/JitBlue/Runtime_NNNNN/Runtime_NNNNN.csproj b/src/tests/JIT/Regression/JitBlue/Runtime_NNNNN/Runtime_NNNNN.csproj new file mode 100644 index 00000000000000..971c955364bc7e --- /dev/null +++ b/src/tests/JIT/Regression/JitBlue/Runtime_NNNNN/Runtime_NNNNN.csproj @@ -0,0 +1,16 @@ + + + True + + true + + + + + From ac7087102870eec1a36c59581a93e76246932739 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Thu, 23 Jul 2026 14:16:51 -0500 Subject: [PATCH 2/3] Rename Bug C regression test to Runtime_131285 Fills in the placeholder Runtime_NNNNN name now that the tracking issue (dotnet/runtime#131285) is filed: renames the folder, the .cs/.csproj, and the test class. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 964b4f25-1c41-44d6-81fb-a6a75ee7d3e4 --- .../Runtime_NNNNN.cs => Runtime_131285/Runtime_131285.cs} | 2 +- .../Runtime_131285.csproj} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/tests/JIT/Regression/JitBlue/{Runtime_NNNNN/Runtime_NNNNN.cs => Runtime_131285/Runtime_131285.cs} (98%) rename src/tests/JIT/Regression/JitBlue/{Runtime_NNNNN/Runtime_NNNNN.csproj => Runtime_131285/Runtime_131285.csproj} (100%) diff --git a/src/tests/JIT/Regression/JitBlue/Runtime_NNNNN/Runtime_NNNNN.cs b/src/tests/JIT/Regression/JitBlue/Runtime_131285/Runtime_131285.cs similarity index 98% rename from src/tests/JIT/Regression/JitBlue/Runtime_NNNNN/Runtime_NNNNN.cs rename to src/tests/JIT/Regression/JitBlue/Runtime_131285/Runtime_131285.cs index 738f5b0f3a5cad..d88b3a329814af 100644 --- a/src/tests/JIT/Regression/JitBlue/Runtime_NNNNN/Runtime_NNNNN.cs +++ b/src/tests/JIT/Regression/JitBlue/Runtime_131285/Runtime_131285.cs @@ -23,7 +23,7 @@ using System.Threading.Tasks; using Xunit; -public class Runtime_NNNNN +public class Runtime_131285 { [Fact] public static void TestEntryPoint() diff --git a/src/tests/JIT/Regression/JitBlue/Runtime_NNNNN/Runtime_NNNNN.csproj b/src/tests/JIT/Regression/JitBlue/Runtime_131285/Runtime_131285.csproj similarity index 100% rename from src/tests/JIT/Regression/JitBlue/Runtime_NNNNN/Runtime_NNNNN.csproj rename to src/tests/JIT/Regression/JitBlue/Runtime_131285/Runtime_131285.csproj From bd473ea026fd01409a33762da9b112b3459e33ff Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Thu, 23 Jul 2026 14:24:00 -0500 Subject: [PATCH 3/3] Condense fgwasm.cpp branch-out comment Tightens the explanatory comment on the outermost-escaped-catch-try Block placement to match the terser JIT comment style. No code change. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 964b4f25-1c41-44d6-81fb-a6a75ee7d3e4 --- src/coreclr/jit/fgwasm.cpp | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/src/coreclr/jit/fgwasm.cpp b/src/coreclr/jit/fgwasm.cpp index 092f89470d2ffd..39ebe5aa9cfd64 100644 --- a/src/coreclr/jit/fgwasm.cpp +++ b/src/coreclr/jit/fgwasm.cpp @@ -1458,28 +1458,19 @@ PhaseStatus Compiler::fgWasmControlFlow() continue; } - // Non-contiguous, non-subsumed forward branch. When this branch exits one - // or more enclosing try/catch regions, start the Block at the OUTERMOST - // such region's try-begin so the Block encloses those trys' ends. Each TRY - // interval emits a trailing validation `unreachable` right after its `end` - // (codegenwasm.cpp genEmitStartBlock), and for equal extents the interval - // sort nests a Block just outside a Try. Starting the Block only at the - // innermost try (the previous behavior) leaves it nested inside an outer try - // that shares this branch's target as its end cursor; the branch then lands - // on that outer try's trailing `unreachable` instead of the continuation. - // - // This matters for wasm catch resumption (resume-after-catch), where the - // paired ExnRefWrapper is stretched past the try-end to cover the catch- - // resumption dispatcher, so the normal-completion continuation sits exactly - // at the outer try's end cursor. For a single-level try/catch the outermost - // escaped region is also the innermost, so this reproduces the previous - // behavior. + // Non-contiguous, non-subsumed forward branch. If it exits enclosing + // try/catch regions, start the Block at the outermost escaped catch-try + // so the Block encloses those trys' ends. Each Try emits a trailing + // validation `unreachable` after its `end`; starting only at the innermost + // try would nest the Block inside an outer try sharing this target as its + // end cursor, so the branch would land on that `unreachable` instead of the + // continuation. Single-level try/catch is unchanged (outermost == innermost). // BasicBlock* blockStart = block; for (EHblkDsc* tryDsc = ehGetBlockTryDsc(block); tryDsc != nullptr;) { - // Once succ is contained in an enclosing try, all further-out trys - // contain it too, so the branch does not exit them. + // Once succ is contained in an enclosing try, all outer trys contain it + // too, so the branch does not exit them. // if (bbInTryRegions(ehGetIndex(tryDsc), succ)) {