[wasm] Fix JIT helper calls modeled with the wrong return arity#131383
Draft
lewing wants to merge 4 commits into
Draft
[wasm] Fix JIT helper calls modeled with the wrong return arity#131383lewing wants to merge 4 commits into
lewing wants to merge 4 commits into
Conversation
… 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 dotnet#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
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 dotnet#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
|
Azure Pipelines: Successfully started running 5 pipeline(s). 11 pipeline(s) were filtered out due to trigger conditions. There may be pipelines that require an authorized user to comment /azp run to run. |
Contributor
|
Tagging subscribers to this area: @JulieLeeMSFT, @jakobbotsch |
Contributor
There was a problem hiding this comment.
Pull request overview
This PR corrects several CoreCLR JIT helper-call nodes whose modeled return type didn’t match the actual helper signature, which is especially problematic for wasm where the emitted call signature is derived from the modeled return type.
Changes:
- Retypes an
UNBOX→UNBOX_TYPETESThelper retarget inObjectAllocator::RewriteUsesto ensure the call node becomesTYP_VOID. - Updates
fgMorphInitGC-check insertion to modelCORINFO_HELP_CHECK_OBJas value-returning and explicitly discard the result. - Updates the inline
unboxslow-path helper call to use a wasm-appropriate modeled return type and discard the result viagtUnusedValNode. - Introduces a wasm-specific modeled return type constant for the unbox helper to centralize this choice.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| src/coreclr/jit/objectalloc.cpp | Retypes retargeted UNBOX_TYPETEST helper calls to TYP_VOID to match helper signature. |
| src/coreclr/jit/morph.cpp | Models CHECK_OBJ as TYP_REF and discards the unused result to keep statements effectively void. |
| src/coreclr/jit/importer.cpp | Models inline-unbox slow-path helper calls with a wasm-correct return type and discards the value. |
| src/coreclr/jit/compiler.h | Adds a wasm-specific constant to drive modeled helper return type for discarded unbox results. |
…lper 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
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
Comment on lines
+2
to
+7
| <PropertyGroup> | ||
| <!-- Object stack allocation only runs with optimizations enabled, and the | ||
| rewrite it performs is what introduces the bad call signature. --> | ||
| <Optimize>True</Optimize> | ||
| <JitOptimizationSensitive>true</JitOptimizationSensitive> | ||
| <!-- The bug is wasm R2R (crossgen) codegen. On non-browser legs this runs as |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Three JIT helper calls on wasm were modeled with a return arity that does not match the helper they call. On wasm the emitted call signature is derived from the JIT-modeled return type in
CodeGen::genCallInstruction:so the common "model a value-returning helper as
TYP_VOIDbecause the result is unused" shortcut — harmless on register machines — produces afunction signature mismatchengine trap. This is the same class of defect as #130813, which fixed it for the class-init helpers.1. Inline-unbox slow path (
importer.cpp)The inline expansion of
unboxlowers to(*clone == typeToken) ? nop : CORINFO_HELP_UNBOX(clone, typeToken)and created the slow-path helper call asTYP_VOID, because the unboxed byref is formed separately fromclone + TARGET_POINTER_SIZE. ButCastHelpers.Unboxreturnsref byte, so this emitted a(...)->()call_indirectfor a helper the runtime dispatches through an i32-returning thunk — e.g. on the first cold unbox inSystem.Enum.ToObjectduring reflection-heavy startup.Follows #130813's shape with a
HelperUnboxRetTypeconstant, and discards the unused result viagtUnusedValNodeso the enclosingCOLON/QMARKremain void. On targets where the helper staysTYP_VOIDtheCOMMAfolds away in morph. The non-inline path in the same switch already modeled this helper asTYP_BYREF, so the two paths are now consistent.2.
ObjectAllocator::RewriteUses(objectalloc.cpp) — fixes #131377This pass retargets a
CORINFO_HELP_UNBOXcall node toCORINFO_HELP_UNBOX_TYPETESTwhen the unboxed object is a stack-allocated box, but never retyped the node.CastHelpers.Unboxreturnsref bytewhileCastHelpers.Unbox_TypeTestreturnsvoid, so the node was left claiming a byref return for a void helper.isForEffectis still computed from the original type so the existingCOMMA(call, ADD(obj, TARGET_POINTER_SIZE))shape is preserved.The two commits must ship together. Change 1 is what makes this reachable: previously the inline-unbox call arrived at this pass as
TYP_VOID, soisForEffectwas true and the void model matchedUnbox_TypeTestby accident. This was confirmed experimentally — see validation below.3.
CORINFO_HELP_CHECK_OBJinfgMorphInit(morph.cpp) — fixes #131378JIT_CheckObjreturnsObject*, andimporter.cppalready models it asTYP_REF; onlyfgMorphInitusedTYP_VOID. Only fires underDOTNET_JitGCChecks.This one is defensive — it has no reachable runtime manifestation today, and the issue as I originally filed it overstated the impact.
CORINFO_HELP_CHECK_OBJis not handled anywhere inCorInfoImpl.ReadyToRun.cs, so it falls through todefault: throw new NotImplementedException— it is not even in the gracefulRequiresRuntimeJitExceptionlist. Any method that would emitCHECK_OBJtherefore fails R2R codegen outright, on every target, long before return-arity modeling reachescall_indirectemission:So no R2R image containing a
CHECK_OBJcall can be built, and wasm has no runtime JIT (R2R + interpreter only), so there is no other path where the modeling would bite. The change is kept because it aligns the model withJIT_CheckObj's actualObject*return and with the model the importer already uses for the same helper, and it would become live ifCHECK_OBJever gains R2R support.Notes for reviewers
objectalloc.cppchange is deliberately not wasm-guarded. The node was mistyped on every target; it is simply benign on register machines, where the unused return value is left in the return register. Correcting it there too seemed better than adding a target-specific divergence, but this is the part of the PR with broad blast radius and is the main thing worth a second opinion.TYP_VOID/ effective-void modeling, and the addedgtUnusedValNodewrapper collapses back to the bare call in morph when the call isTYP_VOID.Validation
Verified on a browser R2R rig (there is no wasm-R2R CI leg, so this cannot be validated by CI alone).
#131377 proven as a real runtime trap, statically and dynamically. Disassembling the stack-allocated unbox method shows the defect directly at the
UNBOX_TYPETESTsite:call_indirect (type 5)+drop— i32-returning signature for a void helpercall_indirect (type 4)— void, no dropThe images differ only at that site. Runtime differential across three JIT states, forced through the helper:
function signature mismatchtrap, ec=1, 3/3InvalidCastException(caught) → ec=100, 3/3The third row confirms change 1 alone regresses this path, hence the ship-together requirement.
Regression sweep, both commits combined:
System.Text.Jsondiscovery 12166/12166 with sig-mismatch=0, nre=0, oob=0 across seeds 1/42/99;Unsafe128/128 andBcl.Memory550/550 with both R2R-off and R2R-on, trap=0. TheHelperUnboxRetType+gtUnusedValNoderefactor produces behavior identical to the original inline#ifdefform.Build/format:
clr.alljitsand theclrjit_universal_wasmcross-JIT build clean in Checked and Release on osx-arm64, 0 warnings;clang-format(repo's own, 17.0.6) reports 0 replacements.Native (arm64) before/after
I was initially unsure whether the un-guarded
objectalloc.cppchange affects native codegen at all. It does, on one narrow path, and the effect is measurable.On the hot path the node already arrives as
TYP_VOIDon native (the importer's inline-unbox expansion), soisForEffectis true and the retype is a no-op — fixed and unfixed JITs produce identical trees. But when the unbox lands in a rarely-run block the importer takes the non-inline path, which models the helperTYP_BYREF, and the bug reproduces on arm64:CALL help byref CORINFO_HELP_UNBOX_TYPETESTCALL help void CORINFO_HELP_UNBOX_TYPETESTDiffing the full arm64 dumps for that method, with ASLR address noise filtered out, the only differences are:
REG x0→REG NA), andByref regs: 0000 {} => 0001 {x0}/Byref regs: 0001 {x0} => 0000 {}.Total bytes of code 236, instruction count 59is identical in both. So the unfixed JIT was briefly reportingx0as a live byref in GC info across a call to a helper that returns nothing. It is dead immediately, so it is not practically observable, but it is bogus GC info rather than a purely cosmetic modeling wart.Practical upshot for review: expect GC-info diffs from SPMI, not instruction diffs.
Still outstanding
NotImplementedExceptionabove — not merely untested. Verified by building the Checked wasm JIT and attempting to crossgen a GC-ref-param method with--codegenopt:JitGCChecks=1.jit-format/clang-tidy run.Regression test
Added
src/tests/JIT/Regression/JitBlue/Runtime_131377.The existing
src/tests/JIT/opt/ObjectStackAllocation/ObjectStackAllocationTests.csCase3was written to triggerCORINFO_HELP_UNBOX_TYPETEST, but as written it would not catch this trap even under R2R. Thecall_indirectis guarded by an inline method table check that short-circuits the helper whenever the unbox type matches.Case3is only ever called withnull, which stack-allocates aGuidand takes the matching path, so the mismatched instruction is compiled but never executed. It also cannot simply be extended in place:ZeroAllocTestcapturesGC.GetAllocatedBytesForCurrentThread()once and asserts zero allocation after each case, so allocating a non-matching object there would break the assertion for every subsequent case. That project additionally sets<CrossGenTest>false</CrossGenTest>(#109314), so it gets no R2R coverage today.The new test drives a non-matching type through the same unbox site so the type-test slow path actually executes, and sets
AlwaysUseCrossGen2forTargetOS=browserso the browser leg compiles it to wasm R2R instead of running it as ordinary JIT and false-passing.Verified locally on osx-arm64 Checked that it compiles, that the assertion is correct (
InvalidCastExceptionis thrown, exit 100), and — via JIT dump — that it genuinely exercises the path:Allocating V03 on the stackfollowed byRewriting to invoke box type test helper.Confirmed to be a real guard on the browser R2R rig, crossgen'd standalone (
--targetos:browser --targetarch:wasm -O) against each JIT and run under WasmTestRunner + node, 3x each:Tests run: 1, Passed: 1, Failed: 0, ec=0, sig-mismatch=0 — 3/3function signature mismatch, module aborts before any result, ec=1 — 3/3On the unfixed JIT the engine traps at the
call_indirectbeforeAssert.Throwsever runs, so the test fails as a crash rather than an assertion failure. The two test images differ only at theUNBOX_TYPETESTsite.Follow-up
#131379 tracks the systemic gap: nothing validates modeled helper return arity against the real helper signature, which is why this class of bug has now been found three times by runtime traps rather than at JIT time.
Contributes to #129622.
Note
This PR was created with assistance from GitHub Copilot (AI).