dotnet / dotnet/runtime

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

Open
#129,030 1 comment 0 reactions 0 assignees View on GitHub
area-Tools-ILVerification
Dominant language
C#
Stars
18.3k
Forks
5.6k
PR merge metrics
PR metrics pending

Description

### Description

[ILVerify](https://github.com/dotnet/runtime/tree/main/src/coreclr/tools/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`](https://github.com/dotnet/runtime/blob/main/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`:

```csharp
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`:

```xml


Exe
net10.0
latest

```

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

```text
$ 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:

```text
.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:

```text
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`](https://github.com/dotnet/runtime/blob/main/src/coreclr/tools/ILVerification/ILImporter.Verify.cs#L1912):

```csharp
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](https://github.com/dotnet/runtime/blob/main/src/coreclr/tools/ILVerification/ILImporter.Verify.cs#L1452):

```csharp
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:
```csharp
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`:
```csharp
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

- #37391 — *"Add option for IL validation without IL verification"* (Open, Priority:2). #89691 was closed as a duplicate of this one. Both are tracked as workarounds — bypassing the verifier — rather than fixing the underlying check. The issue here is narrower and directly fixable in the verifier.
- #94090 — *"ILVerify incorrectly complains about use of initonly field outside of constructor+"* — a separate but spiritually similar false‑positive that's already tracked and `in-pr`.

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`

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.