dotnet / dotnet/runtime

JIT: (bug) Tail-recursion-to-loop conversion drops the `callvirt` receiver null check

Open
#133,861 3 comments 0 reactions 0 assignees View on GitHub
area-CodeGen-coreclr
Dominant language
C#
Stars
18.3k
Forks
5.6k
PR merge metrics
PR metrics pending

Description

A `callvirt` to a non-virtual method is a direct call (`IsVirtual()` is false) but still carries
`GTF_CALL_NULLCHECK`. The recursive-fast-tailcall-to-loop transform deletes the call node without materializing
that null check, so a `callvirt` on a `null` receiver silently succeeds.

### Minimal Repro

```csharp
using System;
using System.Runtime.CompilerServices;

public class C
{
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
public int Test(C o, int n, int acc)
{
if (n == 0)
return acc;
return o.Test(o, n - 1, acc + n); // callvirt on possibly-null 'o'
}
}

public class Program
{
public static void Main()
{
C c = new C();
Console.WriteLine(c.Test(c, 5, 0));
try
{
Console.WriteLine("returned " + c.Test(null, 1, 0));
}
catch (NullReferenceException)
{
Console.WriteLine("NullReferenceException");
}
}
}
```

### Expected

```
15
NullReferenceException
```

### Actual

```
15
returned 1
```

Both `this` (V00) and `o` (V01) become `zero-ref` and no null check is emitted:

```asm
G_M3746_IG02:
test r8d, r8d
je SHORT G_M3746_IG04
G_M3746_IG03:
add r9d, r8d
dec r8d
jne SHORT G_M3746_IG03
```

### Notes

`fgMorphPotentialTailCall` (`morph.cpp`) gates `fastTailCallToLoop` on `!call->IsVirtual()` but never checks
`call->NeedsNullCheck()`; `fgMorphRecursiveFastTailCallIntoLoop` then emits only the argument stores and the
back-edge. Fix: also require `!call->NeedsNullCheck()`, or materialize an explicit `GT_NULLCHECK`.
Observable with default (tiered) settings; `DOTNET_TieredCompilation=0` masks it in this repro because `Main`
then inlines one level of `Test`. Also reproduces on .NET 10.0.12.

Contributor guide

Open the contributing guide

Research direction

Start in morph.cpp at fgMorphPotentialTailCall and follow fgMorphRecursiveFastTailCallIntoLoop, then run the supplied C# reproducer with default tiered settings. Compare the generated behavior for a null receiver with the expected NullReferenceException; the work is done when the recursive fast-tailcall path preserves that behavior without breaking the successful case.

Written by the indexing model from the issue text.

Assessment

Tech stack
csharp
Domain
compilers
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Stale
Clarity
Clearly specified
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.