Repository navigation
NativeAOT binds closed static delegates over value-type first parameters #136101
Description
Description
On NativeAOT, a delegate closed over the first argument of a static method whose first parameter is a value type binds, and calling it passes the reference to the boxed target where the method expects the value. MethodInfo.CreateDelegate(Type, object), MethodInfo.CreateDelegate<T>(object) and Delegate.CreateDelegate(Type, object, MethodInfo[, bool]) all bind, and for an int parameter the call returns a value derived from the box's address, so it changes on every run.
CoreCLR refuses to bind such a delegate: CreateDelegate throws ArgumentException, and Delegate.CreateDelegate(..., throwOnBindFailure: false) returns null.
Reproduction Steps
using System;
using System.Reflection;
MethodInfo method = typeof(C).GetMethod(nameof(C.F))!;
Delegate? noThrow = Delegate.CreateDelegate(typeof(Func<int>), 42, method, throwOnBindFailure: false);
Console.WriteLine(noThrow is null ? "null" : ((Func<int>)noThrow)().ToString());
Func<int> d = method.CreateDelegate<Func<int>>(42);
Console.WriteLine(d());
static class C
{
public static int F(int x) => x;
}Project: <OutputType>Exe</OutputType>, <TargetFramework>net10.0</TargetFramework>, <Nullable>enable</Nullable>. On CoreCLR: dotnet build -c Release, then dotnet Repro.dll. On NativeAOT: dotnet publish -c Release -r linux-x64 -p:PublishAot=true, then ./Repro.
Expected behavior
The same as CoreCLR: the delegate does not bind. CoreCLR 10.0.12 prints (stack trace trimmed):
null
Unhandled exception. System.ArgumentException: Cannot bind to the target method because its signature is not compatible with that of the delegate type.
Other options considered:
- Binding, with the call receiving the value (
42here): NativeAOT would still accept delegates that CoreCLR rejects. - Rejecting only boxed targets: a
nulltarget would still bind overintandint*, which CoreCLR rejects.
Actual behavior
NativeAOT binds both delegates and prints different numbers on every run, never 42. Four runs:
1921014520
1921032568
1447058168
1447076216
1308646136
1308664184
-1723835656
-1723816216
The returned value is the boxed target's address: with static nint F(nint x) => x closed over a boxed (nint)0x1234, the call returns exactly the address of the box object, and with static int F(int x) closed over a boxed 42 it returns the low 32 bits of that address.
NativeAOT also binds, and the call returns a wrong value, for these other value-type first parameters: long, byte, char, double, an int-based enum, a struct with one int field, a struct with three long fields, int?, a generic method G<T>(T x) instantiated over int (unconstrained and where T : struct), and static string F(T x) on a generic class instantiated as GenCls<int>. All of these throw on CoreCLR.
With a null target, NativeAOT also binds closed delegates over int and int* first parameters (the call receives 0 / a null pointer); CoreCLR rejects both. On Windows 10.0.12, NativeAOT also binds over delegate*<int>, ref int, Span<int>, a ref struct and TypedReference with a null target; CoreCLR rejects all five.
Reference-type first parameters (string, object, IComparable closed over a boxed int, a class, G<string>) bind and return the right values on both.
Regression?
No. On NativeAOT 8.0.28 and 9.0.17 (Linux x64) the cases run there bind and are rejected as on 10.0.12, and the nint case returns the box address there too.
Known Workarounds
Capture the value in a lambda instead of closing a delegate over it: Func<int> d = () => C.F(42); returns 42 on NativeAOT.
Configuration
- NativeAOT 10.0.12 (also 8.0.28 and 9.0.17), compared with CoreCLR 10.0.12; the repro also on 10.0.13 (Windows)
- Ubuntu 24.04.5 LTS, x64; Windows 11, x64
Other information
The NativeAOT binder checks only that a non-null target is assignable to the first parameter, in RuntimeMethodInfo.cs:359-367:
// Closed static: This is the "weird" v2.0 case where the delegate is closed over the target method's first parameter.
// (it make some kinda sense if you think of extension methods.)
if (!allowClosed)
return null;
isOpen = false;
if (!targetParameterEnumerator.MoveNext())
return null;
if (target != null && !IsAssignableFrom(targetParameterEnumerator.Current.ParameterType, target.GetType()))
return null;The delegate then stores the target as an object in Delegate.NativeAot.cs:236-242:
private void InitializeClosedStaticThunk(object firstParameter, IntPtr functionPointer, IntPtr functionPointerThunk)
{
_extraFunctionPointerOrData = functionPointer;
_helperObject = firstParameter;
_methodPtr = functionPointerThunk;
_target = this;
}and the compiler-generated closed static thunk passes that object reference as the first argument, calling the target through a signature whose first parameter is object, in DelegateThunks.cs:251-252:
TypeDesc[] targetMethodParameters = new TypeDesc[Signature.Length + 1];
targetMethodParameters[0] = Context.GetWellKnownType(WellKnownType.Object);and DelegateThunks.cs:265-279:
// Load the stored 'this'
codeStream.EmitLdArg(0);
codeStream.Emit(ILOpcode.ldfld, emitter.NewToken(HelperObjectField));
// Load all arguments except 'this'
for (int i = 0; i < Signature.Length; i++)
{
codeStream.EmitLdArg(i + 1);
}
// Indirectly call the delegate target static method.
codeStream.EmitLdArg(0);
codeStream.Emit(ILOpcode.ldfld, emitter.NewToken(ExtraFunctionPointerOrDataField));
codeStream.Emit(ILOpcode.calli, emitter.NewToken(targetMethodSignature));CoreCLR rejects this shape up front, in comdelegate.cpp:2450-2468:
// Delegates closed over static methods have a further constraint: the first argument of the target must be an object
// reference type (otherwise the argument shuffling logic could get complicated).
if (!fIsOpenDelegate)
{
if (thFirstTargetArg.IsGenericVariable())
{
// If the first argument of the target is a generic variable, it must be constrained to be an object reference.
TypeVarTypeDesc *varFirstTargetArg = thFirstTargetArg.AsGenericVariable();
if (!varFirstTargetArg->ConstrainedAsObjRef())
return false;
}
else
{
// Otherwise the code:CorElementType of the argument must be classified as an object reference.
CorElementType etFirstTargetArg = thFirstTargetArg.GetInternalCorElementType();
if (!CorTypeInfo::IsObjRef(etFirstTargetArg))
return false;
}
}I'm working on a fix and will open a PR for it. Could this be assigned to me?
Note
AI-generated, written at my direction and reviewed by me before posting.
Source read at dotnet/runtime 9b108d9. Repro and per-type results run on Ubuntu 24.04.5 LTS x64 with CoreCLR 10.0.12 (dotnet Repro.dll) and NativeAOT 10.0.12 (dotnet publish -c Release -r linux-x64 -p:PublishAot=true -p:RuntimeFrameworkVersion=10.0.12 -p:ILCompilerVersion=10.0.12), 9.0.17 and 8.0.28 (the same command with net9.0 / net8.0 and no version properties). The repro also runs on Windows 11 x64, there also with CoreCLR and NativeAOT 10.0.13 (SDK 10.0.402, -r win-x64), and the delegate*<int>, ref int, Span<int>, ref struct and TypedReference results ran only there, with CoreCLR 10.0.12 and NativeAOT 10.0.12 (-r win-x64, ILCompiler 10.0.12).
Metadata
Metadata
Labels
Type
Projects
- StatusShow more project fieldsNo status
Activity
dotnet-policy-service commentedon Oct 19, 2026
Tagging subscribers to this area: @agocke, @dotnet/ilc-contrib
See info in area-owners.md if you want to be subscribed.
MichalPetryka commentedon Oct 19, 2026
@jkotas isn't this supposed to work via unboxing stubs?
EDIT:
runtime/src/coreclr/vm/comdelegate.cpp
Lines 1059 to 1073 in bc9c5f5
Reject NativeAOT closed static delegates over non-reference first par…