.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.
.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::EnumFormatEtccall on the COM interface of aDataObjectleaks one reference on the returnedFormatEnumeratorCCW. TheFormatEnumeratorholds theDataStore, so everything stored in the data object is kept alive for the rest of the process lifetime.ole32 calls
EnumFormatEtcduring everyDoDragDrop. 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:(Note: SOS
gcrootin dotnet-dump 9.0.661903 reportsFound 0 unique roots.for such objects, which makes this easy to misdiagnose as "already released".)Suspected cause
NativeToRuntimeAdapter.EnumFormatEtcreleases the pointer returned by the nativeEnumFormatEtc(viaComScope) and then callsComHelpers.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
The reference added by
QueryInterfaceis never released (GetObjectForIUnknown(IUnknown*)does not take ownership). For a ComWrappers-created CCW,ComWrappers.TryGetObjectunwraps to the managedFormatEnumerator, and the leaked reference keeps the CCW's ref-counted handle strong forever. The other callers ofGetObjectForIUnknown<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 duringDoDragDrop: take theComTypes.IDataObjectinterface, callEnumFormatEtc, then release every pointer correctly.Output on 10.0.12:
Control: skipping the
EnumFormatEtccall (only get and release theIDataObjectpointer) printsFalse. We also reproduced the retention with a realSystem.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
DoDragDropreturns, overwrite every format we put into theDataObjectwith a small placeholder (SetData(format, placeholder, false)). This cuts the path to the payload, but theFormatEnumerator/DataStorethemselves still leak on every drag.