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 with Grid Row/ColumnDefinitions #16145

Merged
merged 1 commit into from
Jul 18, 2023

Conversation

jonathanpeppers
Copy link
Member

@jonathanpeppers jonathanpeppers commented Jul 13, 2023

Fixes: #15860
Fixes: #15986

In a customer's app, they have the setup:

<!-- Resources/Styles/Styles.xaml -->
<Style TargetType="Grid" x:Key="GridStyleWithColumnDefinitions">
    <Setter Property="ColumnDefinitions" Value="18,*"/>
</Style>

Then a page with:

<Grid Style="{StaticResource GridStyleWithColumnDefinitions}" />

Navigating forward & back from this page would show that the entire Page would live forever! What is interesting, is simply removing the Grid's Style solved the problem?!?

I narrowed this down to a unit test:

[Fact]
public async Task ColumnDefinitionDoesNotLeak()
{
    // Long-lived column, like from a Style in App.Resources
    var column = new ColumnDefinition();
    WeakReference reference;

    {
        var grid = new Grid();
        grid.ColumnDefinitions.Add(column);
        reference = new(grid);
    }

    await Task.Yield();
    GC.Collect();
    GC.WaitForPendingFinalizers();

    Assert.False(reference.IsAlive, "Grid should not be alive!");
}

colDef.ItemSizeChanged += ((Grid)bindable).DefinitionsChanged;

Grid subscribes to DefinitionCollection<T>.ItemSizeChanged, and in this case the DefinitionCollection<T> lived indefinitely in an application-wide ResourceDictionary.

The simplest solution is to make the ItemSizeChanged event use WeakEventManager as was done for other events like Application.RequestedThemeChanged:

public event EventHandler<AppThemeChangedEventArgs> RequestedThemeChanged
{
add => _weakEventManager.AddEventHandler(value);
remove => _weakEventManager.RemoveEventHandler(value);
}

Since MAUI owns the property, we can use the simple solution here.

This is one issue solved in the customer app, but I will need to retest the app in #15860 to see if there are further issues.

Context: dotnet#15860

In a customer's app, they have the setup:

    <!-- Resources/Styles/Styles.xaml -->
    <Style TargetType="Grid" x:Key="GridStyleWithColumnDefinitions">
        <Setter Property="ColumnDefinitions" Value="18,*"/>
    </Style>

Then a page with:

    <Grid Style="{StaticResource GridStyleWithColumnDefinitions}" />

Navigating forward & back from this page would show that the entire
`Page` would live forever! What is interesting, is simply removing the
Grid's `Style` solved the problem?!?

I narrowed this down to a unit test:

    [Fact]
    public async Task ColumnDefinitionDoesNotLeak()
    {
        // Long-lived column, like from a Style in App.Resources
        var column = new ColumnDefinition();
        WeakReference reference;

        {
            var grid = new Grid();
            grid.ColumnDefinitions.Add(column);
            reference = new(grid);
        }

        await Task.Yield();
        GC.Collect();
        GC.WaitForPendingFinalizers();

        Assert.False(reference.IsAlive, "Grid should not be alive!");
    }

https://github.com/dotnet/maui/blob/cda3eb3381cfb686567ed05e3eb8e8f26c02d785/src/Controls/src/Core/Layout/Grid.cs#L20

`Grid` subscribes to `DefinitionCollection<T>.ItemSizeChanged`, and in
this case the `DefinitionCollection<T>` lived indefinitely in an
application-wide `ResourceDictionary`.

The simplest solution is to make the `ItemSizeChanged` event use
`WeakEventManager` as was done for other events like
`Application.RequestedThemeChanged`:

https://github.com/dotnet/maui/blob/cda3eb3381cfb686567ed05e3eb8e8f26c02d785/src/Controls/src/Core/Application/Application.cs#L221-L225

Since MAUI owns the property, we can use the simple solution here.

This is one issue solved in the customer app, but I will need to retest
the app in dotnet#15860 to see if there are further issues.
@jonathanpeppers jonathanpeppers added layout-grid memory-leak 💦 Memory usage grows / objects live forever labels Jul 13, 2023
Comment on lines -104 to +109
public event EventHandler ItemSizeChanged;
public event EventHandler ItemSizeChanged
{
add => _weakEventManager.AddEventHandler(value);
remove => _weakEventManager.RemoveEventHandler(value);
}
Copy link
Member Author

Choose a reason for hiding this comment

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

There are some existing tests that check this event still fires:

[Fact]
public void ChangingChildRowInvalidatesGrid()

[Fact]
public void ChangingChildColumnInvalidatesGrid()

@jonathanpeppers jonathanpeppers marked this pull request as ready for review July 13, 2023 17:51
@jonathanpeppers
Copy link
Member Author

I did some testing and I think this does actually fix: #15860

@rachelkang
Copy link
Member

The simplest solution is to make the ItemSizeChanged event use WeakEventManager as was done for other events

How does that work exactly? How does using WeakEventManager solve the issue of the DefinitionCollection living indefinitely?

(not reviewing, just asking questions to learn)

@jonathanpeppers
Copy link
Member Author

How does using WeakEventManager solve the issue of the DefinitionCollection living indefinitely?

When you make a C# event:

class Child {
    public event EventHandler MyEvent;

If you are inside a class Parent and do:

child.MyEvent += this.OnMyEvent; // let's say this is inside the Parent's constructor

void OnMyEvent(object sender, EventArgs e) { }

The child now has a "strong" reference to the parent. If you put a breakpoint and looked at child.MyEvent the EventHandler has a Target property that points to the parent. This feels kind of backwards in the design of these two types: Parent should have a strong reference to Child like a List<Child> Children { get; set; }, but we now have the reverse, too. This is a general problem with all C# events.

WeakEventManager holds a WeakReference to the subscriber instead:

public readonly WeakReference? Subscriber;

A WeakReference is a special type that allows the subscriber to be collected by the GC. You can access WeakReference.IsAlive or WeakReference.Target will return null if the object is gone.

So now Grid has a strong reference to the DefinitionCollection via Grid.ColumnDefinitions, but DefinitionCollection only have a weak reference back to the Grid.

@rachelkang
Copy link
Member

Interesting! Learned so much, thanks :)

This is a general problem with all C# events.

Would you say this design was intentional or more of an oversight? Is there anything that is generally good about the child having a strong reference to the parent?

@jonathanpeppers
Copy link
Member Author

I think C# events have been like this since .NET Framework 1.0. Back then there was only WinForms, no WPF or MVVM patterns. Most events were used for things like Button.Click. It seems like it would harder to create memory leaks with only WinForms. It was definitely better than if you wrote an app back then in C++.

There is an api proposal about making "weak events" a thing:

@jonathanpeppers
Copy link
Member Author

@rachelkang I wrote down some of the comments here for the future. Should be useful to us and customers:

https://github.com/dotnet/maui/wiki/Memory-Leaks

I didn't write a section about circular references in iOS/Catalyst yet. There is unfortunately more I need to add here...

@PureWeen PureWeen merged commit 443d3a0 into dotnet:main Jul 18, 2023
@jonathanpeppers jonathanpeppers deleted the GridMemoryLeaks branch July 18, 2023 03:06
@github-actions github-actions bot locked and limited conversation to collaborators Dec 8, 2023
@samhouts samhouts added the fixed-in-8.0.0-preview.7.8842 Look for this fix in 8.0.0-preview.7.8842! label Aug 2, 2024
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
fixed-in-8.0.0-preview.7.8842 Look for this fix in 8.0.0-preview.7.8842! layout-grid memory-leak 💦 Memory usage grows / objects live forever
Projects
None yet
6 participants