Skip to content

Conversation

walterlv
Copy link
Contributor

@walterlv walterlv commented Jul 1, 2025

What does the pull request do?

The LightweightObservableBase<T> holds IObserver<T> references in the PublishNext method for a brief moment. However, it does not clear the shared array after returning it to the pool. This can cause a memory leak that persists for about 30 seconds, even when the GC runs multiple collection cycles.

This pull request clears the references to observers in the shared array after calling the OnNext method. This way, we can prevent the memory leak.

    protected void PublishNext(T value)
    {
        // Some codes ...
        count = _observers.Count;
        switch (count)
        {
            case 3:
                observer0 = _observers[0];
                observer1 = _observers[1];
                observer2 = _observers[2];
                break;
            case 2:
                observer0 = _observers[0];
                observer1 = _observers[1];
                break;
            case 1:
                observer0 = _observers[0];
                break;
            case 0:
                return;
            default:
            {
                observers = ArrayPool<IObserver<T>>.Shared.Rent(count);
                _observers.CopyTo(observers);
                break;
            }
        }
        // Some codes ...
        for(int i = 0; i < count; i++)
        {
            observers[i].OnNext(value);
++          // Clear reference to avoid memory leak.
++          observers[i] = null!;
        }

        ArrayPool<IObserver<T>>.Shared.Return(observers);
        // Some codes ...
    }

Looking at the code in LightweightObservableBase, we can see that when the subscriber count exceeds 3, a shared array is rented from the pool. Therefore, to reproduce the memory leak, we need more than 3 subscribers to the same observable.

What is the current behavior?

See the image below. This is a memory analysis from dotMemory.

Key Retention Path of a Window

After the Window is closed, pressing the Force GC button on dotMemory several times shows that the Window is still retained in memory. After about 30 seconds, the Window will finally be collected by the GC.

What is the updated/expected behavior with this PR?

After the Window is closed, it should be collected by the GC during the next garbage collection cycle. The memory analysis should no longer show the Window being retained in memory.

How was the solution implemented (if it's not obvious)?

This is a simple fix. We clear the references to observers in the shared array after calling the OnNext method. An alternative solution would be to pass true to ArrayPool<T>.Shared.Return, but that approach may be slightly slower than the solution implemented in this pull request.

Checklist

Breaking changes

None. There are no breaking changes in this pull request.

Obsoletions / Deprecations

Fixed issues

@maxkatz6 maxkatz6 added bug backport-candidate-11.3.x Consider this PR for backporting to 11.3 branch labels Jul 1, 2025
@avaloniaui-bot
Copy link

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

Copy link
Member

@MrJul MrJul left a comment

Choose a reason for hiding this comment

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

LGTM, thank you!

@MrJul MrJul added this pull request to the merge queue Jul 1, 2025
Merged via the queue into AvaloniaUI:master with commit e6ef371 Jul 1, 2025
11 checks passed
MrJul pushed a commit that referenced this pull request Aug 5, 2025
@MrJul MrJul added backported-11.3.x and removed backport-candidate-11.3.x Consider this PR for backporting to 11.3 branch labels Aug 5, 2025
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.

4 participants