From d037ae32c3c047ec91461481effb71a00e564632 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Fri, 16 Oct 2026 13:40:30 -0500 Subject: [PATCH 1/7] JIT: treat throwing trees as GC safe points on wasm for zero-init removal optRemoveRedundantZeroInits marks a GC local as explicitly initialized, so the prolog doesn't zero it, when no GC safe point precedes its first store. That's sound where tracked locals are only reported while live, but wasm reports every GC slot on the frame for the whole method, including aborted frames during exception dispatch. Wasm also has no hardware exceptions: a null check or other throwing tree becomes a call to a throw helper. So a GC ref local stored after a null check (e.g. Object.GetType's temps) was reported with whatever the stack held when the check threw, which failed pMT->Validate() in TypeIntrinsics_r on the browser-wasm R2R leg. On wasm, IsPotentialGCSafePoint now treats trees with GTF_EXCEPT as safe points. System.Private.CoreLib.wasm grows by 6,944 bytes (0.02%). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 45f42362-f012-4030-8643-26f4c1fbf2e1 --- src/coreclr/jit/compiler.hpp | 10 +++ .../JIT/Regression_ro_2/Runtime_135966.cs | 77 +++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 src/tests/JIT/Regression_ro_2/Runtime_135966.cs diff --git a/src/coreclr/jit/compiler.hpp b/src/coreclr/jit/compiler.hpp index 9db68788f7209d..8f5c50e978d2f1 100644 --- a/src/coreclr/jit/compiler.hpp +++ b/src/coreclr/jit/compiler.hpp @@ -3319,6 +3319,16 @@ inline bool Compiler::IsPotentialGCSafePoint(GenTree* tree) const } } +#ifdef TARGET_WASM + // Wasm has no hardware exceptions, so a tree that may throw becomes a call to a throw helper. + // Wasm also reports every GC slot on the frame for the whole method, including while the + // exception is dispatched, so such a tree is a gc safe point for all of them. + if ((tree->gtFlags & GTF_EXCEPT) != 0) + { + return true; + } +#endif // TARGET_WASM + // TYP_STRUCT-typed stores might be converted into calls (with gc safe points) in Lower. // This is quite a conservative fix as it's hard to prove Lower won't do it at this point. if (tree->OperIsLocalStore()) diff --git a/src/tests/JIT/Regression_ro_2/Runtime_135966.cs b/src/tests/JIT/Regression_ro_2/Runtime_135966.cs new file mode 100644 index 00000000000000..4a841bdd066ecf --- /dev/null +++ b/src/tests/JIT/Regression_ro_2/Runtime_135966.cs @@ -0,0 +1,77 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Runtime.CompilerServices; +using Xunit; + +// Victim's GC ref local is first stored after a null check that throws. The garbage +// left on the stack by DirtyStack must not be reported for it when a GC happens +// while the exception is dispatched. +public class Runtime_135966 +{ + private class Holder + { + public object Value = new object(); + } + + [InlineArray(32)] + private struct Garbage + { + private nint _element; + } + + [Fact] + public static void TestEntryPoint() + { + for (int i = 0; i < 10; i++) + { + DirtyStack(); + + bool caught = false; + try + { + Victim(null); + } + catch (NullReferenceException) when (Collect()) + { + caught = true; + } + + Assert.True(caught); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void DirtyStack() + { + Garbage garbage = default; + Span span = garbage; + span.Fill(0x1000); + Consume(span); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void Consume(Span span) + { + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static object Victim(Holder holder) + { + object value = holder.Value; + KeepAlive(value); + return value; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void KeepAlive(object value) + { + } + + private static bool Collect() + { + GC.Collect(); + return true; + } +} From b1bc76109115c683c66b610805d47a0c0a3ba41b Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Fri, 16 Oct 2026 13:56:09 -0500 Subject: [PATCH 2/7] Tighten comments for wasm throwing-tree safe points Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 45f42362-f012-4030-8643-26f4c1fbf2e1 --- src/coreclr/jit/compiler.hpp | 6 +++--- src/tests/JIT/Regression_ro_2/Runtime_135966.cs | 5 ++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/coreclr/jit/compiler.hpp b/src/coreclr/jit/compiler.hpp index 8f5c50e978d2f1..9a4c26d771742d 100644 --- a/src/coreclr/jit/compiler.hpp +++ b/src/coreclr/jit/compiler.hpp @@ -3320,9 +3320,9 @@ inline bool Compiler::IsPotentialGCSafePoint(GenTree* tree) const } #ifdef TARGET_WASM - // Wasm has no hardware exceptions, so a tree that may throw becomes a call to a throw helper. - // Wasm also reports every GC slot on the frame for the whole method, including while the - // exception is dispatched, so such a tree is a gc safe point for all of them. + // On wasm a tree that may throw calls a throw helper (see StackLevelSetter::MayUseThrowHelperBlock), + // and every GC slot on the frame is reported for the whole method, including while the exception + // is dispatched. if ((tree->gtFlags & GTF_EXCEPT) != 0) { return true; diff --git a/src/tests/JIT/Regression_ro_2/Runtime_135966.cs b/src/tests/JIT/Regression_ro_2/Runtime_135966.cs index 4a841bdd066ecf..1a5dd0350babee 100644 --- a/src/tests/JIT/Regression_ro_2/Runtime_135966.cs +++ b/src/tests/JIT/Regression_ro_2/Runtime_135966.cs @@ -5,9 +5,8 @@ using System.Runtime.CompilerServices; using Xunit; -// Victim's GC ref local is first stored after a null check that throws. The garbage -// left on the stack by DirtyStack must not be reported for it when a GC happens -// while the exception is dispatched. +// Victim's null check throws before its GC ref local is stored, so a GC while the +// exception is dispatched must not see DirtyStack's garbage in that local. public class Runtime_135966 { private class Holder From c9379083381dc11e8860f944c2e7fa43e683c5dd Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Fri, 16 Oct 2026 14:02:13 -0500 Subject: [PATCH 3/7] Describe the wasm GC-local reporting invariant precisely Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 45f42362-f012-4030-8643-26f4c1fbf2e1 --- src/coreclr/jit/compiler.hpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/coreclr/jit/compiler.hpp b/src/coreclr/jit/compiler.hpp index 9a4c26d771742d..88518eb24f1744 100644 --- a/src/coreclr/jit/compiler.hpp +++ b/src/coreclr/jit/compiler.hpp @@ -3320,9 +3320,9 @@ inline bool Compiler::IsPotentialGCSafePoint(GenTree* tree) const } #ifdef TARGET_WASM - // On wasm a tree that may throw calls a throw helper (see StackLevelSetter::MayUseThrowHelperBlock), - // and every GC slot on the frame is reported for the whole method, including while the exception - // is dispatched. + // On wasm, GC locals on the frame are reported as untracked slots, including for aborted frames, + // and a tree that may throw can call a throw helper (see StackLevelSetter::MayUseThrowHelperBlock) + // before a local's first store. if ((tree->gtFlags & GTF_EXCEPT) != 0) { return true; From e8083cd4d32b5781675ba3118300e7235d22eb4e Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Fri, 16 Oct 2026 14:45:09 -0500 Subject: [PATCH 4/7] JIT: zero-init untracked GC locals stored after a throwing tree on all targets Move the fix from IsPotentialGCSafePoint into optRemoveRedundantZeroInits and make it target-independent. A GC local reported as an untracked slot is reported whenever its frame is, including at a throw helper call that a tree before the local's first store may make. Natively, an address-exposed object local stored after a bounds check was reported uninitialized at the RNGCHKFAIL call, and the test now crashes without the fix on osx-arm64 even without GC stress. Wasm reports every GC local on the frame as untracked, so its codegen is unchanged from the previous commits. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 45f42362-f012-4030-8643-26f4c1fbf2e1 --- src/coreclr/jit/compiler.hpp | 10 ----- src/coreclr/jit/optimizer.cpp | 18 ++++++++- .../JIT/Regression_ro_2/Runtime_135966.cs | 39 +++++++++++++++++-- 3 files changed, 51 insertions(+), 16 deletions(-) diff --git a/src/coreclr/jit/compiler.hpp b/src/coreclr/jit/compiler.hpp index 88518eb24f1744..9db68788f7209d 100644 --- a/src/coreclr/jit/compiler.hpp +++ b/src/coreclr/jit/compiler.hpp @@ -3319,16 +3319,6 @@ inline bool Compiler::IsPotentialGCSafePoint(GenTree* tree) const } } -#ifdef TARGET_WASM - // On wasm, GC locals on the frame are reported as untracked slots, including for aborted frames, - // and a tree that may throw can call a throw helper (see StackLevelSetter::MayUseThrowHelperBlock) - // before a local's first store. - if ((tree->gtFlags & GTF_EXCEPT) != 0) - { - return true; - } -#endif // TARGET_WASM - // TYP_STRUCT-typed stores might be converted into calls (with gc safe points) in Lower. // This is quite a conservative fix as it's hard to prove Lower won't do it at this point. if (tree->OperIsLocalStore()) diff --git a/src/coreclr/jit/optimizer.cpp b/src/coreclr/jit/optimizer.cpp index d20fd07dfc18e2..e3f9baec2cf006 100644 --- a/src/coreclr/jit/optimizer.cpp +++ b/src/coreclr/jit/optimizer.cpp @@ -5696,6 +5696,8 @@ typedef JitHashTable, unsigned> Lc // the store is to a local (and not a field), // the local is not lvLiveInOutOfHndlr or no exceptions can be thrown between the prolog and the store, // either the local has no gc pointers or there are no gc-safe points between the prolog and the store, +// and, if the local is reported as an untracked gc slot, no exceptions can be thrown between the prolog +// and the store, // then the local is marked with lvHasExplicitInit which tells the codegen not to insert zero initialization // for this local in the prolog. // @@ -5714,6 +5716,7 @@ void Compiler::optRemoveRedundantZeroInits() BitVec zeroInitLocals = BitVecOps::MakeEmpty(&bitVecTraits); bool hasGCSafePoint = false; bool hasImplicitControlFlow = false; + bool mayThrow = false; assert(fgNodeThreading == NodeThreading::AllTrees); @@ -5764,7 +5767,9 @@ void Compiler::optRemoveRedundantZeroInits() Statement* next = stmt->GetNextStmt(); for (GenTree* const tree : stmt->TreeList()) { - hasImplicitControlFlow |= hasEHSuccs && ((tree->gtFlags & GTF_EXCEPT) != 0); + const bool treeMayThrow = (tree->gtFlags & GTF_EXCEPT) != 0; + hasImplicitControlFlow |= hasEHSuccs && treeMayThrow; + mayThrow |= treeMayThrow; hasGCSafePoint |= IsPotentialGCSafePoint(tree); switch (tree->gtOper) @@ -5922,7 +5927,16 @@ void Compiler::optRemoveRedundantZeroInits() // insert a call to CORINFO_HELP_INIT_PINVOKE_FRAME but that is not a gc-safe point. assert(s_helperCallProperties.IsNoGC(CORINFO_HELP_INIT_PINVOKE_FRAME)); - if (!lclDsc->HasGCPtr() || (!GetInterruptible() && !hasGCSafePoint)) +#ifdef TARGET_WASM + // Wasm reports all gc locals on the frame as untracked slots. + const bool reportedAsUntracked = true; +#else + const bool reportedAsUntracked = !lclDsc->lvTracked; +#endif + // An untracked gc slot is reported whenever its frame is, including from + // a throw helper that a tree before this store may call. + if (!lclDsc->HasGCPtr() || + (!GetInterruptible() && !hasGCSafePoint && !(mayThrow && reportedAsUntracked))) { // The local hasn't been used and won't be reported to the gc between // the prolog and this explicit initialization. Therefore, it doesn't diff --git a/src/tests/JIT/Regression_ro_2/Runtime_135966.cs b/src/tests/JIT/Regression_ro_2/Runtime_135966.cs index 1a5dd0350babee..5c4a09fc0f73a0 100644 --- a/src/tests/JIT/Regression_ro_2/Runtime_135966.cs +++ b/src/tests/JIT/Regression_ro_2/Runtime_135966.cs @@ -5,8 +5,8 @@ using System.Runtime.CompilerServices; using Xunit; -// Victim's null check throws before its GC ref local is stored, so a GC while the -// exception is dispatched must not see DirtyStack's garbage in that local. +// Each victim throws before its GC ref local is first stored, so a GC while the throw +// helper runs or the exception is dispatched must not see DirtyStack's garbage in it. public class Runtime_135966 { private class Holder @@ -23,6 +23,8 @@ private struct Garbage [Fact] public static void TestEntryPoint() { + int[] array = new int[1]; + for (int i = 0; i < 10; i++) { DirtyStack(); @@ -30,7 +32,7 @@ public static void TestEntryPoint() bool caught = false; try { - Victim(null); + NullCheckVictim(null); } catch (NullReferenceException) when (Collect()) { @@ -38,6 +40,20 @@ public static void TestEntryPoint() } Assert.True(caught); + + DirtyStack(); + + caught = false; + try + { + BoundsCheckVictim(array, 5); + } + catch (IndexOutOfRangeException) when (Collect()) + { + caught = true; + } + + Assert.True(caught); } } @@ -56,18 +72,33 @@ private static void Consume(Span span) } [MethodImpl(MethodImplOptions.NoInlining)] - private static object Victim(Holder holder) + private static object NullCheckVictim(Holder holder) { object value = holder.Value; KeepAlive(value); return value; } + // The local is address-exposed, so it is an untracked GC slot on every target. + [MethodImpl(MethodImplOptions.NoInlining)] + private static object BoundsCheckVictim(int[] array, int index) + { + int element = array[index]; + object value = array; + KeepAlive(ref value); + return element == 0 ? value : null; + } + [MethodImpl(MethodImplOptions.NoInlining)] private static void KeepAlive(object value) { } + [MethodImpl(MethodImplOptions.NoInlining)] + private static void KeepAlive(ref object value) + { + } + private static bool Collect() { GC.Collect(); From 8f3d1ef38e615713bd05fd9a07812720dfbbe053 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Fri, 16 Oct 2026 14:58:02 -0500 Subject: [PATCH 5/7] JIT: never skip prolog zeroing for untracked GC locals Instead of extending the safe-point scan with throwing trees, don't mark GC locals reported as untracked slots as explicitly initialized at all. They are reported whenever their frame is, and optRemoveRedundantZeroInits runs before later phases (helper expansion, lowering) that can still introduce calls, so it can't rule out every gc-safe point before the store. Tracked GC locals are only reported where they're live and keep the optimization. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 45f42362-f012-4030-8643-26f4c1fbf2e1 --- src/coreclr/jit/optimizer.cpp | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/src/coreclr/jit/optimizer.cpp b/src/coreclr/jit/optimizer.cpp index e3f9baec2cf006..5745bed53d3607 100644 --- a/src/coreclr/jit/optimizer.cpp +++ b/src/coreclr/jit/optimizer.cpp @@ -5695,9 +5695,8 @@ typedef JitHashTable, unsigned> Lc // 2. If the following conditions are true: // the store is to a local (and not a field), // the local is not lvLiveInOutOfHndlr or no exceptions can be thrown between the prolog and the store, -// either the local has no gc pointers or there are no gc-safe points between the prolog and the store, -// and, if the local is reported as an untracked gc slot, no exceptions can be thrown between the prolog -// and the store, +// either the local has no gc pointers or it is not reported as an untracked gc slot and there are no +// gc-safe points between the prolog and the store, // then the local is marked with lvHasExplicitInit which tells the codegen not to insert zero initialization // for this local in the prolog. // @@ -5716,7 +5715,6 @@ void Compiler::optRemoveRedundantZeroInits() BitVec zeroInitLocals = BitVecOps::MakeEmpty(&bitVecTraits); bool hasGCSafePoint = false; bool hasImplicitControlFlow = false; - bool mayThrow = false; assert(fgNodeThreading == NodeThreading::AllTrees); @@ -5767,9 +5765,7 @@ void Compiler::optRemoveRedundantZeroInits() Statement* next = stmt->GetNextStmt(); for (GenTree* const tree : stmt->TreeList()) { - const bool treeMayThrow = (tree->gtFlags & GTF_EXCEPT) != 0; - hasImplicitControlFlow |= hasEHSuccs && treeMayThrow; - mayThrow |= treeMayThrow; + hasImplicitControlFlow |= hasEHSuccs && ((tree->gtFlags & GTF_EXCEPT) != 0); hasGCSafePoint |= IsPotentialGCSafePoint(tree); switch (tree->gtOper) @@ -5933,10 +5929,9 @@ void Compiler::optRemoveRedundantZeroInits() #else const bool reportedAsUntracked = !lclDsc->lvTracked; #endif - // An untracked gc slot is reported whenever its frame is, including from - // a throw helper that a tree before this store may call. - if (!lclDsc->HasGCPtr() || - (!GetInterruptible() && !hasGCSafePoint && !(mayThrow && reportedAsUntracked))) + // An untracked gc slot is reported whenever its frame is, and this phase can't + // rule out every gc-safe point before the store, so it keeps its prolog zeroing. + if (!lclDsc->HasGCPtr() || (!reportedAsUntracked && !GetInterruptible() && !hasGCSafePoint)) { // The local hasn't been used and won't be reported to the gc between // the prolog and this explicit initialization. Therefore, it doesn't From c04e1c4ea15e08e082236cb12dbfa56e9ceca004 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Fri, 16 Oct 2026 16:03:01 -0500 Subject: [PATCH 6/7] Use an ifdef'd condition instead of a local for untracked GC locals Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 45f42362-f012-4030-8643-26f4c1fbf2e1 --- src/coreclr/jit/optimizer.cpp | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/coreclr/jit/optimizer.cpp b/src/coreclr/jit/optimizer.cpp index 5745bed53d3607..4516bebd9595a0 100644 --- a/src/coreclr/jit/optimizer.cpp +++ b/src/coreclr/jit/optimizer.cpp @@ -5923,15 +5923,14 @@ void Compiler::optRemoveRedundantZeroInits() // insert a call to CORINFO_HELP_INIT_PINVOKE_FRAME but that is not a gc-safe point. assert(s_helperCallProperties.IsNoGC(CORINFO_HELP_INIT_PINVOKE_FRAME)); -#ifdef TARGET_WASM + // An untracked gc slot is reported whenever its frame is, and this phase can't + // rule out every gc-safe point before the store, so it keeps its prolog zeroing. // Wasm reports all gc locals on the frame as untracked slots. - const bool reportedAsUntracked = true; +#ifdef TARGET_WASM + if (!lclDsc->HasGCPtr()) #else - const bool reportedAsUntracked = !lclDsc->lvTracked; + if (!lclDsc->HasGCPtr() || (lclDsc->lvTracked && !GetInterruptible() && !hasGCSafePoint)) #endif - // An untracked gc slot is reported whenever its frame is, and this phase can't - // rule out every gc-safe point before the store, so it keeps its prolog zeroing. - if (!lclDsc->HasGCPtr() || (!reportedAsUntracked && !GetInterruptible() && !hasGCSafePoint)) { // The local hasn't been used and won't be reported to the gc between // the prolog and this explicit initialization. Therefore, it doesn't From 0e216dfff261cd7eed4bbfcc593c617925f2f49e Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Wed, 21 Oct 2026 16:23:24 -0500 Subject: [PATCH 7/7] JIT: treat throwing trees as GC safe points on all targets Per review, go back to counting trees with GTF_EXCEPT as potential GC safe points in IsPotentialGCSafePoint, without limiting it to wasm: a tree that may throw can call a throw helper, which is a GC safe point on every target. This replaces the untracked-local special case in optRemoveRedundantZeroInits. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 45f42362-f012-4030-8643-26f4c1fbf2e1 --- src/coreclr/jit/compiler.hpp | 6 ++++++ src/coreclr/jit/optimizer.cpp | 12 ++---------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/coreclr/jit/compiler.hpp b/src/coreclr/jit/compiler.hpp index 9db68788f7209d..c0db7ae36e18c8 100644 --- a/src/coreclr/jit/compiler.hpp +++ b/src/coreclr/jit/compiler.hpp @@ -3319,6 +3319,12 @@ inline bool Compiler::IsPotentialGCSafePoint(GenTree* tree) const } } + // A tree that may throw can call a throw helper, which is a gc safe point. + if ((tree->gtFlags & GTF_EXCEPT) != 0) + { + return true; + } + // TYP_STRUCT-typed stores might be converted into calls (with gc safe points) in Lower. // This is quite a conservative fix as it's hard to prove Lower won't do it at this point. if (tree->OperIsLocalStore()) diff --git a/src/coreclr/jit/optimizer.cpp b/src/coreclr/jit/optimizer.cpp index 58388f7e2d688a..47bce3f36b2d5f 100644 --- a/src/coreclr/jit/optimizer.cpp +++ b/src/coreclr/jit/optimizer.cpp @@ -5699,8 +5699,7 @@ typedef JitHashTable, unsigned> Lc // 2. If the following conditions are true: // the store is to a local (and not a field), // the local is not lvLiveInOutOfHndlr or no exceptions can be thrown between the prolog and the store, -// either the local has no gc pointers or it is not reported as an untracked gc slot and there are no -// gc-safe points between the prolog and the store, +// either the local has no gc pointers or there are no gc-safe points between the prolog and the store, // then the local is marked with lvHasExplicitInit which tells the codegen not to insert zero initialization // for this local in the prolog. // @@ -5927,14 +5926,7 @@ void Compiler::optRemoveRedundantZeroInits() // insert a call to CORINFO_HELP_INIT_PINVOKE_FRAME but that is not a gc-safe point. assert(s_helperCallProperties.IsNoGC(CORINFO_HELP_INIT_PINVOKE_FRAME)); - // An untracked gc slot is reported whenever its frame is, and this phase can't - // rule out every gc-safe point before the store, so it keeps its prolog zeroing. - // Wasm reports all gc locals on the frame as untracked slots. -#ifdef TARGET_WASM - if (!lclDsc->HasGCPtr()) -#else - if (!lclDsc->HasGCPtr() || (lclDsc->lvTracked && !GetInterruptible() && !hasGCSafePoint)) -#endif + if (!lclDsc->HasGCPtr() || (!GetInterruptible() && !hasGCSafePoint)) { // The local hasn't been used and won't be reported to the gc between // the prolog and this explicit initialization. Therefore, it doesn't