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 1/3] 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 } } From a256bd241bbcfae5037705cd5df6532b56d2c8a4 Mon Sep 17 00:00:00 2001 From: "Aleksandar Marinov (INFRAGISTICS INC)" Date: Mon, 29 Jun 2026 19:45:47 +0300 Subject: [PATCH 2/3] Disconnect dead-peer ElementProxy instances to release leaked UIA CCWs PR #11657 bounded automation peers but ElementProxy bridge objects whose weak peer had been collected stayed rooted by their UI Automation wrapper (CCW), so memory still grew under a sustained UIA client. Add a background sweep that calls UiaDisconnectProvider on ElementProxy instances whose weak peer is gone (which already throw ENA, so the sweep cannot disturb a walk of a live element). The sweep runs on a Background DispatcherTimer, is bounded per tick, and self-stops when idle. Weak proxies cache their runtime id so disconnect can still serve it. Gated by the Switch.System.Windows.Automation.Peers.DisableUiaProviderDisconnect AppContext kill-switch (default off = sweep enabled). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MS/internal/Automation/ElementProxy.cs | 278 +++++++++++++++--- .../MS/internal/CoreAppContextSwitches.cs | 20 ++ .../Internal/Automation/UiaCoreProviderApi.cs | 8 + .../Provider/AutomationInteropProvider.cs | 12 + .../ref/UIAutomationProvider.cs | 1 + 5 files changed, 273 insertions(+), 46 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 ee9e6a26086..53c9a193cf1 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.Concurrent; using System.Collections.Generic; using System.Windows; using System.Windows.Automation; @@ -39,15 +40,20 @@ internal class ElementProxy: IRawElementProviderFragmentRoot, IRawElementProvide // private ctor - the Wrap() pseudo-ctor is used instead. private ElementProxy(AutomationPeer peer) { - // 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. + // Weakly reference peers whose lifetime is owned elsewhere so UIA references don't pin + // recycled/virtualized peers - and the controls they root - in memory. Data-item peers + // 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); + + // Let the sweep release this proxy's CCW once its peer is collected. + if (!CoreAppContextSwitches.DisableUiaProviderDisconnect) + { + ProxyDisconnector.Register(this, peer.Dispatcher); + } } else { @@ -156,9 +162,19 @@ public int [ ] GetRuntimeId() AutomationPeer peer = Peer; if (peer == null) { + // Serve the cached id during disconnect so the call can identify this provider. + if (_disconnecting && _cachedRuntimeId != null) + { + return _cachedRuntimeId; + } throw new ElementNotAvailableException(); } - return (int []) ElementUtil.Invoke( peer, state => ((ElementProxy)state).InContextGetRuntimeId(), this); + int[] runtimeId = (int []) ElementUtil.Invoke( peer, state => ((ElementProxy)state).InContextGetRuntimeId(), this); + if (_peer is WeakReference) + { + _cachedRuntimeId = runtimeId; + } + return runtimeId; } public Rect BoundingRectangle @@ -282,10 +298,13 @@ internal static ElementProxy StaticWrap(AutomationPeer peer, AutomationPeer refe { 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); + // Root the peer for the in-flight traversal: it is only weakly held by its proxy and + // could be collected between being returned to UIA and its first callback. Skip when + // peers are strongly referenced - the proxy already roots them. + if (!CoreAppContextSwitches.UseStrongReferenceForItemAutomationPeers) + { + PeerKeepAlive.KeepAlive(peer); + } } } } @@ -304,12 +323,10 @@ internal AutomationPeer Peer { 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()) + // A data-item peer can be collected mid-walk when its row virtualizes out, surfacing ENA. + // Park a short-lived strong root (see PeerKeepAlive) that outlives the walk but is + // released once the peer stops being touched. + if (peer != null && !CoreAppContextSwitches.UseStrongReferenceForItemAutomationPeers && peer.IsDataItemAutomationPeer()) { PeerKeepAlive.KeepAlive(peer); } @@ -524,27 +541,23 @@ private object InContextFragmentRoot() #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. + // Bounds the lifetime of weakly-referenced data-item peers so an in-flight UIA traversal cannot + // observe one collected mid-walk, without reintroducing the unbounded leak. Touched peers are + // strong-rooted and rotated out by a Background DispatcherTimer once untouched for a window. 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). + // A peer stays rooted until untouched for one full window and at most two (9-18 s). 9 s is ~2x + // the measured worst-case ~4.4 s gap between an element's surface and readback touches under + // forced-Gen2 GC, so it survives blocking GC while still releasing idle peers within seconds. 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; + + // Lock-free on the hot path: KeepAlive runs at the top of nearly every UIA interface call and only + // stamps the peer with the current generation (a striped concurrent write). OnTick advances the + // generation and drops peers untouched for two windows. Only the rare timer start/stop takes a lock. + private static readonly ConcurrentDictionary _tracked = new ConcurrentDictionary(); + private static volatile int _generation; + private static readonly object _timerLock = new object(); + private static volatile DispatcherTimer _timer; internal static void KeepAlive(AutomationPeer peer) { @@ -554,39 +567,212 @@ internal static void KeepAlive(AutomationPeer peer) return; } - lock (_lock) + _tracked[peer] = _generation; + + if (_timer == null) { - _current.Add(peer); - if (_timer == null) + lock (_timerLock) { // 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); + _timer ??= new DispatcherTimer(Window, DispatcherPriority.Background, OnTick, dispatcher); } } } private static void OnTick(object sender, EventArgs e) { + // Runs only on the timer's dispatcher thread, so it is the sole writer of _generation. + int cutoff = ++_generation - 1; + foreach (KeyValuePair entry in _tracked) + { + // Value-matched removal: a concurrent KeepAlive re-stamp changes the value, so a peer + // touched again mid-tick is not evicted while UIA is still using it. + if (entry.Value < cutoff) + { + _tracked.TryRemove(entry); + } + } + + if (_tracked.IsEmpty) + { + lock (_timerLock) + { + // Re-check under the lock so a peer added between the test and the stop keeps ticking. + if (_tracked.IsEmpty && _timer != null) + { + _timer.Stop(); + _timer = null; + + // A KeepAlive can add a peer and still see the not-yet-cleared timer; recreate it so + // that peer is not left rooted with no tick to release it. + if (!_tracked.IsEmpty) + { + _timer = new DispatcherTimer(Window, DispatcherPriority.Background, OnTick, Dispatcher.CurrentDispatcher); + } + } + } + } + } + } + + #endregion data-item peer keep-alive + + #region dead-peer proxy disconnect + + // volatile: _cachedRuntimeId is written on the UIA worker thread but read on the dispatcher + // thread during the re-entrant GetRuntimeId that UiaDisconnectProvider issues, so the read needs + // an acquire barrier to avoid observing a stale null. + private volatile bool _disconnecting; + private volatile int[] _cachedRuntimeId; + + // Safe because a collected peer already throws ENA, so disconnect can't disturb a live walk. + private bool IsPeerCollected + { + get { return _peer is WeakReference weak && weak.Target == null; } + } + + private bool Disconnect() + { + _disconnecting = true; + try + { + AutomationInteropProvider.DisconnectProvider(this); + return true; + } + catch (Exception) + { + // Disconnect failed (e.g. transient input-sync re-entrancy); caller re-queues for retry. + return false; + } + finally + { + _disconnecting = false; + } + } + + // Background sweep that disconnects weakly-held ElementProxy instances whose peer has been collected. + private static class ProxyDisconnector + { + private static readonly TimeSpan Window = TimeSpan.FromSeconds(2); + + // Cap per-tick work so a large backlog drains over several ticks instead of one COM burst. + private const int MaxDisconnectsPerTick = 5000; + + // Give up re-queuing a proxy after this many failed disconnects. Real transient failures clear + // in 1-2 ticks, so this never trips for them; it caps the pathological case (a disconnect that + // throws every time, e.g. runtime id never cached) so the timer can still self-stop. + private const int MaxDisconnectAttempts = 8; + + private static readonly object _lock = new object(); + private static readonly List> _registry = new List>(); + + // Attempt counts kept only for the rare re-queued failures, not as a field on every proxy. + private static readonly Dictionary _failedAttempts = new Dictionary(); + private static DispatcherTimer _timer; + + internal static void Register(ElementProxy proxy, Dispatcher dispatcher) + { + if (dispatcher == null) + { + return; + } + 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) + _registry.Add(new WeakReference(proxy)); + _timer ??= new DispatcherTimer(Window, DispatcherPriority.Background, OnTick, dispatcher); + } + } + + private static void OnTick(object sender, EventArgs e) + { + // Pick targets under the lock; disconnect outside it so Register never blocks. + List toDisconnect = null; + + lock (_lock) + { + int write = 0; + int disconnectCount = 0; + + for (int read = 0; read < _registry.Count; read++) + { + WeakReference slot = _registry[read]; + if (!slot.TryGetTarget(out ElementProxy proxy)) + { + continue; + } + + if (proxy.IsPeerCollected && disconnectCount < MaxDisconnectsPerTick) + { + (toDisconnect ??= new List()).Add(proxy); + disconnectCount++; + continue; + } + + _registry[write++] = slot; + } + + _registry.RemoveRange(write, _registry.Count - write); + + if (_registry.Count == 0 && toDisconnect == null) { _timer.Stop(); _timer = null; } } + + if (toDisconnect != null) + { + List succeeded = null; + List failed = null; + foreach (ElementProxy proxy in toDisconnect) + { + if (proxy.Disconnect()) + { + (succeeded ??= new List()).Add(proxy); + } + else + { + (failed ??= new List()).Add(proxy); + } + } + + lock (_lock) + { + if (succeeded != null && _failedAttempts.Count != 0) + { + foreach (ElementProxy proxy in succeeded) + { + _failedAttempts.Remove(proxy); + } + } + + if (failed != null) + { + // Re-queue a failed disconnect (its CCW is a GC root that would otherwise leak the + // proxy) until the attempt cap, then give up - leaking only the lightweight shell so + // the timer can still self-stop. The timer is alive because this tick had targets. + foreach (ElementProxy proxy in failed) + { + int attempts = _failedAttempts.TryGetValue(proxy, out int n) ? n + 1 : 1; + if (attempts < MaxDisconnectAttempts) + { + _failedAttempts[proxy] = attempts; + _registry.Add(new WeakReference(proxy)); + } + else + { + _failedAttempts.Remove(proxy); + } + } + } + } + } } } - #endregion data-item peer keep-alive + #endregion dead-peer proxy disconnect #region disable switch for ElementProxy weak reference fix 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 91cde8e3137..c1b6f0e75e5 100644 --- a/src/Microsoft.DotNet.Wpf/src/PresentationCore/MS/internal/CoreAppContextSwitches.cs +++ b/src/Microsoft.DotNet.Wpf/src/PresentationCore/MS/internal/CoreAppContextSwitches.cs @@ -465,5 +465,25 @@ public static bool UseStrongReferenceForItemAutomationPeers } #endregion + + #region DisableUiaProviderDisconnect + + /// + /// When false (the default), a background sweep disconnects ElementProxy instances whose weak + /// automation peer has been collected, releasing the UIA wrapper that keeps the dead proxy alive. + /// Set to true to restore the legacy behavior. + /// + internal const string DisableUiaProviderDisconnectSwitchName = "Switch.System.Windows.Automation.Peers.DisableUiaProviderDisconnect"; + private static int _disableUiaProviderDisconnect; + public static bool DisableUiaProviderDisconnect + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get + { + return LocalAppContext.GetCachedSwitchValue(DisableUiaProviderDisconnectSwitchName, ref _disableUiaProviderDisconnect); + } + } + + #endregion } } diff --git a/src/Microsoft.DotNet.Wpf/src/UIAutomation/UIAutomationProvider/MS/Internal/Automation/UiaCoreProviderApi.cs b/src/Microsoft.DotNet.Wpf/src/UIAutomation/UIAutomationProvider/MS/Internal/Automation/UiaCoreProviderApi.cs index 5efe4a1a53a..c261ac2f21a 100644 --- a/src/Microsoft.DotNet.Wpf/src/UIAutomation/UIAutomationProvider/MS/Internal/Automation/UiaCoreProviderApi.cs +++ b/src/Microsoft.DotNet.Wpf/src/UIAutomation/UIAutomationProvider/MS/Internal/Automation/UiaCoreProviderApi.cs @@ -39,6 +39,11 @@ internal static IRawElementProviderSimple UiaHostProviderFromHwnd(IntPtr hwnd) CheckError(RawUiaHostProviderFromHwnd(hwnd, out provider)); return provider; } + + internal static void UiaDisconnectProvider(IRawElementProviderSimple provider) + { + CheckError(RawUiaDisconnectProvider(provider)); + } #endregion Provider methods // @@ -119,6 +124,9 @@ private static void CheckError(int hr) [DllImport(DllImport.UIAutomationCore, EntryPoint = "UiaHostProviderFromHwnd", CharSet = CharSet.Unicode)] private static extern int RawUiaHostProviderFromHwnd(IntPtr hwnd, [MarshalAs(UnmanagedType.Interface)] out IRawElementProviderSimple provider); + [DllImport(DllImport.UIAutomationCore, EntryPoint = "UiaDisconnectProvider", CharSet = CharSet.Unicode)] + private static extern int RawUiaDisconnectProvider(IRawElementProviderSimple provider); + // Event APIs... [DllImport(DllImport.UIAutomationCore, EntryPoint = "UiaRaiseAutomationPropertyChangedEvent", CharSet = CharSet.Unicode)] diff --git a/src/Microsoft.DotNet.Wpf/src/UIAutomation/UIAutomationProvider/System/Windows/Automation/Provider/AutomationInteropProvider.cs b/src/Microsoft.DotNet.Wpf/src/UIAutomation/UIAutomationProvider/System/Windows/Automation/Provider/AutomationInteropProvider.cs index d70e7ad2dc0..a9948b507dd 100644 --- a/src/Microsoft.DotNet.Wpf/src/UIAutomation/UIAutomationProvider/System/Windows/Automation/Provider/AutomationInteropProvider.cs +++ b/src/Microsoft.DotNet.Wpf/src/UIAutomation/UIAutomationProvider/System/Windows/Automation/Provider/AutomationInteropProvider.cs @@ -75,6 +75,18 @@ public static IntPtr ReturnRawElementProvider (IntPtr hwnd, IntPtr wParam, IntPt return UiaCoreProviderApi.UiaReturnRawElementProvider(hwnd, wParam, lParam, el); } + /// + /// Releases UIA's references to a provider whose element is gone so it can be reclaimed; afterwards + /// requests for it throw ElementNotAvailableException. Not callable while handling a SendMessage. + /// + /// The server-side provider to disconnect. + public static void DisconnectProvider(IRawElementProviderSimple provider) + { + ArgumentNullException.ThrowIfNull(provider); + + UiaCoreProviderApi.UiaDisconnectProvider(provider); + } + /// /// Called by a server to determine if there are any listeners for events. /// diff --git a/src/Microsoft.DotNet.Wpf/src/UIAutomation/UIAutomationProvider/ref/UIAutomationProvider.cs b/src/Microsoft.DotNet.Wpf/src/UIAutomation/UIAutomationProvider/ref/UIAutomationProvider.cs index b4501acc1ef..1d04073253a 100644 --- a/src/Microsoft.DotNet.Wpf/src/UIAutomation/UIAutomationProvider/ref/UIAutomationProvider.cs +++ b/src/Microsoft.DotNet.Wpf/src/UIAutomation/UIAutomationProvider/ref/UIAutomationProvider.cs @@ -10,6 +10,7 @@ public static partial class AutomationInteropProvider public const int ItemsInvalidateLimit = 5; public const int RootObjectId = -25; public static bool ClientsAreListening { get { throw null; } } + public static void DisconnectProvider(System.Windows.Automation.Provider.IRawElementProviderSimple provider) { } public static System.Windows.Automation.Provider.IRawElementProviderSimple HostProviderFromHandle(System.IntPtr hwnd) { throw null; } public static void RaiseAutomationEvent(System.Windows.Automation.AutomationEvent eventId, System.Windows.Automation.Provider.IRawElementProviderSimple provider, System.Windows.Automation.AutomationEventArgs e) { } public static void RaiseAutomationPropertyChangedEvent(System.Windows.Automation.Provider.IRawElementProviderSimple element, System.Windows.Automation.AutomationPropertyChangedEventArgs e) { } From 934cb33ee2309817a6ae102b7af535dd12d78155 Mon Sep 17 00:00:00 2001 From: "Aleksandar Marinov (INFRAGISTICS INC)" Date: Fri, 3 Jul 2026 14:01:06 +0200 Subject: [PATCH 3/3] Consolidate keep-alive eligibility guard and skip redundant stamps Apply low-risk review suggestions to the data-item peer keep-alive: - Move the (UseStrongReference / IsDataItemAutomationPeer) eligibility guard into PeerKeepAlive.KeepAlive so the two call sites cannot drift, and additionally honor the legacy AutomationWeakReferenceDisallow key (AutomationInteropReferenceType) that the StaticWrap stamp ignored. - Skip the striped-lock write when a peer is already stamped for the current generation (repeat touches are the common case). - Document that IsPeerCollected must not touch Peer, since doing so would hand out a transient strong reference and defeat the sweep. No behavioral change to the leak fix; keep-alive and disconnect semantics are unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MS/internal/Automation/ElementProxy.cs | 39 ++++++++++++------- 1 file changed, 26 insertions(+), 13 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 53c9a193cf1..298f518e055 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 @@ -299,12 +299,9 @@ internal static ElementProxy StaticWrap(AutomationPeer peer, AutomationPeer refe peer.AddToParentProxyWeakRefCache(); // Root the peer for the in-flight traversal: it is only weakly held by its proxy and - // could be collected between being returned to UIA and its first callback. Skip when - // peers are strongly referenced - the proxy already roots them. - if (!CoreAppContextSwitches.UseStrongReferenceForItemAutomationPeers) - { - PeerKeepAlive.KeepAlive(peer); - } + // could be collected between being returned to UIA and its first callback. KeepAlive + // self-guards on the switch / registry-key / data-item eligibility. + PeerKeepAlive.KeepAlive(peer); } } } @@ -325,11 +322,8 @@ internal AutomationPeer Peer // A data-item peer can be collected mid-walk when its row virtualizes out, surfacing ENA. // Park a short-lived strong root (see PeerKeepAlive) that outlives the walk but is - // released once the peer stops being touched. - if (peer != null && !CoreAppContextSwitches.UseStrongReferenceForItemAutomationPeers && peer.IsDataItemAutomationPeer()) - { - PeerKeepAlive.KeepAlive(peer); - } + // released once the peer stops being touched. KeepAlive self-guards on eligibility. + PeerKeepAlive.KeepAlive(peer); return peer; } @@ -559,15 +553,32 @@ private static class PeerKeepAlive private static readonly object _timerLock = new object(); private static volatile DispatcherTimer _timer; + // Renew the keep-alive window for a weakly-held data-item peer. The full eligibility guard lives + // here (not at the call sites) so the two callers cannot drift apart: the opt-out switch, the + // legacy AutomationWeakReferenceDisallow registry key (surfaced as AutomationInteropReferenceType), + // and data-item-ness. A null peer or non-weak reference type is a no-op. internal static void KeepAlive(AutomationPeer peer) { + if (peer == null || + CoreAppContextSwitches.UseStrongReferenceForItemAutomationPeers || + AutomationInteropReferenceType != ReferenceType.Weak || + !peer.IsDataItemAutomationPeer()) + { + return; + } + Dispatcher dispatcher = peer.Dispatcher; if (dispatcher == null) { return; } - _tracked[peer] = _generation; + // Skip the striped-lock write when this peer is already stamped for the current generation: + // repeat touches within a window are the common case and need no update. + if (!_tracked.TryGetValue(peer, out int g) || g != _generation) + { + _tracked[peer] = _generation; + } if (_timer == null) { @@ -626,7 +637,9 @@ private static void OnTick(object sender, EventArgs e) private volatile bool _disconnecting; private volatile int[] _cachedRuntimeId; - // Safe because a collected peer already throws ENA, so disconnect can't disturb a live walk. + // True once the weakly-held peer has been collected. Must NOT touch Peer: doing so would hand out a + // transient strong reference and defeat the very collection the sweep is waiting for. Safe because a + // collected peer already throws ENA, so disconnect can't disturb a live walk. private bool IsPeerCollected { get { return _peer is WeakReference weak && weak.Target == null; }