Skip to content

JIT: RangeCheck keeps a stale range when assertion tightening yields an empty range, causing bad conditional folding #132841

Closed

Description

@AndyAyersMS

Description

RangeCheck::GetRangeFromAssertionsWorker can leave a stale, un-tightened range in *pRange when tightening with an incoming assertion produces an empty (invalid) range. An empty range is a proof that the block is unreachable, but instead of reporting that (or bailing to Unknown), the code returns with the previous, wider range still in place. Callers then treat that range as a valid fact.

Since #129354 ("Use SSA-based TryGetRange to fold conditions") assertion propagation folds conditions using these ranges, so the stale range gets used to fold a branch — producing a miscompile.

In the repro below the JIT folds the int.MinValue guard inside an inlined Math.Abs into an unconditional throw, so a program that should print 00000001 instead dies with OverflowException.

This is a .NET 11 regression; .NET 10 is unaffected.

Reproduction Steps

using System;
using System.Runtime.CompilerServices;
using System.Threading;

public static class Program
{
    // Inlineable helper. Math.Abs(x1) is only ever reached with x1 == 1.
    public static int H1(int x0, int x1)
    {
        unchecked
        {
            int r0 = x0 - x1;
            int r1 = Math.Abs(x1);
            int r2 = unchecked((int)0xBFFEE70F) ^ r0;
            int r3 = Math.Clamp(r1, r2, unchecked((int)0x7FFFFFFF));
            return 0 ^ r3;
        }
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    public static int F(int p0, int p1)
    {
        unchecked
        {
            // Establishes range assertions on p0 (requires min=2 <= max=p0).
            int v2 = Math.Clamp(0, 2, p0);

            // Wraps: 0x91960 + 2147168000 -> -2147202976, which is strictly
            // greater than int.MinValue, so Math.Abs(v3) is well defined.
            int v3 = unchecked((int)0x00091960) + p0;
            int v4 = (v3 < p0) ? 1 : 0;
            if (v4 == 0) goto b10;

            int v8 = Math.Abs(v3);
            int v9 = Math.Abs(unchecked((int)0xFFFFFFFF));

            int v25 = (0 < p1) ? 1 : 0;
            if (v25 == 0) goto b10;

            return H1(v8, v9);

        b10:
            return v2 & 0;
        }
    }

    public static int Main()
    {
        // Call repeatedly so tiered compilation promotes F to tier1.
        for (int i = 0; i < 200; i++)
        {
            int r = F(2147168000, 290901);
            if (r != 1) { Console.WriteLine($"FAIL at iter {i}: got {r:X8}, expected 00000001"); return 101; }
            Thread.Sleep(2);
        }
        Console.WriteLine("PASS");
        return 100;
    }
}

dotnet run -c Release, no environment variables set.

A single-call version (no loop) reproduces with DOTNET_TieredCompilation=0.

Expected behavior

PASS

v3 = 0x91960 + 2147168000 wraps to -2147202976, which is strictly greater than int.MinValue, so Math.Abs(v3) is well defined and F returns 1.

Actual behavior

Unhandled exception. System.OverflowException: Negating the minimum value of a twos complement number is invalid.
   at Program.F(Int32 p0, Int32 p1)
   at Program.Main()

Regression?

Yes — regression in .NET 11. Same binary, same machine:

Runtime Result
Microsoft.NETCore.App 10.0.5 PASS
Microsoft.NETCore.App 10.0.11 PASS
Microsoft.NETCore.App 11.0.0-preview.6.26359.118 OverflowException
main (b5cb548f880, x64 checked, local build) OverflowException

Also confirmed: MinOpts is correct (00000001), FullOpts and Tier1+PGO both fail. The bug needs JitDoAssertionProp + JitDoValueNumber + inlining — disabling any one of the three hides it. No JitStress/JitStressRegs involved.

Analysis

F is reached with p0 = 2147168000. Math.Clamp(0, 2, p0) establishes the assertion p0 >= 2. The block containing Math.Abs(v3) is guarded by v3 < p0, which is true only when the ADD wrapped — i.e. v3 is negative there.

Range analysis computes v3 from p0 >= 2 assuming the ADD does not overflow, giving [596322, 2147483646]. It then tries to tighten that with the v3 < 0 assertion from the guarding branch. The result is empty, correctly signalling that the two facts are contradictory — but the contradiction is discarded:

Merging assertions from pred edges of BB12 for op [000053] $101
#01 VN $80 >= 2
Tightening pRange: [<-2147483648, 2147483647>] with assertedRange: [<2, Unknown>] into [<2, 2147483647>]
#06 VN $101 < 0
Tightening pRange: [<-2147483648, 2147483647>] with assertedRange: [<Unknown, -1>] into [<-2147483648, -1>]
#04 VN $101 < (VN_ADD_CNS $80 + 0)
Tightening pRange: [<2, 2147483647>] with assertedRange: [<-2147483647, Unknown>] into [<2, 2147483647>]
#04 VN $101 < (VN_ADD_CNS $80 + 0)
Tightening pRange: [<596322, 2147483646>] with assertedRange: [<Unknown, 2147483646>] into [<596322, 2147483646>]
#06 VN $101 < 0
Tightening pRange: [<596322, 2147483646>] with assertedRange: [<Unknown, -1>] into [<596322, -1>]
invalid range after tightening              <-- contradiction detected, then dropped
         Computed Range [000053] => <596322, 2147483646>   <-- stale, still positive
      }
      Computed Range [000054] => <-2147483646, -596322>    <-- negation of the stale range

Because v3 is believed to be positive, -v3 is believed to be always negative, so the if (-value < 0) throw guard inside the inlined Math.Abs folds to always-true:

Conditional folded at BB12
BB12 becomes a BBJ_ALWAYS to BB13     <-- BB13 is the throw block

The offending code is in rangecheck.cpp:

Range copy  = *pRange;
copy.lLimit = TightenLimit(assertedRange.lLimit, copy.lLimit, preferredBoundVN, true);
copy.uLimit = TightenLimit(assertedRange.uLimit, copy.uLimit, preferredBoundVN, false);

JITDUMP("[%s]\n", copy.ToString(comp));
if (copy.IsValid())
{
    *pRange = copy;
}
else
{
    JITDUMP("invalid range after tightening\n");
    return;                       // <-- *pRange keeps the old, wider range
}

The early return leaves the caller holding a range that has already been contradicted. An empty intersection means the block is unreachable, so the safe options are to bail out to Unknown (conservative) or to propagate "unreachable" and let the block be removed. Retaining the pre-tightening range is not sound, because it asserts a fact that the incoming assertions have just disproved.

Note this pattern also shows up in the earlier assertedRange.IsValid() bail-out a few lines above (line ~1659), which likewise returns while leaving *pRange populated.

Configuration

  • .NET 11.0.0-preview.6.26359.118 and main @ b5cb548f880
  • Windows 11 x64, checked and release JIT
  • Not architecture-specific as far as I can tell (the folding happens in the phase-independent range/assertion logic)

Other information

Found by a randomized differential C# generator (semantic reification) comparing MinOpts / FullOpts / Tier1+PGO output across ~95,000 generated programs; this was the only true positive in that run. The failing case reduced to the 47 lines above.

Suspect commit is #129354 ("Use SSA-based TryGetRange to fold conditions"), which introduced the GetRange helper in assertionprop.cpp that consumes these ranges to fold conditions — none of the recent rangecheck changes (#129354, #129303, #128906, #130426) are present in release/10.0, which matches the observed version split. The underlying stale-range behaviour in GetRangeFromAssertionsWorker may predate it and only have become observable once ranges started driving conditional folding.

Activity

  1. added
    area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI
    on Aug 27, 2026
  2. dotnet-policy-service commented on Aug 27, 2026

    @dotnet-policy-service
    Contributor

    Tagging subscribers to this area: @JulieLeeMSFT, @jakobbotsch
    See info in area-owners.md if you want to be subscribed.

  3. self-assigned this
    on Aug 27, 2026
  4. AndyAyersMS commented on Aug 27, 2026

    @AndyAyersMS
    MemberAuthor

    Second example: the same root cause also produces a silent wrong value, with no exception at all.

    The Math.Abs case above folds an internal int.MinValue guard into a throw, which at least fails loudly. But nothing requires the consumer of the bad range to be a throwing one — if the derived value feeds an ordinary comparison, the program just returns the wrong answer.

    using System;
    using System.Runtime.CompilerServices;
    using System.Threading;
    
    public static class Program
    {
        private static int P0 = 2145211143;
        private static int P1 = 754328;
    
        // Inlineable. `a` only ever reaches here as the wrapped (negative) value
        // -1774704624, so -a is +1774704624 and this must return 0.
        public static int H(int a)
        {
            unchecked
            {
                return ((-a) < 0) ? 1 : 0;
            }
        }
    
        [MethodImpl(MethodImplOptions.NoInlining)]
        public static int Case(int p0, int p1)
        {
            unchecked
            {
                // Establishes the assertion p0 >= 1.
                int g = Math.Clamp(0, 1, p0);
    
                // 375051529 + 2145211143 = 2520262672 > int.MaxValue, so this
                // wraps to -1774704624 -- negative, and not int.MinValue.
                int v = 375051529 + p0;
    
                // True only because the add above overflowed.
                if (!(v < p0)) goto done;
                if (p1 <= 0) goto done;
    
                return H(v);
            done:
                return g & 0;
            }
        }
    
        public static int Main()
        {
            int result = 0;
            for (int round = 0; round < 60; round++)
            {
                for (int warm = 0; warm < 8; warm++)
                {
                    result = Case(P0, P1);
                }
                Thread.Sleep(2);
            }
    
            Console.WriteLine($"Case returned {result}, expected 0");
            return result == 0 ? 100 : 101;
        }
    }

    v wraps to -1774704624, so -v is +1774704624 and ((-v) < 0) must be false.

    Configuration Result
    MinOpts 0 ✔
    FullOpts 1 ✘
    Default (tiered + PGO) 1 ✘
    Microsoft.NETCore.App 10.0.5 / 10.0.11 0 ✔
    Microsoft.NETCore.App 11.0.0-preview.6.26359.118 1 ✘

    Same regression window as the original repro. Same mechanism: the stale positive range for v is negated to an all-negative range for -v, so the comparison folds to a constant.

    Note this one reproduces under default settings — no DOTNET_* variables — because the loop lets Case tier up. It is strictly more dangerous than the throwing variant: wrong data, silently.


    #132849 fixes both

    I applied the JIT hunk from #132849 to a clean tree, rebuilt, and re-ran the generator that found these with identical seed bases:

    seeds run under both baseline and patched JIT : 450
      failing on baseline JIT : 199
      failing on patched JIT  : 0
      fixed by #132849        : 199
      newly broken            : 0
    

    Both minimal repros also go green. The GT_NEG arm of that change is what covers this case.

    Since Runtime_132841.cs in the PR currently only exercises the throwing variant, it may be worth adding this one too — it guards a different consumer shape (GT_NEG feeding a compare, rather than Math.Abs's internal guard), so a future regression that only affects non-throwing consumers would slip past the existing test.

    Both were found by a randomized differential C# generator that builds blocks reachable only when a preceding arithmetic operation overflowed. On an unpatched x64 JIT the wrong-value shape reproduces on ~44% of generated programs and the throwing shape on ~28%; MinOpts is correct on 100% of them.

  5. added this to the 11.0.0 milestone on Aug 31, 2026
  6. removed
    untriagedNew issue has not been triaged by the area owner
    on Aug 31, 2026
  7. added a commit that references this issue on Sep 1, 2026
    c2af89b
  8. locked and limited conversation to collaborators on Oct 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI

Type

No type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions