Skip to content

[Regression] COMWrapper Leak when targeting .NET 10 on a WinUI3 project #129191

Closed

Description

@macy-kairospower

Description

Updated a Winui3 application from .NET8 -> .NET10 and saw massive increase in memory usage.

  • .NET8 ~250 Mb
  • .NET10 ate up to 13 Gb (Over a week or so) before we stopped the app running.

The app routes a high volume of INotifyPropertyChanged events through the DispatcherQueue which might be part of the cause.
Tested on the latest Winui3 release and the issue was still present.

Posted the issue here because it seems like a runtime issue, not a library issue.

Configuration

Windows 11 26200.8457
Target x64

<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
...
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.1839" />
<PackageReference Include="Microsoft.WindowsAppSDK" Version="2.1.3" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools.WinApp" Version="0.3.1" />

Reproduction Repo Here

https://github.com/macy-kairospower/WinUI__NET10_Leak

Regression

The app sees a buildup of ComWrappers when targeting .net10

.NET8 Memory Profile : Mostly System Types.
Image

.NET10 Memory Profile : an Explosion of Wrappers
Image

Data

The production app eats a ton of memory and it seems to bounce all over the place.
The reproduction app suffers from the same leaks, but sees a plateau of memory usage (unlike the production app)
Image

Analysis

The production application routes a large number of updates through the dispatcher queue using a modified version of the ObservableObject Class
The leak looks like it's coming from the binding, not sure if its a C#/WinRT issue or an Issue with the new GC in .NET10

namespace Observability.Common
{

    /// <summary>
    /// Extension of the observable objects class,
    /// Allows other threads to trigger binding updates
    /// </summary>
    public partial class MoreObservableObject : ObservableObject
    {

        private static DispatcherQueue Queue { get; set; }
        public static void BindGUIThread(DispatcherQueue queue)
        {
            Queue = queue;
        }
        public static void Update(Action action)
        {
            if (Queue == null)
            {
                action();
                return;
            }
            Queue.TryEnqueue(() => action());
        }
        protected override void OnPropertyChanged(PropertyChangedEventArgs e)
        {
            Update(() => base.OnPropertyChanged(e));
        }
    }

    public class MoreObservableCollection<T> : ObservableCollection<T>
    {
        public new void Add(T item)
        {
            MoreObservableObject.Update(() => base.Add(item));
        }

        public new void Clear()
        {
            MoreObservableObject.Update(() => base.Clear());
        }

        public new void RemoveAt(int index)
        {
            MoreObservableObject.Update(() => {
                if (base.Count > index) base.RemoveAt(index);
            });
        }

    }
}

Activity

  1. added
    needs-area-labelAn area label is needed to ensure this gets routed to the appropriate area owners
    on Jun 9, 2026
  2. removed
    needs-area-labelAn area label is needed to ensure this gets routed to the appropriate area owners
    on Jun 10, 2026
  3. DobriyTauren commented on Aug 15, 2026

    @DobriyTauren

    We hit this too and tracked it down to the root cause. It is not specific to WinUI or to XAML bindings — it reproduces with plain ComWrappers and no UI framework at all.

    Root cause

    ComWrappers keeps a diagnostics-only registry of every CCW ever handed out for an object:

    private static readonly ConditionalWeakTable<object, List<ManagedObjectWrapperHolder>> s_allManagedObjectWrapperTable = [];

    GetOrCreateComInterfaceForObject calls RegisterManagedObjectWrapperForDiagnostics after the GetOrAdd that returns the cached wrapper:

    ManagedObjectWrapperHolder managedObjectWrapper = _managedObjectWrapperTable.GetOrAdd(instance, static (c, state) => { ... }, new { This = this, flags });
    
    managedObjectWrapper.AddRef();
    RegisterManagedObjectWrapperForDiagnostics(instance, managedObjectWrapper);

    and that method appends unconditionally:

    List<ManagedObjectWrapperHolder> allWrappersForThisInstance = s_allManagedObjectWrapperTable.GetOrCreateValue(instance);
    lock (allWrappersForThisInstance)
    {
        allWrappersForThisInstance.Add(wrapper);
    }

    Two consequences:

    1. The append runs on every call, including cache hits that return an already-existing wrapper. So the list grows with the number of calls, not with the number of distinct wrappers.
    2. Nothing ever removes from, nulls out, or compacts that list.

    For any long-lived object that is repeatedly marshalled to native code, the List<T> therefore doubles forever. The entries are duplicates — the same holder added over and over — so they carry no information even for the stated diagnostic purpose.

    (src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs — the code is the same on release/10.0 and on main as of today.)

    When it appeared

    Neither s_allManagedObjectWrapperTable nor RegisterManagedObjectWrapperForDiagnostics exists on release/9.0: there the file is a ~6 KB partial over the native implementation, against ~84 KB on release/10.0 after the managed implementation was shared between CoreCLR and NativeAOT (#113907). So on CoreCLR this registry is new in .NET 10, which lines up with the .NET 8/9 -> .NET 10 reports here and in microsoft-ui-xaml#10981.

    Minimal repro, no WinUI

    using System.Runtime.InteropServices;
    
    internal sealed unsafe class MinimalComWrappers : ComWrappers
    {
        protected override ComInterfaceEntry* ComputeVtables(object obj, CreateComInterfaceFlags flags, out int count)
        {
            count = 0;
            return null;
        }
        protected override object? CreateObject(nint externalComObject, CreateObjectFlags flags) => null;
        protected override void ReleaseObjects(System.Collections.IEnumerable objects) { }
    }
    
    internal static class Program
    {
        private static void Main()
        {
            var cw = new MinimalComWrappers();
            var target = new object();          // one long-lived instance
    
            for (int i = 1; i <= 20_000_000; i++)
            {
                IntPtr p = cw.GetOrCreateComInterfaceForObject(target, CreateComInterfaceFlags.None);
                Marshal.Release(p);             // balanced - no COM reference is retained
    
                if (i % 5_000_000 == 0) Report(i);
            }
    
            GC.Collect(2, GCCollectionMode.Forced, blocking: true);
            GC.WaitForPendingFinalizers();
            GC.Collect(2, GCCollectionMode.Forced, blocking: true);
            Console.Write("after full blocking GC: ");
            Report(20_000_000);
    
            GC.KeepAlive(target);
        }
    
        private static void Report(int i)
        {
            var info = GC.GetGCMemoryInfo();
            double loh = info.GenerationInfo[3].SizeAfterBytes / 1024.0 / 1024.0;
            Console.WriteLine($"calls={i,12:N0}  managed heap={GC.GetTotalMemory(false) / 1048576.0,8:N1} MB  LOH={loh,8:N1} MB");
        }
    }

    Console app, net10.0, runtime 10.0.10, x64, Windows 11. Nothing is retained by the caller: the pointer is released on every iteration.

    As shipped (Debugger.IsSupported unset, defaults to true):

    calls=           0  managed heap=     0.1 MB  LOH=     0.0 MB
    calls=   5,000,000  managed heap=    69.1 MB  LOH=    64.0 MB
    calls=  10,000,000  managed heap=   136.1 MB  LOH=   128.0 MB
    calls=  15,000,000  managed heap=   129.2 MB  LOH=   128.0 MB
    calls=  20,000,000  managed heap=   260.2 MB  LOH=   256.0 MB
    after full blocking GC: managed heap=   256.1 MB  LOH=   256.0 MB
    

    Same binary, only "System.Diagnostics.Debugger.IsSupported": false added to runtimeconfig.json:

    calls=           0  managed heap=     0.1 MB  LOH=     0.0 MB
    calls=   5,000,000  managed heap=     3.2 MB  LOH=     0.0 MB
    calls=  10,000,000  managed heap=     6.2 MB  LOH=     0.0 MB
    calls=  15,000,000  managed heap=     9.2 MB  LOH=     0.0 MB
    calls=  20,000,000  managed heap=     2.3 MB  LOH=     0.0 MB
    after full blocking GC: managed heap=     0.1 MB  LOH=     0.0 MB
    

    256 MB of live LOH from 20 million balanced calls, surviving a full blocking gen2 collection — versus completely flat with the diagnostics registration disabled.

    Evidence from a real app

    A WinUI 3 desktop app on the same runtime, during a voice call with a live-updating UI.

    LOH doubling series from our own logging (one call, ~5 h)
    17:20:08  call connected
    17:20:50  [alloc]     4 MB  ManagedObjectWrapperHolder[]  kind=LOH
    17:21:32  [alloc]     8 MB
    17:23:07  [alloc]    16 MB
    17:25:39  [alloc]    32 MB
    17:31:55  [alloc]    64 MB
    17:54:23  [alloc]   128 MB
    18:23:46  [alloc]   256 MB
    

    Earlier sessions reached 1024 MB. Working set followed: 245 MB -> 1189 MB.

    A process dump taken 42 minutes into a call, walked with ClrMD (dependent handles: primary = the CWT key, secondary = the List<ManagedObjectWrapperHolder>):

    10,507,428 entries   MyApp.ViewModels.CallViewModel     <- one view model
         3,228 entries   System.String
           731 entries   MyApp.ViewModels.PeerVolumeViewModel
           481 entries   MyApp.ViewModels.ChatSessionViewModel
    
    live ManagedObjectWrapperHolder instances in the whole process: 1,745
    

    10.5 million list entries against 1,745 live holder objects — the duplication is the whole leak. gen2 stayed at 5–6 MB throughout, handle and thread counts were flat; only the LOH array grew.

    Measured rate: ~4,180 calls/second against that single object, constant for the duration of the call, and it kept growing across a hangup because the key object outlives the call.

    With Debugger.IsSupported=false in the Release build, a subsequent 5 h 32 min call plus a further 1 h 42 min call showed LOH pinned at 4.8 MB and not a single ManagedObjectWrapperHolder[] allocation.

    Why this hits WinUI apps in particular

    The growth rate is (rate of managed→native crossings of one long-lived object). In XAML that is amplified by the binding style:

    • classic {Binding} attaches a separate native INotifyPropertyChanged subscriber per binding expression, so a single PropertyChanged raise fans out to all of them, marshalling the source object once per handler;
    • x:Bind compiles to one generated Bindings class that subscribes once and dispatches by property name in managed code.

    The contrast is visible in the dump: our view model, bound by 140 classic binding expressions spread over several views, accumulated 10.5 million entries, while views using x:Bind show 41 entries per generated bindings object.

    We then measured the effect of converting one of those views. 77 of the 140 expressions moved to x:Bind; the remaining 63 come from three instances of another user control that inherits the same DataContext and still uses classic bindings. Rate before: ~4,180/s. Rate after, taken across two consecutive doublings of the array: ~2,400–2,600/s. Roughly proportional to the number of remaining subscribers, which is what the per-expression model predicts.

    Worth stating plainly though: binding style is not a mitigation. Removing more than half of the subscribers only changed how long it takes to reach a gigabyte — from roughly five hours to roughly nine.

    This is also why the repro in this issue and in microsoft-ui-xaml#10981 (same reporter, filed against the XAML repo in January, still unresolved and not cross-linked) both involve high-frequency INotifyPropertyChanged traffic through DispatcherQueue.

    Workaround

    Set the feature switch, which makes RegisterManagedObjectWrapperForDiagnostics return early:

    <ItemGroup>
      <RuntimeHostConfigurationOption Include="System.Diagnostics.Debugger.IsSupported" Value="false" Trim="true" />
    </ItemGroup>

    Verify that "System.Diagnostics.Debugger.IsSupported": false actually lands in the generated runtimeconfig.json.

    On the duplicated entries

    Worth separating two things: the registry existing at all, and what it stores. The 10.5 million entries above are the same holder repeated — 1,745 distinct holder objects existed in the entire process. So the list grows with the number of calls rather than with the number of wrappers, and the repeated entries carry no additional information for the stated purpose in the comment above the method ("keep all ManagedObjectWrapperHolders alive as long as the managed object is alive").

    Classification

    Would it make sense to treat this as a regression rather than tenet-performance? GetOrCreateComInterfaceForObject is meant to be called repeatedly — it AddRefs and returns the cached pointer — and code that was flat on .NET 8/9 now grows without bound on .NET 10 with no opt-in.

  4. added this to the 10.0.x milestone on Sep 4, 2026
  5. locked and limited conversation to collaborators on Oct 18, 2026
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

Relationships

None yet

Development

No branches or pull requests

Issue actions