Skip to content

.NET 11 regression: AutoDispatch marshals an unrelated COM object into a typed managed array #134581

Description

@MichalStrehovsky

Description

Built-in COM interop accepts an unrelated object when marshalling a SAFEARRAY into a strongly typed managed array parameter on .NET 11.

The repro invokes Target.Accept(Expected[] values) through IDispatch, supplying a SAFEARRAY of IUnknown containing the COM view of an Unrelated instance. Both classes use the default AutoDispatch class interface.

On .NET 10, the call rejects the element with InvalidCastException. On .NET 11, the managed method runs with an array whose runtime type is Expected[], but whose first element has runtime type Unrelated.

Reproduction steps

Create an empty directory outside the runtime repository and save the following as Repro.csproj and Program.cs. No external native library or NuGet package is required; the example uses Windows built-in COM interop.

Repro.csproj

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
    <Nullable>enable</Nullable>
  </PropertyGroup>
</Project>

Program.cs

// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Runtime.InteropServices;

[assembly: ComVisible(true)]

internal static unsafe class Program
{
    private const ushort VT_UNKNOWN = 13;
    private const ushort VT_ARRAY = 0x2000;
    private const ushort DispatchMethod = 1;

    private static int Main()
    {
        Console.WriteLine(RuntimeInformation.FrameworkDescription);
        if (!OperatingSystem.IsWindows())
        {
            Console.WriteLine("Requires Windows built-in COM interop.");
            return 2;
        }

        // A SAFEARRAY holding the COM view of a class that has nothing to do with Expected.
        var unrelated = new Unrelated();
        nint unrelatedUnknown = Marshal.GetIUnknownForObject(unrelated);
        SafeArray* array = SafeArrayCreateVector(VT_UNKNOWN, 0, 1);
        if (array is null)
        {
            Console.WriteLine("SafeArrayCreateVector failed.");
            Marshal.Release(unrelatedUnknown);
            return 2;
        }

        ((nint*)array->Data)[0] = unrelatedUnknown;

        var target = new Target();
        nint dispatch = Marshal.GetIDispatchForObject(target);
        try
        {
            int dispId = GetDispId(dispatch, nameof(Target.Accept));
            var argument = new Variant { Vt = VT_ARRAY | VT_UNKNOWN, Data1 = (nint)array };

            try
            {
                Invoke(dispatch, dispId, &argument);
            }
            catch (InvalidCastException ex)
            {
                Console.WriteLine($"PASS: the unrelated element was rejected: {ex.Message}");
                return 0;
            }

            Console.WriteLine($"Array type: {target.ArrayType}");
            Console.WriteLine($"Stored element type: {target.ElementType}");
            Console.WriteLine($"Element is Expected: {target.ElementIsExpected} (expected True)");
            Console.WriteLine(target.ElementIsExpected
                ? "PASS"
                : $"BUG: an {target.ElementType} was stored into an {target.ArrayType} without a cast check.");
            return target.ElementIsExpected ? 0 : 1;
        }
        finally
        {
            Marshal.Release(dispatch);
            SafeArrayDestroy(array);
        }
    }

    private static int GetDispId(nint dispatch, string name)
    {
        Guid riid = Guid.Empty;
        fixed (char* namePtr = name)
        {
            char* names = namePtr;
            int dispId;
            var getIdsOfNames = (delegate* unmanaged[Stdcall]<nint, Guid*, char**, uint, uint, int*, int>)
                (*(*(void***)dispatch + 5));
            Marshal.ThrowExceptionForHR(getIdsOfNames(dispatch, &riid, &names, 1, 0, &dispId));
            return dispId;
        }
    }

    private static void Invoke(nint dispatch, int dispId, Variant* argument)
    {
        var parameters = new DispParams
        {
            Arguments = argument,
            NamedArguments = null,
            ArgumentCount = 1,
            NamedArgumentCount = 0
        };

        byte* exceptionInfo = stackalloc byte[128];
        uint argumentError;
        Guid riid = Guid.Empty;
        var invoke = (delegate* unmanaged[Stdcall]<nint, int, Guid*, uint, ushort, DispParams*, Variant*, void*, uint*, int>)
            (*(*(void***)dispatch + 6));
        Marshal.ThrowExceptionForHR(
            invoke(dispatch, dispId, &riid, 0, DispatchMethod, &parameters, null, exceptionInfo, &argumentError));
    }

    [DllImport("oleaut32.dll")]
    private static extern SafeArray* SafeArrayCreateVector(ushort vt, int lowerBound, uint count);

    [DllImport("oleaut32.dll")]
    private static extern int SafeArrayDestroy(SafeArray* array);

    [StructLayout(LayoutKind.Sequential)]
    private struct Variant
    {
        public ushort Vt;
        public ushort Reserved1;
        public ushort Reserved2;
        public ushort Reserved3;
        public nint Data1;
        public nint Data2;
    }

    [StructLayout(LayoutKind.Sequential)]
    private struct SafeArray
    {
        public ushort Dimensions;
        public ushort Features;
        public uint ElementSize;
        public uint Locks;
        public nint Data;
        public uint Elements;
        public int LowerBound;
    }

    [StructLayout(LayoutKind.Sequential)]
    private struct DispParams
    {
        public Variant* Arguments;
        public int* NamedArguments;
        public uint ArgumentCount;
        public uint NamedArgumentCount;
    }
}

[ComVisible(true)]
public class Expected
{
    public int Value => 1;
}

[ComVisible(true)]
public class Unrelated
{
    public int Other => 2;
}

[ComVisible(true)]
public class Target
{
    public string? ArrayType { get; private set; }

    public string? ElementType { get; private set; }

    public bool ElementIsExpected { get; private set; }

    public void Accept(Expected[] values)
    {
        ArrayType = values.GetType().Name;
        object element = values[0];
        ElementType = element.GetType().Name;
        ElementIsExpected = element is Expected;
    }
}

Build once with dotnet build Repro.csproj. Run the generated apphost against the two runtimes:

$env:DOTNET_ROLL_FORWARD_TO_PRERELEASE = '1'
$env:DOTNET_ROLL_FORWARD = 'LatestPatch'
& .\bin\Debug\net10.0\Repro.exe

$env:DOTNET_ROLL_FORWARD = 'LatestMajor'
& .\bin\Debug\net10.0\Repro.exe

These commands assume the apphost resolves to a .NET installation containing the tested .NET 10 and .NET 11 runtimes, with no newer major version. If needed, set DOTNET_ROOT to that installation. Check the framework version printed by the program rather than assuming which runtime was selected. Remove the temporary roll-forward environment variables after the comparison.

Expected behavior

Reject the incompatible element before invoking the managed method, as on .NET 10:

.NET 10.0.5
PASS: the unrelated element was rejected: Unable to cast object of type 'Unrelated' to type 'Expected'.

Actual behavior

.NET 11 invokes the method with an Unrelated element inside an Expected[]:

.NET 11.0.0-rc.1.26420.103
Array type: Expected[]
Stored element type: Unrelated
Element is Expected: False (expected True)
BUG: an Unrelated was stored into an Expected[] without a cast check.

Regression

Confirmed by running the same net10.0-targeted program on .NET 10.0.5 and .NET 11.0.0-rc.1.26420.103. The only change to the program included above is removal of an introductory explanatory comment.

The exact introducing commit has not been established by a runtime build-and-bisect. The implementation observations below are based on source inspection.

Configuration

  • Windows x64, CoreCLR, built-in COM interop.
  • Passing runtime: .NET 10.0.5.
  • Failing runtime: .NET 11.0.0-rc.1.26420.103.
  • Compiled with SDK 11.0.100-rc.1.26420.103, targeting net10.0.
  • The repository runtime was not rebuilt for this comparison. Other architectures, Mono, NativeAOT, and source-generated COM interop were not tested.

Implementation observations

For this AutoDispatch class, GetElementTypeForSafeArrayVarType selects System.Object as the converter's element type, while the allocated managed array retains its actual Expected[] type.

The generic array conversion loop creates a span over the array's raw data using the selected element type. InterfaceArrayElementMarshaler.ConvertToManaged assigns the result of Marshal.GetObjectForIUnknown without checking compatibility with the actual destination array element type.

The pre-migration native converter, MarshalInterfaceArrayOleToCom, called CanCastComObject against the destination element type and threw InvalidCastException for an incompatible element. That destination-type validation needs to be preserved by the managed conversion path.

Note

This report was prepared with GitHub Copilot assistance; the included runtime outputs were reproduced locally.

Activity

  1. dotnet-policy-service commented on Sep 24, 2026

    @dotnet-policy-service
    Contributor

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

  2. added this to the 11.0.0 milestone on Oct 2, 2026
  3. removed
    untriagedNew issue has not been triaged by the area owner
    on Oct 2, 2026
  4. added a commit that references this issue on Oct 6, 2026
    74e4ec9
  5. added a commit that references this issue on Oct 6, 2026
    a2f7d0b
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    • Status
      No status

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions