From d1d3737f88c66726f4d1be4bd3482b2d094f6f5b Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Sat, 25 Jul 2026 19:07:48 -0500 Subject: [PATCH 01/21] [wasm][R2R] Model inline-unbox slow-path helper as value-returning on wasm The inline expansion of `unbox` in the importer lowers to `(*clone == typeToken) ? nop : CORINFO_HELP_UNBOX(clone, typeToken)`, creating the slow-path helper call as `TYP_VOID` because its result is unused (the unboxed byref is formed separately from `clone + TARGET_POINTER_SIZE`). On wasm the unbox helper is dispatched through a portable entry point whose compiled function returns a byref (`CastHelpers.Unbox` returns `ref byte`), and the `call_indirect` signature emitted at the call site is derived from the JIT-modeled return type. Modeling the helper as `TYP_VOID` emits a `(...)->()` `call_indirect` for a helper the runtime dispatches through an i32-returning thunk, so the wasm engine traps with `function signature mismatch` (e.g. on the first cold unbox in `System.Enum.ToObject` during reflection-heavy startup). This is the same class of defect as #130813 (class-init helpers modeled value-returning on wasm) applied to the inline-unbox slow path. Follow that fix's shape with a `HelperUnboxRetType` constant, and discard the unused result via `gtUnusedValNode` so the enclosing `COLON`/`QMARK` remain void. On targets where the helper stays `TYP_VOID` the COMMA folds away in morph. Note the non-inline path already modeled this helper as `TYP_BYREF`, so the two paths are now consistent. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 86fe3398-c7ec-4555-b4ba-c732819b4dc5 --- src/coreclr/jit/compiler.h | 7 +++++-- src/coreclr/jit/importer.cpp | 4 +++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/coreclr/jit/compiler.h b/src/coreclr/jit/compiler.h index 56f79d07641ec3..44cb6a66101399 100644 --- a/src/coreclr/jit/compiler.h +++ b/src/coreclr/jit/compiler.h @@ -3359,11 +3359,14 @@ class Compiler unsigned helper, var_types type, GenTree* arg1 = nullptr, GenTree* arg2 = nullptr, GenTree* arg3 = nullptr, GenTree* arg4 = nullptr); #ifdef TARGET_WASM - // On wasm these helpers return void* (InitHelpers.InitClass/InitInstantiatedClass). Model them as - // value-returning so the call_indirect signature matches the compiled managed helper; the value is unused. + // On wasm the emitted call signature is derived from the modeled return type, so helpers whose + // result we discard must still be modeled as value-returning. InitClass/InitInstantiatedClass + // return void*, CastHelpers.Unbox returns a byref. static constexpr var_types HelperInitClassRetType = TYP_I_IMPL; + static constexpr var_types HelperUnboxRetType = TYP_BYREF; #else static constexpr var_types HelperInitClassRetType = TYP_VOID; + static constexpr var_types HelperUnboxRetType = TYP_VOID; #endif // TARGET_WASM GenTreeCall* gtNewVirtualFunctionLookupHelperCallNode( diff --git a/src/coreclr/jit/importer.cpp b/src/coreclr/jit/importer.cpp index 90396e6a294bc5..735f7128881802 100644 --- a/src/coreclr/jit/importer.cpp +++ b/src/coreclr/jit/importer.cpp @@ -10700,7 +10700,9 @@ void Compiler::impImportBlockCode(BasicBlock* block) { // compDonotInline() return; } - op1 = gtNewHelperCallNode(helper, TYP_VOID, op2, op1); + // The helper result is unused; the byref is formed from clone + TARGET_POINTER_SIZE + // below. Discard it so the enclosing COLON/QMARK remain void. + op1 = gtUnusedValNode(gtNewHelperCallNode(helper, HelperUnboxRetType, op2, op1)); op1 = new (this, GT_COLON) GenTreeColon(TYP_VOID, gtNewNothingNode(), op1); op1 = gtNewQmarkNode(TYP_VOID, condBox, op1->AsColon()); From d1c5be245780a6b1182ee797dd5050f3c187d4a7 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Sat, 25 Jul 2026 19:37:38 -0500 Subject: [PATCH 02/21] [wasm] Fix two more helper calls modeled with the wrong return arity On wasm the emitted call signature is derived from the JIT-modeled return type of the call node, so a helper modeled with the wrong return arity traps with `function signature mismatch`. Two more instances of the #130813 pattern: - `ObjectAllocator::RewriteUses` retargets a `CORINFO_HELP_UNBOX` call to `CORINFO_HELP_UNBOX_TYPETEST` when the unboxed object is a stack allocated box, but never retyped the node. `CastHelpers.Unbox` returns `ref byte` while `CastHelpers.Unbox_TypeTest` returns void, so the node was left claiming a byref return for a void helper. `isForEffect` is still computed from the original type so the existing `COMMA(call, ADD(obj, TARGET_POINTER_SIZE))` shape is preserved. - `fgMorphInit` modeled `CORINFO_HELP_CHECK_OBJ` as `TYP_VOID`, but `JIT_CheckObj` returns `Object*` (and the importer already models it as `TYP_REF`). This one only fires under `DOTNET_JitGCChecks`. Both are benign on register-based targets, where the unused return value is simply left in the return register, which is why they went unnoticed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 86fe3398-c7ec-4555-b4ba-c732819b4dc5 --- src/coreclr/jit/morph.cpp | 2 +- src/coreclr/jit/objectalloc.cpp | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/coreclr/jit/morph.cpp b/src/coreclr/jit/morph.cpp index 39e2c9f0eea43e..cf812722a7dba9 100644 --- a/src/coreclr/jit/morph.cpp +++ b/src/coreclr/jit/morph.cpp @@ -64,7 +64,7 @@ PhaseStatus Compiler::fgMorphInit() { // confirm that the argument is a GC pointer (for debugging (GC stress)) GenTree* op = gtNewLclvNode(i, TYP_REF); - op = gtNewHelperCallNode(CORINFO_HELP_CHECK_OBJ, TYP_VOID, op); + op = gtUnusedValNode(gtNewHelperCallNode(CORINFO_HELP_CHECK_OBJ, TYP_REF, op)); fgNewStmtAtBeg(fgFirstBB, op); madeChanges = true; diff --git a/src/coreclr/jit/objectalloc.cpp b/src/coreclr/jit/objectalloc.cpp index 9f9e7baeaa6cd9..a624f6289a0bd7 100644 --- a/src/coreclr/jit/objectalloc.cpp +++ b/src/coreclr/jit/objectalloc.cpp @@ -2837,7 +2837,11 @@ void ObjectAllocator::RewriteUses() // JITDUMP("Rewriting to invoke box type test helper%s\n", isForEffect ? " for side effect" : ""); + // Unbox_TypeTest returns void, unlike Unbox. Note isForEffect above uses the + // original type. call->gtCallMethHnd = m_compiler->eeFindHelper(CORINFO_HELP_UNBOX_TYPETEST); + call->gtType = TYP_VOID; + call->gtReturnType = TYP_VOID; GenTree* const mt = m_compiler->gtNewMethodTableLookup(lcl, /* onStack */ true); call->gtArgs.Remove(secondArg); call->gtArgs.PushBack(m_compiler, NewCallArg::Primitive(mt)); From d8dba2a638a1491e893be9e5a0001e73287fffe2 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Sat, 25 Jul 2026 21:02:49 -0500 Subject: [PATCH 03/21] [wasm] Add regression test for the stack-allocated unbox type-test helper Guards the objectalloc UNBOX_TYPETEST retype: under wasm R2R the unfixed JIT emits a value-returning call_indirect for the void CastHelpers.Unbox_TypeTest helper and the engine traps with "function signature mismatch". The mismatched call_indirect is guarded by an inline method table check that short-circuits the helper whenever the unbox type matches, so a same-type unbox compiles the bad instruction but never executes it. The test therefore drives a non-matching type through the same unbox site so the type-test slow path actually runs. This is why the existing JIT/opt/ObjectStackAllocation Case3, which is only ever called with null, does not catch the trap. AlwaysUseCrossGen2 is set for TargetOS=browser so the browser leg actually compiles this to wasm R2R rather than running it as ordinary JIT and false-passing. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 86fe3398-c7ec-4555-b4ba-c732819b4dc5 --- .../JitBlue/Runtime_131377/Runtime_131377.cs | 55 +++++++++++++++++++ .../Runtime_131377/Runtime_131377.csproj | 19 +++++++ 2 files changed, 74 insertions(+) create mode 100644 src/tests/JIT/Regression/JitBlue/Runtime_131377/Runtime_131377.cs create mode 100644 src/tests/JIT/Regression/JitBlue/Runtime_131377/Runtime_131377.csproj diff --git a/src/tests/JIT/Regression/JitBlue/Runtime_131377/Runtime_131377.cs b/src/tests/JIT/Regression/JitBlue/Runtime_131377/Runtime_131377.cs new file mode 100644 index 00000000000000..00bf818ff1f318 --- /dev/null +++ b/src/tests/JIT/Regression/JitBlue/Runtime_131377/Runtime_131377.cs @@ -0,0 +1,55 @@ +// 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 object stack +// allocation. When a non-escaping box is stack allocated, ObjectAllocator +// rewrites the CORINFO_HELP_UNBOX call to CORINFO_HELP_UNBOX_TYPETEST. Unbox +// returns a byref while Unbox_TypeTest returns void, but the call node kept its +// byref type, so wasm emitted a value-returning call_indirect for a void helper +// and the engine trapped with "function signature mismatch". +// +// The mismatched call_indirect is guarded by an inline method table check that +// short-circuits the helper whenever the unbox type matches, so a same-type +// unbox compiles the bad instruction but never executes it. Driving a +// non-matching type through the same unbox site is what makes the slow path -- +// and the trap -- actually run. +// +// Reproduces only under crossgen wasm R2R (TargetOS=browser), where the unfixed +// JIT aborts the module before InvalidCastException can be thrown. Passes +// trivially on all other targets. + +using System; +using System.Runtime.CompilerServices; +using Xunit; + +public class Runtime_131377 +{ + [Fact] + public static void TestEntryPoint() + { + // Matching type: the box is stack allocated and the inline method table + // check succeeds, so the type-test helper is skipped. + Unbox(null); + + // Non-matching type: the inline check fails, so the type-test helper + // runs and reaches the call_indirect that trapped under the unfixed JIT. + Assert.Throws(() => Unbox("not a guid")); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void Unbox(object o) + { + if (o is null) + { + // Non-escaping box, so this is stack allocated. + o = new Guid(); + } + + Consume((Guid)o); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void Consume(T _) + { + } +} diff --git a/src/tests/JIT/Regression/JitBlue/Runtime_131377/Runtime_131377.csproj b/src/tests/JIT/Regression/JitBlue/Runtime_131377/Runtime_131377.csproj new file mode 100644 index 00000000000000..416bbcfbfa89b5 --- /dev/null +++ b/src/tests/JIT/Regression/JitBlue/Runtime_131377/Runtime_131377.csproj @@ -0,0 +1,19 @@ + + + + True + true + + true + + + + + From 0993520457bccff7fa20e50d476dd29ca1af7db6 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Sat, 25 Jul 2026 21:07:55 -0500 Subject: [PATCH 04/21] [wasm] Rename HelperUnboxRetType to make the discard intent explicit CastHelpers.Unbox returns a byref on every target, so a constant named HelperUnboxRetType that evaluates to TYP_VOID off wasm reads like the helper's actual return type and invites a future value-producing call site to model the unbox helper as void. Name it for what it is -- the type to model with when the result is discarded -- and say so. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 86fe3398-c7ec-4555-b4ba-c732819b4dc5 --- src/coreclr/jit/compiler.h | 8 +++++--- src/coreclr/jit/importer.cpp | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/coreclr/jit/compiler.h b/src/coreclr/jit/compiler.h index 44cb6a66101399..246e687cd4b386 100644 --- a/src/coreclr/jit/compiler.h +++ b/src/coreclr/jit/compiler.h @@ -3363,10 +3363,12 @@ class Compiler // result we discard must still be modeled as value-returning. InitClass/InitInstantiatedClass // return void*, CastHelpers.Unbox returns a byref. static constexpr var_types HelperInitClassRetType = TYP_I_IMPL; - static constexpr var_types HelperUnboxRetType = TYP_BYREF; + // Only for unbox sites that discard the result. CastHelpers.Unbox returns a byref on every + // target, so a site that consumes the result must model TYP_BYREF directly. + static constexpr var_types HelperUnboxDiscardedRetType = TYP_BYREF; #else - static constexpr var_types HelperInitClassRetType = TYP_VOID; - static constexpr var_types HelperUnboxRetType = TYP_VOID; + static constexpr var_types HelperInitClassRetType = TYP_VOID; + static constexpr var_types HelperUnboxDiscardedRetType = TYP_VOID; #endif // TARGET_WASM GenTreeCall* gtNewVirtualFunctionLookupHelperCallNode( diff --git a/src/coreclr/jit/importer.cpp b/src/coreclr/jit/importer.cpp index 735f7128881802..b189abe9a1afd4 100644 --- a/src/coreclr/jit/importer.cpp +++ b/src/coreclr/jit/importer.cpp @@ -10702,7 +10702,7 @@ void Compiler::impImportBlockCode(BasicBlock* block) } // The helper result is unused; the byref is formed from clone + TARGET_POINTER_SIZE // below. Discard it so the enclosing COLON/QMARK remain void. - op1 = gtUnusedValNode(gtNewHelperCallNode(helper, HelperUnboxRetType, op2, op1)); + op1 = gtUnusedValNode(gtNewHelperCallNode(helper, HelperUnboxDiscardedRetType, op2, op1)); op1 = new (this, GT_COLON) GenTreeColon(TYP_VOID, gtNewNothingNode(), op1); op1 = gtNewQmarkNode(TYP_VOID, condBox, op1->AsColon()); From fff0265b47c2253cc39f39b3ff46bd93e25b69f4 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Sun, 26 Jul 2026 10:01:30 -0500 Subject: [PATCH 05/21] Trim comments Keep only what the code doesn't say itself, per the house style in #131374. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 86fe3398-c7ec-4555-b4ba-c732819b4dc5 --- src/coreclr/jit/compiler.h | 8 +++--- src/coreclr/jit/importer.cpp | 4 +-- src/coreclr/jit/objectalloc.cpp | 2 +- .../JitBlue/Runtime_131377/Runtime_131377.cs | 25 +++---------------- .../Runtime_131377/Runtime_131377.csproj | 10 +------- 5 files changed, 10 insertions(+), 39 deletions(-) diff --git a/src/coreclr/jit/compiler.h b/src/coreclr/jit/compiler.h index 246e687cd4b386..8f031e64f77ba7 100644 --- a/src/coreclr/jit/compiler.h +++ b/src/coreclr/jit/compiler.h @@ -3359,12 +3359,10 @@ class Compiler unsigned helper, var_types type, GenTree* arg1 = nullptr, GenTree* arg2 = nullptr, GenTree* arg3 = nullptr, GenTree* arg4 = nullptr); #ifdef TARGET_WASM - // On wasm the emitted call signature is derived from the modeled return type, so helpers whose - // result we discard must still be modeled as value-returning. InitClass/InitInstantiatedClass - // return void*, CastHelpers.Unbox returns a byref. + // Wasm derives the call signature from the modeled return type, so helpers whose result we + // discard must still be modeled as value-returning. static constexpr var_types HelperInitClassRetType = TYP_I_IMPL; - // Only for unbox sites that discard the result. CastHelpers.Unbox returns a byref on every - // target, so a site that consumes the result must model TYP_BYREF directly. + // CastHelpers.Unbox returns a byref on every target; sites that consume it model TYP_BYREF. static constexpr var_types HelperUnboxDiscardedRetType = TYP_BYREF; #else static constexpr var_types HelperInitClassRetType = TYP_VOID; diff --git a/src/coreclr/jit/importer.cpp b/src/coreclr/jit/importer.cpp index b189abe9a1afd4..806c8334838b4e 100644 --- a/src/coreclr/jit/importer.cpp +++ b/src/coreclr/jit/importer.cpp @@ -10700,8 +10700,8 @@ void Compiler::impImportBlockCode(BasicBlock* block) { // compDonotInline() return; } - // The helper result is unused; the byref is formed from clone + TARGET_POINTER_SIZE - // below. Discard it so the enclosing COLON/QMARK remain void. + // The byref is formed from clone + TARGET_POINTER_SIZE below, so discard the + // helper result to keep the enclosing COLON/QMARK void. op1 = gtUnusedValNode(gtNewHelperCallNode(helper, HelperUnboxDiscardedRetType, op2, op1)); op1 = new (this, GT_COLON) GenTreeColon(TYP_VOID, gtNewNothingNode(), op1); diff --git a/src/coreclr/jit/objectalloc.cpp b/src/coreclr/jit/objectalloc.cpp index a624f6289a0bd7..bfcb0f4c7b3a96 100644 --- a/src/coreclr/jit/objectalloc.cpp +++ b/src/coreclr/jit/objectalloc.cpp @@ -2837,7 +2837,7 @@ void ObjectAllocator::RewriteUses() // JITDUMP("Rewriting to invoke box type test helper%s\n", isForEffect ? " for side effect" : ""); - // Unbox_TypeTest returns void, unlike Unbox. Note isForEffect above uses the + // Unbox_TypeTest returns void, unlike Unbox. isForEffect above reads the // original type. call->gtCallMethHnd = m_compiler->eeFindHelper(CORINFO_HELP_UNBOX_TYPETEST); call->gtType = TYP_VOID; diff --git a/src/tests/JIT/Regression/JitBlue/Runtime_131377/Runtime_131377.cs b/src/tests/JIT/Regression/JitBlue/Runtime_131377/Runtime_131377.cs index 00bf818ff1f318..73c926946e9697 100644 --- a/src/tests/JIT/Regression/JitBlue/Runtime_131377/Runtime_131377.cs +++ b/src/tests/JIT/Regression/JitBlue/Runtime_131377/Runtime_131377.cs @@ -1,22 +1,9 @@ // 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 object stack -// allocation. When a non-escaping box is stack allocated, ObjectAllocator -// rewrites the CORINFO_HELP_UNBOX call to CORINFO_HELP_UNBOX_TYPETEST. Unbox -// returns a byref while Unbox_TypeTest returns void, but the call node kept its -// byref type, so wasm emitted a value-returning call_indirect for a void helper -// and the engine trapped with "function signature mismatch". -// -// The mismatched call_indirect is guarded by an inline method table check that -// short-circuits the helper whenever the unbox type matches, so a same-type -// unbox compiles the bad instruction but never executes it. Driving a -// non-matching type through the same unbox site is what makes the slow path -- -// and the trap -- actually run. -// -// Reproduces only under crossgen wasm R2R (TargetOS=browser), where the unfixed -// JIT aborts the module before InvalidCastException can be thrown. Passes -// trivially on all other targets. +// Stack allocating a box retargets its unbox to the void UNBOX_TYPETEST helper, but the call kept +// its byref type, so wasm emitted a value-returning call_indirect and trapped. The non-Guid arg is +// load bearing: a same-type unbox skips the helper. Only reproduces under crossgen wasm R2R. using System; using System.Runtime.CompilerServices; @@ -27,12 +14,7 @@ public class Runtime_131377 [Fact] public static void TestEntryPoint() { - // Matching type: the box is stack allocated and the inline method table - // check succeeds, so the type-test helper is skipped. Unbox(null); - - // Non-matching type: the inline check fails, so the type-test helper - // runs and reaches the call_indirect that trapped under the unfixed JIT. Assert.Throws(() => Unbox("not a guid")); } @@ -41,7 +23,6 @@ private static void Unbox(object o) { if (o is null) { - // Non-escaping box, so this is stack allocated. o = new Guid(); } diff --git a/src/tests/JIT/Regression/JitBlue/Runtime_131377/Runtime_131377.csproj b/src/tests/JIT/Regression/JitBlue/Runtime_131377/Runtime_131377.csproj index 416bbcfbfa89b5..e278e04c124f97 100644 --- a/src/tests/JIT/Regression/JitBlue/Runtime_131377/Runtime_131377.csproj +++ b/src/tests/JIT/Regression/JitBlue/Runtime_131377/Runtime_131377.csproj @@ -1,16 +1,8 @@ - True true - + true From 96a1e5683eb056b21b3b9ce0b60258c08c41539f Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Sun, 26 Jul 2026 10:43:22 -0500 Subject: [PATCH 06/21] Model the inline unbox helper as TYP_BYREF on all targets CastHelpers.Unbox returns a byref everywhere, and the non-inline path a few lines below already models it that way, with an assert to match. Modeling it per-target meant a constant whose non-wasm value described nothing about the helper. Drop the constant and use TYP_BYREF directly; the result is still discarded via gtUnusedValNode. compiler.h is untouched now: HelperInitClassRetType stays as #130813 shipped it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 86fe3398-c7ec-4555-b4ba-c732819b4dc5 --- src/coreclr/jit/compiler.h | 9 +++------ src/coreclr/jit/importer.cpp | 2 +- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/coreclr/jit/compiler.h b/src/coreclr/jit/compiler.h index 8f031e64f77ba7..56f79d07641ec3 100644 --- a/src/coreclr/jit/compiler.h +++ b/src/coreclr/jit/compiler.h @@ -3359,14 +3359,11 @@ class Compiler unsigned helper, var_types type, GenTree* arg1 = nullptr, GenTree* arg2 = nullptr, GenTree* arg3 = nullptr, GenTree* arg4 = nullptr); #ifdef TARGET_WASM - // Wasm derives the call signature from the modeled return type, so helpers whose result we - // discard must still be modeled as value-returning. + // On wasm these helpers return void* (InitHelpers.InitClass/InitInstantiatedClass). Model them as + // value-returning so the call_indirect signature matches the compiled managed helper; the value is unused. static constexpr var_types HelperInitClassRetType = TYP_I_IMPL; - // CastHelpers.Unbox returns a byref on every target; sites that consume it model TYP_BYREF. - static constexpr var_types HelperUnboxDiscardedRetType = TYP_BYREF; #else - static constexpr var_types HelperInitClassRetType = TYP_VOID; - static constexpr var_types HelperUnboxDiscardedRetType = TYP_VOID; + static constexpr var_types HelperInitClassRetType = TYP_VOID; #endif // TARGET_WASM GenTreeCall* gtNewVirtualFunctionLookupHelperCallNode( diff --git a/src/coreclr/jit/importer.cpp b/src/coreclr/jit/importer.cpp index 806c8334838b4e..e4f0cc0b6df904 100644 --- a/src/coreclr/jit/importer.cpp +++ b/src/coreclr/jit/importer.cpp @@ -10702,7 +10702,7 @@ void Compiler::impImportBlockCode(BasicBlock* block) } // The byref is formed from clone + TARGET_POINTER_SIZE below, so discard the // helper result to keep the enclosing COLON/QMARK void. - op1 = gtUnusedValNode(gtNewHelperCallNode(helper, HelperUnboxDiscardedRetType, op2, op1)); + op1 = gtUnusedValNode(gtNewHelperCallNode(helper, TYP_BYREF, op2, op1)); op1 = new (this, GT_COLON) GenTreeColon(TYP_VOID, gtNewNothingNode(), op1); op1 = gtNewQmarkNode(TYP_VOID, condBox, op1->AsColon()); From d34c5d5246901757378798c05f867ef4390b03b7 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Sun, 26 Jul 2026 18:29:40 -0500 Subject: [PATCH 07/21] Model the discarded unbox helper as value-returning only on wasm SPMI showed the unconditional TYP_BYREF form costs code size on every native target -- code size regressions across linux arm64/x64, osx arm64, and windows arm64/x64, mostly at inline unbox sites such as Enum.ToObject and RuntimeType.IsEnumDefined -- for no benefit, since the result is discarded and only wasm derives the call signature from the modeled type. Restore the target-conditional constant. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 86fe3398-c7ec-4555-b4ba-c732819b4dc5 --- src/coreclr/jit/compiler.h | 6 +++++- src/coreclr/jit/importer.cpp | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/coreclr/jit/compiler.h b/src/coreclr/jit/compiler.h index 56f79d07641ec3..2e32fed1691a2f 100644 --- a/src/coreclr/jit/compiler.h +++ b/src/coreclr/jit/compiler.h @@ -3362,8 +3362,12 @@ class Compiler // On wasm these helpers return void* (InitHelpers.InitClass/InitInstantiatedClass). Model them as // value-returning so the call_indirect signature matches the compiled managed helper; the value is unused. static constexpr var_types HelperInitClassRetType = TYP_I_IMPL; + // Likewise for CastHelpers.Unbox at sites that discard the result. Modeling it TYP_BYREF off + // wasm is equally correct but costs code size for no benefit. + static constexpr var_types HelperUnboxDiscardedRetType = TYP_BYREF; #else - static constexpr var_types HelperInitClassRetType = TYP_VOID; + static constexpr var_types HelperInitClassRetType = TYP_VOID; + static constexpr var_types HelperUnboxDiscardedRetType = TYP_VOID; #endif // TARGET_WASM GenTreeCall* gtNewVirtualFunctionLookupHelperCallNode( diff --git a/src/coreclr/jit/importer.cpp b/src/coreclr/jit/importer.cpp index e4f0cc0b6df904..806c8334838b4e 100644 --- a/src/coreclr/jit/importer.cpp +++ b/src/coreclr/jit/importer.cpp @@ -10702,7 +10702,7 @@ void Compiler::impImportBlockCode(BasicBlock* block) } // The byref is formed from clone + TARGET_POINTER_SIZE below, so discard the // helper result to keep the enclosing COLON/QMARK void. - op1 = gtUnusedValNode(gtNewHelperCallNode(helper, TYP_BYREF, op2, op1)); + op1 = gtUnusedValNode(gtNewHelperCallNode(helper, HelperUnboxDiscardedRetType, op2, op1)); op1 = new (this, GT_COLON) GenTreeColon(TYP_VOID, gtNewNothingNode(), op1); op1 = gtNewQmarkNode(TYP_VOID, condBox, op1->AsColon()); From 93b5bcdb856c4dc16e668f6fdfdbd5995630c5d4 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Mon, 27 Jul 2026 08:44:02 -0500 Subject: [PATCH 08/21] Drop the gtUnusedValNode wrapper in fgMorphInit A bare value-producing call is a valid statement root; CEE_POP skips the wrapper for calls via an explicit !OperIs(GT_CALL) guard. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 86fe3398-c7ec-4555-b4ba-c732819b4dc5 --- src/coreclr/jit/morph.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/coreclr/jit/morph.cpp b/src/coreclr/jit/morph.cpp index cf812722a7dba9..56ee7c3ddad5b6 100644 --- a/src/coreclr/jit/morph.cpp +++ b/src/coreclr/jit/morph.cpp @@ -64,7 +64,7 @@ PhaseStatus Compiler::fgMorphInit() { // confirm that the argument is a GC pointer (for debugging (GC stress)) GenTree* op = gtNewLclvNode(i, TYP_REF); - op = gtUnusedValNode(gtNewHelperCallNode(CORINFO_HELP_CHECK_OBJ, TYP_REF, op)); + op = gtNewHelperCallNode(CORINFO_HELP_CHECK_OBJ, TYP_REF, op); fgNewStmtAtBeg(fgFirstBB, op); madeChanges = true; From 99c4432e441300c191ab6072bbdbabc4cf2d6502 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Sat, 25 Jul 2026 19:07:48 -0500 Subject: [PATCH 09/21] [wasm][R2R] Model inline-unbox slow-path helper as value-returning on wasm The inline expansion of `unbox` in the importer lowers to `(*clone == typeToken) ? nop : CORINFO_HELP_UNBOX(clone, typeToken)`, creating the slow-path helper call as `TYP_VOID` because its result is unused (the unboxed byref is formed separately from `clone + TARGET_POINTER_SIZE`). On wasm the unbox helper is dispatched through a portable entry point whose compiled function returns a byref (`CastHelpers.Unbox` returns `ref byte`), and the `call_indirect` signature emitted at the call site is derived from the JIT-modeled return type. Modeling the helper as `TYP_VOID` emits a `(...)->()` `call_indirect` for a helper the runtime dispatches through an i32-returning thunk, so the wasm engine traps with `function signature mismatch` (e.g. on the first cold unbox in `System.Enum.ToObject` during reflection-heavy startup). This is the same class of defect as #130813 (class-init helpers modeled value-returning on wasm) applied to the inline-unbox slow path. Follow that fix's shape with a `HelperUnboxRetType` constant, and discard the unused result via `gtUnusedValNode` so the enclosing `COLON`/`QMARK` remain void. On targets where the helper stays `TYP_VOID` the COMMA folds away in morph. Note the non-inline path already modeled this helper as `TYP_BYREF`, so the two paths are now consistent. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 86fe3398-c7ec-4555-b4ba-c732819b4dc5 --- src/coreclr/jit/compiler.h | 7 +++++-- src/coreclr/jit/importer.cpp | 4 +++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/coreclr/jit/compiler.h b/src/coreclr/jit/compiler.h index 56f79d07641ec3..44cb6a66101399 100644 --- a/src/coreclr/jit/compiler.h +++ b/src/coreclr/jit/compiler.h @@ -3359,11 +3359,14 @@ class Compiler unsigned helper, var_types type, GenTree* arg1 = nullptr, GenTree* arg2 = nullptr, GenTree* arg3 = nullptr, GenTree* arg4 = nullptr); #ifdef TARGET_WASM - // On wasm these helpers return void* (InitHelpers.InitClass/InitInstantiatedClass). Model them as - // value-returning so the call_indirect signature matches the compiled managed helper; the value is unused. + // On wasm the emitted call signature is derived from the modeled return type, so helpers whose + // result we discard must still be modeled as value-returning. InitClass/InitInstantiatedClass + // return void*, CastHelpers.Unbox returns a byref. static constexpr var_types HelperInitClassRetType = TYP_I_IMPL; + static constexpr var_types HelperUnboxRetType = TYP_BYREF; #else static constexpr var_types HelperInitClassRetType = TYP_VOID; + static constexpr var_types HelperUnboxRetType = TYP_VOID; #endif // TARGET_WASM GenTreeCall* gtNewVirtualFunctionLookupHelperCallNode( diff --git a/src/coreclr/jit/importer.cpp b/src/coreclr/jit/importer.cpp index 90396e6a294bc5..735f7128881802 100644 --- a/src/coreclr/jit/importer.cpp +++ b/src/coreclr/jit/importer.cpp @@ -10700,7 +10700,9 @@ void Compiler::impImportBlockCode(BasicBlock* block) { // compDonotInline() return; } - op1 = gtNewHelperCallNode(helper, TYP_VOID, op2, op1); + // The helper result is unused; the byref is formed from clone + TARGET_POINTER_SIZE + // below. Discard it so the enclosing COLON/QMARK remain void. + op1 = gtUnusedValNode(gtNewHelperCallNode(helper, HelperUnboxRetType, op2, op1)); op1 = new (this, GT_COLON) GenTreeColon(TYP_VOID, gtNewNothingNode(), op1); op1 = gtNewQmarkNode(TYP_VOID, condBox, op1->AsColon()); From 31d63ea081fdbe0faca860c8de10f1bf68b30d03 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Sat, 25 Jul 2026 19:37:38 -0500 Subject: [PATCH 10/21] [wasm] Fix two more helper calls modeled with the wrong return arity On wasm the emitted call signature is derived from the JIT-modeled return type of the call node, so a helper modeled with the wrong return arity traps with `function signature mismatch`. Two more instances of the #130813 pattern: - `ObjectAllocator::RewriteUses` retargets a `CORINFO_HELP_UNBOX` call to `CORINFO_HELP_UNBOX_TYPETEST` when the unboxed object is a stack allocated box, but never retyped the node. `CastHelpers.Unbox` returns `ref byte` while `CastHelpers.Unbox_TypeTest` returns void, so the node was left claiming a byref return for a void helper. `isForEffect` is still computed from the original type so the existing `COMMA(call, ADD(obj, TARGET_POINTER_SIZE))` shape is preserved. - `fgMorphInit` modeled `CORINFO_HELP_CHECK_OBJ` as `TYP_VOID`, but `JIT_CheckObj` returns `Object*` (and the importer already models it as `TYP_REF`). This one only fires under `DOTNET_JitGCChecks`. Both are benign on register-based targets, where the unused return value is simply left in the return register, which is why they went unnoticed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 86fe3398-c7ec-4555-b4ba-c732819b4dc5 --- src/coreclr/jit/morph.cpp | 2 +- src/coreclr/jit/objectalloc.cpp | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/coreclr/jit/morph.cpp b/src/coreclr/jit/morph.cpp index 39e2c9f0eea43e..cf812722a7dba9 100644 --- a/src/coreclr/jit/morph.cpp +++ b/src/coreclr/jit/morph.cpp @@ -64,7 +64,7 @@ PhaseStatus Compiler::fgMorphInit() { // confirm that the argument is a GC pointer (for debugging (GC stress)) GenTree* op = gtNewLclvNode(i, TYP_REF); - op = gtNewHelperCallNode(CORINFO_HELP_CHECK_OBJ, TYP_VOID, op); + op = gtUnusedValNode(gtNewHelperCallNode(CORINFO_HELP_CHECK_OBJ, TYP_REF, op)); fgNewStmtAtBeg(fgFirstBB, op); madeChanges = true; diff --git a/src/coreclr/jit/objectalloc.cpp b/src/coreclr/jit/objectalloc.cpp index 9f9e7baeaa6cd9..a624f6289a0bd7 100644 --- a/src/coreclr/jit/objectalloc.cpp +++ b/src/coreclr/jit/objectalloc.cpp @@ -2837,7 +2837,11 @@ void ObjectAllocator::RewriteUses() // JITDUMP("Rewriting to invoke box type test helper%s\n", isForEffect ? " for side effect" : ""); + // Unbox_TypeTest returns void, unlike Unbox. Note isForEffect above uses the + // original type. call->gtCallMethHnd = m_compiler->eeFindHelper(CORINFO_HELP_UNBOX_TYPETEST); + call->gtType = TYP_VOID; + call->gtReturnType = TYP_VOID; GenTree* const mt = m_compiler->gtNewMethodTableLookup(lcl, /* onStack */ true); call->gtArgs.Remove(secondArg); call->gtArgs.PushBack(m_compiler, NewCallArg::Primitive(mt)); From 648c6effb46f9dbc878ee97755fa9d1cc903d178 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Sat, 25 Jul 2026 21:02:49 -0500 Subject: [PATCH 11/21] [wasm] Add regression test for the stack-allocated unbox type-test helper Guards the objectalloc UNBOX_TYPETEST retype: under wasm R2R the unfixed JIT emits a value-returning call_indirect for the void CastHelpers.Unbox_TypeTest helper and the engine traps with "function signature mismatch". The mismatched call_indirect is guarded by an inline method table check that short-circuits the helper whenever the unbox type matches, so a same-type unbox compiles the bad instruction but never executes it. The test therefore drives a non-matching type through the same unbox site so the type-test slow path actually runs. This is why the existing JIT/opt/ObjectStackAllocation Case3, which is only ever called with null, does not catch the trap. AlwaysUseCrossGen2 is set for TargetOS=browser so the browser leg actually compiles this to wasm R2R rather than running it as ordinary JIT and false-passing. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 86fe3398-c7ec-4555-b4ba-c732819b4dc5 --- .../JitBlue/Runtime_131377/Runtime_131377.cs | 55 +++++++++++++++++++ .../Runtime_131377/Runtime_131377.csproj | 19 +++++++ 2 files changed, 74 insertions(+) create mode 100644 src/tests/JIT/Regression/JitBlue/Runtime_131377/Runtime_131377.cs create mode 100644 src/tests/JIT/Regression/JitBlue/Runtime_131377/Runtime_131377.csproj diff --git a/src/tests/JIT/Regression/JitBlue/Runtime_131377/Runtime_131377.cs b/src/tests/JIT/Regression/JitBlue/Runtime_131377/Runtime_131377.cs new file mode 100644 index 00000000000000..00bf818ff1f318 --- /dev/null +++ b/src/tests/JIT/Regression/JitBlue/Runtime_131377/Runtime_131377.cs @@ -0,0 +1,55 @@ +// 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 object stack +// allocation. When a non-escaping box is stack allocated, ObjectAllocator +// rewrites the CORINFO_HELP_UNBOX call to CORINFO_HELP_UNBOX_TYPETEST. Unbox +// returns a byref while Unbox_TypeTest returns void, but the call node kept its +// byref type, so wasm emitted a value-returning call_indirect for a void helper +// and the engine trapped with "function signature mismatch". +// +// The mismatched call_indirect is guarded by an inline method table check that +// short-circuits the helper whenever the unbox type matches, so a same-type +// unbox compiles the bad instruction but never executes it. Driving a +// non-matching type through the same unbox site is what makes the slow path -- +// and the trap -- actually run. +// +// Reproduces only under crossgen wasm R2R (TargetOS=browser), where the unfixed +// JIT aborts the module before InvalidCastException can be thrown. Passes +// trivially on all other targets. + +using System; +using System.Runtime.CompilerServices; +using Xunit; + +public class Runtime_131377 +{ + [Fact] + public static void TestEntryPoint() + { + // Matching type: the box is stack allocated and the inline method table + // check succeeds, so the type-test helper is skipped. + Unbox(null); + + // Non-matching type: the inline check fails, so the type-test helper + // runs and reaches the call_indirect that trapped under the unfixed JIT. + Assert.Throws(() => Unbox("not a guid")); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void Unbox(object o) + { + if (o is null) + { + // Non-escaping box, so this is stack allocated. + o = new Guid(); + } + + Consume((Guid)o); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void Consume(T _) + { + } +} diff --git a/src/tests/JIT/Regression/JitBlue/Runtime_131377/Runtime_131377.csproj b/src/tests/JIT/Regression/JitBlue/Runtime_131377/Runtime_131377.csproj new file mode 100644 index 00000000000000..416bbcfbfa89b5 --- /dev/null +++ b/src/tests/JIT/Regression/JitBlue/Runtime_131377/Runtime_131377.csproj @@ -0,0 +1,19 @@ + + + + True + true + + true + + + + + From f2fec2e068c03bb64240fb40d9328a8e4550e353 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Sat, 25 Jul 2026 21:07:55 -0500 Subject: [PATCH 12/21] [wasm] Rename HelperUnboxRetType to make the discard intent explicit CastHelpers.Unbox returns a byref on every target, so a constant named HelperUnboxRetType that evaluates to TYP_VOID off wasm reads like the helper's actual return type and invites a future value-producing call site to model the unbox helper as void. Name it for what it is -- the type to model with when the result is discarded -- and say so. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 86fe3398-c7ec-4555-b4ba-c732819b4dc5 --- src/coreclr/jit/compiler.h | 8 +++++--- src/coreclr/jit/importer.cpp | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/coreclr/jit/compiler.h b/src/coreclr/jit/compiler.h index 44cb6a66101399..246e687cd4b386 100644 --- a/src/coreclr/jit/compiler.h +++ b/src/coreclr/jit/compiler.h @@ -3363,10 +3363,12 @@ class Compiler // result we discard must still be modeled as value-returning. InitClass/InitInstantiatedClass // return void*, CastHelpers.Unbox returns a byref. static constexpr var_types HelperInitClassRetType = TYP_I_IMPL; - static constexpr var_types HelperUnboxRetType = TYP_BYREF; + // Only for unbox sites that discard the result. CastHelpers.Unbox returns a byref on every + // target, so a site that consumes the result must model TYP_BYREF directly. + static constexpr var_types HelperUnboxDiscardedRetType = TYP_BYREF; #else - static constexpr var_types HelperInitClassRetType = TYP_VOID; - static constexpr var_types HelperUnboxRetType = TYP_VOID; + static constexpr var_types HelperInitClassRetType = TYP_VOID; + static constexpr var_types HelperUnboxDiscardedRetType = TYP_VOID; #endif // TARGET_WASM GenTreeCall* gtNewVirtualFunctionLookupHelperCallNode( diff --git a/src/coreclr/jit/importer.cpp b/src/coreclr/jit/importer.cpp index 735f7128881802..b189abe9a1afd4 100644 --- a/src/coreclr/jit/importer.cpp +++ b/src/coreclr/jit/importer.cpp @@ -10702,7 +10702,7 @@ void Compiler::impImportBlockCode(BasicBlock* block) } // The helper result is unused; the byref is formed from clone + TARGET_POINTER_SIZE // below. Discard it so the enclosing COLON/QMARK remain void. - op1 = gtUnusedValNode(gtNewHelperCallNode(helper, HelperUnboxRetType, op2, op1)); + op1 = gtUnusedValNode(gtNewHelperCallNode(helper, HelperUnboxDiscardedRetType, op2, op1)); op1 = new (this, GT_COLON) GenTreeColon(TYP_VOID, gtNewNothingNode(), op1); op1 = gtNewQmarkNode(TYP_VOID, condBox, op1->AsColon()); From 4331eb3c4e6e552d41ebb4f1892a3289656dc899 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Sun, 26 Jul 2026 10:01:30 -0500 Subject: [PATCH 13/21] Trim comments Keep only what the code doesn't say itself, per the house style in #131374. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 86fe3398-c7ec-4555-b4ba-c732819b4dc5 --- src/coreclr/jit/compiler.h | 8 +++--- src/coreclr/jit/importer.cpp | 4 +-- src/coreclr/jit/objectalloc.cpp | 2 +- .../JitBlue/Runtime_131377/Runtime_131377.cs | 25 +++---------------- .../Runtime_131377/Runtime_131377.csproj | 10 +------- 5 files changed, 10 insertions(+), 39 deletions(-) diff --git a/src/coreclr/jit/compiler.h b/src/coreclr/jit/compiler.h index 246e687cd4b386..8f031e64f77ba7 100644 --- a/src/coreclr/jit/compiler.h +++ b/src/coreclr/jit/compiler.h @@ -3359,12 +3359,10 @@ class Compiler unsigned helper, var_types type, GenTree* arg1 = nullptr, GenTree* arg2 = nullptr, GenTree* arg3 = nullptr, GenTree* arg4 = nullptr); #ifdef TARGET_WASM - // On wasm the emitted call signature is derived from the modeled return type, so helpers whose - // result we discard must still be modeled as value-returning. InitClass/InitInstantiatedClass - // return void*, CastHelpers.Unbox returns a byref. + // Wasm derives the call signature from the modeled return type, so helpers whose result we + // discard must still be modeled as value-returning. static constexpr var_types HelperInitClassRetType = TYP_I_IMPL; - // Only for unbox sites that discard the result. CastHelpers.Unbox returns a byref on every - // target, so a site that consumes the result must model TYP_BYREF directly. + // CastHelpers.Unbox returns a byref on every target; sites that consume it model TYP_BYREF. static constexpr var_types HelperUnboxDiscardedRetType = TYP_BYREF; #else static constexpr var_types HelperInitClassRetType = TYP_VOID; diff --git a/src/coreclr/jit/importer.cpp b/src/coreclr/jit/importer.cpp index b189abe9a1afd4..806c8334838b4e 100644 --- a/src/coreclr/jit/importer.cpp +++ b/src/coreclr/jit/importer.cpp @@ -10700,8 +10700,8 @@ void Compiler::impImportBlockCode(BasicBlock* block) { // compDonotInline() return; } - // The helper result is unused; the byref is formed from clone + TARGET_POINTER_SIZE - // below. Discard it so the enclosing COLON/QMARK remain void. + // The byref is formed from clone + TARGET_POINTER_SIZE below, so discard the + // helper result to keep the enclosing COLON/QMARK void. op1 = gtUnusedValNode(gtNewHelperCallNode(helper, HelperUnboxDiscardedRetType, op2, op1)); op1 = new (this, GT_COLON) GenTreeColon(TYP_VOID, gtNewNothingNode(), op1); diff --git a/src/coreclr/jit/objectalloc.cpp b/src/coreclr/jit/objectalloc.cpp index a624f6289a0bd7..bfcb0f4c7b3a96 100644 --- a/src/coreclr/jit/objectalloc.cpp +++ b/src/coreclr/jit/objectalloc.cpp @@ -2837,7 +2837,7 @@ void ObjectAllocator::RewriteUses() // JITDUMP("Rewriting to invoke box type test helper%s\n", isForEffect ? " for side effect" : ""); - // Unbox_TypeTest returns void, unlike Unbox. Note isForEffect above uses the + // Unbox_TypeTest returns void, unlike Unbox. isForEffect above reads the // original type. call->gtCallMethHnd = m_compiler->eeFindHelper(CORINFO_HELP_UNBOX_TYPETEST); call->gtType = TYP_VOID; diff --git a/src/tests/JIT/Regression/JitBlue/Runtime_131377/Runtime_131377.cs b/src/tests/JIT/Regression/JitBlue/Runtime_131377/Runtime_131377.cs index 00bf818ff1f318..73c926946e9697 100644 --- a/src/tests/JIT/Regression/JitBlue/Runtime_131377/Runtime_131377.cs +++ b/src/tests/JIT/Regression/JitBlue/Runtime_131377/Runtime_131377.cs @@ -1,22 +1,9 @@ // 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 object stack -// allocation. When a non-escaping box is stack allocated, ObjectAllocator -// rewrites the CORINFO_HELP_UNBOX call to CORINFO_HELP_UNBOX_TYPETEST. Unbox -// returns a byref while Unbox_TypeTest returns void, but the call node kept its -// byref type, so wasm emitted a value-returning call_indirect for a void helper -// and the engine trapped with "function signature mismatch". -// -// The mismatched call_indirect is guarded by an inline method table check that -// short-circuits the helper whenever the unbox type matches, so a same-type -// unbox compiles the bad instruction but never executes it. Driving a -// non-matching type through the same unbox site is what makes the slow path -- -// and the trap -- actually run. -// -// Reproduces only under crossgen wasm R2R (TargetOS=browser), where the unfixed -// JIT aborts the module before InvalidCastException can be thrown. Passes -// trivially on all other targets. +// Stack allocating a box retargets its unbox to the void UNBOX_TYPETEST helper, but the call kept +// its byref type, so wasm emitted a value-returning call_indirect and trapped. The non-Guid arg is +// load bearing: a same-type unbox skips the helper. Only reproduces under crossgen wasm R2R. using System; using System.Runtime.CompilerServices; @@ -27,12 +14,7 @@ public class Runtime_131377 [Fact] public static void TestEntryPoint() { - // Matching type: the box is stack allocated and the inline method table - // check succeeds, so the type-test helper is skipped. Unbox(null); - - // Non-matching type: the inline check fails, so the type-test helper - // runs and reaches the call_indirect that trapped under the unfixed JIT. Assert.Throws(() => Unbox("not a guid")); } @@ -41,7 +23,6 @@ private static void Unbox(object o) { if (o is null) { - // Non-escaping box, so this is stack allocated. o = new Guid(); } diff --git a/src/tests/JIT/Regression/JitBlue/Runtime_131377/Runtime_131377.csproj b/src/tests/JIT/Regression/JitBlue/Runtime_131377/Runtime_131377.csproj index 416bbcfbfa89b5..e278e04c124f97 100644 --- a/src/tests/JIT/Regression/JitBlue/Runtime_131377/Runtime_131377.csproj +++ b/src/tests/JIT/Regression/JitBlue/Runtime_131377/Runtime_131377.csproj @@ -1,16 +1,8 @@ - True true - + true From f2a71f6e7c8255ef6a560eeddb85951f347440c2 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Sun, 26 Jul 2026 10:43:22 -0500 Subject: [PATCH 14/21] Model the inline unbox helper as TYP_BYREF on all targets CastHelpers.Unbox returns a byref everywhere, and the non-inline path a few lines below already models it that way, with an assert to match. Modeling it per-target meant a constant whose non-wasm value described nothing about the helper. Drop the constant and use TYP_BYREF directly; the result is still discarded via gtUnusedValNode. compiler.h is untouched now: HelperInitClassRetType stays as #130813 shipped it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 86fe3398-c7ec-4555-b4ba-c732819b4dc5 --- src/coreclr/jit/compiler.h | 9 +++------ src/coreclr/jit/importer.cpp | 2 +- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/coreclr/jit/compiler.h b/src/coreclr/jit/compiler.h index 8f031e64f77ba7..56f79d07641ec3 100644 --- a/src/coreclr/jit/compiler.h +++ b/src/coreclr/jit/compiler.h @@ -3359,14 +3359,11 @@ class Compiler unsigned helper, var_types type, GenTree* arg1 = nullptr, GenTree* arg2 = nullptr, GenTree* arg3 = nullptr, GenTree* arg4 = nullptr); #ifdef TARGET_WASM - // Wasm derives the call signature from the modeled return type, so helpers whose result we - // discard must still be modeled as value-returning. + // On wasm these helpers return void* (InitHelpers.InitClass/InitInstantiatedClass). Model them as + // value-returning so the call_indirect signature matches the compiled managed helper; the value is unused. static constexpr var_types HelperInitClassRetType = TYP_I_IMPL; - // CastHelpers.Unbox returns a byref on every target; sites that consume it model TYP_BYREF. - static constexpr var_types HelperUnboxDiscardedRetType = TYP_BYREF; #else - static constexpr var_types HelperInitClassRetType = TYP_VOID; - static constexpr var_types HelperUnboxDiscardedRetType = TYP_VOID; + static constexpr var_types HelperInitClassRetType = TYP_VOID; #endif // TARGET_WASM GenTreeCall* gtNewVirtualFunctionLookupHelperCallNode( diff --git a/src/coreclr/jit/importer.cpp b/src/coreclr/jit/importer.cpp index 806c8334838b4e..e4f0cc0b6df904 100644 --- a/src/coreclr/jit/importer.cpp +++ b/src/coreclr/jit/importer.cpp @@ -10702,7 +10702,7 @@ void Compiler::impImportBlockCode(BasicBlock* block) } // The byref is formed from clone + TARGET_POINTER_SIZE below, so discard the // helper result to keep the enclosing COLON/QMARK void. - op1 = gtUnusedValNode(gtNewHelperCallNode(helper, HelperUnboxDiscardedRetType, op2, op1)); + op1 = gtUnusedValNode(gtNewHelperCallNode(helper, TYP_BYREF, op2, op1)); op1 = new (this, GT_COLON) GenTreeColon(TYP_VOID, gtNewNothingNode(), op1); op1 = gtNewQmarkNode(TYP_VOID, condBox, op1->AsColon()); From 60c1db7a462f308764ddb58bf62fde30ac41316e Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Sun, 26 Jul 2026 18:29:40 -0500 Subject: [PATCH 15/21] Model the discarded unbox helper as value-returning only on wasm SPMI showed the unconditional TYP_BYREF form costs code size on every native target -- code size regressions across linux arm64/x64, osx arm64, and windows arm64/x64, mostly at inline unbox sites such as Enum.ToObject and RuntimeType.IsEnumDefined -- for no benefit, since the result is discarded and only wasm derives the call signature from the modeled type. Restore the target-conditional constant. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 86fe3398-c7ec-4555-b4ba-c732819b4dc5 --- src/coreclr/jit/compiler.h | 6 +++++- src/coreclr/jit/importer.cpp | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/coreclr/jit/compiler.h b/src/coreclr/jit/compiler.h index 56f79d07641ec3..2e32fed1691a2f 100644 --- a/src/coreclr/jit/compiler.h +++ b/src/coreclr/jit/compiler.h @@ -3362,8 +3362,12 @@ class Compiler // On wasm these helpers return void* (InitHelpers.InitClass/InitInstantiatedClass). Model them as // value-returning so the call_indirect signature matches the compiled managed helper; the value is unused. static constexpr var_types HelperInitClassRetType = TYP_I_IMPL; + // Likewise for CastHelpers.Unbox at sites that discard the result. Modeling it TYP_BYREF off + // wasm is equally correct but costs code size for no benefit. + static constexpr var_types HelperUnboxDiscardedRetType = TYP_BYREF; #else - static constexpr var_types HelperInitClassRetType = TYP_VOID; + static constexpr var_types HelperInitClassRetType = TYP_VOID; + static constexpr var_types HelperUnboxDiscardedRetType = TYP_VOID; #endif // TARGET_WASM GenTreeCall* gtNewVirtualFunctionLookupHelperCallNode( diff --git a/src/coreclr/jit/importer.cpp b/src/coreclr/jit/importer.cpp index e4f0cc0b6df904..806c8334838b4e 100644 --- a/src/coreclr/jit/importer.cpp +++ b/src/coreclr/jit/importer.cpp @@ -10702,7 +10702,7 @@ void Compiler::impImportBlockCode(BasicBlock* block) } // The byref is formed from clone + TARGET_POINTER_SIZE below, so discard the // helper result to keep the enclosing COLON/QMARK void. - op1 = gtUnusedValNode(gtNewHelperCallNode(helper, TYP_BYREF, op2, op1)); + op1 = gtUnusedValNode(gtNewHelperCallNode(helper, HelperUnboxDiscardedRetType, op2, op1)); op1 = new (this, GT_COLON) GenTreeColon(TYP_VOID, gtNewNothingNode(), op1); op1 = gtNewQmarkNode(TYP_VOID, condBox, op1->AsColon()); From 94a357249b553619b8e1c4d37c311afc5c0ff21d Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Mon, 27 Jul 2026 08:44:02 -0500 Subject: [PATCH 16/21] Drop the gtUnusedValNode wrapper in fgMorphInit A bare value-producing call is a valid statement root; CEE_POP skips the wrapper for calls via an explicit !OperIs(GT_CALL) guard. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 86fe3398-c7ec-4555-b4ba-c732819b4dc5 --- src/coreclr/jit/morph.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/coreclr/jit/morph.cpp b/src/coreclr/jit/morph.cpp index cf812722a7dba9..56ee7c3ddad5b6 100644 --- a/src/coreclr/jit/morph.cpp +++ b/src/coreclr/jit/morph.cpp @@ -64,7 +64,7 @@ PhaseStatus Compiler::fgMorphInit() { // confirm that the argument is a GC pointer (for debugging (GC stress)) GenTree* op = gtNewLclvNode(i, TYP_REF); - op = gtUnusedValNode(gtNewHelperCallNode(CORINFO_HELP_CHECK_OBJ, TYP_REF, op)); + op = gtNewHelperCallNode(CORINFO_HELP_CHECK_OBJ, TYP_REF, op); fgNewStmtAtBeg(fgFirstBB, op); madeChanges = true; From f6aa26d7008d699344d7944007856795324e118c Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Mon, 27 Jul 2026 13:30:59 -0500 Subject: [PATCH 17/21] Fix argument order for the box type test helper Unbox_TypeTest(pMT1, pMT2) forwards to ThrowInvalidCastException(from, to), so pMT1 is the source type. The rewrite removed the object arg and appended the method table, producing (type, objMT) and naming the types backwards in the exception message -- the reverse of the shape the comment above it describes. Casting a string to Guid through a stack allocated box reported "Unable to cast object of type 'System.Guid' to type 'System.String'", where the ordinary unbox path reports "'System.String' to 'System.Guid'". Both now agree. The test itself is symmetric, so only the message was affected. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 86fe3398-c7ec-4555-b4ba-c732819b4dc5 --- src/coreclr/jit/objectalloc.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/coreclr/jit/objectalloc.cpp b/src/coreclr/jit/objectalloc.cpp index bfcb0f4c7b3a96..a439c247a82239 100644 --- a/src/coreclr/jit/objectalloc.cpp +++ b/src/coreclr/jit/objectalloc.cpp @@ -2844,7 +2844,7 @@ void ObjectAllocator::RewriteUses() call->gtReturnType = TYP_VOID; GenTree* const mt = m_compiler->gtNewMethodTableLookup(lcl, /* onStack */ true); call->gtArgs.Remove(secondArg); - call->gtArgs.PushBack(m_compiler, NewCallArg::Primitive(mt)); + call->gtArgs.PushFront(m_compiler, NewCallArg::Primitive(mt)); if (isForEffect) { From f226e24ca906a5427a32e2b3eb732826ce7c84c7 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Mon, 27 Jul 2026 14:11:17 -0500 Subject: [PATCH 18/21] Make Runtime_131377 a normal merged test, comment the arg swap Drop the per-test csproj and its AlwaysUseCrossGen2 forcing; outerloop runs already cover the crossgen config. The .cs joins Regression_o_2, which builds with Optimize=True as object stack allocation requires. Also explain why the type test helper's arguments are swapped relative to the unbox helper's. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 86fe3398-c7ec-4555-b4ba-c732819b4dc5 --- src/coreclr/jit/objectalloc.cpp | 2 ++ .../JitBlue/Runtime_131377/Runtime_131377.csproj | 11 ----------- src/tests/JIT/Regression/Regression_o_2.csproj | 1 + 3 files changed, 3 insertions(+), 11 deletions(-) delete mode 100644 src/tests/JIT/Regression/JitBlue/Runtime_131377/Runtime_131377.csproj diff --git a/src/coreclr/jit/objectalloc.cpp b/src/coreclr/jit/objectalloc.cpp index a439c247a82239..cb6c33e2f1a302 100644 --- a/src/coreclr/jit/objectalloc.cpp +++ b/src/coreclr/jit/objectalloc.cpp @@ -2843,6 +2843,8 @@ void ObjectAllocator::RewriteUses() call->gtType = TYP_VOID; call->gtReturnType = TYP_VOID; GenTree* const mt = m_compiler->gtNewMethodTableLookup(lcl, /* onStack */ true); + // Unbox takes (target, obj) but Unbox_TypeTest takes (source, target), and names + // them in that order in the cast exception message. call->gtArgs.Remove(secondArg); call->gtArgs.PushFront(m_compiler, NewCallArg::Primitive(mt)); diff --git a/src/tests/JIT/Regression/JitBlue/Runtime_131377/Runtime_131377.csproj b/src/tests/JIT/Regression/JitBlue/Runtime_131377/Runtime_131377.csproj deleted file mode 100644 index e278e04c124f97..00000000000000 --- a/src/tests/JIT/Regression/JitBlue/Runtime_131377/Runtime_131377.csproj +++ /dev/null @@ -1,11 +0,0 @@ - - - True - true - - true - - - - - diff --git a/src/tests/JIT/Regression/Regression_o_2.csproj b/src/tests/JIT/Regression/Regression_o_2.csproj index 1958b8b1b006cf..3b62a711a25445 100644 --- a/src/tests/JIT/Regression/Regression_o_2.csproj +++ b/src/tests/JIT/Regression/Regression_o_2.csproj @@ -127,6 +127,7 @@ + From a53d8e140873ce81f9f39922bdba348cd771a4cb Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Tue, 28 Jul 2026 11:09:00 -0500 Subject: [PATCH 19/21] Match RhUnboxTypeTest to the new Unbox_TypeTest argument order The JIT now passes (source, target) to CORINFO_HELP_UNBOX_TYPETEST so the CoreCLR cast exception names the types in the right order. NativeAOT's RhUnboxTypeTest is not symmetric the way CastHelpers.Unbox_TypeTest is: it asserts the first argument is a value type and resolves the exception from it, so the swap tripped Debug.Assert(pType->IsValueType) on every stack-allocated box unbox, e.g. Loader/classloader/generics/ByRefLike/Validate. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 77f83e48-ef2e-4f33-b6f4-5c20e251b20d --- .../Runtime.Base/src/System/Runtime/RuntimeExports.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/coreclr/nativeaot/Runtime.Base/src/System/Runtime/RuntimeExports.cs b/src/coreclr/nativeaot/Runtime.Base/src/System/Runtime/RuntimeExports.cs index 63a9a23f6e73aa..8207d014ed275b 100644 --- a/src/coreclr/nativeaot/Runtime.Base/src/System/Runtime/RuntimeExports.cs +++ b/src/coreclr/nativeaot/Runtime.Base/src/System/Runtime/RuntimeExports.cs @@ -231,7 +231,9 @@ public static unsafe void RhUnboxNullable(ref byte data, MethodTable* pUnboxToEE RhUnbox(obj, ref data, pUnboxToEEType); } - public static unsafe void RhUnboxTypeTest(MethodTable* pType, MethodTable* pBoxType) + // Takes (source, target) to match CoreCLR's CastHelpers.Unbox_TypeTest, which names them in + // that order in the cast exception message. + public static unsafe void RhUnboxTypeTest(MethodTable* pBoxType, MethodTable* pType) { Debug.Assert(pType->IsValueType); From 4a20aa8e1384d9dddcfe2e89f098cb203b017d7d Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Tue, 28 Jul 2026 12:04:41 -0500 Subject: [PATCH 20/21] Revert "Match RhUnboxTypeTest to the new Unbox_TypeTest argument order" This reverts commit a53d8e140873ce81f9f39922bdba348cd771a4cb. --- .../Runtime.Base/src/System/Runtime/RuntimeExports.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/coreclr/nativeaot/Runtime.Base/src/System/Runtime/RuntimeExports.cs b/src/coreclr/nativeaot/Runtime.Base/src/System/Runtime/RuntimeExports.cs index 8207d014ed275b..63a9a23f6e73aa 100644 --- a/src/coreclr/nativeaot/Runtime.Base/src/System/Runtime/RuntimeExports.cs +++ b/src/coreclr/nativeaot/Runtime.Base/src/System/Runtime/RuntimeExports.cs @@ -231,9 +231,7 @@ public static unsafe void RhUnboxNullable(ref byte data, MethodTable* pUnboxToEE RhUnbox(obj, ref data, pUnboxToEEType); } - // Takes (source, target) to match CoreCLR's CastHelpers.Unbox_TypeTest, which names them in - // that order in the cast exception message. - public static unsafe void RhUnboxTypeTest(MethodTable* pBoxType, MethodTable* pType) + public static unsafe void RhUnboxTypeTest(MethodTable* pType, MethodTable* pBoxType) { Debug.Assert(pType->IsValueType); From 2247634110fac3da3c8c44fca4ed878a1a29abbe Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Tue, 28 Jul 2026 12:42:06 -0500 Subject: [PATCH 21/21] Fix the reversed unbox cast message in Unbox_TypeTest_Helper Restore the original (target, source) argument order the JIT passes to CORINFO_HELP_UNBOX_TYPETEST, which NativeAOT's RhUnboxTypeTest also expects, and instead swap the arguments where the message is actually built. ThrowInvalidCastException takes (source, target). Also correct the JIT comment describing the rewritten call, which had the operands the wrong way round and prompted the earlier fix attempt. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 77f83e48-ef2e-4f33-b6f4-5c20e251b20d --- .../src/System/Runtime/CompilerServices/CastHelpers.cs | 4 +++- src/coreclr/jit/objectalloc.cpp | 6 ++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/CastHelpers.cs b/src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/CastHelpers.cs index 3723dccb9b3211..52d3a6234aee98 100644 --- a/src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/CastHelpers.cs +++ b/src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/CastHelpers.cs @@ -695,7 +695,9 @@ private static void Unbox_TypeTest_Helper(MethodTable *pMT1, MethodTable *pMT2) #endif // FEATURE_TYPEEQUIVALENCE ) { - CastHelpers.ThrowInvalidCastException(pMT1, pMT2); + // The JIT passes (target, source) to match Unbox, but ThrowInvalidCastException + // takes (source, target) and names them in that order in the message. + CastHelpers.ThrowInvalidCastException(pMT2, pMT1); } } diff --git a/src/coreclr/jit/objectalloc.cpp b/src/coreclr/jit/objectalloc.cpp index cb6c33e2f1a302..923cc274700cdd 100644 --- a/src/coreclr/jit/objectalloc.cpp +++ b/src/coreclr/jit/objectalloc.cpp @@ -2832,7 +2832,7 @@ void ObjectAllocator::RewriteUses() // Rewrite the call to make the box accesses explicit in jitted code. // user = COMMA( - // CALL(UNBOX_HELPER_TYPETEST, obj->MethodTable, type), + // CALL(UNBOX_HELPER_TYPETEST, type, obj->MethodTable), // ADD(obj, TARGET_POINTER_SIZE)) // JITDUMP("Rewriting to invoke box type test helper%s\n", isForEffect ? " for side effect" : ""); @@ -2843,10 +2843,8 @@ void ObjectAllocator::RewriteUses() call->gtType = TYP_VOID; call->gtReturnType = TYP_VOID; GenTree* const mt = m_compiler->gtNewMethodTableLookup(lcl, /* onStack */ true); - // Unbox takes (target, obj) but Unbox_TypeTest takes (source, target), and names - // them in that order in the cast exception message. call->gtArgs.Remove(secondArg); - call->gtArgs.PushFront(m_compiler, NewCallArg::Primitive(mt)); + call->gtArgs.PushBack(m_compiler, NewCallArg::Primitive(mt)); if (isForEffect) {