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

Fix TabItem memory leak #12418

Merged
merged 4 commits into from Sep 23, 2023

Conversation

DmitryZhelnin
Copy link
Contributor

@DmitryZhelnin DmitryZhelnin commented Aug 2, 2023

What does the pull request do?

Moves update of TabStripPlacement to TabContol.

What is the current behavior?

Subscription is not disposed and TabItem controls remain in memory.

Fixed issues

Fixes #12416

@avaloniaui-team
Copy link
Contributor

You can test this PR using the following package version. 11.0.999-cibuild0038279-beta. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@TomEdwardsEnscape
Copy link
Contributor

The trouble with doing this when TabItem is detached from the visual tree tree is that it can be frequently detached as the user clicks around an application, causing the property to stop updating at unpredictable times. This behaviour can be reproduced in the Avalonia control gallery, which has a nested TabControl in its "TabControl" section. Enter and exit this section and you will see that OnDetachedFromVisualTree is called.

In the specific case of TabStripPlacement, the property not updating when the control is detached is unlikely to lead to real-world problems. However, if a property is defined then it really ought to behave correctly at all times, as if nothing else someone might copy or extend this code in the future.

Alternatives

When I start thinking about dynamically adding and removing tab items, I start questioning whether property makes sense at all. It's entirely legal for a single TabItem to be within the Items collection of multiple TabControls, as long as it is only attached to the visual tree in one location at a time. What should the value be in this scenario?

Perhaps it would be better to remove this property (or mark it as obsolete), then have templates instead bind to $parent[TabControl].TabStripPlacement. This makes retrieving the value an explicit visual tree operation, which nobody would expect to work when the TabItem was detached.

Another alternative is switching to logical tree attach/detach. These events are much more predictable. But are they raised when adding a tab item dynamically?

A third option is to use a weak event subscription and let the garbage collector take care of things.

@TomEdwardsEnscape
Copy link
Contributor

Wait a second, the documentation for AvaloniaObject.GetObservable says that "The subscription is created using a weak reference". So it shouldn't be causing a leak in the first place!

@DmitryZhelnin
Copy link
Contributor Author

DmitryZhelnin commented Aug 12, 2023

As I understand, weak reference points to the TabControl, so the whole TabControl can be garbage collected even if any TabItem is still alive and referenced by another object.

@DmitryZhelnin
Copy link
Contributor Author

@TomEdwardsEnscape I agree with you on the point whether property makes sense at all. Looks like TabStripPlacement can be removed from TabItem control.

@Gillibald
Copy link
Contributor

Managing subscriptions via logical tree should work

@DmitryZhelnin
Copy link
Contributor Author

Or we can move update logic to the TabControl. In this case no subscriptions needed, but setter of TabItem.TabStripPlacement has to be internal. For example:

TabStripPlacementProperty.Changed.AddClassHandler<TabControl>((x, args) => x.UpdateTabStripPlacement());
private void UpdateTabStripPlacement()
{
    for (int i = 0; i < Items.Count; i++)
    {
        var control = ContainerFromIndex(i);
        if (control is TabItem tabItem)
        {
            tabItem.TabStripPlacement = TabStripPlacement;
        }
    }
}

And additionally call UpdateTabStripPlacement in OnApplyTemplate after ItemsPresenterPart was initialized.

@rabbitism
Copy link
Contributor

@TomEdwardsEnscape I agree with you on the point whether property makes sense at all. Looks like TabStripPlacement can be removed from TabItem control.

Placement is widely used for custom styles. (like menu like TabControl with items on left side with an indicator. )

@TomEdwardsEnscape
Copy link
Contributor

TomEdwardsEnscape commented Aug 12, 2023

I worked out where the strong reference is coming from. It's not from GetObservable but from the lambda method in the subsequent Subscribe call. This is implicitly capturing the TabItem.

The class below avoids this, but still leaves WeakObserver objects lying around (though these are at least very small).

The proper fix for this is a "weak subscribe" method that uses ConditionalWeakTable to send notifications to the right targets, while still allowing the observers to be GCed.

Moving the update logic to the TabControl instead, as Dmitry suggests, would also be fine with me. TabControl generates TabItems anyway, so it's not adding a new dependency.

protected void SubscribeToOwnerProperties(AvaloniaObject owner)
{
    _ownerSubscriptions = owner.GetObservable(TabControl.TabStripPlacementProperty).Subscribe(new WeakObserver(this));
}

private class WeakObserver : IObserver<Dock>
{
    private GCHandle _weakRef;

    public WeakObserver(TabItem owner)
    {
        _weakRef = GCHandle.Alloc(owner, GCHandleType.Weak);
    }

    ~WeakObserver()
    {
        _weakRef.Free();
    }

    public void OnCompleted()
    {
        if (_weakRef.Target is TabItem tabItem)
        {
            tabItem.TabStripPlacement = null;
        }

        _weakRef.Free();
        GC.SuppressFinalize(this);
    }

    public void OnError(Exception error)
    {
        // ignore
    }

    public void OnNext(Dock value)
    {
        if (_weakRef.Target is TabItem tabItem)
        {
            tabItem.TabStripPlacement = value;
        }
    }
}

@Gillibald
Copy link
Contributor

Or we can move update logic to the TabControl. In this case no subscriptions needed, but setter of TabItem.TabStripPlacement has to be internal. For example:

TabStripPlacementProperty.Changed.AddClassHandler<TabControl>((x, args) => x.UpdateTabStripPlacement());
private void UpdateTabStripPlacement()
{
    for (int i = 0; i < Items.Count; i++)
    {
        var control = ContainerFromIndex(i);
        if (control is TabItem tabItem)
        {
            tabItem.TabStripPlacement = TabStripPlacement;
        }
    }
}

And additionally call UpdateTabStripPlacement in OnApplyTemplate after ItemsPresenterPart was initialized.

@DmitryZhelnin Please update the PR to use this approach and we can merge

@avaloniaui-team
Copy link
Contributor

You can test this PR using the following package version. 11.0.999-cibuild0038716-beta. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@DmitryZhelnin
Copy link
Contributor Author

@Gillibald PR is updated.

BTW I tried to run this test

public void TabItem_Is_Freed()

and neither new version nor old one are passing. But if I take memory snapshots I can see that fixed version is working as intended. Are these tests out of date or I should fix it?

@DmitryZhelnin DmitryZhelnin force-pushed the fix-tabitem-memory-leak branch 2 times, most recently from 83af3ea to bb150ea Compare September 14, 2023 07:53
Comment on lines 315 to 328
for (int i = 0; i < Items.Count; i++)
{
var control = ContainerFromIndex(i);
if (control is TabItem tabItem)
{
tabItem.TabStripPlacement = TabStripPlacement;
}
}
}
Copy link
Member

@maxkatz6 maxkatz6 Sep 19, 2023

Choose a reason for hiding this comment

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

You can iterate over ItemsPresenterPart children instead, without calling ContainerFromIndex for each item. Which in theory can be virtualized too (==a lot of unnecessary iterations).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

}
}

protected void SubscribeToOwnerProperties(AvaloniaObject owner)
Copy link
Member

Choose a reason for hiding this comment

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

Removal of this method is a breaking change. As noticed from the CI fail.
Can yo keep this method empty with Obsolete attribute? Just in case.

Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@maxkatz6 done

auto-merge was automatically disabled September 22, 2023 14:13

Head branch was pushed to by a user without write access

@avaloniaui-team
Copy link
Contributor

You can test this PR using the following package version. 11.0.999-cibuild0039810-beta. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@avaloniaui-team
Copy link
Contributor

You can test this PR using the following package version. 11.0.999-cibuild0039817-beta. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@maxkatz6 maxkatz6 added this pull request to the merge queue Sep 23, 2023
Merged via the queue into AvaloniaUI:master with commit 8e07f9f Sep 23, 2023
6 checks passed
@maxkatz6 maxkatz6 added the backport-candidate-11.0.x Consider this PR for backporting to 11.0 branch label Oct 8, 2023
maxkatz6 pushed a commit that referenced this pull request Dec 5, 2023
* TabItem: dispose _ownerSubscriptions when item is detached from visual tree

* TabItem: update of TabStripPlacement moved to TabControl

* TabControl: iterate over ItemsPresenterPart's children instead of calling ContainerFromIndex

* TabItem: SubscribeToOwnerProperties marked as obsolete

---------

Co-authored-by: Dmitry Zhelnin <d.zhelnin@of-group.ru>
@maxkatz6 maxkatz6 added backported-11.0.x and removed backport-candidate-11.0.x Consider this PR for backporting to 11.0 branch labels Dec 5, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

TabItem memory leak
6 participants