From 6604bff0dbb6435eeaddd4d9696574cd8353f3bb Mon Sep 17 00:00:00 2001 From: "Aleksandar Marinov (INFRAGISTICS INC)" Date: Wed, 24 Jun 2026 11:36:56 +0300 Subject: [PATCH] Fix UIA memory leak in virtualized ItemsControls via weak-referenced item peers ElementProxy now holds a weak reference to data-item automation peers (those for which AutomationPeer.IsDataItemAutomationPeer() returns true: ItemAutomationPeer and its subclasses, DataGridCellItemAutomationPeer, and DateTimeAutomationPeer), matching the weak-reference treatment already applied to UIElement/ContentElement/ UIElement3D peers. This lets UI Automation client references release virtualized item peers - and the controls they transitively root - so they can be collected, fixing unbounded provider-side growth (customer OOM on a ~200k-row DataGrid under continuous UIA querying). To avoid an ElementNotAvailableException if a peer is collected mid-walk or during property readback, a short-lived strong "keep-alive" (PeerKeepAlive) roots every peer touched at StaticWrap/Peer access for a bounded window using a two-bucket rotation. The 9s window was sized from a measured worst-case FindAll+readback (~4.4s under GC stress, validated 16/16 trials clean across GC modes). A new opt-out AppContext switch, Switch.System.Windows.Automation.Peers.UseStrongReferenceForItemAutomationPeers, restores the legacy strong-reference behavior. Supersedes the earlier disconnect-based approach, which had an unrecoverable mid-walk regression. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MS/internal/Automation/ElementProxy.cs | 92 ++++++++++++++++++- .../MS/internal/CoreAppContextSwitches.cs | 20 ++++ 2 files changed, 110 insertions(+), 2 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..ee9e6a26086 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.Collections.Generic; using System.Windows; using System.Windows.Automation; using System.Windows.Automation.Provider; @@ -38,8 +39,13 @@ internal class ElementProxy: IRawElementProviderFragmentRoot, IRawElementProvide // private ctor - the Wrap() pseudo-ctor is used instead. private ElementProxy(AutomationPeer peer) { - if ((AutomationInteropReferenceType == ReferenceType.Weak) && - (peer is UIElementAutomationPeer || peer is ContentElementAutomationPeer || peer is UIElement3DAutomationPeer)) + // Weak-reference peers whose lifetime is owned elsewhere (the visual tree element, or the parent + // ItemsControl/Calendar peer that tracks data-item peers in its own weak storage) so UIA client + // references don't pin recycled/virtualized peers - and the controls they root - in memory. + // Data-item peers (IsDataItemAutomationPeer) are gated by an opt-out switch. + if ((AutomationInteropReferenceType == ReferenceType.Weak) && + (peer is UIElementAutomationPeer || peer is ContentElementAutomationPeer || peer is UIElement3DAutomationPeer || + (peer.IsDataItemAutomationPeer() && !CoreAppContextSwitches.UseStrongReferenceForItemAutomationPeers))) { _peer = new WeakReference(peer); } @@ -275,6 +281,11 @@ internal static ElementProxy StaticWrap(AutomationPeer peer, AutomationPeer refe if(peer.IsDataItemAutomationPeer()) { peer.AddToParentProxyWeakRefCache(); + + // Root the peer for the duration of the in-flight traversal: it is being surfaced to UIA + // here but is only weakly held by its proxy, so without this it could be collected in the + // window between being returned to UIA and UIA's first call back on it. + PeerKeepAlive.KeepAlive(peer); } } } @@ -292,6 +303,17 @@ internal AutomationPeer Peer if (_peer is WeakReference) { AutomationPeer peer = (AutomationPeer)((WeakReference)_peer).Target; + + // A data-item peer is rooted only by its parent ItemsControl/Calendar peer, whose + // _dataChildren entry and wrapper-peer EventsSource link both vanish in one layout pass when + // the row virtualizes out - so a GC mid-walk could collect it and surface + // ElementNotAvailableException. Park a short-lived strong root (see PeerKeepAlive) + // that outlives the walk but is released once the peer stops being touched. + if (peer != null && peer.IsDataItemAutomationPeer()) + { + PeerKeepAlive.KeepAlive(peer); + } + return peer; } else @@ -500,6 +522,72 @@ private object InContextFragmentRoot() return StaticWrap(root, peer); } + #region data-item peer keep-alive + + // Bounds the lifetime of weakly-referenced data-item peers so an in-flight UIA traversal cannot observe one + // being collected mid-walk, without reintroducing the unbounded leak the weak reference exists to fix. + // Each peer surfaced to UIA (at StaticWrap) or touched on a UIA callback (the Peer getter) is stored in a + // strong-rooted "current" bucket; a Background-priority DispatcherTimer rotates the buckets once per window + // (drop the oldest, promote "current" to "previous"). A fixed time window is deliberate rather than a + // dispatcher-idle callback: under non-concurrent GC, blocking collection pauses leave the dispatcher + // transiently idle mid-walk, which an idle-driven rotation would mistake for "walk finished". The retained + // set is bounded by the in-flight working set, not by the data-set size. + private static class PeerKeepAlive + { + // A peer stays rooted until it has gone untouched for at least one full window and at most two (9-18 s). + // The lower bound is sized from measurement: under forced-Gen2 GC stress a single FindAll + readback + // iteration peaks at ~4.4 s (walk-dominated, stretched by GC pauses) - the worst-case gap between an + // element's surface-time touch and its readback touch. A 9 s window is ~2x that, so the guaranteed + // minimum survival (one window) covers it even under blocking GC, while still releasing peers the + // client has stopped touching within seconds (bounding the retained set to the in-flight working set). + private static readonly TimeSpan Window = TimeSpan.FromSeconds(9); + private static readonly object _lock = new object(); + private static HashSet _current = new HashSet(); + private static HashSet _previous = new HashSet(); + private static DispatcherTimer _timer; + + internal static void KeepAlive(AutomationPeer peer) + { + Dispatcher dispatcher = peer.Dispatcher; + if (dispatcher == null) + { + return; + } + + lock (_lock) + { + _current.Add(peer); + if (_timer == null) + { + // The constructor associates the timer with the supplied dispatcher and starts it, so this + // is safe to call from the UIA worker thread that drives the proxy. + _timer = new DispatcherTimer(Window, DispatcherPriority.Background, OnTick, dispatcher); + } + } + } + + private static void OnTick(object sender, EventArgs e) + { + lock (_lock) + { + // Drop the oldest bucket and promote the current one. + HashSet recycled = _previous; + recycled.Clear(); + _previous = _current; + _current = recycled; + + // Nothing left to keep alive - stop ticking until the next peer is surfaced. + if (_previous.Count == 0) + { + _timer.Stop(); + _timer = null; + } + } + } + } + + #endregion data-item peer keep-alive + #region disable switch for ElementProxy weak reference fix internal enum ReferenceType diff --git a/src/Microsoft.DotNet.Wpf/src/PresentationCore/MS/internal/CoreAppContextSwitches.cs b/src/Microsoft.DotNet.Wpf/src/PresentationCore/MS/internal/CoreAppContextSwitches.cs index c244ffbbc95..91cde8e3137 100644 --- a/src/Microsoft.DotNet.Wpf/src/PresentationCore/MS/internal/CoreAppContextSwitches.cs +++ b/src/Microsoft.DotNet.Wpf/src/PresentationCore/MS/internal/CoreAppContextSwitches.cs @@ -445,5 +445,25 @@ public static bool DisableWpfGfxBoundsCheckProtection } #endregion + + #region UseStrongReferenceForItemAutomationPeers + + /// + /// When false (the default), holds data-item automation + /// peers () weakly, + /// fixing a memory leak in virtualized ItemsControls; true restores the legacy strong reference. + /// + internal const string UseStrongReferenceForItemAutomationPeersSwitchName = "Switch.System.Windows.Automation.Peers.UseStrongReferenceForItemAutomationPeers"; + private static int _useStrongReferenceForItemAutomationPeers; + public static bool UseStrongReferenceForItemAutomationPeers + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get + { + return LocalAppContext.GetCachedSwitchValue(UseStrongReferenceForItemAutomationPeersSwitchName, ref _useStrongReferenceForItemAutomationPeers); + } + } + + #endregion } }