Skip to content

DataObject payload leaked after drag and drop: ComHelpers.GetObjectForIUnknown<T> QueryInterface(IUnknown) is never released (EnumFormatEtc) #15199

Description

@cuiliang

.NET version

.NET 10.0.12 (Microsoft.WindowsDesktop.App 10.0.12), Windows 11 26200, x64. Found in a WPF app.

Did it work in .NET Framework?

Not tested.

Issue description

Every IDataObject::EnumFormatEtc call on the COM interface of a DataObject leaks one reference on the returned FormatEnumerator CCW. The FormatEnumerator holds the DataStore, so everything stored in the data object is kept alive for the rest of the process lifetime.

ole32 calls EnumFormatEtc during every DoDragDrop. In practice this means that every WPF drag and drop pins its payload permanently: dropped, cancelled, Esc, same-process or cross-process targets all leak. We hit this in a WPF app where a view model object was used as the drag payload. Through event subscriptions it kept a whole closed window (visual tree + view models) alive. A heap snapshot (dotMemory) shows the root:

RefCounted handle
 -> ComWrappers+ManagedObjectWrapperHolder
 -> FormatEnumerator
 -> DataStore<WpfOleServices>._mappedData
 -> <payload>

(Note: SOS gcroot in dotnet-dump 9.0.661903 reports Found 0 unique roots. for such objects, which makes this easy to misdiagnose as "already released".)

Suspected cause

NativeToRuntimeAdapter.EnumFormatEtc releases the pointer returned by the native EnumFormatEtc (via ComScope) and then calls ComHelpers.GetObjectForIUnknown(nativeFormat):

https://github.com/dotnet/winforms/blob/v10.0.12/src/System.Private.Windows.Core/src/Windows/Win32/System/Com/ComHelpers.cs#L261-L279

unknown->QueryInterface(IID.Get<IUnknown>(), (void**)&unknown).ThrowOnFailure();
return GetObjectForIUnknown(unknown);

The reference added by QueryInterface is never released (GetObjectForIUnknown(IUnknown*) does not take ownership). For a ComWrappers-created CCW, ComWrappers.TryGetObject unwraps to the managed FormatEnumerator, and the leaked reference keeps the CCW's ref-counted handle strong forever. The other callers of GetObjectForIUnknown<TInterface> may have the same imbalance (not checked).

Steps to reproduce

Minimal repro (net10.0-windows, UseWPF=true, AllowUnsafeBlocks=true). It mimics what ole32 does during DoDragDrop: take the ComTypes.IDataObject interface, call EnumFormatEtc, then release every pointer correctly.

using System;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Windows;
using ComTypes = System.Runtime.InteropServices.ComTypes;

internal static unsafe class Program
{
    [STAThread]
    private static void Main()
    {
        Console.WriteLine($"Runtime: {Environment.Version}");
        WeakReference payload = CreateEnumerateAndReleaseEverything();

        for (int i = 0; i < 5; i++)
        {
            GC.Collect();
            GC.WaitForPendingFinalizers();
        }

        // Expected: False. Actual on 10.0.12: True (payload is kept alive forever).
        Console.WriteLine($"Payload alive after all COM references were released: {payload.IsAlive}");
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    private static WeakReference CreateEnumerateAndReleaseEverything()
    {
        var payload = new byte[4 * 1024 * 1024];
        var dataObject = new DataObject("MyFormat", payload);

        // Same COM interface WPF hands to ole32 in DragDrop.DoDragDrop / OleSetClipboard.
        IntPtr pDataObject = Marshal.GetComInterfaceForObject(dataObject, typeof(ComTypes.IDataObject));

        // IDataObject::EnumFormatEtc is vtable slot 8, exactly what ole32 calls during DoDragDrop.
        var enumFormatEtc = (delegate* unmanaged[Stdcall]<IntPtr, uint, IntPtr*, int>)(*(IntPtr**)pDataObject)[8];
        IntPtr pEnum;
        Marshal.ThrowExceptionForHR(enumFormatEtc(pDataObject, 1 /* DATADIR_GET */, &pEnum));

        // A well-behaved caller releases everything it received.
        Marshal.Release(pEnum);
        Marshal.Release(pDataObject);

        return new WeakReference(payload);
    }
}

Output on 10.0.12:

Runtime: 10.0.12
Payload alive after all COM references were released: True

Control: skipping the EnumFormatEtc call (only get and release the IDataObject pointer) prints False. We also reproduced the retention with a real System.Windows.DragDrop.DoDragDrop (same-process and cross-process drop targets, drop and cancel); the payload was still alive after 600 seconds.

Workaround we use for now: after DoDragDrop returns, overwrite every format we put into the DataObject with a small placeholder (SetData(format, placeholder, false)). This cuts the path to the payload, but the FormatEnumerator / DataStore themselves still leak on every drag.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions