diff --git a/src/Controls/src/Core/BindableObject.cs b/src/Controls/src/Core/BindableObject.cs index 0e7d7d65c219..8590a2d2d10b 100644 --- a/src/Controls/src/Core/BindableObject.cs +++ b/src/Controls/src/Core/BindableObject.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +using System.Threading; using Microsoft.Extensions.Logging; using Microsoft.Maui.Controls.Internals; using Microsoft.Maui.Dispatching; @@ -26,8 +27,18 @@ public abstract class BindableObject : INotifyPropertyChanged, IDynamicResourceH /// Gets the dispatcher that was available when this bindable object was created, /// otherwise tries to find the nearest available dispatcher (probably the window's/app's). /// - public IDispatcher Dispatcher => - _dispatcher ??= this.FindDispatcher(); + public IDispatcher Dispatcher + { + get + { + var dispatcher = GetDispatcherIfAvailable(); + if (dispatcher is not null) + return dispatcher; + + SetDispatcherIfUnset(this.FindDispatcher()); + return GetDispatcherIfAvailable(); + } + } /// /// Initializes a new instance of the class. @@ -38,12 +49,55 @@ public BindableObject() _dispatcher = Dispatching.Dispatcher.GetForCurrentThread(); } + internal void SetDispatcherIfUnset(IDispatcher dispatcher) + { + if (dispatcher is not null) + Interlocked.CompareExchange(ref _dispatcher, dispatcher, null); + } + + internal IDispatcher GetDispatcherIfAvailable() => + Volatile.Read(ref _dispatcher); + + internal bool HasDispatcher => + GetDispatcherIfAvailable() is not null; + internal ushort _triggerCount = 0; internal Dictionary _triggerSpecificity = new(); readonly Dictionary _properties = new(4); bool _applying; + WeakReference _inheritedBindingContext; WeakReference _inheritedContext; + internal sealed class InheritedBindingContextReference : WeakReference + { + internal InheritedBindingContextReference(object target) + : base(target) + { + } + } + + sealed class PendingInheritedBindingContextCleanup : WeakReference + { + int _dispatchScheduled; + + internal PendingInheritedBindingContextCleanup(WeakReference inheritedContext, bool isBindingContextBinding) + : base(null) + { + InheritedContext = inheritedContext; + IsBindingContextBinding = isBindingContextBinding; + } + + internal WeakReference InheritedContext { get; } + + internal bool IsBindingContextBinding { get; } + + internal bool TryScheduleDispatch() => + Interlocked.CompareExchange(ref _dispatchScheduled, 1, 0) == 0; + + internal void ResetScheduledDispatch() => + Interlocked.Exchange(ref _dispatchScheduled, 0); + } + /// Bindable property for . public static readonly BindableProperty BindingContextProperty = BindableProperty.Create(nameof(BindingContext), typeof(object), typeof(BindableObject), default(object), @@ -55,7 +109,25 @@ public BindableObject() /// public object BindingContext { - get => _inheritedContext?.Target ?? GetValue(BindingContextProperty); + get + { + var inheritedContext = Volatile.Read(ref _inheritedContext); + var inheritedBindingContext = Volatile.Read(ref _inheritedBindingContext); + if (inheritedContext is PendingInheritedBindingContextCleanup + || inheritedBindingContext is PendingInheritedBindingContextCleanup) + { + DispatchInheritedBindingContextCleanup(clearIfDispatchNotRequired: true); + inheritedContext = Volatile.Read(ref _inheritedContext); + inheritedBindingContext = Volatile.Read(ref _inheritedBindingContext); + if (inheritedContext is PendingInheritedBindingContextCleanup + || inheritedBindingContext is PendingInheritedBindingContextCleanup) + { + return null; + } + } + + return inheritedContext?.Target ?? GetValue(BindingContextProperty); + } set => SetValue(BindingContextProperty, value); } @@ -344,7 +416,7 @@ internal void SetBinding(BindableProperty targetProperty, BindingBase binding, S targetProperty.BindingChanging?.Invoke(this, oldBinding, binding); - binding.Apply(BindingContext, this, targetProperty, false, specificity); + binding.Apply(GetBindingContextForBindingApplication(targetProperty), this, targetProperty, false, specificity); } /// @@ -355,32 +427,189 @@ internal void SetBinding(BindableProperty targetProperty, BindingBase binding, S /// For internal use only. This API can be changed or removed without notice at any time. [EditorBrowsable(EditorBrowsableState.Never)] public static void SetInheritedBindingContext(BindableObject bindable, object value) + { + SetInheritedBindingContextCore(bindable, value, force: false); + } + + internal static void SetInheritedBindingContextForBinding(BindableObject bindable, object value) + { + SetInheritedBindingContextCore(bindable, value, force: true); + } + + static void SetInheritedBindingContextCore(BindableObject bindable, object value, bool force) { // I wonder if we couldn't treat BindingContext with specificities BindablePropertyContext bpContext = bindable.GetContext(BindingContextProperty); - if (bpContext != null && bpContext.Values.GetSpecificity() >= SetterSpecificity.ManualValueSetter) + var binding = bpContext?.Bindings.GetValue(); + if (bpContext != null + && bpContext.Values.GetSpecificity() >= SetterSpecificity.ManualValueSetter + && (!force || binding is null)) + { return; + } - if (ReferenceEquals(bindable._inheritedContext?.Target, value)) + var inheritedContext = Volatile.Read(ref bindable._inheritedContext); + var inheritedBindingContext = Volatile.Read(ref bindable._inheritedBindingContext); + if (!force + && inheritedContext is not PendingInheritedBindingContextCleanup + && inheritedBindingContext is not PendingInheritedBindingContextCleanup + && ReferenceEquals((inheritedContext ?? inheritedBindingContext)?.Target, value)) return; - var binding = bpContext?.Bindings.GetValue(); - if (binding != null) { - binding.Context = value; - bindable._inheritedContext = null; + var bindingContext = new InheritedBindingContextReference(value); + binding.Context = bindingContext; + Volatile.Write(ref bindable._inheritedContext, null); + Volatile.Write(ref bindable._inheritedBindingContext, bindingContext); // OnBindingContextChanged fires from within BindingContextProperty propertyChanged callback bindable.ApplyBinding(bpContext, fromBindingContextChanged: true); } else { - bindable._inheritedContext = new WeakReference(value); + Volatile.Write(ref bindable._inheritedBindingContext, null); + Volatile.Write(ref bindable._inheritedContext, new InheritedBindingContextReference(value)); bindable.ApplyBindings(fromBindingContextChanged: true); bindable.OnBindingContextChanged(); } } + internal WeakReference MarkInheritedBindingContextForCleanup() + { + var inheritedContext = Volatile.Read(ref _inheritedContext); + if (inheritedContext is not null) + { + if (inheritedContext is PendingInheritedBindingContextCleanup) + return null; + + return MarkInheritedBindingContextForCleanup( + ref _inheritedContext, + inheritedContext, + isBindingContextBinding: false); + } + + var inheritedBindingContext = Volatile.Read(ref _inheritedBindingContext); + if (inheritedBindingContext is null or PendingInheritedBindingContextCleanup) + return null; + + return MarkInheritedBindingContextForCleanup( + ref _inheritedBindingContext, + inheritedBindingContext, + isBindingContextBinding: true); + } + + static WeakReference MarkInheritedBindingContextForCleanup( + ref WeakReference inheritedContext, + WeakReference observedContext, + bool isBindingContextBinding) + { + var pendingCleanup = new PendingInheritedBindingContextCleanup(observedContext, isBindingContextBinding); + return ReferenceEquals( + Interlocked.CompareExchange(ref inheritedContext, pendingCleanup, observedContext), + observedContext) + ? pendingCleanup + : null; + } + + internal void CancelInheritedBindingContextCleanup(WeakReference cleanupToken) + { + if (cleanupToken is not PendingInheritedBindingContextCleanup pendingCleanup) + return; + + if (pendingCleanup.IsBindingContextBinding) + { + Interlocked.CompareExchange( + ref _inheritedBindingContext, + pendingCleanup.InheritedContext, + pendingCleanup); + } + else + { + Interlocked.CompareExchange( + ref _inheritedContext, + pendingCleanup.InheritedContext, + pendingCleanup); + } + } + + internal void DispatchInheritedBindingContextCleanup(bool clearIfDispatchNotRequired = false) + { + var pendingCleanup = Volatile.Read(ref _inheritedContext) as PendingInheritedBindingContextCleanup + ?? Volatile.Read(ref _inheritedBindingContext) as PendingInheritedBindingContextCleanup; + if (pendingCleanup is null) + return; + + // Finalizer callers only queue work here. Binding callbacks run on the dispatcher + // or remain pending for a normal access path to clear safely. + var dispatcher = GetDispatcherIfAvailable(); + // Only normal access paths may resolve dispatchers because discovery can read + // handler or application service providers that are already disposed at finalization. + if (dispatcher is null && + clearIfDispatchNotRequired && + this.TryFindDispatcher( + includeParents: false) is IDispatcher discoveredDispatcher) + { + // The parent reference has already been cleared, so walking the parent + // hierarchy here would re-enter this cleanup path. + SetDispatcherIfUnset(discoveredDispatcher); + dispatcher = GetDispatcherIfAvailable(); + } + + bool isDispatchRequired = false; + try + { + isDispatchRequired = dispatcher?.IsDispatchRequired == true; + } + catch (ObjectDisposedException) + { + // A cached dispatcher can outlive its window. Keep the cleanup pending + // instead of leaking disposal through Parent or BindingContext access. + return; + } + + if (isDispatchRequired) + { + if (!pendingCleanup.TryScheduleDispatch()) + return; + + bool dispatchAccepted = false; + try + { + dispatchAccepted = dispatcher.Dispatch(() => ClearPendingInheritedBindingContext(pendingCleanup)); + } + catch (ObjectDisposedException) + { + return; + } + finally + { + if (!dispatchAccepted) + pendingCleanup.ResetScheduledDispatch(); + } + + return; + } + + if (clearIfDispatchNotRequired) + ClearPendingInheritedBindingContext(pendingCleanup); + } + + void ClearPendingInheritedBindingContext(PendingInheritedBindingContextCleanup pendingCleanup) + { + ref WeakReference inheritedContext = ref (pendingCleanup.IsBindingContextBinding + ? ref _inheritedBindingContext + : ref _inheritedContext); + + if (!ReferenceEquals( + Interlocked.CompareExchange(ref inheritedContext, null, pendingCleanup), + pendingCleanup)) + { + return; + } + + SetInheritedBindingContextCore(this, null, force: true); + } + /// /// Applies all the current bindings to . /// @@ -738,24 +967,66 @@ void ApplyBinding(BindablePropertyContext context, bool fromBindingContextChange var specificity = kvp.Key; binding.Unapply(fromBindingContextChanged); - binding.Apply(BindingContext, this, context.Property, fromBindingContextChanged, specificity); + binding.Apply( + GetBindingContextForBindingApplication(context.Property), + this, + context.Property, + fromBindingContextChanged, + specificity); + } + + object GetBindingContextForBindingApplication(BindableProperty property) + { + if (!ReferenceEquals(property, BindingContextProperty)) + return BindingContext; + + var inheritedBindingContext = Volatile.Read(ref _inheritedBindingContext); + if (inheritedBindingContext is PendingInheritedBindingContextCleanup) + return null; + + if (Volatile.Read(ref _inheritedContext) is PendingInheritedBindingContextCleanup) + return null; + + return inheritedBindingContext is null + ? BindingContext + : inheritedBindingContext.Target; } static void BindingContextPropertyBindingChanging(BindableObject bindable, BindingBase oldBindingBase, BindingBase newBindingBase) { - object context = bindable._inheritedContext?.Target; - var oldBinding = oldBindingBase as Binding; - var newBinding = newBindingBase as Binding; + var inheritedBindingContext = Volatile.Read(ref bindable._inheritedBindingContext); + var inheritedContext = Volatile.Read(ref bindable._inheritedContext); + + if (newBindingBase is null) + { + if (inheritedBindingContext is not PendingInheritedBindingContextCleanup) + Volatile.Write(ref bindable._inheritedBindingContext, null); + return; + } + + if (inheritedBindingContext is PendingInheritedBindingContextCleanup + || inheritedContext is PendingInheritedBindingContextCleanup) + { + newBindingBase.Context = new InheritedBindingContextReference(null); + return; + } + + if (inheritedContext is null) + inheritedContext = inheritedBindingContext; - if (context == null && oldBinding != null) - context = oldBinding.Context; - if (context != null && newBinding != null) - newBinding.Context = context; + if (inheritedContext is null) + return; + + var bindingContext = inheritedContext as InheritedBindingContextReference + ?? new InheritedBindingContextReference(inheritedContext.Target); + Volatile.Write(ref bindable._inheritedContext, null); + Volatile.Write(ref bindable._inheritedBindingContext, bindingContext); + newBindingBase.Context = bindingContext; } static void BindingContextPropertyChanged(BindableObject bindable, object oldvalue, object newvalue) { - bindable._inheritedContext = null; + Volatile.Write(ref bindable._inheritedContext, null); bindable.ApplyBindings(fromBindingContextChanged: true); bindable.OnBindingContextChanged(); } @@ -806,6 +1077,10 @@ void RemoveBinding(BindableProperty property, BindablePropertyContext context, S var currentbinding = context.Bindings.GetValue(); var binding = context.Bindings[specificity]; var isCurrent = binding == currentbinding; + var pendingCleanup = isCurrent && ReferenceEquals(property, BindingContextProperty) + ? Volatile.Read(ref _inheritedBindingContext) as PendingInheritedBindingContextCleanup + ?? Volatile.Read(ref _inheritedContext) as PendingInheritedBindingContextCleanup + : null; if (isCurrent) { @@ -817,10 +1092,55 @@ void RemoveBinding(BindableProperty property, BindablePropertyContext context, S property.BindingChanging?.Invoke(this, binding, currentbinding); - currentbinding?.Apply(BindingContext, this, property, false, context.Bindings.GetClearedSpecificity()); + currentbinding?.Apply( + GetBindingContextForBindingApplication(property), + this, + property, + false, + context.Bindings.GetClearedSpecificity()); } context.Bindings.Remove(specificity); + if (pendingCleanup is not null) + CompletePendingInheritedBindingContextCleanupAfterBindingRemoval(context, specificity, pendingCleanup); + } + + void CompletePendingInheritedBindingContextCleanupAfterBindingRemoval( + BindablePropertyContext context, + SetterSpecificity removedSpecificity, + PendingInheritedBindingContextCleanup pendingCleanup) + { + bool hasBinding = context.Bindings.GetValue() is not null; + var clearedContext = new InheritedBindingContextReference(null); + bool claimedCleanup; + + if (pendingCleanup.IsBindingContextBinding) + { + claimedCleanup = ReferenceEquals( + Interlocked.CompareExchange( + ref _inheritedBindingContext, + hasBinding ? clearedContext : null, + pendingCleanup), + pendingCleanup); + } + else + { + claimedCleanup = ReferenceEquals( + Interlocked.CompareExchange(ref _inheritedContext, null, pendingCleanup), + pendingCleanup); + if (claimedCleanup && hasBinding) + Volatile.Write(ref _inheritedBindingContext, clearedContext); + } + + if (!claimedCleanup) + return; + + ClearValueCore(BindingContextProperty, removedSpecificity); + if (!hasBinding) + { + Volatile.Write(ref _inheritedBindingContext, null); + Volatile.Write(ref _inheritedContext, clearedContext); + } } /// diff --git a/src/Controls/src/Core/BindingBase.cs b/src/Controls/src/Core/BindingBase.cs index cefb63920392..82a2acdc34a0 100644 --- a/src/Controls/src/Core/BindingBase.cs +++ b/src/Controls/src/Core/BindingBase.cs @@ -100,7 +100,15 @@ public object FallbackValue internal bool AllowChaining { get; set; } - internal object Context { get; set; } + object _context; + + internal object Context + { + get => _context is BindableObject.InheritedBindingContextReference inheritedContext + ? inheritedContext.Target + : _context; + set => _context = value; + } internal bool IsApplied { get; private set; } diff --git a/src/Controls/src/Core/DispatcherExtensions.cs b/src/Controls/src/Core/DispatcherExtensions.cs index 92bfedc3a2f3..cf3ea75cbbaf 100644 --- a/src/Controls/src/Core/DispatcherExtensions.cs +++ b/src/Controls/src/Core/DispatcherExtensions.cs @@ -9,15 +9,36 @@ namespace Microsoft.Maui.Controls internal static class DispatcherExtensions { public static IDispatcher FindDispatcher(this BindableObject? bindableObject) + { + if (bindableObject.TryFindDispatcher(includeParents: true) is IDispatcher dispatcher) + return dispatcher; + + if (bindableObject is not Application && + Application.Current?.Dispatcher is IDispatcher appDispatcher) + { + return appDispatcher; + } + + throw new InvalidOperationException("BindableObject was not instantiated on a thread with a dispatcher nor does the current application have a dispatcher."); + } + + internal static IDispatcher? TryFindDispatcher( + this BindableObject? bindableObject, + bool includeParents) { // try find the dispatcher in the current hierarchy // Exclude Application because we don't want to jump // directly to the Application IDispatcher at this point if (bindableObject is not Application && - bindableObject is Element element && - element.FindMauiContext() is IMauiContext context && - context.Services.GetService() is IDispatcher handlerDispatcher) - return handlerDispatcher; + bindableObject is Element element) + { + var context = includeParents + ? element.FindMauiContext() + : (element as Maui.IElement)?.Handler?.MauiContext; + + if (context?.Services.GetService() is IDispatcher handlerDispatcher) + return handlerDispatcher; + } // maybe this thread has a dispatcher if (Dispatcher.GetForCurrentThread() is IDispatcher globalDispatcher) @@ -25,26 +46,28 @@ bindableObject is Element element && // If BO is of type Application then return the Dispatcher from ApplicationDispatcher if (bindableObject is Application app && - app.FindMauiContext() is IMauiContext appMauiContext) - { - if (appMauiContext.Services.GetOptionalApplicationDispatcher() is IDispatcher appDispatcherServiceDispatcher) - return appDispatcherServiceDispatcher; + TryFindApplicationDispatcher(app) is IDispatcher appDispatcher) + return appDispatcher; - // If BO is of type Application then check for its Dispatcher - if (appMauiContext.Services.GetService() is IDispatcher appHandlerDispatcher) - return appHandlerDispatcher; + // Try the static app's registered dispatcher without calling its Dispatcher + // property, which may throw. The public FindDispatcher path preserves that + // fallback after this non-throwing lookup returns null. + if (bindableObject is not Application && Application.Current is Application currentApp) + { + if (TryFindApplicationDispatcher(currentApp) is IDispatcher currentAppDispatcherService) + return currentAppDispatcherService; } - // try looking on the static app - // We don't include Application because Application.Dispatcher will call - // `FindDispatcher` if it's _dispatcher property isn't initialized so this - // could cause a Stack Overflow Exception - if (bindableObject is not Application && - Application.Current?.Dispatcher is IDispatcher appDispatcher) - return appDispatcher; + return null; + } - // no dispatchers found at all - throw new InvalidOperationException("BindableObject was not instantiated on a thread with a dispatcher nor does the current application have a dispatcher."); + static IDispatcher? TryFindApplicationDispatcher(Application app) + { + if (app.FindMauiContext() is not IMauiContext appMauiContext) + return null; + + return appMauiContext.Services.GetOptionalApplicationDispatcher() + ?? appMauiContext.Services.GetService(); } public static void DispatchIfRequired(this IDispatcher? dispatcher, Action action) diff --git a/src/Controls/src/Core/Element/Element.cs b/src/Controls/src/Core/Element/Element.cs index 9982d9f2422a..5d9fde603c96 100644 --- a/src/Controls/src/Core/Element/Element.cs +++ b/src/Controls/src/Core/Element/Element.cs @@ -6,10 +6,12 @@ using System.ComponentModel; using System.Linq; using System.Runtime.CompilerServices; +using System.Threading; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Maui.Controls.Hosting; using Microsoft.Maui.Controls.Internals; +using Microsoft.Maui.Dispatching; namespace Microsoft.Maui.Controls { @@ -333,32 +335,80 @@ internal Element ParentOverride } WeakReference _realParent; - Element TryGetRealParent(bool logWarningIfParentHasBeenCollected = true) + Element TryGetRealParent( + bool logWarningIfParentHasBeenCollected = true, + bool clearInheritedContextIfDispatchNotRequired = true) { - var realParent = _realParent; - if (realParent is null) + while (true) { - return null; - } - if (realParent.TryGetTarget(out var parent)) - { - return parent; - } - else - { - // Clear the weak reference since the target has been garbage collected - // This prevents repeated checks and warnings on subsequent accesses - _realParent = null; + var realParent = Volatile.Read(ref _realParent); + if (realParent is null) + { + if (clearInheritedContextIfDispatchNotRequired) + { + DispatchInheritedBindingContextCleanup(clearIfDispatchNotRequired: true); + if (Volatile.Read(ref _realParent) is not null) + continue; + } + + return null; + } + + if (realParent.TryGetTarget(out var parent)) + return parent; + + if (!ClearRealParentAndInheritedContextIfCollected( + realParent, + clearInheritedContextIfDispatchNotRequired)) + continue; + if (logWarningIfParentHasBeenCollected) { Application.Current? .FindMauiContext()? .CreateLogger()? .LogWarning($"The RealParent on {this} has been Garbage Collected. This should never happen. Please log a bug: https://github.com/dotnet/maui"); + logWarningIfParentHasBeenCollected = false; } } + } - return null; + internal void ClearRealParentAndInheritedContextIfCollected() + { + var realParent = Volatile.Read(ref _realParent); + if (realParent is not null) + ClearRealParentAndInheritedContextIfCollected( + realParent, + clearInheritedContextIfDispatchNotRequired: false); + } + + bool ClearRealParentAndInheritedContextIfCollected( + WeakReference realParent, + bool clearInheritedContextIfDispatchNotRequired) + { + if (realParent.TryGetTarget(out _)) + return false; + + // Only clear the reference we observed; a new parent may be assigned concurrently. + if (!ReferenceEquals(Interlocked.CompareExchange(ref _realParent, null, realParent), realParent)) + return false; + + if (Volatile.Read(ref _realParent) is not null) + return true; + + var inheritedContext = MarkInheritedBindingContextForCleanup(); + if (inheritedContext is null) + return true; + + if (Volatile.Read(ref _realParent) is not null) + { + CancelInheritedBindingContextCleanup(inheritedContext); + return true; + } + + DispatchInheritedBindingContextCleanup(clearInheritedContextIfDispatchNotRequired); + + return true; } /// For internal use by .NET MAUI. @@ -369,9 +419,9 @@ public Element RealParent private set { if (value is null) - _realParent = null; + Volatile.Write(ref _realParent, null); else - _realParent = new WeakReference(value); + Volatile.Write(ref _realParent, new WeakReference(value)); } } @@ -395,13 +445,22 @@ public Element Parent void SetParent(Element value) { - Element realParent = TryGetRealParent(false); + Element realParent = TryGetRealParent( + logWarningIfParentHasBeenCollected: false, + clearInheritedContextIfDispatchNotRequired: value is null); if (realParent == value) { return; } + if (!HasDispatcher && value is not null) + { + var parentDispatcher = value.GetDispatcherIfAvailable() + ?? value.TryFindDispatcher(includeParents: true); + SetDispatcherIfUnset(parentDispatcher); + } + OnPropertyChanging(nameof(Parent)); if (_parentOverride == null) @@ -420,12 +479,12 @@ void SetParent(Element value) } RealParent = value; - if (RealParent != null) + if (value != null) { var resources = GetParentResourcesForParentSet(); if (resources != null) OnParentResourcesChanged(resources); - ((IElementDefinition)RealParent).AddResourcesChangedListener(OnParentResourcesChanged); + ((IElementDefinition)value).AddResourcesChangedListener(OnParentResourcesChanged); } object context = value?.BindingContext; @@ -1086,6 +1145,9 @@ void SetHandler(IElementHandler newHandler) OnHandlerChangingCore(new HandlerChangingEventArgs(_previousHandler, newHandler)); _handler = newHandler; + if (!HasDispatcher && + _handler?.MauiContext?.Services.GetService() is IDispatcher handlerDispatcher) + SetDispatcherIfUnset(handlerDispatcher); // Only call disconnect if the previous handler is still connected to this virtual view. // If a handler is being reused for a different VirtualView then the virtual diff --git a/src/Controls/src/Core/GradientBrush.cs b/src/Controls/src/Core/GradientBrush.cs index 8a3fb106e6b0..7e2d07373ed3 100644 --- a/src/Controls/src/Core/GradientBrush.cs +++ b/src/Controls/src/Core/GradientBrush.cs @@ -1,5 +1,6 @@ #nullable disable using System; +using System.Collections.Generic; using System.Collections.Specialized; using System.ComponentModel; @@ -15,6 +16,10 @@ public GradientBrush() GradientStops = new GradientStopCollection(); } + GradientStopSubscriptions _gradientStopSubscriptions; + NotifyCollectionChangedEventHandler _gradientStopsCollectionChanged; + PropertyChangedEventHandler _gradientStopPropertyChanged; + public event EventHandler InvalidateGradientBrushRequested; /// Bindable property for . @@ -41,49 +46,63 @@ protected override void OnBindingContextChanged() { base.OnBindingContextChanged(); - foreach (var gradientStop in GradientStops) + var gradientStops = GradientStops; + if (gradientStops is null) + return; + + foreach (var gradientStop in gradientStops) + { + if (gradientStop is null) + continue; + SetInheritedBindingContext(gradientStop, BindingContext); + } } void UpdateGradientStops(GradientStopCollection oldCollection, GradientStopCollection newCollection) { if (oldCollection != null) { - oldCollection.CollectionChanged -= OnGradientStopCollectionChanged; - - foreach (var oldStop in oldCollection) - { - oldStop.Parent = null; - oldStop.PropertyChanged -= OnGradientStopPropertyChanged; - } + _gradientStopSubscriptions?.UnsubscribeAll(this); } - if (newCollection == null) - return; + if (newCollection != null) + { + _gradientStopsCollectionChanged ??= OnGradientStopCollectionChanged; + _gradientStopPropertyChanged ??= OnGradientStopPropertyChanged; - newCollection.CollectionChanged += OnGradientStopCollectionChanged; + var subscriptions = _gradientStopSubscriptions ??= new GradientStopSubscriptions(); + subscriptions.Subscribe(newCollection, _gradientStopsCollectionChanged); - foreach (var newStop in newCollection) - { - if (newStop is not null) + foreach (var newStop in newCollection) { - newStop.Parent = this; - newStop.PropertyChanged += OnGradientStopPropertyChanged; + if (newStop is not null) + { + newStop.Parent = this; + subscriptions.Add(newStop, _gradientStopPropertyChanged); + } } } + + // Collection replacement must invalidate even when the new value is null or empty. + Invalidate(); } void OnGradientStopCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) { - if (e.OldItems != null) + if (e.Action == NotifyCollectionChangedAction.Reset) + { + _gradientStopSubscriptions?.ResetStops(this); + } + else if (e.OldItems != null) { foreach (var oldItem in e.OldItems) { if (!(oldItem is GradientStop oldStop)) continue; - oldStop.Parent = null; - oldStop.PropertyChanged -= OnGradientStopPropertyChanged; + _gradientStopSubscriptions?.Remove(oldStop); + ClearGradientStopParentIfUnused(oldStop); } } @@ -95,13 +114,113 @@ void OnGradientStopCollectionChanged(object sender, NotifyCollectionChangedEvent continue; newStop.Parent = this; - newStop.PropertyChanged += OnGradientStopPropertyChanged; + _gradientStopSubscriptions?.Add(newStop, _gradientStopPropertyChanged); } } Invalidate(); } + void ClearGradientStopParentIfUnused(GradientStop gradientStop) + { + if (!ReferenceEquals(gradientStop.Parent, this)) + return; + + var gradientStops = GradientStops; + if (gradientStops is not null) + { + for (int i = 0; i < gradientStops.Count; i++) + { + if (ReferenceEquals(gradientStops[i], gradientStop)) + return; + } + } + + gradientStop.Parent = null; + } + + sealed class GradientStopSubscriptions + { + readonly WeakNotifyCollectionChangedProxy _collectionProxy = new(); + readonly List _stopProxies = new(); + + ~GradientStopSubscriptions() => UnsubscribeAllForFinalization(); + + public void Subscribe(GradientStopCollection source, NotifyCollectionChangedEventHandler handler) + { + _collectionProxy.Subscribe(source, handler); + } + + public void Add(GradientStop source, PropertyChangedEventHandler handler) + { + _stopProxies.Add(new WeakNotifyPropertyChangedProxy(source, handler)); + } + + public void Remove(GradientStop source) + { + bool removed = false; + for (int i = _stopProxies.Count - 1; i >= 0; i--) + { + var proxy = _stopProxies[i]; + if (!proxy.TryGetSource(out var proxySource)) + { + proxy.Unsubscribe(); + _stopProxies.RemoveAt(i); + } + else if (!removed && ReferenceEquals(proxySource, source)) + { + proxy.Unsubscribe(); + _stopProxies.RemoveAt(i); + removed = true; + } + } + } + + public void ResetStops(GradientBrush owner) + { + UnsubscribeStops(owner); + } + + public void UnsubscribeAll(GradientBrush owner) + { + UnsubscribeAllCore(owner); + } + + void UnsubscribeAllForFinalization() + { + UnsubscribeAllCore(owner: null); + } + + void UnsubscribeAllCore(GradientBrush owner) + { + _collectionProxy.Unsubscribe(); + UnsubscribeStops(owner); + } + + void UnsubscribeStops(GradientBrush owner) + { + var proxies = _stopProxies.ToArray(); + _stopProxies.Clear(); + + foreach (var proxy in proxies) + { + GradientStop gradientStop = null; + if (proxy.TryGetSource(out var source)) + gradientStop = source as GradientStop; + + proxy.Unsubscribe(); + + if (gradientStop is null) + continue; + + if (owner is not null) + owner.ClearGradientStopParentIfUnused(gradientStop); + else + gradientStop.ClearRealParentAndInheritedContextIfCollected(); + } + } + } + void OnGradientStopPropertyChanged(object sender, PropertyChangedEventArgs e) { Invalidate(); diff --git a/src/Controls/src/Core/MultiBinding.cs b/src/Controls/src/Core/MultiBinding.cs index 1f59b28d48f6..2b1a9fdf2cbd 100644 --- a/src/Controls/src/Core/MultiBinding.cs +++ b/src/Controls/src/Core/MultiBinding.cs @@ -20,6 +20,7 @@ public sealed class MultiBinding : BindingBase BindableObject _targetObject; BindableObject _proxyObject; BindableProperty[] _bpProxies; + SetterSpecificity _specificity; bool _applying; /// @@ -114,7 +115,12 @@ internal override void Apply(bool fromTarget) return; } // ManualValueSetter specificity ensures TwoWay bindings continue updating after ConvertBack. - _targetObject.SetValueCore(_targetProperty, value, SetValueFlags.ClearDynamicResource, BindableObject.SetValuePrivateFlags.Default | BindableObject.SetValuePrivateFlags.Converted, specificity: SetterSpecificity.ManualValueSetter); + // BindingContext bindings retain binding specificity so inherited-context cleanup can distinguish + // them from explicit local values. + var specificity = ReferenceEquals(_targetProperty, BindableObject.BindingContextProperty) + ? _specificity + : SetterSpecificity.ManualValueSetter; + _targetObject.SetValueCore(_targetProperty, value, SetValueFlags.ClearDynamicResource, BindableObject.SetValuePrivateFlags.Default | BindableObject.SetValuePrivateFlags.Converted, specificity); _applying = false; } } @@ -151,6 +157,7 @@ internal override void Apply(object context, BindableObject targetObject, Bindab throw new InvalidOperationException("Cannot apply MultiBinding because both Converter and StringFormat are null."); base.Apply(context, targetObject, targetProperty, fromBindingContextChanged, specificity); + _specificity = specificity; if (!ReferenceEquals(_targetObject, targetObject)) { @@ -173,7 +180,7 @@ internal override void Apply(object context, BindableObject targetObject, Bindab _applying = false; } } - _proxyObject.BindingContext = context; + BindableObject.SetInheritedBindingContextForBinding(_proxyObject, context); if (this.GetRealizedMode(_targetProperty) == BindingMode.OneWayToSource) return; diff --git a/src/Controls/tests/Core.UnitTests/BindableObjectUnitTests.cs b/src/Controls/tests/Core.UnitTests/BindableObjectUnitTests.cs index f1f9316cf5e7..d6dcc98f428d 100644 --- a/src/Controls/tests/Core.UnitTests/BindableObjectUnitTests.cs +++ b/src/Controls/tests/Core.UnitTests/BindableObjectUnitTests.cs @@ -1,7 +1,10 @@ using System; using System.ComponentModel; using System.Globalization; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; using Microsoft.Maui.Controls.Internals; +using Microsoft.Maui.UnitTests; using Xunit; using Xunit.Sdk; @@ -75,6 +78,14 @@ internal class ToBazConverter : TypeConverter public class BindableObjectUnitTests : BaseTestFixture { + [MethodImpl(MethodImplOptions.NoInlining)] + static WeakReference SetParentWithBindingContext(Element child, object bindingContext) + { + var parent = new ContentView { BindingContext = bindingContext }; + child.Parent = parent; + return new WeakReference(parent); + } + [Fact] public void BindingContext() { @@ -720,6 +731,132 @@ public void BindingContextGetter() Assert.Same(label.BindingContext, label.GetValue(BindableObject.BindingContextProperty)); } + [Fact] + public async Task BoundBindingContextReturnsNullWhileInheritedCleanupIsPending() + { + bool dispatchAccepted = false; + int dispatchAttempts = 0; + Action dispatchedCleanup = null; + DispatcherProviderStubOptions.IsInvokeRequired = () => true; + DispatcherProviderStubOptions.InvokeOnMainThread = action => dispatchedCleanup = action; + DispatcherProviderStubOptions.DispatchResult = () => + { + dispatchAttempts++; + return dispatchAccepted; + }; + + MockBindable bindable; + try + { + bindable = new MockBindable(); + } + finally + { + DispatcherProviderStubOptions.IsInvokeRequired = null; + DispatcherProviderStubOptions.InvokeOnMainThread = null; + DispatcherProviderStubOptions.DispatchResult = null; + } + + var inheritedContext = new MockViewModel { Text = "FooBar" }; + bindable.SetBinding(BindableObject.BindingContextProperty, nameof(MockViewModel.Text)); + var weakParent = SetParentWithBindingContext(bindable, inheritedContext); + int bindingContextChanged = 0; + bindable.BindingContextChanged += (_, _) => bindingContextChanged++; + + Assert.Equal("FooBar", bindable.BindingContext); + Assert.False(await weakParent.WaitForCollect(), "Parent should not be alive!"); + + Assert.Null(bindable.Parent); + Assert.Null(bindable.BindingContext); + Assert.Null(dispatchedCleanup); + Assert.True(dispatchAttempts > 0); + Assert.Equal(0, bindingContextChanged); + + int attemptsBeforeAcceptedDispatch = dispatchAttempts; + dispatchAccepted = true; + Assert.Null(bindable.BindingContext); + Assert.NotNull(dispatchedCleanup); + Assert.Equal(attemptsBeforeAcceptedDispatch + 1, dispatchAttempts); + Assert.Equal(0, bindingContextChanged); + + for (int i = 0; i < 10; i++) + Assert.Null(bindable.BindingContext); + + Assert.Equal(attemptsBeforeAcceptedDispatch + 1, dispatchAttempts); + + dispatchedCleanup(); + + Assert.Null(bindable.BindingContext); + Assert.Equal(1, bindingContextChanged); + GC.KeepAlive(inheritedContext); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task CleanupLeavesPendingWhenDispatcherIsDisposed(bool throwFromIsDispatchRequired) + { + bool dispatcherDisposed = true; + bool dispatchRequired = true; + DispatcherProviderStubOptions.IsInvokeRequired = () => + { + if (dispatcherDisposed && throwFromIsDispatchRequired) + throw new ObjectDisposedException("dispatcher"); + + return dispatchRequired; + }; + DispatcherProviderStubOptions.DispatchResult = () => + { + if (dispatcherDisposed && !throwFromIsDispatchRequired) + throw new ObjectDisposedException("dispatcher"); + + return true; + }; + + MockBindable bindable; + try + { + bindable = new MockBindable(); + } + finally + { + DispatcherProviderStubOptions.IsInvokeRequired = null; + DispatcherProviderStubOptions.DispatchResult = null; + } + + var inheritedContext = new MockViewModel { Text = "FooBar" }; + bindable.SetBinding(BindableObject.BindingContextProperty, nameof(MockViewModel.Text)); + var weakParent = SetParentWithBindingContext(bindable, inheritedContext); + int bindingContextChanged = 0; + bindable.BindingContextChanged += (_, _) => bindingContextChanged++; + + Assert.Equal("FooBar", bindable.BindingContext); + Assert.False(await weakParent.WaitForCollect(), "Parent should not be alive!"); + + var exception = Record.Exception(bindable.ClearRealParentAndInheritedContextIfCollected); + + Assert.Null(exception); + + Element parent = null; + object bindingContext = new(); + var parentException = Record.Exception(() => parent = bindable.Parent); + var bindingContextException = Record.Exception(() => bindingContext = bindable.BindingContext); + + Assert.Null(parentException); + Assert.Null(bindingContextException); + Assert.Null(parent); + Assert.Null(bindingContext); + Assert.Equal(0, bindingContextChanged); + Assert.Equal("FooBar", bindable.GetValue(BindableObject.BindingContextProperty)); + + dispatcherDisposed = false; + dispatchRequired = false; + + Assert.Null(bindable.BindingContext); + Assert.Equal(1, bindingContextChanged); + GC.KeepAlive(inheritedContext); + } + [Fact] public void BoundBindingContextUpdate() { diff --git a/src/Controls/tests/Core.UnitTests/DispatcherExtensionsTests.cs b/src/Controls/tests/Core.UnitTests/DispatcherExtensionsTests.cs index 868c8ebd6f0c..1ff202eae09b 100644 --- a/src/Controls/tests/Core.UnitTests/DispatcherExtensionsTests.cs +++ b/src/Controls/tests/Core.UnitTests/DispatcherExtensionsTests.cs @@ -1,6 +1,8 @@ using System; +using System.Threading; using System.Threading.Tasks; using Microsoft.Maui.Dispatching; +using Microsoft.Maui.UnitTests; using NSubstitute; using Xunit; @@ -8,6 +10,80 @@ namespace Microsoft.Maui.Controls.Core.UnitTests; public class DispatcherExtensionsTest : BaseTestFixture { + [Fact] + public void DispatcherGetterPreservesDispatcherCapturedDuringHandlerAttachment() + { + MockBindable parent; + MockBindable bindable; + DispatcherProviderStubOptions.SkipDispatcherCreation = true; + try + { + parent = new MockBindable(); + bindable = new MockBindable { Parent = parent }; + } + finally + { + DispatcherProviderStubOptions.SkipDispatcherCreation = false; + } + + using var lookupEntered = new ManualResetEventSlim(); + using var releaseLookup = new ManualResetEventSlim(); + var backgroundDispatcher = Substitute.For(); + var parentServices = Substitute.For(); + parentServices.GetService(typeof(IDispatcher)).Returns(_ => + { + lookupEntered.Set(); + releaseLookup.Wait(); + return backgroundDispatcher; + }); + var parentMauiContext = Substitute.For(); + parentMauiContext.Services.Returns(parentServices); + var parentHandler = Substitute.For(); + IMauiContext currentParentMauiContext = null; + parentHandler.MauiContext.Returns(_ => currentParentMauiContext); + parent.Handler = parentHandler; + currentParentMauiContext = parentMauiContext; + + var attachedDispatcher = Substitute.For(); + var attachedServices = Substitute.For(); + attachedServices.GetService(typeof(IDispatcher)).Returns(attachedDispatcher); + var attachedMauiContext = Substitute.For(); + attachedMauiContext.Services.Returns(attachedServices); + var attachedHandler = Substitute.For(); + attachedHandler.MauiContext.Returns(attachedMauiContext); + + IDispatcher resolvedDispatcher = null; + Exception getterException = null; + var getterThread = new Thread(() => + { + try + { + resolvedDispatcher = bindable.Dispatcher; + } + catch (Exception exception) + { + getterException = exception; + } + }); + getterThread.IsBackground = true; + + getterThread.Start(); + try + { + Assert.True(lookupEntered.Wait(TimeSpan.FromSeconds(5)), "Dispatcher lookup did not start."); + bindable.Handler = attachedHandler; + } + finally + { + releaseLookup.Set(); + } + + Assert.True(getterThread.Join(TimeSpan.FromSeconds(5)), "Dispatcher lookup did not complete."); + Assert.Null(getterException); + Assert.Same(attachedDispatcher, resolvedDispatcher); + Assert.Same(attachedDispatcher, bindable.Dispatcher); + } + [Fact] public void DispatchIfRequired_ShouldCallDispatch_WhenDispatchIsRequired() { diff --git a/src/Controls/tests/Core.UnitTests/GradientBrushMemoryTests.cs b/src/Controls/tests/Core.UnitTests/GradientBrushMemoryTests.cs new file mode 100644 index 000000000000..a206b4e244d5 --- /dev/null +++ b/src/Controls/tests/Core.UnitTests/GradientBrushMemoryTests.cs @@ -0,0 +1,1244 @@ +using System; +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Maui.Dispatching; +using Microsoft.Maui.UnitTests; +using NSubstitute; +using Xunit; + +namespace Microsoft.Maui.Controls.Core.UnitTests +{ + public class GradientBrushMemoryTests : BaseTestFixture + { + [MethodImpl(MethodImplOptions.NoInlining)] + static WeakReference CreateBrushWithSharedGradientStops(GradientStopCollection sharedStops, object bindingContext = null) + { + var brush = new LinearGradientBrush { GradientStops = sharedStops }; + if (bindingContext is not null) + brush.BindingContext = bindingContext; + + return new WeakReference(brush); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static (WeakReference Brush, WeakReference BindingContext) CreateBrushWithNewBindingContext( + GradientStopCollection sharedStops) + { + var bindingContext = new GradientStop { Offset = 0.25f }; + var brush = new LinearGradientBrush + { + BindingContext = bindingContext, + GradientStops = sharedStops + }; + + return (new WeakReference(brush), new WeakReference(bindingContext)); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static WeakReference CreateBrushWithHandlerAndSharedGradientStops( + GradientStopCollection sharedStops, + object bindingContext, + IMauiContext mauiContext) + { + var handler = new ElementHandlerStub(); + handler.SetMauiContext(mauiContext); + var brush = new LinearGradientBrush + { + BindingContext = bindingContext, + Handler = handler, + GradientStops = sharedStops + }; + + return new WeakReference(brush); + } + + static IMauiContext CreateMauiContext(IDispatcher dispatcher) + { + var services = Substitute.For(); + services.GetService(typeof(IDispatcher)).Returns(dispatcher); + var mauiContext = Substitute.For(); + mauiContext.Services.Returns(services); + return mauiContext; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static WeakReference CreateFinalizerBlocker( + ManualResetEventSlim finalizerEntered, + ManualResetEventSlim releaseFinalizer) + { + var blocker = new FinalizerBlocker(finalizerEntered, releaseFinalizer); + return new WeakReference(blocker); + } + + [Fact] + public async Task GradientBrushDoesNotLeakWhenSharingGradientStops() + { + // A long-lived/shared GradientStopCollection, exactly as the issue describes. + var sharedStops = new GradientStopCollection + { + new GradientStop() + }; + var weakBrush = CreateBrushWithSharedGradientStops(sharedStops); + + Assert.False(await weakBrush.WaitForCollect(), "LinearGradientBrush should not be alive!"); + GC.KeepAlive(sharedStops); + } + + [Fact] + public async Task CollectedBrushDoesNotLeaveStaleGradientStopParent() + { + ApplicationExtensions.CreateAndSetMockApplication(); + try + { + var stop = new GradientStop(); + var sharedStops = new GradientStopCollection { stop }; + var weakBrush = CreateBrushWithSharedGradientStops(sharedStops); + + Assert.False(await weakBrush.WaitForCollect(), "LinearGradientBrush should not be alive!"); + Assert.Null(stop.Parent); + Assert.DoesNotContain(MockApplication.MockLogger.Messages, + message => message.Contains("RealParent", StringComparison.Ordinal)); + GC.KeepAlive(sharedStops); + } + finally + { + Application.ClearCurrent(); + } + } + + [Fact] + public async Task CollectedBrushClearsGradientStopInheritedBindingContext() + { + var bindingContext = new GradientStop { Offset = 0.25f }; + var stop = new GradientStop(); + stop.SetBinding(GradientStop.OffsetProperty, nameof(GradientStop.Offset)); + var sharedStops = new GradientStopCollection { stop }; + var weakBrush = CreateBrushWithSharedGradientStops(sharedStops, bindingContext); + + Assert.Same(bindingContext, stop.BindingContext); + Assert.Equal(0.25f, stop.Offset); + + bindingContext.Offset = 0.5f; + Assert.Equal(0.5f, stop.Offset); + + Assert.False(await weakBrush.WaitForCollect(), "LinearGradientBrush should not be alive!"); + Assert.Null(stop.Parent); + Assert.Null(stop.BindingContext); + Assert.Equal(0f, stop.Offset); + + bindingContext.Offset = 0.75f; + Assert.Equal(0f, stop.Offset); + GC.KeepAlive(bindingContext); + GC.KeepAlive(sharedStops); + } + + [Fact] + public async Task CollectedBrushClearsGradientStopBindingContextPropertyBindingSource() + { + var inheritedContext = new GradientStop { Offset = 0.25f }; + var stop = new GradientStop(); + stop.SetBinding(BindableObject.BindingContextProperty, nameof(GradientStop.Offset)); + var sharedStops = new GradientStopCollection { stop }; + var weakBrush = CreateBrushWithSharedGradientStops(sharedStops, inheritedContext); + int bindingContextChanged = 0; + stop.BindingContextChanged += (_, _) => bindingContextChanged++; + + Assert.Equal(0.25f, stop.BindingContext); + Assert.False(await weakBrush.WaitForCollect(), "LinearGradientBrush should not be alive!"); + Assert.Null(stop.Parent); + Assert.Null(stop.BindingContext); + Assert.Equal(1, bindingContextChanged); + + inheritedContext.Offset = 0.5f; + Assert.Null(stop.BindingContext); + GC.KeepAlive(inheritedContext); + GC.KeepAlive(sharedStops); + } + + [Fact] + public async Task CollectedBrushWithoutDispatcherReleasesBindingContextPropertyBindingSource() + { + GradientStop stop; + GradientStopCollection sharedStops; + WeakReference weakBrush; + WeakReference weakBindingContext; + DispatcherProviderStubOptions.SkipDispatcherCreation = true; + try + { + stop = new GradientStop(); + stop.SetBinding(BindableObject.BindingContextProperty, nameof(GradientStop.Offset)); + sharedStops = new GradientStopCollection { stop }; + (weakBrush, weakBindingContext) = CreateBrushWithNewBindingContext(sharedStops); + } + finally + { + DispatcherProviderStubOptions.SkipDispatcherCreation = false; + } + + int bindingContextChanged = 0; + stop.BindingContextChanged += (_, _) => bindingContextChanged++; + + Assert.False(await weakBrush.WaitForCollect(), "LinearGradientBrush should not be alive!"); + Assert.False(await weakBindingContext.WaitForCollect(), "Inherited binding context should not be alive!"); + Assert.Equal(0, bindingContextChanged); + Assert.Equal(0.25f, stop.GetValue(BindableObject.BindingContextProperty)); + + Assert.Null(stop.BindingContext); + Assert.Equal(1, bindingContextChanged); + GC.KeepAlive(sharedStops); + } + + [Fact] + public async Task CollectedBrushWithoutDispatcherReleasesMultiBindingContextPropertyBindingSource() + { + GradientStop stop; + GradientStopCollection sharedStops; + WeakReference weakBrush; + WeakReference weakBindingContext; + DispatcherProviderStubOptions.SkipDispatcherCreation = true; + try + { + stop = new GradientStop(); + stop.SetBinding(BindableObject.BindingContextProperty, new MultiBinding + { + StringFormat = "{0}", + Bindings = + { + new Binding(nameof(GradientStop.Offset)) + } + }); + sharedStops = new GradientStopCollection { stop }; + (weakBrush, weakBindingContext) = CreateBrushWithNewBindingContext(sharedStops); + } + finally + { + DispatcherProviderStubOptions.SkipDispatcherCreation = false; + } + + int bindingContextChanged = 0; + stop.BindingContextChanged += (_, _) => bindingContextChanged++; + + Assert.False(await weakBrush.WaitForCollect(), "LinearGradientBrush should not be alive!"); + Assert.False(await weakBindingContext.WaitForCollect(), "Inherited binding context should not be alive!"); + Assert.Equal(0, bindingContextChanged); + + Assert.Equal(string.Empty, stop.BindingContext); + Assert.Equal(1, bindingContextChanged); + GC.KeepAlive(sharedStops); + } + + [Fact] + public void CollectedInheritedContextStillClearsAppliedBindingValues() + { + using var finalizerEntered = new ManualResetEventSlim(); + using var releaseFinalizer = new ManualResetEventSlim(); + CreateFinalizerBlocker(finalizerEntered, releaseFinalizer); + GC.Collect(); + + Assert.True(finalizerEntered.Wait(TimeSpan.FromSeconds(5)), "Finalizer blocker did not start."); + + GradientStop stop; + GradientStopCollection sharedStops; + WeakReference weakBrush; + WeakReference weakBindingContext; + DispatcherProviderStubOptions.SkipDispatcherCreation = true; + try + { + stop = new GradientStop(); + stop.SetBinding(GradientStop.OffsetProperty, nameof(GradientStop.Offset)); + sharedStops = new GradientStopCollection { stop }; + (weakBrush, weakBindingContext) = CreateBrushWithNewBindingContext(sharedStops); + } + finally + { + DispatcherProviderStubOptions.SkipDispatcherCreation = false; + } + + int bindingContextChanged = 0; + stop.BindingContextChanged += (_, _) => bindingContextChanged++; + + try + { + for (int attempt = 0; + attempt < 20 && (weakBrush.IsAlive || weakBindingContext.IsAlive); + attempt++) + { + GC.Collect(); + Thread.Sleep(10); + } + + Assert.False(weakBrush.IsAlive, "LinearGradientBrush should not be alive!"); + Assert.False(weakBindingContext.IsAlive, "Inherited binding context should not be alive!"); + Assert.Equal(0.25f, stop.GetValue(GradientStop.OffsetProperty)); + Assert.Equal(0, bindingContextChanged); + } + finally + { + releaseFinalizer.Set(); + GC.WaitForPendingFinalizers(); + } + + Assert.Equal(0, bindingContextChanged); + Assert.Equal(0.25f, stop.GetValue(GradientStop.OffsetProperty)); + + Assert.Null(stop.BindingContext); + Assert.Equal(1, bindingContextChanged); + Assert.Equal(0f, stop.Offset); + GC.KeepAlive(sharedStops); + } + + [Fact] + public async Task CollectedBrushDispatchesGradientStopInheritedBindingContextCleanup() + { + var dispatchedCleanup = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + DispatcherProviderStubOptions.IsInvokeRequired = () => true; + DispatcherProviderStubOptions.InvokeOnMainThread = action => dispatchedCleanup.TrySetResult(action); + + GradientStop bindingContext; + GradientStop stop; + GradientStopCollection sharedStops; + WeakReference weakBrush; + try + { + bindingContext = new GradientStop { Offset = 0.25f }; + stop = new GradientStop(); + stop.SetBinding(GradientStop.OffsetProperty, nameof(GradientStop.Offset)); + sharedStops = new GradientStopCollection { stop }; + weakBrush = CreateBrushWithSharedGradientStops(sharedStops, bindingContext); + } + finally + { + DispatcherProviderStubOptions.IsInvokeRequired = null; + DispatcherProviderStubOptions.InvokeOnMainThread = null; + } + + int bindingContextChanged = 0; + stop.BindingContextChanged += (_, _) => bindingContextChanged++; + + Assert.False(await weakBrush.WaitForCollect(), "LinearGradientBrush should not be alive!"); + var cleanup = await dispatchedCleanup.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Equal(0, bindingContextChanged); + Assert.Equal(0.25f, stop.Offset); + + cleanup(); + + Assert.Equal(1, bindingContextChanged); + Assert.Null(stop.BindingContext); + Assert.Equal(0f, stop.Offset); + GC.KeepAlive(bindingContext); + GC.KeepAlive(sharedStops); + } + + [Fact] + public async Task CollectedBrushUsesDispatcherAttachedAfterGradientStopCreation() + { + var dispatchedCleanup = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var dispatcher = new DispatcherStub( + () => true, + action => dispatchedCleanup.TrySetResult(action)); + var mauiContext = CreateMauiContext(dispatcher); + var handler = Substitute.For(); + handler.MauiContext.Returns(mauiContext); + + GradientStop bindingContext; + GradientStop stop; + GradientStopCollection sharedStops; + WeakReference weakBrush; + DispatcherProviderStubOptions.SkipDispatcherCreation = true; + try + { + bindingContext = new GradientStop { Offset = 0.25f }; + stop = new GradientStop(); + stop.SetBinding(GradientStop.OffsetProperty, nameof(GradientStop.Offset)); + sharedStops = new GradientStopCollection { stop }; + weakBrush = CreateBrushWithSharedGradientStops(sharedStops, bindingContext); + } + finally + { + DispatcherProviderStubOptions.SkipDispatcherCreation = false; + } + + stop.Handler = handler; + int bindingContextChanged = 0; + stop.BindingContextChanged += (_, _) => bindingContextChanged++; + + Assert.False(await weakBrush.WaitForCollect(), "LinearGradientBrush should not be alive!"); + var cleanup = await dispatchedCleanup.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Equal(0, bindingContextChanged); + Assert.Equal(0.25f, stop.Offset); + + cleanup(); + + Assert.Equal(1, bindingContextChanged); + Assert.Null(stop.BindingContext); + Assert.Equal(0f, stop.Offset); + GC.KeepAlive(bindingContext); + GC.KeepAlive(sharedStops); + } + + [Fact] + public async Task CollectedBrushFinalizerDoesNotResolveLateHandlerDispatcher() + { + var dispatchedCleanup = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var dispatcher = new DispatcherStub( + () => true, + action => dispatchedCleanup.TrySetResult(action)); + int dispatcherAvailable = 0; + int dispatcherResolutionCount = 0; + var services = Substitute.For(); + services.GetService(typeof(IDispatcher)).Returns(_ => + { + if (Volatile.Read(ref dispatcherAvailable) == 0) + return null; + + Interlocked.Increment(ref dispatcherResolutionCount); + return dispatcher; + }); + var mauiContext = Substitute.For(); + mauiContext.Services.Returns(services); + var handler = Substitute.For(); + handler.MauiContext.Returns(mauiContext); + + GradientStop bindingContext; + GradientStop stop; + GradientStopCollection sharedStops; + WeakReference weakBrush; + DispatcherProviderStubOptions.SkipDispatcherCreation = true; + try + { + bindingContext = new GradientStop { Offset = 0.25f }; + stop = new GradientStop(); + stop.SetBinding(GradientStop.OffsetProperty, nameof(GradientStop.Offset)); + sharedStops = new GradientStopCollection { stop }; + weakBrush = CreateBrushWithSharedGradientStops(sharedStops, bindingContext); + } + finally + { + DispatcherProviderStubOptions.SkipDispatcherCreation = false; + } + + stop.Handler = handler; + Volatile.Write(ref dispatcherAvailable, 1); + int bindingContextChanged = 0; + stop.BindingContextChanged += (_, _) => bindingContextChanged++; + + Assert.False(await weakBrush.WaitForCollect(), "LinearGradientBrush should not be alive!"); + Assert.Equal(0, Volatile.Read(ref dispatcherResolutionCount)); + Assert.False(dispatchedCleanup.Task.IsCompleted); + Assert.Equal(0, bindingContextChanged); + Assert.Equal(0.25f, stop.Offset); + + Assert.Null(stop.BindingContext); + Assert.Equal(1, Volatile.Read(ref dispatcherResolutionCount)); + var cleanup = await dispatchedCleanup.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Equal(0, bindingContextChanged); + cleanup(); + + Assert.Equal(1, bindingContextChanged); + Assert.Null(stop.BindingContext); + Assert.Equal(0f, stop.Offset); + GC.KeepAlive(bindingContext); + GC.KeepAlive(sharedStops); + } + + [Fact] + public async Task CollectedBrushUsesDispatcherAvailableWhenParentIsAssigned() + { + var dispatchedCleanup = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var dispatcher = new DispatcherStub( + () => true, + action => dispatchedCleanup.TrySetResult(action)); + var mauiContext = CreateMauiContext(dispatcher); + + GradientStop bindingContext; + GradientStop stop; + GradientStopCollection sharedStops; + WeakReference weakBrush; + DispatcherProviderStubOptions.SkipDispatcherCreation = true; + try + { + bindingContext = new GradientStop { Offset = 0.25f }; + stop = new GradientStop(); + stop.SetBinding(GradientStop.OffsetProperty, nameof(GradientStop.Offset)); + sharedStops = new GradientStopCollection { stop }; + weakBrush = CreateBrushWithHandlerAndSharedGradientStops( + sharedStops, + bindingContext, + mauiContext); + } + finally + { + DispatcherProviderStubOptions.SkipDispatcherCreation = false; + } + + int bindingContextChanged = 0; + stop.BindingContextChanged += (_, _) => bindingContextChanged++; + + Assert.False(await weakBrush.WaitForCollect(), "LinearGradientBrush should not be alive!"); + var cleanup = await dispatchedCleanup.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Equal(0, bindingContextChanged); + Assert.Equal(0.25f, stop.Offset); + + cleanup(); + + Assert.Equal(1, bindingContextChanged); + Assert.Null(stop.BindingContext); + Assert.Equal(0f, stop.Offset); + GC.KeepAlive(bindingContext); + GC.KeepAlive(sharedStops); + } + + [Fact] + public async Task BindingContextAccessBeforeDispatchedCleanupDoesNotRunCallbacks() + { + var dispatchedCleanup = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + DispatcherProviderStubOptions.IsInvokeRequired = () => true; + DispatcherProviderStubOptions.InvokeOnMainThread = action => dispatchedCleanup.TrySetResult(action); + + GradientStop bindingContext; + GradientStop stop; + GradientStopCollection sharedStops; + WeakReference weakBrush; + try + { + bindingContext = new GradientStop { Offset = 0.25f }; + stop = new GradientStop(); + stop.SetBinding(GradientStop.OffsetProperty, nameof(GradientStop.Offset)); + sharedStops = new GradientStopCollection { stop }; + weakBrush = CreateBrushWithSharedGradientStops(sharedStops, bindingContext); + } + finally + { + DispatcherProviderStubOptions.IsInvokeRequired = null; + DispatcherProviderStubOptions.InvokeOnMainThread = null; + } + + int bindingContextChanged = 0; + stop.BindingContextChanged += (_, _) => Interlocked.Increment(ref bindingContextChanged); + + Assert.False(await weakBrush.WaitForCollect(), "LinearGradientBrush should not be alive!"); + var cleanup = await dispatchedCleanup.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Null(await Task.Run(() => stop.BindingContext)); + int callbacksBeforeCleanup = Volatile.Read(ref bindingContextChanged); + + cleanup(); + + Assert.Equal(0, callbacksBeforeCleanup); + Assert.Equal(1, Volatile.Read(ref bindingContextChanged)); + Assert.Null(stop.BindingContext); + Assert.Equal(0f, stop.Offset); + GC.KeepAlive(bindingContext); + GC.KeepAlive(sharedStops); + } + + [Fact] + public async Task ParentAccessAfterCollectedBrushClearsGradientStopInheritedBindingContext() + { + var bindingContext = new GradientStop { Offset = 0.25f }; + var stop = new GradientStop(); + stop.SetBinding(GradientStop.OffsetProperty, nameof(GradientStop.Offset)); + var sharedStops = new GradientStopCollection { stop }; + var weakBrush = CreateBrushWithSharedGradientStops(sharedStops, bindingContext); + int bindingContextChanged = 0; + stop.BindingContextChanged += (_, _) => bindingContextChanged++; + + Assert.False(await weakBrush.WaitForCollect(), "LinearGradientBrush should not be alive!"); + Assert.Equal(0, bindingContextChanged); + Assert.Equal(0.25f, stop.Offset); + + Assert.Null(stop.Parent); + + Assert.Equal(1, bindingContextChanged); + Assert.Equal(0f, stop.Offset); + + bindingContext.Offset = 0.75f; + Assert.Equal(0f, stop.Offset); + Assert.Null(stop.BindingContext); + GC.KeepAlive(bindingContext); + GC.KeepAlive(sharedStops); + } + + [Fact] + public async Task FailedDispatchLeavesGradientStopInheritedBindingContextCleanupPending() + { + bool dispatchRequired = true; + DispatcherProviderStubOptions.IsInvokeRequired = () => dispatchRequired; + DispatcherProviderStubOptions.DispatchResult = () => false; + + GradientStop bindingContext; + GradientStop stop; + GradientStopCollection sharedStops; + WeakReference weakBrush; + try + { + bindingContext = new GradientStop { Offset = 0.25f }; + stop = new GradientStop(); + stop.SetBinding(GradientStop.OffsetProperty, nameof(GradientStop.Offset)); + sharedStops = new GradientStopCollection { stop }; + weakBrush = CreateBrushWithSharedGradientStops(sharedStops, bindingContext); + } + finally + { + DispatcherProviderStubOptions.IsInvokeRequired = null; + DispatcherProviderStubOptions.DispatchResult = null; + } + + int bindingContextChanged = 0; + stop.BindingContextChanged += (_, _) => bindingContextChanged++; + + Assert.False(await weakBrush.WaitForCollect(), "LinearGradientBrush should not be alive!"); + Assert.Equal(0, bindingContextChanged); + Assert.Equal(0.25f, stop.Offset); + + Assert.Null(stop.Parent); + + Assert.Equal(0, bindingContextChanged); + Assert.Equal(0.25f, stop.Offset); + + dispatchRequired = false; + Assert.Null(stop.Parent); + + Assert.Equal(1, bindingContextChanged); + Assert.Equal(0f, stop.Offset); + + bindingContext.Offset = 0.75f; + Assert.Equal(0f, stop.Offset); + Assert.Null(stop.BindingContext); + GC.KeepAlive(bindingContext); + GC.KeepAlive(sharedStops); + } + + [Fact] + public void ParentAccessBeforeSubscriptionFinalizerClearsInheritedBindingContext() + { + using var finalizerEntered = new ManualResetEventSlim(); + using var releaseFinalizer = new ManualResetEventSlim(); + CreateFinalizerBlocker(finalizerEntered, releaseFinalizer); + GC.Collect(); + + Assert.True(finalizerEntered.Wait(TimeSpan.FromSeconds(5)), "Finalizer blocker did not start."); + + var bindingContext = new GradientStop { Offset = 0.25f }; + var stop = new GradientStop(); + stop.SetBinding(GradientStop.OffsetProperty, nameof(GradientStop.Offset)); + var sharedStops = new GradientStopCollection { stop }; + var weakBrush = CreateBrushWithSharedGradientStops(sharedStops, bindingContext); + + try + { + GC.Collect(); + + Assert.False(weakBrush.IsAlive, "LinearGradientBrush should not be alive!"); + Assert.Null(stop.Parent); + Assert.Null(stop.BindingContext); + Assert.Equal(0f, stop.Offset); + + bindingContext.Offset = 0.75f; + Assert.Equal(0f, stop.Offset); + } + finally + { + releaseFinalizer.Set(); + GC.WaitForPendingFinalizers(); + } + + GC.KeepAlive(bindingContext); + GC.KeepAlive(sharedStops); + } + + [Fact] + public async Task CollectingPreviousBrushPreservesCurrentGradientStopParent() + { + var stop = new GradientStop(); + var firstStops = new GradientStopCollection { stop }; + var weakFirstBrush = CreateBrushWithSharedGradientStops(firstStops); + var secondBindingContext = new object(); + var secondBrush = new LinearGradientBrush + { + BindingContext = secondBindingContext, + GradientStops = new GradientStopCollection { stop } + }; + + Assert.Same(secondBrush, stop.Parent); + Assert.False(await weakFirstBrush.WaitForCollect(), "Previous LinearGradientBrush should not be alive!"); + Assert.Same(secondBrush, stop.Parent); + Assert.Same(secondBindingContext, stop.BindingContext); + GC.KeepAlive(firstStops); + GC.KeepAlive(secondBrush); + } + + [Fact] + public async Task CollectingPreviousBrushPreservesCurrentGradientStopBindingContextPropertyBindingSource() + { + var stop = new GradientStop(); + stop.SetBinding(BindableObject.BindingContextProperty, nameof(GradientStop.Offset)); + var firstBindingContext = new GradientStop { Offset = 0.25f }; + var firstStops = new GradientStopCollection { stop }; + var weakFirstBrush = CreateBrushWithSharedGradientStops(firstStops, firstBindingContext); + var secondBindingContext = new GradientStop { Offset = 0.5f }; + var secondBrush = new LinearGradientBrush + { + BindingContext = secondBindingContext, + GradientStops = new GradientStopCollection { stop } + }; + + Assert.Same(secondBrush, stop.Parent); + Assert.Equal(0.5f, stop.BindingContext); + Assert.False(await weakFirstBrush.WaitForCollect(), "Previous LinearGradientBrush should not be alive!"); + Assert.Same(secondBrush, stop.Parent); + Assert.Equal(0.5f, stop.BindingContext); + GC.KeepAlive(firstStops); + GC.KeepAlive(firstBindingContext); + GC.KeepAlive(secondBrush); + GC.KeepAlive(secondBindingContext); + } + + [Fact] + public async Task ClearingManualBindingContextAfterCollectedBrushDoesNotRestoreStaleBindingValue() + { + var inheritedContext = new GradientStop { Offset = 0.25f }; + var stop = new GradientStop(); + stop.SetBinding(BindableObject.BindingContextProperty, nameof(GradientStop.Offset)); + var sharedStops = new GradientStopCollection { stop }; + var weakBrush = CreateBrushWithSharedGradientStops(sharedStops, inheritedContext); + var manualBindingContext = stop.BindingContext; + stop.BindingContext = manualBindingContext; + int bindingContextChanged = 0; + stop.BindingContextChanged += (_, _) => bindingContextChanged++; + + Assert.False(await weakBrush.WaitForCollect(), "LinearGradientBrush should not be alive!"); + Assert.Null(stop.Parent); + Assert.Same(manualBindingContext, stop.BindingContext); + Assert.Equal(0, bindingContextChanged); + + stop.ClearValue(BindableObject.BindingContextProperty); + + Assert.Null(stop.BindingContext); + Assert.Equal(1, bindingContextChanged); + GC.KeepAlive(inheritedContext); + GC.KeepAlive(sharedStops); + } + + [Fact] + public async Task ReplacingBindingContextBindingWhileCleanupIsPendingUsesNullInheritedSource() + { + var dispatchedCleanups = new ConcurrentQueue(); + DispatcherProviderStubOptions.IsInvokeRequired = () => true; + DispatcherProviderStubOptions.InvokeOnMainThread = dispatchedCleanups.Enqueue; + + GradientStop inheritedContext; + GradientStop stop; + GradientStopCollection sharedStops; + WeakReference weakBrush; + try + { + inheritedContext = new GradientStop { Offset = 0.25f }; + stop = new GradientStop(); + stop.SetBinding(BindableObject.BindingContextProperty, nameof(GradientStop.Offset)); + sharedStops = new GradientStopCollection { stop }; + weakBrush = CreateBrushWithSharedGradientStops(sharedStops, inheritedContext); + } + finally + { + DispatcherProviderStubOptions.IsInvokeRequired = null; + DispatcherProviderStubOptions.InvokeOnMainThread = null; + } + + int bindingContextChanged = 0; + stop.BindingContextChanged += (_, _) => bindingContextChanged++; + + Assert.False(await weakBrush.WaitForCollect(), "LinearGradientBrush should not be alive!"); + Assert.True(dispatchedCleanups.TryDequeue(out var cleanup)); + + stop.SetBinding(BindableObject.BindingContextProperty, nameof(GradientStop.Offset)); + + Assert.Null(stop.GetValue(BindableObject.BindingContextProperty)); + Assert.Equal(1, bindingContextChanged); + + cleanup(); + + Assert.Null(stop.GetValue(BindableObject.BindingContextProperty)); + Assert.Equal(1, bindingContextChanged); + GC.KeepAlive(inheritedContext); + GC.KeepAlive(sharedStops); + } + + [Fact] + public async Task RemovingBindingContextBindingWhileCleanupIsPendingClearsStoredBindingValue() + { + var dispatchedCleanups = new ConcurrentQueue(); + DispatcherProviderStubOptions.IsInvokeRequired = () => true; + DispatcherProviderStubOptions.InvokeOnMainThread = dispatchedCleanups.Enqueue; + + GradientStop inheritedContext; + GradientStop stop; + GradientStopCollection sharedStops; + WeakReference weakBrush; + try + { + inheritedContext = new GradientStop { Offset = 0.25f }; + stop = new GradientStop(); + stop.SetBinding(BindableObject.BindingContextProperty, nameof(GradientStop.Offset)); + sharedStops = new GradientStopCollection { stop }; + weakBrush = CreateBrushWithSharedGradientStops(sharedStops, inheritedContext); + } + finally + { + DispatcherProviderStubOptions.IsInvokeRequired = null; + DispatcherProviderStubOptions.InvokeOnMainThread = null; + } + + var manualBindingContext = stop.BindingContext; + stop.BindingContext = manualBindingContext; + int bindingContextChanged = 0; + stop.BindingContextChanged += (_, _) => bindingContextChanged++; + + Assert.False(await weakBrush.WaitForCollect(), "LinearGradientBrush should not be alive!"); + Assert.True(dispatchedCleanups.TryDequeue(out var cleanup)); + + stop.RemoveBinding(BindableObject.BindingContextProperty); + + Assert.Same(manualBindingContext, stop.BindingContext); + Assert.Equal(0, bindingContextChanged); + + cleanup(); + stop.ClearValue(BindableObject.BindingContextProperty); + + Assert.Null(stop.BindingContext); + Assert.Equal(1, bindingContextChanged); + GC.KeepAlive(inheritedContext); + GC.KeepAlive(sharedStops); + } + + [Fact] + public async Task StaleDispatchedCleanupDoesNotClearNewPendingBindingContextCleanup() + { + var dispatchedCleanups = new ConcurrentQueue(); + DispatcherProviderStubOptions.IsInvokeRequired = () => true; + DispatcherProviderStubOptions.InvokeOnMainThread = dispatchedCleanups.Enqueue; + + GradientStop stop; + GradientStop firstBindingContext; + GradientStopCollection firstStops; + WeakReference weakFirstBrush; + try + { + stop = new GradientStop(); + stop.SetBinding(BindableObject.BindingContextProperty, nameof(GradientStop.Offset)); + firstBindingContext = new GradientStop { Offset = 0.25f }; + firstStops = new GradientStopCollection { stop }; + weakFirstBrush = CreateBrushWithSharedGradientStops(firstStops, firstBindingContext); + } + finally + { + DispatcherProviderStubOptions.IsInvokeRequired = null; + DispatcherProviderStubOptions.InvokeOnMainThread = null; + } + + int bindingContextChanged = 0; + stop.BindingContextChanged += (_, _) => bindingContextChanged++; + + Assert.False(await weakFirstBrush.WaitForCollect(), "First LinearGradientBrush should not be alive!"); + Assert.True(dispatchedCleanups.TryDequeue(out var firstCleanup)); + + var secondBindingContext = new GradientStop { Offset = 0.5f }; + var secondStops = new GradientStopCollection { stop }; + var weakSecondBrush = CreateBrushWithSharedGradientStops(secondStops, secondBindingContext); + while (dispatchedCleanups.TryDequeue(out var duplicateFirstCleanup)) + duplicateFirstCleanup(); + int changesAfterReparenting = bindingContextChanged; + + Assert.False(await weakSecondBrush.WaitForCollect(), "Second LinearGradientBrush should not be alive!"); + Assert.True(dispatchedCleanups.TryDequeue(out var secondCleanup)); + + firstCleanup(); + + Assert.Equal(changesAfterReparenting, bindingContextChanged); + Assert.Equal(0.5f, stop.GetValue(BindableObject.BindingContextProperty)); + + secondCleanup(); + + Assert.Equal(changesAfterReparenting + 1, bindingContextChanged); + Assert.Null(stop.GetValue(BindableObject.BindingContextProperty)); + GC.KeepAlive(firstStops); + GC.KeepAlive(firstBindingContext); + GC.KeepAlive(secondStops); + GC.KeepAlive(secondBindingContext); + } + + [Fact] + public async Task GradientStopChangesStillInvalidateAfterGc() + { + var stop = new GradientStop(); + var brush = new LinearGradientBrush + { + GradientStops = new GradientStopCollection { stop } + }; + bool invalidated = false; + brush.InvalidateGradientBrushRequested += (_, __) => invalidated = true; + + await TestHelpers.Collect(); + + stop.Offset = 0.5f; + + Assert.True(invalidated); + GC.KeepAlive(brush); + } + + [Fact] + public async Task SharedGradientStopsInvalidateEachLiveBrushAfterGc() + { + var stop = new GradientStop(); + var sharedStops = new GradientStopCollection { stop }; + var firstBrush = new LinearGradientBrush { GradientStops = sharedStops }; + var secondBrush = new LinearGradientBrush { GradientStops = sharedStops }; + int firstInvalidationCount = 0; + int secondInvalidationCount = 0; + firstBrush.InvalidateGradientBrushRequested += (_, __) => firstInvalidationCount++; + secondBrush.InvalidateGradientBrushRequested += (_, __) => secondInvalidationCount++; + + await TestHelpers.Collect(); + + stop.Offset = 0.5f; + + Assert.Equal(1, firstInvalidationCount); + Assert.Equal(1, secondInvalidationCount); + GC.KeepAlive(firstBrush); + GC.KeepAlive(secondBrush); + } + + [Fact] + public async Task AliveBrushStillInvalidatesAfterSiblingBrushIsCollected() + { + var stop = new GradientStop(); + var sharedStops = new GradientStopCollection { stop }; + var weakCollectedBrush = CreateBrushWithSharedGradientStops(sharedStops); + var aliveBrush = new LinearGradientBrush { GradientStops = sharedStops }; + int invalidationCount = 0; + aliveBrush.InvalidateGradientBrushRequested += (_, __) => invalidationCount++; + + Assert.False(await weakCollectedBrush.WaitForCollect(), "Sibling LinearGradientBrush should not be alive!"); + + stop.Offset = 0.5f; + + Assert.Equal(1, invalidationCount); + GC.KeepAlive(aliveBrush); + GC.KeepAlive(sharedStops); + } + + [Fact] + public void RemovingAndReplacingGradientStopsMovesSubscriptions() + { + var removedStop = new GradientStop { Offset = 0.1f }; + var retainedStop = new GradientStop { Offset = 0.2f }; + var oldStops = new GradientStopCollection { removedStop, retainedStop }; + var brush = new LinearGradientBrush { GradientStops = oldStops }; + int invalidationCount = 0; + brush.InvalidateGradientBrushRequested += (_, __) => invalidationCount++; + + oldStops.Remove(removedStop); + invalidationCount = 0; + + removedStop.Offset = 0.3f; + Assert.Equal(0, invalidationCount); + + retainedStop.Offset = 0.4f; + Assert.Equal(1, invalidationCount); + + var replacementStop = new GradientStop { Offset = 0.1f }; + brush.GradientStops = new GradientStopCollection { replacementStop }; + invalidationCount = 0; + + retainedStop.Offset = 0.5f; + oldStops.Add(new GradientStop()); + + Assert.Equal(0, invalidationCount); + + replacementStop.Offset = 0.6f; + + Assert.Equal(1, invalidationCount); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ReplacingGradientStopsInvalidatesBrush(bool replaceWithNull) + { + var brush = new LinearGradientBrush + { + GradientStops = new GradientStopCollection { new GradientStop() } + }; + int invalidationCount = 0; + brush.InvalidateGradientBrushRequested += (_, __) => invalidationCount++; + + brush.GradientStops = replaceWithNull ? null : new GradientStopCollection(); + + Assert.Equal(1, invalidationCount); + } + + [Fact] + public void NullGradientStopCollectionAllowsBindingContextChange() + { + var brush = new LinearGradientBrush { GradientStops = null }; + var bindingContext = new object(); + + brush.BindingContext = bindingContext; + + Assert.Same(bindingContext, brush.BindingContext); + } + + [Fact] + public void NullGradientStopEntryAllowsBindingContextChange() + { + var brush = new LinearGradientBrush + { + GradientStops = new GradientStopCollection { null } + }; + var bindingContext = new object(); + + brush.BindingContext = bindingContext; + + Assert.Same(bindingContext, brush.BindingContext); + } + + [Fact] + public void SharedGradientStopsPreserveExistingMostRecentlyAssignedParentBehavior() + { + var stop = new GradientStop(); + var sharedStops = new GradientStopCollection { stop }; + var firstBindingContext = new object(); + var firstBrush = new LinearGradientBrush + { + BindingContext = firstBindingContext, + GradientStops = sharedStops + }; + + Assert.Same(firstBrush, stop.Parent); + Assert.Same(firstBindingContext, stop.BindingContext); + + var secondBindingContext = new object(); + var secondBrush = new LinearGradientBrush + { + BindingContext = secondBindingContext, + GradientStops = sharedStops + }; + + Assert.Same(secondBrush, stop.Parent); + Assert.Same(secondBindingContext, stop.BindingContext); + GC.KeepAlive(firstBrush); + } + + [Theory] + [InlineData(SharedStopDetachment.ClearCollection)] + [InlineData(SharedStopDetachment.RemoveStop)] + [InlineData(SharedStopDetachment.ReplaceCollection)] + public void DetachingSharedStopFromPreviousBrushPreservesCurrentParent(SharedStopDetachment detachment) + { + var stop = new GradientStop(); + var firstStops = new GradientStopCollection { stop }; + var secondStops = new GradientStopCollection { stop }; + var firstBrush = new LinearGradientBrush { GradientStops = firstStops }; + var secondBindingContext = new object(); + var secondBrush = new LinearGradientBrush + { + BindingContext = secondBindingContext, + GradientStops = secondStops + }; + + switch (detachment) + { + case SharedStopDetachment.ClearCollection: + firstStops.Clear(); + break; + case SharedStopDetachment.RemoveStop: + firstStops.Remove(stop); + break; + case SharedStopDetachment.ReplaceCollection: + firstBrush.GradientStops = new GradientStopCollection(); + break; + } + + Assert.Same(secondBrush, stop.Parent); + Assert.Same(secondBindingContext, stop.BindingContext); + } + + [Fact] + public void DuplicateGradientStopsPreserveOccurrenceSubscriptions() + { + var sharedStop = new GradientStop(); + var stops = new GradientStopCollection { sharedStop, sharedStop }; + var brush = new LinearGradientBrush { GradientStops = stops }; + int invalidationCount = 0; + brush.InvalidateGradientBrushRequested += (_, __) => invalidationCount++; + + sharedStop.Offset = 0.1f; + Assert.Equal(2, invalidationCount); + Assert.Same(brush, sharedStop.Parent); + + stops.Remove(sharedStop); + invalidationCount = 0; + + sharedStop.Offset = 0.2f; + Assert.Equal(1, invalidationCount); + Assert.Same(brush, sharedStop.Parent); + + stops.Remove(sharedStop); + invalidationCount = 0; + + sharedStop.Offset = 0.3f; + Assert.Equal(0, invalidationCount); + Assert.Null(sharedStop.Parent); + } + + [Theory] + [InlineData(ValueEqualStopDetachment.RemoveStop)] + [InlineData(ValueEqualStopDetachment.ReplaceStop)] + [InlineData(ValueEqualStopDetachment.ReplaceCollection)] + public void DetachingValueEqualStopClearsRemovedParent(ValueEqualStopDetachment detachment) + { + var removedStop = new GradientStop { Offset = 0.5f }; + var equalStop = new GradientStop { Offset = 0.5f }; + var stops = new GradientStopCollection { removedStop, equalStop }; + var brush = new LinearGradientBrush { GradientStops = stops }; + + Assert.Equal(removedStop, equalStop); + + switch (detachment) + { + case ValueEqualStopDetachment.RemoveStop: + stops.RemoveAt(0); + break; + case ValueEqualStopDetachment.ReplaceStop: + stops[0] = new GradientStop { Offset = 0.5f }; + break; + case ValueEqualStopDetachment.ReplaceCollection: + brush.GradientStops = new GradientStopCollection { equalStop }; + break; + } + + Assert.Null(removedStop.Parent); + Assert.All(brush.GradientStops, stop => Assert.Same(brush, stop.Parent)); + } + + [Fact] + public void RemovingGradientStopAllowsReentrantReuse() + { + var stop = new GradientStop(); + var stops = new GradientStopCollection { stop }; + var brush = new LinearGradientBrush { GradientStops = stops }; + bool replaced = false; + int invalidationCount = 0; + brush.InvalidateGradientBrushRequested += (_, __) => + { + invalidationCount++; + if (!replaced && stop.Parent is null) + { + replaced = true; + brush.GradientStops = new GradientStopCollection { stop }; + } + }; + + stops.Remove(stop); + + Assert.True(replaced); + Assert.Same(brush, stop.Parent); + + invalidationCount = 0; + stop.Offset = 0.5f; + + Assert.Equal(1, invalidationCount); + } + + [Fact] + public void ClearingGradientStopsAllowsReentrantReplacement() + { + var brush = new LinearGradientBrush + { + GradientStops = new GradientStopCollection { new GradientStop() } + }; + bool replaced = false; + int invalidationCount = 0; + brush.InvalidateGradientBrushRequested += (_, __) => + { + invalidationCount++; + if (!replaced && brush.GradientStops.Count == 0) + { + replaced = true; + brush.GradientStops = new GradientStopCollection { new GradientStop() }; + } + }; + + brush.GradientStops.Clear(); + + Assert.True(replaced); + Assert.Single(brush.GradientStops); + // Clear and the reentrant replacement are distinct state changes. + Assert.Equal(2, invalidationCount); + } + + [Fact] + public async Task ClearingAndReusingGradientStopsKeepsNewStopsSubscribed() + { + var stops = new GradientStopCollection { new GradientStop() }; + var brush = new LinearGradientBrush + { + GradientStops = stops + }; + bool invalidated = false; + brush.InvalidateGradientBrushRequested += (_, __) => invalidated = true; + + stops.Clear(); + var newStop = new GradientStop(); + stops.Add(newStop); + + await TestHelpers.Collect(); + invalidated = false; + + newStop.Offset = 0.5f; + + Assert.True(invalidated); + GC.KeepAlive(brush); + } + + public enum SharedStopDetachment + { + ClearCollection, + RemoveStop, + ReplaceCollection, + } + + public enum ValueEqualStopDetachment + { + RemoveStop, + ReplaceStop, + ReplaceCollection, + } + + sealed class FinalizerBlocker + { + readonly ManualResetEventSlim _finalizerEntered; + readonly ManualResetEventSlim _releaseFinalizer; + + public FinalizerBlocker( + ManualResetEventSlim finalizerEntered, + ManualResetEventSlim releaseFinalizer) + { + _finalizerEntered = finalizerEntered; + _releaseFinalizer = releaseFinalizer; + } + + ~FinalizerBlocker() + { + _finalizerEntered.Set(); + _releaseFinalizer.Wait(); + } + } + } +} diff --git a/src/Core/tests/UnitTests/TestClasses/DispatcherStub.cs b/src/Core/tests/UnitTests/TestClasses/DispatcherStub.cs index 1de488cc13e4..cdb683505f53 100644 --- a/src/Core/tests/UnitTests/TestClasses/DispatcherStub.cs +++ b/src/Core/tests/UnitTests/TestClasses/DispatcherStub.cs @@ -10,11 +10,16 @@ class DispatcherStub : IDispatcher { readonly Func? _isInvokeRequired; readonly Action? _invokeOnMainThread; + readonly Func? _dispatchResult; - public DispatcherStub(Func? isInvokeRequired, Action? invokeOnMainThread) + public DispatcherStub( + Func? isInvokeRequired, + Action? invokeOnMainThread, + Func? dispatchResult = null) { _isInvokeRequired = isInvokeRequired; _invokeOnMainThread = invokeOnMainThread; + _dispatchResult = dispatchResult; ManagedThreadId = Environment.CurrentManagedThreadId; } @@ -26,6 +31,9 @@ public DispatcherStub(Func? isInvokeRequired, Action? invokeOnMain public bool Dispatch(Action action) { + if (_dispatchResult?.Invoke() == false) + return false; + if (_invokeOnMainThread is null) action(); else @@ -98,7 +106,8 @@ class DispatcherProviderStub : IDispatcherProvider, IDisposable ? null : new DispatcherStub( DispatcherProviderStubOptions.IsInvokeRequired, - DispatcherProviderStubOptions.InvokeOnMainThread)); + DispatcherProviderStubOptions.InvokeOnMainThread, + DispatcherProviderStubOptions.DispatchResult)); public void Dispose() => s_dispatcherInstance.Dispose(); @@ -126,6 +135,9 @@ public class DispatcherProviderStubOptions [ThreadStatic] public static Action? InvokeOnMainThread; + + [ThreadStatic] + public static Func? DispatchResult; } public static class DispatcherTest