Skip to content

NativeAOT: generic 'o is T' with T = Nullable<int> never returns, so MemoryCache.TryGetValue(key, out int?) hangs #134799

Closed

Description

@MarcelRoozekrans

Description

Under NativeAOT, a type test o is T inside a generic method never returns when T is a Nullable<T> instantiation such as int? and o is a boxed int. The process hangs, pegging a core. The same code returns true immediately under JIT.

This affects Microsoft.Extensions.Caching.Memory: CacheExtensions.TryGetValue<TItem> does result is TItem item, so cache.TryGetValue("k", out int? value) on a hit hangs forever in a NativeAOT app.

Reproduction Steps

repro.csproj:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <PublishAot>true</PublishAot>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.*" />
  </ItemGroup>
</Project>

Program.cs:

using Microsoft.Extensions.Caching.Memory;

object boxed = 5;
var mode = args.Length > 0 ? args[0] : "";
Console.WriteLine($"start {mode}");
switch (mode)
{
    case "local": Console.WriteLine(LocalIs<int?>(boxed, out _)); break;
    case "class": Console.WriteLine(Helpers.Is<int?>(boxed, out _)); break;
    case "int":   Console.WriteLine(Helpers.Is<int>(boxed, out _)); break;
    case "cache":
        using (var c = new MemoryCache(new MemoryCacheOptions()))
        {
            c.Set("k", 5);
            Console.WriteLine(c.TryGetValue("k", out int? v) + " " + v);
        }
        break;
}
Console.WriteLine("DONE");

static bool LocalIs<T>(object o, out T? value)
{
    if (o is T t) { value = t; return true; }
    value = default;
    return false;
}

static class Helpers
{
    public static bool Is<T>(object o, out T? value)
    {
        if (o is T t) { value = t; return true; }
        value = default;
        return false;
    }
}

In mcr.microsoft.com/dotnet/sdk:10.0, with clang and zlib1g-dev installed:

dotnet publish -c Release -r linux-x64 -o /tmp/o
for m in local class cache int; do timeout 15 /tmp/o/repro $m; echo "exit=$?"; done

Expected behavior

Each mode prints True (or True 5) and then DONE, as it does under JIT with dotnet run -c Release -- <mode>.

Actual behavior

mode NativeAOT JIT
local: generic local function, T = int? prints start local, then hangs; timeout kills it, exit 124 True, DONE
class: generic static method, T = int? hangs, exit 124 True, DONE
cache: MemoryCache.TryGetValue("k", out int? v) hangs, exit 124 True 5, DONE
int: same generic method, T = int True, DONE True, DONE

A non-generic boxed is int? returns True under NativeAOT. Is<string>("x", out _) returns normally too. Only the generic instantiation over a nullable value type hangs.

The hang is the same with the default OptimizationPreference, with Size and with Speed.

Regression?

Not determined. Only tested on the version below.

Known Workarounds

Test against the concrete underlying type outside the generic context, for example cache.TryGetValue(key, out object? o) && o is int i.

Configuration

  • .NET SDK 10.0.401
  • runtime.linux-x64.Microsoft.DotNet.ILCompiler 10.0.12, taken from project.assets.json
  • linux-x64, in the mcr.microsoft.com/dotnet/sdk:10.0 container, x64 host
  • Microsoft.Extensions.Caching.Memory 10.0.x

Other information

Found through ZeroAlloc.Cache, whose source-generated caching proxies call TryGetValue<T> with a nullable T for value-type returns: ZeroAlloc-Net/ZeroAlloc.Cache#182

Activity

  1. dotnet-policy-service commented on Sep 28, 2026

    @dotnet-policy-service
    Contributor

    Tagging subscribers to this area: @agocke, @dotnet/ilc-contrib
    See info in area-owners.md if you want to be subscribed.

  2. EgorBo commented on Sep 28, 2026

    @EgorBo
    Member

    Root cause is in ILC, not the JIT: SubstitutedILProvider.TryExpandIsInst (added in #104148) folds isinst X; brfalse to "always false" when no constructed MethodTable for X can exist. Nullable<int> never has a constructed MethodTable, but per ECMA-335 isinst Nullable<T> tests for a boxed T. So the "true" block is treated as dead and replaced with the br.s $-2 stub. The JIT then gets IL that loops forever:

    ; Program:Is[System.Nullable`1[int]](System.Object,byref):bool
           test     rcx, rcx
           je       SHORT G_M000_IG05
           lea      rax, [(reloc)]      ; System.Int32
           cmp      qword ptr [rcx], rax
           jne      SHORT G_M000_IG05
    G_M000_IG04:
           jmp      SHORT G_M000_IG04   ; dead-block stub
    G_M000_IG05:
           xor      eax, eax
           mov      qword ptr [rdx], rax
           ret

    ILImporter.Scanner.ImportBranch makes the same assumption and conditions the true block's dependencies on MaximallyConstructableType(Nullable<int>). The JIT-side equivalent, CorInfoImpl.RyuJit.cs's CanNeverHaveInstanceOfSubclassOf, already excludes Nullable.

    Reproduces with ILCompiler 10.0.9 and with current main. Excluding Nullable in both places fixes it: the repro prints True/DONE with a locally built ILC and hangs without the change.

    --- a/src/coreclr/tools/aot/ILCompiler.Compiler/Compiler/SubstitutedILProvider.cs
    +++ b/src/coreclr/tools/aot/ILCompiler.Compiler/Compiler/SubstitutedILProvider.cs
    @@ private bool TryExpandIsInst(in IsInstCheckPatternAnalyzer analyzer, MethodIL me
                 if (type.IsCanonicalDefinitionType(CanonicalFormKind.Any))
                     return false;
     
    +            // isinst Nullable<T> checks for a boxed T; Nullable<T> itself never has a constructed MethodTable.
    +            if (type.IsNullable)
    +                return false;
    +
                 // We don't track types without a constructed MethodTable very well.
                 if (!ConstructedEETypeNode.CreationAllowed(type))
                     return false;
    --- a/src/coreclr/tools/aot/ILCompiler.Compiler/IL/ILImporter.Scanner.cs
    +++ b/src/coreclr/tools/aot/ILCompiler.Compiler/IL/ILImporter.Scanner.cs
    @@ private void ImportBranch(ILOpcode opcode, BasicBlock target, BasicBlock fallthr
                     TypeDesc isinstCheckType = (TypeDesc)_canonMethodIL.GetObject(_isInstCheckPatternAnalyzer.Token);
                     if (ConstructedEETypeNode.CreationAllowed(isinstCheckType)
    +                    // isinst Nullable<T> checks for a boxed T
    +                    && !isinstCheckType.IsNullable
                         // Below makes sure we don't need to worry about variance

    cc @dotnet/ilc-contrib

  3. added this to the 10.0.x milestone on Sep 28, 2026
  4. removed
    untriagedNew issue has not been triaged by the area owner
    on Sep 28, 2026
  5. agocke commented on Sep 28, 2026

    @agocke
    Member

    @EgorBo your fix looked right, so I ported it over and added a regression test

  6. EgorBo commented on Sep 29, 2026

    @EgorBo
    Member

    Yeah I wasn't sure in it, just wanted to make sure it's not a JIT bug 🙂

  7. added a commit that references this issue on Sep 29, 2026
    8692b67
  8. locked and limited conversation to collaborators on Oct 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    • Status
      No status

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions