From d1d3737f88c66726f4d1be4bd3482b2d094f6f5b Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Sat, 25 Jul 2026 19:07:48 -0500 Subject: [PATCH 1/4] [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 2/4] [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 3/4] [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 4/4] [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());