From 65afa494d5e3e7604c36f3a4494eb42895dde022 Mon Sep 17 00:00:00 2001 From: "Aleksandar Marinov (INFRAGISTICS INC)" Date: Thu, 21 May 2026 13:46:17 +0300 Subject: [PATCH 1/3] Disconnect stale automation peers from UIA to fix memory leak in virtualized ItemsControls When a UIA client enumerates automation peers via FindAll, ElementProxy CCWs are created but never released, pinning managed peers and entire visual sub-trees in memory. This change disconnects removed peers in both UpdateChildrenInternal and EnsureChildren, breaking the reference chain so containers can be GC'd. Key changes: - DisconnectPeerFromUia defers UiaDisconnectProvider via Dispatcher.BeginInvoke to avoid calling it during UIA callbacks (re-entrancy unsafe). - proxy.ClearPeer() severs the ElementProxy->peer strong reference immediately, allowing the peer to become GC-eligible before the deferred call executes. - peer._children is nulled to break the chain from disconnected item peers to cached cell peers that root DataGridCell/DataGridRow containers. - EnsureChildren disconnects stale peers gated on _elementProxyWeakReference, making it allocation-free when no UIA client has walked the subtree. Fixes #11337 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MS/internal/Automation/ElementProxy.cs | 23 ++++- .../Automation/Peers/AutomationPeer.cs | 93 ++++++++++++++++++- 2 files changed, 111 insertions(+), 5 deletions(-) diff --git a/src/Microsoft.DotNet.Wpf/src/PresentationCore/MS/internal/Automation/ElementProxy.cs b/src/Microsoft.DotNet.Wpf/src/PresentationCore/MS/internal/Automation/ElementProxy.cs index cbc1e67b909..d00f006f0ce 100644 --- a/src/Microsoft.DotNet.Wpf/src/PresentationCore/MS/internal/Automation/ElementProxy.cs +++ b/src/Microsoft.DotNet.Wpf/src/PresentationCore/MS/internal/Automation/ElementProxy.cs @@ -534,6 +534,27 @@ internal static ReferenceType AutomationInteropReferenceType #endregion Private Methods + //------------------------------------------------------ + // + // Internal Methods + // + //------------------------------------------------------ + + #region Internal Methods + + /// + /// Severs the strong reference from this proxy to the automation peer. + /// Called during UIA disconnect so the peer (and its associated data items) + /// can be garbage collected even if the client still holds a COM reference + /// to this CCW temporarily. + /// + internal void ClearPeer() + { + _peer = null; + } + + #endregion Internal Methods + //------------------------------------------------------ // // Private Fields @@ -542,7 +563,7 @@ internal static ReferenceType AutomationInteropReferenceType #region Private Fields - private readonly object _peer; + private object _peer; #endregion Private Fields } diff --git a/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/Automation/Peers/AutomationPeer.cs b/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/Automation/Peers/AutomationPeer.cs index 5d4ce668f6c..36818350a31 100644 --- a/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/Automation/Peers/AutomationPeer.cs +++ b/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/Automation/Peers/AutomationPeer.cs @@ -4,6 +4,7 @@ //#define ENABLE_AUTOMATIONPEER_LOGGING // uncomment to include logging of various activities using System.Collections; +using System.Runtime.InteropServices; using System.Windows.Threading; using System.Windows.Automation.Provider; using MS.Internal; @@ -1483,6 +1484,7 @@ private void EnsureChildren() // UpdateSubtree is not called on it yet. if (!_childrenValid || _ancestorsInvalid) { + List oldChildren = _children; _children = GetChildrenCore(); if (_children != null) { @@ -1495,6 +1497,23 @@ private void EnsureChildren() } } _childrenValid = true; + + // Disconnect peers that were removed from the children list and + // were previously exposed to a UIA client (have an ElementProxy). + // The _elementProxyWeakReference gate keeps this allocation-free + // on the common path where no UIA client has walked this subtree. + if (oldChildren != null) + { + for (int i = 0; i < oldChildren.Count; i++) + { + AutomationPeer old = oldChildren[i]; + if (old._elementProxyWeakReference == null) + continue; + + if (_children == null || !_children.Contains(old)) + DisconnectPeerFromUia(old); + } + } } } @@ -1812,6 +1831,59 @@ private IRawElementProviderSimple ProviderFromPeerNoDelegation(AutomationPeer pe return ElementProxy.StaticWrap(peer, referencePeer); } + /// + /// Disconnects a peer from the UI Automation framework by calling + /// UiaDisconnectProvider on its ElementProxy CCW. + /// This causes the UIA client-side to release its COM references, allowing + /// the CCW ref count to drop to zero so the managed objects can be GC'd. + /// + /// + /// This method intentionally does NOT recursively disconnect children. + /// In virtualized controls, a removed item peer's cached _children may + /// reference container peers (e.g. DataGridCellAutomationPeer) that have + /// been recycled and are now serving new items. Disconnecting those would + /// break accessibility on currently visible elements. + /// Children are disconnected naturally when their own parent's + /// UpdateChildrenInternal runs and detects them as removed. + /// + private static void DisconnectPeerFromUia(AutomationPeer peer) + { + if (peer == null) + return; + + // Disconnect the peer's own ElementProxy CCW from UIA. + WeakReference proxyWeakRef = peer._elementProxyWeakReference; + if (proxyWeakRef?.Target is ElementProxy proxy) + { + // Sever proxy→peer reference immediately so the peer and its + // data items become GC-eligible without waiting for the deferred call. + proxy.ClearPeer(); + + // UiaDisconnectProvider must NOT be called during a UIA callback + // (e.g. during Navigate/FindAll handling). UpdateChildrenInternal + // is invoked from within UIA callbacks, so defer the actual COM + // disconnect to a separate dispatcher operation. + Dispatcher dispatcher = peer.Dispatcher; + if (dispatcher != null && !dispatcher.HasShutdownStarted) + { + dispatcher.BeginInvoke(DispatcherPriority.Background, + new Action(() => UiaDisconnectProvider(proxy))); + } + } + + peer._elementProxyWeakReference = null; + + // Break the peer→children chain. Without this, a disconnected + // ItemAutomationPeer still roots cached cell peers via _children, + // which in turn root DataGridCell/DataGridRow containers via _owner, + // preventing the entire visual sub-tree from being garbage collected. + peer._children = null; + peer._childrenValid = false; + } + + [DllImport("UIAutomationCore.dll", EntryPoint = "UiaDisconnectProvider", CharSet = CharSet.Unicode)] + private static extern int UiaDisconnectProvider(IRawElementProviderSimple provider); + /// /// When one AutomationPeer is using the pattern of another AutomationPeer instead of exposing /// it in the children collection (example - ListBox exposes IScrollProvider from internal ScrollViewer @@ -1892,10 +1964,6 @@ internal void UpdateChildrenInternal(int invalidateLimit) _childrenValid = false; EnsureChildren(); - // Callers have only checked if automation clients are present so filter for any interest in this particular event. - if (!EventMap.HasRegisteredEvent(AutomationEvents.StructureChanged)) - return; - //store old children in a hashset if(oldChildren != null) { @@ -1937,6 +2005,23 @@ internal void UpdateChildrenInternal(int invalidateLimit) //calls for "bulk" notification, use per-child notification, otherwise use "bulk" int removedCount = (hs == null ? 0 : hs.Count); + // Disconnect removed children from UIA so the client-side releases its + // COM references to the ElementProxy CCWs. Without this the CCW ref count + // never drops to zero, which prevents the managed peer (and its entire + // visual sub-tree) from being garbage collected. + // This must happen regardless of StructureChanged event registration. + if (removedCount > 0) + { + foreach (AutomationPeer removedChild in hs) + { + DisconnectPeerFromUia(removedChild); + } + } + + // Callers have only checked if automation clients are present so filter for any interest in this particular event. + if (!EventMap.HasRegisteredEvent(AutomationEvents.StructureChanged)) + return; + if(removedCount + addedCount > invalidateLimit) //bilk invalidation { StructureChangeType flags; From 1fde193b2f8e503c2e298e0a3762b09978ace04a Mon Sep 17 00:00:00 2001 From: "Aleksandar Marinov (INFRAGISTICS INC)" Date: Tue, 16 Jun 2026 22:42:29 +0300 Subject: [PATCH 2/3] Condense XML doc comments to single-line format for readability Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MS/internal/Automation/ElementProxy.cs | 5 +-- .../Automation/Peers/AutomationPeer.cs | 41 +++++++------------ 2 files changed, 16 insertions(+), 30 deletions(-) diff --git a/src/Microsoft.DotNet.Wpf/src/PresentationCore/MS/internal/Automation/ElementProxy.cs b/src/Microsoft.DotNet.Wpf/src/PresentationCore/MS/internal/Automation/ElementProxy.cs index d00f006f0ce..e8cdaac8eec 100644 --- a/src/Microsoft.DotNet.Wpf/src/PresentationCore/MS/internal/Automation/ElementProxy.cs +++ b/src/Microsoft.DotNet.Wpf/src/PresentationCore/MS/internal/Automation/ElementProxy.cs @@ -544,9 +544,8 @@ internal static ReferenceType AutomationInteropReferenceType /// /// Severs the strong reference from this proxy to the automation peer. - /// Called during UIA disconnect so the peer (and its associated data items) - /// can be garbage collected even if the client still holds a COM reference - /// to this CCW temporarily. + /// Called during UIA disconnect so the peer can be garbage collected + /// even if the client still holds a COM reference to this CCW temporarily. /// internal void ClearPeer() { diff --git a/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/Automation/Peers/AutomationPeer.cs b/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/Automation/Peers/AutomationPeer.cs index 36818350a31..830dd8d783f 100644 --- a/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/Automation/Peers/AutomationPeer.cs +++ b/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/Automation/Peers/AutomationPeer.cs @@ -1498,10 +1498,8 @@ private void EnsureChildren() } _childrenValid = true; - // Disconnect peers that were removed from the children list and - // were previously exposed to a UIA client (have an ElementProxy). - // The _elementProxyWeakReference gate keeps this allocation-free - // on the common path where no UIA client has walked this subtree. + // Disconnect peers that were removed from the children list and were previously exposed to a UIA client (have an ElementProxy). + // The _elementProxyWeakReference gate keeps this allocation-free on the common path where no UIA client has walked this subtree. if (oldChildren != null) { for (int i = 0; i < oldChildren.Count; i++) @@ -1832,19 +1830,14 @@ private IRawElementProviderSimple ProviderFromPeerNoDelegation(AutomationPeer pe } /// - /// Disconnects a peer from the UI Automation framework by calling - /// UiaDisconnectProvider on its ElementProxy CCW. - /// This causes the UIA client-side to release its COM references, allowing - /// the CCW ref count to drop to zero so the managed objects can be GC'd. + /// Disconnects a peer from the UI Automation framework by calling UiaDisconnectProvider on its ElementProxy CCW. + /// This causes the UIA client-side to release its COM references, allowing the CCW ref count to drop to zero so the managed objects can be GC'd. /// /// /// This method intentionally does NOT recursively disconnect children. - /// In virtualized controls, a removed item peer's cached _children may - /// reference container peers (e.g. DataGridCellAutomationPeer) that have - /// been recycled and are now serving new items. Disconnecting those would - /// break accessibility on currently visible elements. - /// Children are disconnected naturally when their own parent's - /// UpdateChildrenInternal runs and detects them as removed. + /// In virtualized controls, a removed item peer's cached _children may reference container peers (e.g. DataGridCellAutomationPeer) that have + /// been recycled and are now serving new items. Disconnecting those would break accessibility on currently visible elements. + /// Children are disconnected naturally when their own parent's UpdateChildrenInternal runs and detects them as removed. /// private static void DisconnectPeerFromUia(AutomationPeer peer) { @@ -1855,14 +1848,11 @@ private static void DisconnectPeerFromUia(AutomationPeer peer) WeakReference proxyWeakRef = peer._elementProxyWeakReference; if (proxyWeakRef?.Target is ElementProxy proxy) { - // Sever proxy→peer reference immediately so the peer and its - // data items become GC-eligible without waiting for the deferred call. + // Sever proxy→peer reference immediately so the peer and its data items become GC-eligible without waiting for the deferred call. proxy.ClearPeer(); - // UiaDisconnectProvider must NOT be called during a UIA callback - // (e.g. during Navigate/FindAll handling). UpdateChildrenInternal - // is invoked from within UIA callbacks, so defer the actual COM - // disconnect to a separate dispatcher operation. + // UiaDisconnectProvider must NOT be called during a UIA callback (e.g. during Navigate/FindAll handling). + // UpdateChildrenInternal is invoked from within UIA callbacks, so defer the actual COM disconnect to a separate dispatcher operation. Dispatcher dispatcher = peer.Dispatcher; if (dispatcher != null && !dispatcher.HasShutdownStarted) { @@ -1873,10 +1863,8 @@ private static void DisconnectPeerFromUia(AutomationPeer peer) peer._elementProxyWeakReference = null; - // Break the peer→children chain. Without this, a disconnected - // ItemAutomationPeer still roots cached cell peers via _children, - // which in turn root DataGridCell/DataGridRow containers via _owner, - // preventing the entire visual sub-tree from being garbage collected. + // Break the peer→children chain. Without this, a disconnected ItemAutomationPeer still roots cached cell peers via _children, + // which in turn root DataGridCell/DataGridRow containers via _owner, preventing the entire visual sub-tree from being GC`d. peer._children = null; peer._childrenValid = false; } @@ -2006,9 +1994,8 @@ internal void UpdateChildrenInternal(int invalidateLimit) int removedCount = (hs == null ? 0 : hs.Count); // Disconnect removed children from UIA so the client-side releases its - // COM references to the ElementProxy CCWs. Without this the CCW ref count - // never drops to zero, which prevents the managed peer (and its entire - // visual sub-tree) from being garbage collected. + // COM references to the ElementProxy CCWs. Without this the CCW ref count + // never drops to 0, which prevents the managed peer from being GC`d. // This must happen regardless of StructureChanged event registration. if (removedCount > 0) { From c0b8c49eea133951ac2a4e37d1c307d8abd29806 Mon Sep 17 00:00:00 2001 From: "Aleksandar Marinov (INFRAGISTICS INC)" Date: Thu, 18 Jun 2026 17:38:07 +0300 Subject: [PATCH 3/3] Address review feedback on PR #11657 - Cache static DispatcherOperationCallback; drop closure/Action allocation per call. - Remove redundant Dispatcher null/HasShutdownStarted guard (matches InvalidatePeer pattern). - Remove redundant peer null check inside DisconnectPeerFromUia (callers guarantee non-null). - Move UiaDisconnectProvider P/Invoke onto ElementProxy as a single Disconnect() method; collapse ClearPeer() into it. Use DllImport.UIAutomationCore string constant per codebase convention. - Build HashSet of new children lazily in EnsureChildren to avoid O(n*m) Contains scan; common path stays allocation-free. - Drop redundant disconnect loop from UpdateChildrenInternal (EnsureChildren already handles it on this path); revert the merged loop back to events-only. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MS/internal/Automation/ElementProxy.cs | 14 +++-- .../Automation/Peers/AutomationPeer.cs | 61 +++++++++---------- 2 files changed, 39 insertions(+), 36 deletions(-) diff --git a/src/Microsoft.DotNet.Wpf/src/PresentationCore/MS/internal/Automation/ElementProxy.cs b/src/Microsoft.DotNet.Wpf/src/PresentationCore/MS/internal/Automation/ElementProxy.cs index e8cdaac8eec..6d61a825b15 100644 --- a/src/Microsoft.DotNet.Wpf/src/PresentationCore/MS/internal/Automation/ElementProxy.cs +++ b/src/Microsoft.DotNet.Wpf/src/PresentationCore/MS/internal/Automation/ElementProxy.cs @@ -8,6 +8,7 @@ // // +using System.Runtime.InteropServices; using System.Windows; using System.Windows.Automation; using System.Windows.Automation.Provider; @@ -543,15 +544,20 @@ internal static ReferenceType AutomationInteropReferenceType #region Internal Methods /// - /// Severs the strong reference from this proxy to the automation peer. - /// Called during UIA disconnect so the peer can be garbage collected - /// even if the client still holds a COM reference to this CCW temporarily. + /// Disconnects this proxy from UI Automation and severs the strong reference + /// to the automation peer so the peer (and the items it transitively roots) + /// can be garbage collected. Must NOT be invoked from inside a UIA callback; + /// callers defer this to a dispatcher operation. /// - internal void ClearPeer() + internal void Disconnect() { + UiaDisconnectProvider(this); _peer = null; } + [DllImport(DllImport.UIAutomationCore, EntryPoint = "UiaDisconnectProvider", CharSet = CharSet.Unicode)] + private static extern int UiaDisconnectProvider(IRawElementProviderSimple provider); + #endregion Internal Methods //------------------------------------------------------ diff --git a/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/Automation/Peers/AutomationPeer.cs b/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/Automation/Peers/AutomationPeer.cs index 830dd8d783f..8a981d89acb 100644 --- a/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/Automation/Peers/AutomationPeer.cs +++ b/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/Automation/Peers/AutomationPeer.cs @@ -4,7 +4,6 @@ //#define ENABLE_AUTOMATIONPEER_LOGGING // uncomment to include logging of various activities using System.Collections; -using System.Runtime.InteropServices; using System.Windows.Threading; using System.Windows.Automation.Provider; using MS.Internal; @@ -1502,13 +1501,28 @@ private void EnsureChildren() // The _elementProxyWeakReference gate keeps this allocation-free on the common path where no UIA client has walked this subtree. if (oldChildren != null) { + HashSet newChildrenSet = null; + bool newChildrenSetBuilt = false; + for (int i = 0; i < oldChildren.Count; i++) { AutomationPeer old = oldChildren[i]; if (old._elementProxyWeakReference == null) continue; - if (_children == null || !_children.Contains(old)) + // Build a HashSet of the new children on first lookup so subsequent + // membership checks are O(1). Avoids O(n*m) when this peer has many + // children (e.g. the top-level peer of a large item host). + if (!newChildrenSetBuilt) + { + newChildrenSetBuilt = true; + if (_children != null && _children.Count > 0) + { + newChildrenSet = new HashSet(_children); + } + } + + if (newChildrenSet == null || !newChildrenSet.Contains(old)) DisconnectPeerFromUia(old); } } @@ -1841,24 +1855,15 @@ private IRawElementProviderSimple ProviderFromPeerNoDelegation(AutomationPeer pe /// private static void DisconnectPeerFromUia(AutomationPeer peer) { - if (peer == null) - return; - // Disconnect the peer's own ElementProxy CCW from UIA. WeakReference proxyWeakRef = peer._elementProxyWeakReference; if (proxyWeakRef?.Target is ElementProxy proxy) { - // Sever proxy→peer reference immediately so the peer and its data items become GC-eligible without waiting for the deferred call. - proxy.ClearPeer(); - - // UiaDisconnectProvider must NOT be called during a UIA callback (e.g. during Navigate/FindAll handling). - // UpdateChildrenInternal is invoked from within UIA callbacks, so defer the actual COM disconnect to a separate dispatcher operation. - Dispatcher dispatcher = peer.Dispatcher; - if (dispatcher != null && !dispatcher.HasShutdownStarted) - { - dispatcher.BeginInvoke(DispatcherPriority.Background, - new Action(() => UiaDisconnectProvider(proxy))); - } + // ElementProxy.Disconnect calls UiaDisconnectProvider and severs the proxy→peer reference. + // UiaDisconnectProvider must NOT be called during a UIA callback (e.g. during Navigate/FindAll + // handling). UpdateChildrenInternal is invoked from within UIA callbacks, so defer the whole + // disconnect to a dispatcher operation that runs after the current callback completes. + peer.Dispatcher.BeginInvoke(DispatcherPriority.Background, _disconnectProxyCallback, proxy); } peer._elementProxyWeakReference = null; @@ -1869,8 +1874,11 @@ private static void DisconnectPeerFromUia(AutomationPeer peer) peer._childrenValid = false; } - [DllImport("UIAutomationCore.dll", EntryPoint = "UiaDisconnectProvider", CharSet = CharSet.Unicode)] - private static extern int UiaDisconnectProvider(IRawElementProviderSimple provider); + private static readonly DispatcherOperationCallback _disconnectProxyCallback = static arg => + { + ((ElementProxy)arg).Disconnect(); + return null; + }; /// /// When one AutomationPeer is using the pattern of another AutomationPeer instead of exposing @@ -1993,23 +2001,14 @@ internal void UpdateChildrenInternal(int invalidateLimit) //calls for "bulk" notification, use per-child notification, otherwise use "bulk" int removedCount = (hs == null ? 0 : hs.Count); - // Disconnect removed children from UIA so the client-side releases its - // COM references to the ElementProxy CCWs. Without this the CCW ref count - // never drops to 0, which prevents the managed peer from being GC`d. - // This must happen regardless of StructureChanged event registration. - if (removedCount > 0) - { - foreach (AutomationPeer removedChild in hs) - { - DisconnectPeerFromUia(removedChild); - } - } + // Removed peers are already disconnected from UIA by EnsureChildren() above; + // this block only raises StructureChanged events. // Callers have only checked if automation clients are present so filter for any interest in this particular event. if (!EventMap.HasRegisteredEvent(AutomationEvents.StructureChanged)) return; - if(removedCount + addedCount > invalidateLimit) //bilk invalidation + if (removedCount + addedCount > invalidateLimit) //bulk invalidation { StructureChangeType flags; @@ -2039,7 +2038,6 @@ internal void UpdateChildrenInternal(int invalidateLimit) IRawElementProviderSimple provider = ProviderFromPeerNoDelegation(this); if (provider != null) { - //hs contains removed children by now foreach (AutomationPeer removedChild in hs) { int[] rid = removedChild.GetRuntimeId(); @@ -2052,7 +2050,6 @@ internal void UpdateChildrenInternal(int invalidateLimit) } if (addedCount > 0) { - //hs contains removed children by now foreach (AutomationPeer addedChild in addedChildren) { //for children added, provider is the child itself