Skip to content

[leak-scan] PathFigure.Segments β€” shared collection strongly retains path figureΒ #38718

Closed

Description

@github-actions

Important

AI-generated by the Daily Memory Leak Hunter workflow. The finding below was reproduced against the shipped Microsoft.Maui.Controls 10.0.0 package.

Description

Assigning a shared or otherwise long-lived PathSegmentCollection to PathFigure.Segments keeps the PathFigure and its binding context alive after application references are dropped.

PathFigure.UpdatePathSegmentCollection subscribes the figure instance to the collection's CollectionChanged event (src/Controls/src/Core/Shapes/PathFigure.cs:97-111). It unsubscribes only when Segments is replaced (PathFigure.cs:97-104), so ordinary collection of the figure cannot begin while the shared publisher retains its delegate.

Retention path

shared PathSegmentCollection -> CollectionChanged delegate -> PathFigure.OnPathSegmentCollectionChanged target -> PathFigure.BindingContext -> transient payload

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.Linq;
using System.Runtime.CompilerServices;
using Microsoft.Maui.Controls.Shapes;
using Xunit;

public class LeakTest
{
	const int N = 30;

	[Fact]
	public void PathFigure_Segments_Leaks()
	{
		var shared = new PathSegmentCollection();
		var control = CreateMany(() => Create(null, false));
		var leaky = CreateMany(() => Create(shared, false));
		var mitigation = CreateMany(() => Create(shared, true));

		for (var i = 0; i < 7; i++)
		{
			GC.Collect();
			GC.WaitForPendingFinalizers();
			GC.Collect();
		}

		Assert.Equal(0, control.Count(x => x.IsAlive));
		Assert.Equal(N, leaky.Count(x => x.IsAlive));
		Assert.Equal(0, mitigation.Count(x => x.IsAlive));
		GC.KeepAlive(shared);
	}

	[MethodImpl(MethodImplOptions.NoInlining)]
	static WeakReference[] CreateMany(Func<WeakReference> factory) =>
		Enumerable.Range(0, N).Select(_ => factory()).ToArray();

	[MethodImpl(MethodImplOptions.NoInlining)]
	static WeakReference Create(PathSegmentCollection? shared, bool mitigate)
	{
		var payload = new Payload();
		var figure = new PathFigure { BindingContext = payload };
		if (shared is not null)
			figure.Segments = shared;
		if (mitigate)
			figure.Segments = new PathSegmentCollection();
		return new WeakReference(payload);
	}

	sealed class Payload
	{
		readonly byte[] _bytes = new byte[1024 * 1024];
	}
}

Run:

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

Observed results

Scenario Payloads alive after full GC Retained payload
Control (private default collection) 0 / 30 0 MB
Mitigation (Segments replaced) 0 / 30 0 MB
Leaky (shared PathSegmentCollection remains assigned) 30 / 30 30 MB

Scope

This is purely managed code and affects all platforms. It occurs only when a PathSegmentCollection outlives the PathFigure; the normal private default collection does not leak. Replacing Segments before release avoids retention, but this is a usage footgun the framework can harden.

Suggested fix: subscribe to the external collection through a weak collection-changed proxy while retaining the existing deterministic unsubscribe when Segments changes.

Generated by Daily Memory Leak Hunter Β· gpt56 Β· 282.5 AIC Β· βŒ– 19.2 AIC Β· ⊞ 28K Β· β—·

Activity

  1. added a commit that references this issue on Sep 24, 2026
    a140ff6
  2. added this to the .NET 10 SR12 milestone on Sep 24, 2026
  3. added 3 commits that reference this issue on Sep 27, 2026
    94f22fe
    0798ef8
    25c95f3
  4. added 6 commits that reference this issue on Oct 13, 2026
    25ac871
    68c742a
    e0e997d
    041be3a
    7c16139
    9bddea0
  5. added a commit that references this issue on Oct 28, 2026
    8ec81bb
  6. locked and limited conversation to collaborators on Nov 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions