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:
-
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.
-
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
Description
ILVerify reports a
ReturnPtrToStackerror when a method returns aref struct(anIsByRefLikevalue type) by value, even though the IL is valid andcsc's emitted output is exactly the same as for any non‑ref structvalue type. The error fires on the finalretof every such method.This is a false positive: the value sitting on the evaluation stack at
retis the value of a local, not a managed pointer to the caller's stack. TheCheckatILImporter.Verify.csline 1912 currently applies theIsPermanentHomerequirement wheneverexpectedReturnType.IsByRefLike, regardless of whether what's on the stack is actually aByRefor a value.I also noticed that
Mono.Linkeralready has to filter this exact diagnostic away in its own ILVerify wrapper — seesrc/tools/illink/test/Mono.Linker.Tests/TestCasesRunner/ILVerifier.cswhereVerifierError.ReturnPtrToStackis 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-ilverify10.0.8 (also reproduces against the in‑tree ILVerify inmain).Compiler: Roslyn shipping in .NET 10.0.8 SDK,
LangVersion=latest.Target:
net10.0.Test.cs:Test.csproj:Build + verify (note: a Release build runs without throwing — this is purely an ILVerify diagnostic):
The actual IL
cscemits is the same value‑typedldloc; retshape it uses for any other struct:The method signature is
valuetype Accumulator Add(...)(i.e. by value), the stack just beforeretholds the value of local0, and theretis 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:
It emits structurally‑identical IL and fails the same
ReturnPtrToStackcheck. 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:The condition treats
IsByRefLikeandIsByRefidentically. That is fine for byref returns of a ref struct, where the stack must hold aByRefto a permanent home. But for by‑value returns of a ref struct (the case above), the stack value'sKindisStackValueKind.ValueType, notByRef, and asking "does thisValueType‑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
ImportLoadVarat line 1452:Locals and arguments are permanent homes (they live in the caller's stack frame for the lifetime of the method), but the pushed
StackValueis never marked as such. The onlySetIsPermanentHome()call in the file is at line 1786, inImportCall, and only forByRef‑kind return values from calls. Even though aldloc/ldargof 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:
Tighten the check to its actual intent at line 1912 — only apply
IsPermanentHomewhen the stack value is a managed pointer that could point into transient storage: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.
Mark locals/arguments as permanent homes in
ImportLoadVar— semantically correct, and would also help anywhere else in the verifier that usesIsPermanentHome:(2) is the more general fix; (1) is the smallest patch.
Related issues
in-pr.I'm happy to send a PR for either option above if a maintainer can confirm which direction is preferred.
Environment
dotnet-ilverify: 10.0.8net10.0