Skip to content

ILVerify false positive: ReturnPtrToStack on by-value return of a ref struct local #129030

@DavidObando

Description

@DavidObando

Description

ILVerify reports a ReturnPtrToStack error when a method returns a ref struct (an IsByRefLike value type) by value, even though the IL is valid and csc's emitted output is exactly the same as for any non‑ref struct value type. The error fires on the final ret of every such method.

This is a false positive: the value sitting on the evaluation stack at ret is the value of a local, not a managed pointer to the caller's stack. The Check at ILImporter.Verify.cs line 1912 currently applies the IsPermanentHome requirement whenever expectedReturnType.IsByRefLike, regardless of whether what's on the stack is actually a ByRef or a value.

I also noticed that Mono.Linker already has to filter this exact diagnostic away in its own ILVerify wrapper — see src/tools/illink/test/Mono.Linker.Tests/TestCasesRunner/ILVerifier.cs where VerifierError.ReturnPtrToStack is suppressed with the comment "ref returning a ref local causes this warning but is okay". That's a workaround for the same underlying limitation surfacing under a different pattern; the root cause is shared.

Reproduction

Tool: dotnet-ilverify 10.0.8 (also reproduces against the in‑tree ILVerify in main).
Compiler: Roslyn shipping in .NET 10.0.8 SDK, LangVersion=latest.
Target: net10.0.

Test.cs:

public ref struct Accumulator
{
    public int Total;
}

public class Program
{
    public static Accumulator Add(Accumulator acc, int n)
    {
        return new Accumulator { Total = acc.Total + n };
    }

    public static void Main()
    {
        var a = new Accumulator { Total = 0 };
        a = Add(a, 5);
        System.Console.WriteLine(a.Total);
    }
}

Test.csproj:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <LangVersion>latest</LangVersion>
  </PropertyGroup>
</Project>

Build + verify (note: a Release build runs without throwing — this is purely an ILVerify diagnostic):

$ dotnet build -c Release -nologo -clp:NoSummary
Build succeeded.
    0 Warning(s)
    0 Error(s)

$ ilverify bin/Release/net10.0/Test.dll -s System.Private.CoreLib \
    -r /usr/local/share/dotnet/shared/Microsoft.NETCore.App/10.0.8/System.*.dll \
    -r /usr/local/share/dotnet/shared/Microsoft.NETCore.App/10.0.8/mscorlib.dll \
    -r /usr/local/share/dotnet/shared/Microsoft.NETCore.App/10.0.8/netstandard.dll
[IL]: Error [ReturnPtrToStack]: [Test.dll : Program::Add([Test]Accumulator, int32)][offset 0x00000018] Return type is ByRef, TypedReference, ArgHandle, or ArgIterator.
1 Error(s) Verifying Test.dll

The actual IL csc emits is the same value‑typed ldloc; ret shape it uses for any other struct:

.method public hidebysig static
    valuetype Accumulator Add (
        valuetype Accumulator acc,
        int32 n
    ) cil managed
{
    .maxstack 3
    .locals init (
        [0] valuetype Accumulator
    )

    IL_0000: ldloca.s   0
    IL_0002: initobj    Accumulator
    IL_0008: ldloca.s   0
    IL_000a: ldarg.0
    IL_000b: ldfld      int32 Accumulator::Total
    IL_0010: ldarg.1
    IL_0011: add
    IL_0012: stfld      int32 Accumulator::Total
    IL_0017: ldloc.0     // load the value
    IL_0018: ret         // return by value — ILVerify flags this as ReturnPtrToStack
}

The method signature is valuetype Accumulator Add(...) (i.e. by value), the stack just before ret holds the value of local 0, and the ret is consuming that value — not a managed pointer. Nothing here is returning a pointer to anything on the callee's stack.

Minimal repro from a second language

The G# compiler I work on hit the exact same false positive on a comparable program:

type Accumulator ref struct {
    Total int32
}

func add(acc Accumulator, n int32) Accumulator {
    return Accumulator{Total: acc.Total + n}
}

It emits structurally‑identical IL and fails the same ReturnPtrToStack check. Different front‑end, same blocker — strong evidence the root cause is in ILVerify, not in any specific compiler.

Root cause analysis

The failing check is at src/coreclr/tools/ILVerification/ILImporter.Verify.cs#L1912:

var actualReturnType = Pop();
CheckIsAssignable(actualReturnType, StackValue.CreateFromType(expectedReturnType));

Check((!expectedReturnType.IsByRef && !expectedReturnType.IsByRefLike) || actualReturnType.IsPermanentHome,
      VerifierError.ReturnPtrToStack);

The condition treats IsByRefLike and IsByRef identically. That is fine for byref returns of a ref struct, where the stack must hold a ByRef to a permanent home. But for by‑value returns of a ref struct (the case above), the stack value's Kind is StackValueKind.ValueType, not ByRef, and asking "does this ValueType‑kind stack slot live in a permanent home?" doesn't carry the same meaning — there is no managed pointer to validate.

The other side of the bug is in ImportLoadVar at line 1452:

void ImportLoadVar(int index, bool argument)
{
    var varType = GetVarType(index, argument);
    if (!argument)
        Check(_initLocals, VerifierError.InitLocals);
    CheckIsNotPointer(varType);

    var stackValue = StackValue.CreateFromType(varType);
    if (index == 0 && argument && _thisType != null)
    {
        Debug.Assert(varType == _thisType);
        stackValue.SetIsThisPtr();
    }

    Push(stackValue);   // <— never calls SetIsPermanentHome()
}

Locals and arguments are permanent homes (they live in the caller's stack frame for the lifetime of the method), but the pushed StackValue is never marked as such. The only SetIsPermanentHome() call in the file is at line 1786, in ImportCall, and only for ByRef‑kind return values from calls. Even though a ldloc/ldarg of a value‑type local is the most basic permanent‑home producer there is, the verifier doesn't track it.

Suggested fix

Two minimally‑invasive options, either of which would close this case:

  1. Tighten the check to its actual intent at line 1912 — only apply IsPermanentHome when the stack value is a managed pointer that could point into transient storage:

    Check(
        (!expectedReturnType.IsByRef && !expectedReturnType.IsByRefLike)
        || actualReturnType.Kind != StackValueKind.ByRef
        || actualReturnType.IsPermanentHome,
        VerifierError.ReturnPtrToStack);

    This preserves the original purpose of the check (rejecting return of a pointer into the callee's stack) while not firing on by‑value ref‑struct returns.

  2. Mark locals/arguments as permanent homes in ImportLoadVar — semantically correct, and would also help anywhere else in the verifier that uses IsPermanentHome:

    var stackValue = StackValue.CreateFromType(varType);
    stackValue.SetIsPermanentHome();   // locals & args live in the caller's frame
    if (index == 0 && argument && _thisType != null)
    {
        Debug.Assert(varType == _thisType);
        stackValue.SetIsThisPtr();
    }
    Push(stackValue);

(2) is the more general fix; (1) is the smallest patch.

Related issues

I'm happy to send a PR for either option above if a maintainer can confirm which direction is preferred.

Environment

  • OS: macOS (Darwin), arm64
  • .NET SDK: 10.0.8
  • dotnet-ilverify: 10.0.8
  • Roslyn: ships with .NET 10.0.8 SDK
  • Target framework: net10.0

Metadata

Metadata

Assignees

Labels

area-Tools-ILVerificationIssues related to ilverify tool and IL verification in generaluntriagedNew issue has not been triaged by the area owner

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions