From 03cdc2d15d039dd27fdfea62ac65d7708a37d574 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 UIA clients enumerate a virtualized ItemsControl (e.g. DataGrid), ElementProxy CCWs are created for each automation peer. Previously, when children were replaced (e.g. due to collection rebinding), the old peers were never disconnected from UIA Core. The COM references held by UIA kept the CCW ref count > 0, pinning the managed peers and their entire visual sub-trees in memory indefinitely. This fix calls UiaDisconnectProvider on removed children's ElementProxy CCWs during UpdateChildrenInternal, causing UIA Core to release its COM references. This allows the CCW ref count to drop to zero so the managed peers can be garbage collected. Key changes: - Add DisconnectPeerFromUia() that disconnects a peer by calling UiaDisconnectProvider on its ElementProxy (non-recursive to avoid disconnecting shared container peers in virtualized controls) - Move the StructureChanged event check after disconnect logic in UpdateChildrenInternal so disconnection happens regardless of event registration - P/Invoke UiaDisconnectProvider from UIAutomationCore.dll Note: UIA clients may observe ElementNotAvailableException when accessing properties of stale elements after disconnection. This is standard UIA behavior that well-behaved clients already handle. Fixes dotnet#11337 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Automation/Peers/AutomationPeer.cs | 55 +++++++++++++++++-- 1 file changed, 51 insertions(+), 4 deletions(-) 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..04b08405c82 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; @@ -1812,6 +1813,39 @@ 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) + { + UiaDisconnectProvider(proxy); + } + + peer._elementProxyWeakReference = null; + } + + [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 +1926,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 +1967,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 b63fc385ef472e62ff8e31b83cdd5472e8bc43bf Mon Sep 17 00:00:00 2001 From: "Galina Paskaleva (INFRAGISTICS INC)" Date: Fri, 12 Jun 2026 19:44:15 +0300 Subject: [PATCH 2/3] chore(*): Break the reference chain so old containers can be garbage collected. --- .../MS/internal/Automation/ElementProxy.cs | 15 +++++- .../Automation/Peers/AutomationPeer.cs | 53 +++++++++++++++---- 2 files changed, 58 insertions(+), 10 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..da66a8e47cf 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 @@ -540,9 +540,22 @@ internal static ReferenceType AutomationInteropReferenceType // //------------------------------------------------------ + #region Internal Methods for Disconnect + + // Called during UIA disconnect to sever the strong reference from this + // proxy to the automation peer. This allows the peer (and its associated + // data items) to be garbage collected even if UIA/client still holds a + // COM reference to this CCW temporarily. + internal void ClearPeer() + { + _peer = null; + } + + #endregion Internal Methods for Disconnect + #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 04b08405c82..aa24199a22a 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 @@ -1484,6 +1484,7 @@ private void EnsureChildren() // UpdateSubtree is not called on it yet. if (!_childrenValid || _ancestorsInvalid) { + List oldChildren = _children; _children = GetChildrenCore(); if (_children != null) { @@ -1496,6 +1497,20 @@ private void EnsureChildren() } } _childrenValid = true; + + // Disconnect removed peers (same logic as UpdateChildrenInternal) + if (oldChildren != null) + { + HashSet newSet = _children != null ? + new HashSet(_children) : null; + foreach (var old in oldChildren) + { + if (newSet == null || !newSet.Contains(old)) + { + DisconnectPeerFromUia(old); + } + } + } } } @@ -1820,27 +1835,47 @@ private IRawElementProviderSimple ProviderFromPeerNoDelegation(AutomationPeer pe /// 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. + /// After disconnecting a peer from UIA, this method also clears the peer's + /// _children list to sever references to child peers. Without this, a + /// disconnected ItemAutomationPeer would still root its cached cell peers, + /// which in turn root DataGridCell/DataGridRow containers via their _owner + /// field — preventing GC of recycled/discarded containers. + /// We do NOT call UiaDisconnectProvider on children (they may be shared with + /// recycled containers that are still live), but clearing the parent's + /// _children list removes the strong reference chain. /// private static void DisconnectPeerFromUia(AutomationPeer peer) { if (peer == null) return; - // Disconnect the peer's own ElementProxy CCW from UIA. + // UiaDisconnectProvider MUST NOT be called during a UIA callback + // (e.g., during Navigate/FindAll handling). EnsureChildren and + // UpdateChildrenInternal are invoked from within UIA callbacks, so + // we must defer the actual disconnect to a separate dispatcher operation. WeakReference proxyWeakRef = peer._elementProxyWeakReference; if (proxyWeakRef?.Target is ElementProxy proxy) { - UiaDisconnectProvider(proxy); + // Sever the strong reference from proxy back to the peer immediately. + // This allows the peer (and its data items) to be GC'd even before + // the deferred UiaDisconnectProvider call executes. + proxy.ClearPeer(); + + // Defer the UIA disconnect to run outside the UIA callback context. + peer.Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Background, + new Action(() => + { + UiaDisconnectProvider(proxy); + })); } peer._elementProxyWeakReference = null; + + // Sever the reference from this peer to its cached children. + // This breaks the chain: disconnected peer → child peers → _owner → UI containers, + // allowing old containers (DataGridRow/Cell) to be collected. + peer._children = null; + peer._childrenValid = false; } [DllImport("UIAutomationCore.dll", EntryPoint = "UiaDisconnectProvider", CharSet = CharSet.Unicode)] From 893be106538b26fcd2384d7bfe650ba1e821e19c Mon Sep 17 00:00:00 2001 From: Alexander Marinov Date: Mon, 15 Jun 2026 15:54:49 +0300 Subject: [PATCH 3/3] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../System/Windows/Automation/Peers/AutomationPeer.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 aa24199a22a..278b58fbc2d 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 @@ -1501,8 +1501,7 @@ private void EnsureChildren() // Disconnect removed peers (same logic as UpdateChildrenInternal) if (oldChildren != null) { - HashSet newSet = _children != null ? - new HashSet(_children) : null; + HashSet newSet = (_children != null) ? new HashSet(_children) : null; foreach (var old in oldChildren) { if (newSet == null || !newSet.Contains(old))