Skip to content

NativeAOT binds closed static delegates over value-type first parameters #136101

Closed

Description

@caraioniurie47

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 (42 here): NativeAOT would still accept delegates that CoreCLR rejects.
  • Rejecting only boxed targets: a null target would still bind over int and int*, 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).

Activity

  1. dotnet-policy-service commented on Oct 19, 2026

    @dotnet-policy-service
    Contributor

    Tagging subscribers to this area: @agocke, @dotnet/ilc-contrib
    See info in area-owners.md if you want to be subscribed.

  2. MichalPetryka commented on Oct 19, 2026

    @MichalPetryka
    Contributor

    @jkotas isn't this supposed to work via unboxing stubs?

    EDIT:

    // Use unboxing stubs for instance and virtual methods on value types.
    // If this is a open delegate to an instance method BindToMethod will rebind it to the non-unboxing method.
    // Open delegate
    // Static: never use unboxing stub
    // BindToMethodInfo/Name will bind to the non-unboxing stub. BindToMethod will reinforce that.
    // Instance: We only support binding to an unboxed value type reference here, so we must never use an unboxing stub
    // BindToMethodInfo/Name will bind to the unboxing stub. BindToMethod will rebind to the non-unboxing stub.
    // Virtual: trivial (not allowed)
    // Closed delegate
    // Static: never use unboxing stub
    // BindToMethodInfo/Name will bind to the non-unboxing stub.
    // Instance: always use unboxing stub
    // BindToMethodInfo/Name will bind to the unboxing stub.
    // Virtual: always use unboxing stub
    // BindToMethodInfo/Name will bind to the unboxing stub.

  3. added a commit that references this issue on Oct 20, 2026
    8ab789d
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

  • Status
    No status

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions