Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[controls] fix memory leak in VisualElement.Background #13656

Merged
merged 3 commits into from
Mar 8, 2023

Conversation

jonathanpeppers
Copy link
Member

@jonathanpeppers jonathanpeppers commented Mar 2, 2023

Fixes #12344
Fixes #13557
Context: https://github.com/dotnet-presentations/dotnet-maui-workshop

While testing the Monkey Finder sample, I found the following scenario causes an issue:

  1. Declare a {StaticResource} Brush at the Application level, with a lifetime of the entire application.

  2. Set Background on an item in a CollectionView, ListView, etc.

  3. Scroll a lot, navigate away, etc.

  4. The Brush will hold onto any View's indefinitely.

The core problem here being VisualElement does:

    void NotifyBackgroundChanges()
    {
        if (Background is ImmutableBrush)
            return;

        if (Background != null)
        {
            Background.Parent = this;
            Background.PropertyChanged += OnBackgroundChanged;

            if (Background is GradientBrush gradientBrush)
                gradientBrush.InvalidateGradientBrushRequested += InvalidateGradientBrushRequested;
        }
    }

If no user code sets Background to null, these events remain subscribed.

To fix this:

  1. Create a WeakNotifyCollectionChangedProxy type for event subscription.

  2. Don't set Background.Parent = this

General Cleanup

Through doing other fixes related to memory leaks & C# events, we've started to gain a collection of WeakEventProxy-related types.

I created some core internal types to be reused:

  • abstract WeakEventProxy<TSource, TEventHandler>
  • WeakNotifyCollectionChangedProxy

The following classes now make use of these new shared types:

  • BindingExpression
  • BindableLayout
  • ListProxy
  • VisualElement

This should hopefully reduce mistakes and reuse code in this area.

Concerns

Since, we are no longer doing:

Background.Parent = this;

This is certainly a behavior change. It is now replaced with:

SetInheritedBindingContext(Background, BindingContext);

I had to update one unit test that was asserting Background.Parent, which can assert Background.BindingContext instead.

As such, this change is definitely sketchy and I wouldn't backport to .NET 7 immediately. We might test it out in a .NET 8 preview first.

Assert.Same(parent, parent.Background.Parent);
Assert.Same(context, parent.Background.BindingContext);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a behavior change that we don't set Background.Parent anymore.

However, Background.BindingContext is set, so you should be able to use {Binding} such as:

<Label>
  <Label.Background>
    <SolidColorBrush Color="{Binding Foo}" />
  </Label.Background>
</Label>

And the Label's BindingContext should pass on to the SolidColorBrush.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this was always interesting as a single brush can be used for multiple views. Is there anything that needs access to the brush/stroke's parent? If there is, then this is really interesting as who wins? Last one? First one?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am also wondering about that binding context... If I set one brush on 2 views, is it just going to overwrite the BC to the last one? Maybe that is just how it works?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it seems like that is how it always worked...

Fixes: dotnet#12344
Fixes: dotnet#13557
Context: https://github.com/dotnet-presentations/dotnet-maui-workshop

While testing the `Monkey Finder` sample, I found the following
scenario causes an issue:

1. Declare a `{StaticResource}` `Brush` at the `Application` level,
   with a lifetime of the entire application.

2. Set `Background` on an item in a `CollectionView`, `ListView`, etc.

3. Scroll a lot, navigate away, etc.

4. The `Brush` will hold onto any `View`'s indefinitely.

The core problem here being `VisualElement` does:

    void NotifyBackgroundChanges()
    {
        if (Background is ImmutableBrush)
            return;

        if (Background != null)
        {
            Background.Parent = this;
            Background.PropertyChanged += OnBackgroundChanged;

            if (Background is GradientBrush gradientBrush)
                gradientBrush.InvalidateGradientBrushRequested += InvalidateGradientBrushRequested;
        }
    }

If no user code sets `Background` to `null`, these events remain
subscribed.

To fix this:

1. Create a `WeakNotifyCollectionChangedProxy` type for event subscription.

2. Don't set `Background.Parent = this`

~~ General Cleanup ~~

Through doing other fixes related to memory leaks & C# events, we've
started to gain a collection of `WeakEventProxy`-related types.

I created some core `internal` types to be reused:

* `abstract WeakEventProxy<TSource, TEventHandler>`
* `WeakNotifyCollectionChangedProxy`

The following classes now make use of these new shared types:

* `BindingExpression`
* `BindableLayout`
* `ListProxy`
* `VisualElement`

This should hopefully reduce mistakes and reuse code in this area.

~~ Concerns ~~

Since, we are no longer doing:

    Background.Parent = this;

This is certainly a behavior change. It is now replaced with:

    SetInheritedBindingContext(Background, BindingContext);

I had to update one unit test that was asserting `Background.Parent`,
which can assert `Background.BindingContext` instead.

As such, this change is definitely sketchy and I wouldn't backport to
.NET 7 immediately. We might test it out in a .NET 8 preview first.
@github-actions
Copy link
Contributor

github-actions bot commented Mar 2, 2023

Thank you for your pull request. We are auto-formating your source code to follow our code guidelines.

Comment on lines +21 to +23
/// <summary>Specifies that an output may be null even if the corresponding type disallows it.</summary>
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, Inherited = false)]
internal sealed class MaybeNullAttribute : Attribute { }
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I got this from:

https://source.dot.net/#Microsoft.Build.Framework/NullableAttributes.cs,68093cc4b5713519

So we can use these NRT annotations in a netstandard2.0 library.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We did bad things in essentials:

#if !NETSTANDARD
		[return: NotNullIfNotNull("defaultValue")]
#endif

One day this can all go away and we use net6/7 everywhere.

@jonathanpeppers jonathanpeppers marked this pull request as ready for review March 3, 2023 01:42
@Eilon Eilon added legacy-area-perf Startup / Runtime performance legacy-area-controls Label, Button, CheckBox, Slider, Stepper, Switch, Picker, Entry, Editor and removed legacy-area-perf Startup / Runtime performance labels Mar 6, 2023
@mattleibow
Copy link
Member

Looks good to me, but maybe a third @PureWeen can hit the merge if there are no weirdos in here?

Copy link
Member

@mattleibow mattleibow left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just spotted something, not sure if I am missing it or it is a thing...

src/Controls/src/Core/VisualElement.cs Show resolved Hide resolved
src/Controls/src/Core/VisualElement.cs Outdated Show resolved Hide resolved
@mattleibow mattleibow merged commit 58a42e5 into dotnet:main Mar 8, 2023
@jonathanpeppers jonathanpeppers deleted the VisualElementLeaks branch March 8, 2023 17:27
@mattleibow mattleibow added the backport/suggested The PR author or issue review has suggested that the change should be backported. label Mar 9, 2023
jonathanpeppers added a commit to jonathanpeppers/maui that referenced this pull request Mar 16, 2023
As seen in dotnet#13973, some of my recent changes had a flaw:

* dotnet#13550
* dotnet#13806
* dotnet#13656

Because nothing held onto the `EventHandler` in some of these cases,
at some point a GC will prevent future events from firing.

So for example, my original attempt to test this behavior:

    [Fact]
    public async Task RectangleGeometrySubscribed()
    {
        var geometry = new RectangleGeometry();
        var visual = new VisualElement { Clip = geometry };

        bool fired = false;
        visual.PropertyChanged += (sender, e) =>
        {
            if (e.PropertyName == nameof(VisualElement.Clip))
                fired = true;
        };

        // Was missing these three lines!!!
        // await Task.Yield();
        // GC.Collect();
        // GC.WaitForPendingFinalizers();

        geometry.Rect = new Rect(1, 2, 3, 4);
        Assert.True(fired, "PropertyChanged did not fire!");
    }

In each case, I added an additional test showing the problem.

I played around with some ideas, but the simplest solution is to save
the `EventHandler` in a member field of the subscriber. Will keep
thinking of smarter ways to handle this.

I also fixed several GC-related tests that were ignored, hoping they
might help find issues in this area. My `await Task.Yield()` trick was
enough to make them pass.
jonathanpeppers added a commit that referenced this pull request Mar 17, 2023
As seen in #13973, some of my recent changes had a flaw:

* #13550
* #13806
* #13656

Because nothing held onto the `EventHandler` in some of these cases,
at some point a GC will prevent future events from firing.

So for example, my original attempt to test this behavior:

    [Fact]
    public async Task RectangleGeometrySubscribed()
    {
        var geometry = new RectangleGeometry();
        var visual = new VisualElement { Clip = geometry };

        bool fired = false;
        visual.PropertyChanged += (sender, e) =>
        {
            if (e.PropertyName == nameof(VisualElement.Clip))
                fired = true;
        };

        // Was missing these three lines!!!
        // await Task.Yield();
        // GC.Collect();
        // GC.WaitForPendingFinalizers();

        geometry.Rect = new Rect(1, 2, 3, 4);
        Assert.True(fired, "PropertyChanged did not fire!");
    }

In each case, I added an additional test showing the problem.

I played around with some ideas, but the simplest solution is to save
the `EventHandler` in a member field of the subscriber. Will keep
thinking of smarter ways to handle this.

I also fixed several GC-related tests that were ignored, hoping they
might help find issues in this area. My `await Task.Yield()` trick was
enough to make them pass.

* Fix tests in Release mode

In `Release` mode, a `GC.KeepAlive()` call is needed for the tests to pass.

Co-authored-by: GitHub Actions Autoformatter <autoformat@example.com>
mattleibow added a commit that referenced this pull request Mar 17, 2023
* Removed BuildTizenDefaultTemplate and just have it call RadioButton's default template since they were identical. (#13996)

* Reinstate WebView cookie functionality for Android & iOS (#13736)

* Fix iOS cookies

* Fix Android Cookies

* Update src/Core/src/Platform/iOS/MauiWKWebView.cs

Co-authored-by: Manuel de la Pena <mandel@microsoft.com>

* Auto-format source code

* Update MauiWKWebView.cs

* Update src/Core/src/Platform/iOS/MauiWKWebView.cs

---------

Co-authored-by: Manuel de la Pena <mandel@microsoft.com>
Co-authored-by: GitHub Actions Autoformatter <autoformat@example.com>

* Revert 10759. Fix Button sizing using HorizontalOptions. (#14005)

* Ensure that Grid is treating star rows/columns as Auto when unconstrained (#13999)

* Ensure that Grid is treating star rows/columns as Auto when unconstrained
Fixes #13993

* Auto-format source code

---------

Co-authored-by: GitHub Actions Autoformatter <autoformat@example.com>

* [iOS] Implement ScrollView Orientation (#13657)

* [iOS] Remove not used mapper for ContentSize

* [iOS] Implement Orientation mapping

* [Samples] Add sample page for ScrollView orientation

* Try without this

* [iOS] Move from extension to helper

* Add back removed API

* Use SetNeedsLayout to call measure of ContentView

* Cleanup

* [Android] Fix Frame Renderer to use Wrapper View correctly (#12218)

* [Android] Fix Frame to call missing mapper methods

* - fix rebase

* Auto-format source code

* - update tests and wrapper view code

* - remove code that's now generalized in ViewHandler

* - cleanup frame renderer

---------

Co-authored-by: GitHub Actions Autoformatter <autoformat@example.com>

* [controls] fix cases a GC causes events to not fire (#13997)

As seen in #13973, some of my recent changes had a flaw:

* #13550
* #13806
* #13656

Because nothing held onto the `EventHandler` in some of these cases,
at some point a GC will prevent future events from firing.

So for example, my original attempt to test this behavior:

    [Fact]
    public async Task RectangleGeometrySubscribed()
    {
        var geometry = new RectangleGeometry();
        var visual = new VisualElement { Clip = geometry };

        bool fired = false;
        visual.PropertyChanged += (sender, e) =>
        {
            if (e.PropertyName == nameof(VisualElement.Clip))
                fired = true;
        };

        // Was missing these three lines!!!
        // await Task.Yield();
        // GC.Collect();
        // GC.WaitForPendingFinalizers();

        geometry.Rect = new Rect(1, 2, 3, 4);
        Assert.True(fired, "PropertyChanged did not fire!");
    }

In each case, I added an additional test showing the problem.

I played around with some ideas, but the simplest solution is to save
the `EventHandler` in a member field of the subscriber. Will keep
thinking of smarter ways to handle this.

I also fixed several GC-related tests that were ignored, hoping they
might help find issues in this area. My `await Task.Yield()` trick was
enough to make them pass.

* Fix tests in Release mode

In `Release` mode, a `GC.KeepAlive()` call is needed for the tests to pass.

Co-authored-by: GitHub Actions Autoformatter <autoformat@example.com>

* [iOS] Scroll with the keyboard to not block entries and editors (#13499)

---------

Co-authored-by: dustin-wojciechowski <dustin.wojciechowski@microsoft.com>
Co-authored-by: Gerald Versluis <gerald.versluis@microsoft.com>
Co-authored-by: Manuel de la Pena <mandel@microsoft.com>
Co-authored-by: GitHub Actions Autoformatter <autoformat@example.com>
Co-authored-by: Javier Suárez <javiersuarezruiz@hotmail.com>
Co-authored-by: E.Z. Hart <hartez@users.noreply.github.com>
Co-authored-by: Rui Marinho <me@ruimarinho.net>
Co-authored-by: Shane Neuville <shneuvil@microsoft.com>
Co-authored-by: Jonathan Peppers <jonathan.peppers@microsoft.com>
Co-authored-by: TJ Lambert <50846373+tj-devel709@users.noreply.github.com>
@daltzctr
Copy link
Contributor

Is it possible that this can get backported to NET 7.0?

@hartez hartez added the backport/NO This change should not be backported. It may break customers. label May 31, 2023
@jonathanpeppers jonathanpeppers added the memory-leak 💦 Memory usage grows / objects live forever label Jul 12, 2023
@github-actions github-actions bot locked and limited conversation to collaborators Dec 12, 2023
@samhouts samhouts added the fixed-in-8.0.0-preview.3.8149 Look for this fix in 8.0.0-preview.3.8149! label Aug 2, 2024
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
backport/NO This change should not be backported. It may break customers. backport/suggested The PR author or issue review has suggested that the change should be backported. fixed-in-8.0.0-preview.3.8149 Look for this fix in 8.0.0-preview.3.8149! legacy-area-controls Label, Button, CheckBox, Slider, Stepper, Switch, Picker, Entry, Editor memory-leak 💦 Memory usage grows / objects live forever
Projects
None yet
7 participants