Repository navigation
Description
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=$?"; doneExpected 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.ILCompiler10.0.12, taken fromproject.assets.json- linux-x64, in the
mcr.microsoft.com/dotnet/sdk:10.0container, 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
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsNo status
Activity
dotnet-policy-service commentedon Sep 28, 2026
Tagging subscribers to this area: @agocke, @dotnet/ilc-contrib
See info in area-owners.md if you want to be subscribed.
test: cover value types in the AOT smoke (#383)
test: cover value types in the AOT smoke (#126)
test: cover value types in the AOT smoke (#137)
fix: bind nullable value-object parameters through their inner value (#…
fix: generate compilable serializers for struct types (#173)
test: cover value types in the AOT smoke (#130)
test: cover value types in the AOT smoke (#201)
fix: wire WithCache to the app's container and run bridge behaviors f…
test: cover value types in the AOT smoke (#213)
test: cover value-type closed generics in the AOT smoke (#174)
test: run the AOT smoke on the net10 ILCompiler (#139)
test: cover value types in the AOT smoke (#277)
EgorBo commentedon Sep 28, 2026
Root cause is in ILC, not the JIT:
SubstitutedILProvider.TryExpandIsInst(added in #104148) foldsisinst X; brfalseto "always false" when no constructed MethodTable forXcan exist.Nullable<int>never has a constructed MethodTable, but per ECMA-335isinst Nullable<T>tests for a boxedT. So the "true" block is treated as dead and replaced with thebr.s $-2stub. The JIT then gets IL that loops forever:ILImporter.Scanner.ImportBranchmakes the same assumption and conditions the true block's dependencies onMaximallyConstructableType(Nullable<int>). The JIT-side equivalent,CorInfoImpl.RyuJit.cs'sCanNeverHaveInstanceOfSubclassOf, already excludesNullable.Reproduces with ILCompiler 10.0.9 and with current main. Excluding
Nullablein both places fixes it: the repro printsTrue/DONEwith a locally built ILC and hangs without the change.cc @dotnet/ilc-contrib
agocke commentedon Sep 28, 2026
@EgorBo your fix looked right, so I ported it over and added a regression test
EgorBo commentedon Sep 29, 2026
Yeah I wasn't sure in it, just wanted to make sure it's not a JIT bug 🙂
Fix NativeAOT nullable isinst branch elimination (#134823)