dotnet / dotnet/maui

[leak-scan] Path.Data — shared Geometry change events retain the Path

Open
#38,513 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

agentic-workflows perf/memory-leak 💦
Dominant language
C#
Stars
23.3k
Forks
2k
Avg merge
1d 14h
Merged PRs (30d)
296

Description

[!IMPORTANT]
AI-generated by the Daily Memory Leak Hunter — dotnet/maui workflow. This finding was empirically confirmed against the shipped Microsoft.Maui.Controls 10.0.0 package on plain net10.0.

Description

Assigning a shared Geometry to Path.Data strongly retains each discarded Path in the shipped package. The geometry's change notification holds the path's instance callback, so a long-lived geometry keeps the path and its managed object graph alive unless Data is explicitly cleared.

Retention path

shared LineGeometry -> PropertyChanged delegate -> Path data-change callback -> transient Path -> attached 1 MiB payload

  • src/Controls/src/Core/Shapes/Path.cs:29-38 defines the Data property callbacks.
  • src/Controls/src/Core/Shapes/Path.cs:75-94 shows the corresponding notification subscription area. Current main uses WeakGeometryChangedProxy; the shipped 10.0.0 package used by this repro still exhibits strong retention.

Standalone repro

leakprobe.csproj:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.Maui.Controls" Version="10.0.0" />
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
    <PackageReference Include="xunit" Version="2.9.2" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
  </ItemGroup>
</Project>

LeakTest.cs:

using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using Microsoft.Maui.Controls;
using Microsoft.Maui.Controls.Shapes;
using Xunit;

public class LeakTest
{
    const int N = 30;
    static readonly BindableProperty PayloadProperty =
        BindableProperty.CreateAttached("Payload", typeof(byte[]), typeof(LeakTest), null);

    [Fact]
    public void Path_Data_Leaks()
    {
        var shared = new LineGeometry();
        var control = Allocate(shared, Scenario.Control);
        var leaky = Allocate(shared, Scenario.Leaky);
        var mitigation = Allocate(shared, Scenario.Mitigation);

        ForceGc();

        Assert.Equal(0, Alive(control));
        Assert.Equal(N, Alive(leaky));
        Assert.Equal(0, Alive(mitigation));
        GC.KeepAlive(shared);
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    static List<WeakReference> Allocate(LineGeometry shared, Scenario scenario)
    {
        var references = new List<WeakReference>(N);
        for (var i = 0; i < N; i++)
        {
            var path = new Microsoft.Maui.Controls.Shapes.Path();
            var payload = new byte[1024 * 1024];
            path.SetValue(PayloadProperty, payload);

            if (scenario != Scenario.Control)
                path.Data = shared;
            if (scenario == Scenario.Mitigation)
                path.Data = null;

            references.Add(new WeakReference(payload));
        }
        return references;
    }

    static int Alive(IEnumerable<WeakReference> references)
    {
        var alive = 0;
        foreach (var reference in references)
            if (reference.IsAlive)
                alive++;
        return alive;
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    static void ForceGc()
    {
        for (var i = 0; i < 7; i++)
        {
            GC.Collect();
            GC.WaitForPendingFinalizers();
            GC.Collect();
        }
    }

    enum Scenario { Control, Leaky, Mitigation }
}

Run:

dotnet test --logger "console;verbosity=normal"

Observed results

Scenario Payloads alive Retained payload
Control: never assign shared geometry 0 / 30 0 MiB
Mitigation: assign, then clear Data 0 / 30 0 MiB
Leaky: leave shared geometry assigned 30 / 30 30 MiB

Impact and suggested fix

This is purely managed behavior and affects all platforms. It requires reuse or long-lived caching of a geometry; per-view geometries that die with their path do not create this root.

Use a weak event proxy for geometry notifications and retain explicit unsubscription when Data changes. Current main appears hardened in this area, so the actionable scope is the shipped 10.0.0 package/servicing lineage rather than a new main implementation change.

Generated by Daily Memory Leak Hunter · gpt56 · 447.6 AIC · ⌖ 20.1 AIC · ⊞ 31.7K ·

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with src/Controls/src/Core/Shapes/Path.cs, especially the Data callbacks at lines 29-38 and notification subscription area at lines 75-94, and compare current main with the shipped 10.0.0 behavior. Run the LeakTest.cs repro with dotnet test; done means the servicing lineage no longer retains discarded Path payloads while the control, leaky, and mitigation scenarios produce the expected results.

Written by the indexing model from the issue text.

Assessment

Tech stack
csharp
Domain
frontend
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
52/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.