[leak-fix] Fix GradientBrush.GradientStops memory leak (Fixes #36363) - #36521
[leak-fix] Fix GradientBrush.GradientStops memory leak (Fixes #36363)#36521github-actions[bot] wants to merge 31 commits into
Conversation
GradientBrush subscribed to the shared GradientStopCollection's CollectionChanged with a strong instance delegate, so a long-lived/shared GradientStopCollection rooted every transient brush assigned to it. Route the subscription through WeakNotifyCollectionChangedProxy (the same weak pattern already used by Border for StrokeDashArray) and add a finalizer that unsubscribes the proxy. Adds a regression test that fails without the fix and passes with it. Generated by the Memory Leak Fixer agentic workflow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This comment has been minimized.
This comment has been minimized.
MauiBot
left a comment
There was a problem hiding this comment.
Expert Review — 1 findings
See inline comments for details.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bbcdaa9d-d75a-4e3b-b375-cf827a689644
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 36521Or
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 36521" |
@MauiBot Addressed the non-empty |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
@MauiBot Updated the PR description to match |
This comment has been minimized.
This comment has been minimized.
@MauiBot The latest review found no current code issues. The live description already documents both weak subscription paths, the private |
This comment has been minimized.
This comment has been minimized.
|
🔍 AI-generated follow-up (GitHub Copilot CLI, on behalf of @kubaflo) @MauiBot Review #4704706363 reports two warnings that are already fixed in the reviewed head
The report itself ranks these two refinements as the winning |
This comment has been minimized.
This comment has been minimized.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bbcdaa9d-d75a-4e3b-b375-cf827a689644
|
🔍 AI-generated review follow-up (GitHub Copilot CLI, on behalf of @kubaflo) @MauiBot Review #4705401238 repeats the earlier proposal to discover a dispatcher from the CLR finalizer path. That change was intentionally not applied because TryFindDispatcher(includeParents: false) reads handler and application service providers, which may already be disposed during teardown. Handler and parent attachment cache dispatchers on normal threads; later normal Parent, RealParent, or BindingContext access may safely perform late discovery. Commit e91b117 adds CollectedBrushFinalizerDoesNotResolveLateHandlerDispatcher and makes the safety boundary explicit in BindableObject. The regression proves finalization performs zero late handler service resolutions, then normal BindingContext access resolves the newly available dispatcher and queues cleanup. Removing the normal-access gate to match the suggested candidate makes the test fail with expected resolution count 0, actual 1; the restored safe implementation passes, along with all 48 focused cleanup/dispatcher tests. The PR description now documents this regression. The separate inherited-BindingContext early-clear disagreement remains intentionally unresolved. |
This comment has been minimized.
This comment has been minimized.
|
🔍 AI-generated follow-up (GitHub Copilot CLI, on behalf of @kubaflo) @MauiBot Review #4705732523 found zero current errors, warnings, or suggestions on No additional code or no-op commit is needed. The separate |
MauiBot
left a comment
There was a problem hiding this comment.
Expert Review — 1 findings
See inline comments for details.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bbcdaa9d-d75a-4e3b-b375-cf827a689644
|
🔍 AI-generated update (GitHub Copilot CLI, on behalf of @kubaflo) @MauiBot The critical disposed-dispatcher finding is addressed in 6685ddf. Finalizer-reachable cleanup now contains ObjectDisposedException without running callbacks or losing retry state, while normal access retains explicit error behavior. The two-case regression failed on the previous head and now passes; all 143 focused GradientBrush, BindableObject, and dispatcher tests pass. The inline thread is resolved and the PR description is current. |
This comment has been minimized.
This comment has been minimized.
MauiBot
left a comment
There was a problem hiding this comment.
Expert Review — 1 findings
See inline comments for details.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bbcdaa9d-d75a-4e3b-b375-cf827a689644
|
🔍 AI-generated update (GitHub Copilot CLI, on behalf of @kubaflo) @MauiBot, the normal-access disposed-dispatcher finding is fixed in 049ec12. Parent and BindingContext reads now safely retain pending cleanup instead of leaking ObjectDisposedException, with regression coverage for both dispatcher throw sites. Ready for re-review. |
|
🔍 AI-generated review follow-up (GitHub Copilot CLI, on behalf of @kubaflo) @MauiBot Review 4707031431 ranks the current |
|
🔍 AI-generated review follow-up (GitHub Copilot CLI, on behalf of @kubaflo) @MauiBot Review 4707379138 is clean on the current head The report’s lower-ranked raw |
|
/azp run |
|
Azure Pipelines: Successfully started running 3 pipeline(s). |
MauiBot
left a comment
There was a problem hiding this comment.
AI Review Summary
@github-actions[bot] — new AI review results are available based on this last commit:
6685ddf. To request a fresh review after new comments or commits, comment/review rerun.
🗂️ Review Sessions — click to expand
🚦 Gate — Test Before & After Fix
Gate Result: ⚠️ INCONCLUSIVE
Platform: ANDROID · Base: main · Merge base: a96cb62b
🩺 Base branch does not compile — the without-fix build failed. The gate's "does the test fail without the fix" check is unreliable here; this usually means main is broken or a merge-base file went missing. Investigate before trusting this gate.
/home/vsts/work/1/s/src/Controls/tests/Core.UnitTests/BindableObjectUnitTests.cs(836,46): error CS1061: 'MockBindable' does not contain a definition for 'ClearRealParentAndInheritedContextIfCollected'...
| Test | Without Fix (expect FAIL) | With Fix (expect PASS) |
|---|---|---|
🧪 BindableObjectUnitTests BindableObjectUnitTests |
🛠️ BUILD ERROR | ✅ PASS — 84s |
🧪 DispatcherExtensionsTest DispatcherExtensionsTest |
🛠️ BUILD ERROR | ✅ PASS — 17s |
🧪 GradientBrushMemoryTests GradientBrushMemoryTests |
🛠️ BUILD ERROR | ✅ PASS — 20s |
🔴 Without fix — 🧪 BindableObjectUnitTests: 🛠️ BUILD ERROR · 108s
Error-relevant lines (filtered from the build log):
/home/vsts/work/1/s/src/Controls/tests/Core.UnitTests/BindableObjectUnitTests.cs(836,46): error CS1061: 'MockBindable' does not contain a definition for 'ClearRealParentAndInheritedContextIfCollected' and no accessible extension method 'ClearRealParentAndInheritedContextIfCollected' accepting a first argument of type 'MockBindable' could be found (are you missing a using directive or an assembly reference?) [/home/vsts/work/1/s/src/Controls/tests/Core.UnitTests/Controls.Core.UnitTests.csproj]
🟢 With fix — 🧪 BindableObjectUnitTests: PASS ✅ · 84s
(no coded error found; showing last 1200 chars)
ed TestBindingOneWayOnReadOnly [< 1 ms]
Passed PropertyChangingSameValue [< 1 ms]
Passed BindingsAppliedUnappliedWithNullContext [< 1 ms]
Passed DefaultValueCreatorIsInvokedOnlyAtFirstTime [< 1 ms]
Passed PropertyChanging [< 1 ms]
Passed RemoveBindingInvalid [< 1 ms]
Passed BindingIsPreservedOnStyleBinding [< 1 ms]
Passed ClearValueDoesNotTriggersINPCOnSameValues [< 1 ms]
Passed PropertyChangedSameValue [< 1 ms]
Passed DoesNotRaiseOnSilent [< 1 ms]
Passed StyleBindingIsOverridenByStyleValue [< 1 ms]
Passed ValueIsPreservedOnStyleBinding [< 1 ms]
Passed PropertyChangingDefaultValue [< 1 ms]
Passed BindingOnBindingContextDoesntReapplyBindingContextBinding [< 1 ms]
Passed GetSetValue [< 1 ms]
[xUnit.net 00:00:01.70] Finished: Microsoft.Maui.Controls.Core.UnitTests
Passed BoundBindingContextReturnsNullWhileInheritedCleanupIsPending [21 ms]
Passed IsSetIsTrueWhenPropSetByDefaultValueCreator [< 1 ms]
Passed PropertyChanged [< 1 ms]
Passed CoerceValue [< 1 ms]
Passed StyleValueIsOverridenByStyleValue [< 1 ms]
Passed BindingContextChangedCompareReferences [< 1 ms]
Test Run Successful.
Total tests: 96
Passed: 96
Total time: 2.2520 Seconds
🔴 Without fix — 🧪 DispatcherExtensionsTest: 🛠️ BUILD ERROR · 29s
Error-relevant lines (filtered from the build log):
/home/vsts/work/1/s/src/Controls/tests/Core.UnitTests/BindableObjectUnitTests.cs(836,46): error CS1061: 'MockBindable' does not contain a definition for 'ClearRealParentAndInheritedContextIfCollected' and no accessible extension method 'ClearRealParentAndInheritedContextIfCollected' accepting a first argument of type 'MockBindable' could be found (are you missing a using directive or an assembly reference?) [/home/vsts/work/1/s/src/Controls/tests/Core.UnitTests/Controls.Core.UnitTests.csproj]
🟢 With fix — 🧪 DispatcherExtensionsTest: PASS ✅ · 17s
(no coded error found; showing last 1200 chars)
n 18.0.1 (x64)
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
[xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v2.8.2+699d445a1a (64-bit .NET 10.0.0)
[xUnit.net 00:00:00.30] Discovering: Microsoft.Maui.Controls.Core.UnitTests
[xUnit.net 00:00:03.57] Discovered: Microsoft.Maui.Controls.Core.UnitTests
[xUnit.net 00:00:03.58] Starting: Microsoft.Maui.Controls.Core.UnitTests
[xUnit.net 00:00:04.01] Finished: Microsoft.Maui.Controls.Core.UnitTests
Passed DispatchIfRequiredAsync_ShouldExecuteAction_WhenDispatchIsNotRequired [160 ms]
Passed DispatchIfRequiredAsync_FuncTask_ShouldCallDispatch_WhenDispatchIsRequired [27 ms]
Passed DispatchIfRequiredAsync_ShouldCallDispatchAsync_WhenDispatchIsRequired [1 ms]
Passed DispatchIfRequiredAsync_FuncTask_ShouldExecuteFunction_WhenDispatchIsNotRequired [5 ms]
Passed DispatchIfRequired_ShouldExecuteAction_WhenDispatchIsNotRequired [3 ms]
Passed DispatchIfRequired_ShouldCallDispatch_WhenDispatchIsRequired [< 1 ms]
Passed DispatcherGetterPreservesDispatcherCapturedDuringHandlerAttachment [111 ms]
Test Run Successful.
Total tests: 7
Passed: 7
Total time: 5.2151 Seconds
🔴 Without fix — 🧪 GradientBrushMemoryTests: 🛠️ BUILD ERROR · 29s
Error-relevant lines (filtered from the build log):
/home/vsts/work/1/s/src/Controls/tests/Core.UnitTests/BindableObjectUnitTests.cs(836,46): error CS1061: 'MockBindable' does not contain a definition for 'ClearRealParentAndInheritedContextIfCollected' and no accessible extension method 'ClearRealParentAndInheritedContextIfCollected' accepting a first argument of type 'MockBindable' could be found (are you missing a using directive or an assembly reference?) [/home/vsts/work/1/s/src/Controls/tests/Core.UnitTests/Controls.Core.UnitTests.csproj]
🟢 With fix — 🧪 GradientBrushMemoryTests: PASS ✅ · 20s
(no coded error found; showing last 1200 chars)
ed CollectedBrushDoesNotLeaveStaleGradientStopParent [224 ms]
Passed CollectedBrushUsesDispatcherAttachedAfterGradientStopCreation [431 ms]
Passed FailedDispatchLeavesGradientStopInheritedBindingContextCleanupPending [96 ms]
Passed CollectedBrushClearsGradientStopBindingContextPropertyBindingSource [95 ms]
Passed DuplicateGradientStopsPreserveOccurrenceSubscriptions [< 1 ms]
Passed NullGradientStopCollectionAllowsBindingContextChange [< 1 ms]
Passed ParentAccessBeforeSubscriptionFinalizerClearsInheritedBindingContext [63 ms]
Passed CollectedBrushUsesDispatcherAvailableWhenParentIsAssigned [105 ms]
Passed ClearingManualBindingContextAfterCollectedBrushDoesNotRestoreStaleBindingValue [104 ms]
[xUnit.net 00:00:06.35] Finished: Microsoft.Maui.Controls.Core.UnitTests
Passed RemovingBindingContextBindingWhileCleanupIsPendingClearsStoredBindingValue [78 ms]
Passed ClearingGradientStopsAllowsReentrantReplacement [2 ms]
Passed CollectedBrushFinalizerDoesNotResolveLateHandlerDispatcher [69 ms]
Passed ReplacingBindingContextBindingWhileCleanupIsPendingUsesNullInheritedSource [68 ms]
Test Run Successful.
Total tests: 40
Passed: 40
Total time: 7.6274 Seconds
⚠️ Failure Details
- 🛠️ BindableObjectUnitTests without fix: build failed before tests could run
/home/vsts/work/1/s/src/Controls/tests/Core.UnitTests/BindableObjectUnitTests.cs(836,46): error CS1061: 'MockBindable' does not contain a definition for 'ClearRealParentAndInheritedContextIfCollected'...
- 🛠️ DispatcherExtensionsTest without fix: build failed before tests could run
/home/vsts/work/1/s/src/Controls/tests/Core.UnitTests/BindableObjectUnitTests.cs(836,46): error CS1061: 'MockBindable' does not contain a definition for 'ClearRealParentAndInheritedContextIfCollected'...
- 🛠️ GradientBrushMemoryTests without fix: build failed before tests could run
/home/vsts/work/1/s/src/Controls/tests/Core.UnitTests/BindableObjectUnitTests.cs(836,46): error CS1061: 'MockBindable' does not contain a definition for 'ClearRealParentAndInheritedContextIfCollected'...
📁 Fix files reverted (6 files)
src/Controls/src/Core/BindableObject.cssrc/Controls/src/Core/BindingBase.cssrc/Controls/src/Core/DispatcherExtensions.cssrc/Controls/src/Core/Element/Element.cssrc/Controls/src/Core/GradientBrush.cssrc/Controls/src/Core/MultiBinding.cs
📱 UI Tests — Brush
Detected UI test categories: Brush
✅ Deep UI tests — 42 passed, 0 failed across 1 category on platform-pool agent (replaces in-process counts above).
🧪 UI Test Execution Results (deep, platform pool)
| Category | Tests | Snapshot diffs |
|---|---|---|
Brush |
42/42 ✓ | — |
📎 Download drop-deep-uitests artifact (TRX + snapshot diffs) |
📋 Pre-Flight — Context & Validation
Issue: #36363 - [leak-scan] GradientBrush.GradientStops — shared GradientStopCollection strongly roots the brush via CollectionChanged
PR: #36521 - [leak-fix] Fix GradientBrush.GradientStops memory leak (Fixes #36363)
Platforms Affected: All platforms; pure managed Controls core code. Requested test platform: android, but the changed code and regression tests are cross-platform unit-test coverage.
Files Changed: 6 implementation, 4 test
Key Findings
- Issue #36363 reports that a long-lived/shared non-empty
GradientStopCollectionroots transientGradientBrushinstances through strongCollectionChangedand per-stopPropertyChangedsubscriptions, plus stop parent/context references. - Current PR fix uses weak collection/per-stop event proxies and adds owner-aware cleanup for stale
GradientStop.Parentand inherited binding-context state. - The implementation also changes shared
BindableObject,Element,BindingBase,MultiBinding, and dispatcher lookup behavior to preserve existing parent/binding semantics while allowing dead brush cleanup. - Prior MauiBot error-level review about per-stop
PropertyChangedstill leaking is addressed in the current diff. - GitHub CLI is unauthenticated in this environment, so PR checks could not be queried; public API/web access was used for issue/PR narrative and comments.
Code Review Summary
Verdict: NEEDS_DISCUSSION
Confidence: low
Errors: 0 | Warnings: 0 | Suggestions: 0
Key code review findings:
- No current high-confidence code findings from the expert reviewer.
- Coverage gap for this run: required CI status is unavailable locally and user-provided gate was inconclusive; this does not imply failure.
Fix Candidates
| # | Source | Approach | Test Result | Files Changed | Notes |
|---|---|---|---|---|---|
| PR | PR #36521 | Weak event proxies plus weak parent/inherited-context cleanup in shared Controls infrastructure | BindableObject.cs, BindingBase.cs, DispatcherExtensions.cs, Element.cs, GradientBrush.cs, MultiBinding.cs, tests |
Original PR; broad but preserves existing GradientStop.Parent behavior |
🔬 Code Review — Deep Analysis
Code Review — PR #36521
Independent Assessment
What this changes: The PR fixes a managed memory leak in GradientBrush.GradientStops by replacing strong CollectionChanged and per-stop PropertyChanged subscriptions with weak event proxies, then broadens inherited BindingContext, parent, and dispatcher cleanup so a collected brush does not leave stale parent/context state on surviving GradientStop instances. It also adds extensive unit tests around shared gradient-stop collections, stale parent cleanup, binding-context cleanup, dispatcher scheduling, and live sibling invalidation.
Inferred motivation: A long-lived or shared GradientStopCollection can retain transient GradientBrush instances through strong event-handler targets and through each stop's parent/inherited-context graph.
Reconciliation with PR Narrative
Author claims: PR #36521 fixes issue #36363, where shared GradientStopCollection instances strongly root transient GradientBrush instances. The PR claims weak event proxy subscriptions, owner-aware cleanup, weak inherited-context storage, dispatcher-safe finalizer cleanup, and broad regression coverage.
Agreement/disagreement: The local diff matches the claim. The current PR fix is substantially broader than the original issue because it preserves existing GradientStop.Parent / inherited binding-context behavior while allowing dead brush cleanup without strong ownership cycles.
Prior Review Reconciliation
| Prior ❌ Error Finding | Source | Status | Evidence |
|---|---|---|---|
Shared non-empty GradientStopCollection still roots the brush through per-stop PropertyChanged handlers; empty-collection test misses this leak. |
MauiBot inline review on GradientBrush.cs |
✅ Fixed | Current GradientBrush.GradientStopSubscriptions uses WeakNotifyPropertyChangedProxy per stop and tests use non-empty shared collections. |
Additional prior warning: MauiBot requested asymmetric live/dead sibling coverage for shared stops. Current GradientBrushMemoryTests.AliveBrushStillInvalidatesAfterSiblingBrushIsCollected covers this.
Blast Radius Assessment
- Runs for all instances: Yes.
BindableObject,Element,BindingBase,MultiBinding, dispatcher lookup, andGradientBrushshared Controls code are affected for all platforms. - Startup impact: Low direct startup impact, but
BindableObject.Dispatcher,Element.Parent, andBindingContextaccess are common framework paths. - Static/shared state: No new global static state; new state is per-object/per-subscription helper.
CI Status
- Required-check result: undetermined
- Classification: undetermined
- Action taken:
ghwas unavailable due missing authentication; confidence capped. User-provided gate result was already inconclusive and not rerun.
Findings
No high-confidence ❌ Error,
Failure-Mode Probing
- Shared non-empty collection with transient brush: weak collection and stop proxies should avoid collection/stop event handlers rooting the brush.
- Dead brush with surviving stop and inherited binding context: PR marks stale inherited context for cleanup and dispatches or drains cleanup safely before exposing normal
BindingContextvalues. - Dead sibling brush with live sibling sharing same stop: owner/occurrence-aware cleanup avoids clearing the live brush parent and weak proxies preserve live invalidation.
- Dispatcher unavailable or disposed during finalizer cleanup: cleanup remains pending instead of resolving services from finalizer or surfacing
ObjectDisposedException. - Reparent/rebinding while cleanup is pending: unique pending cleanup tokens prevent stale dispatcher callbacks from clearing newer cleanup generations.
Verdict: NEEDS_DISCUSSION
Confidence: medium for code correctness, low for merge readiness because CI/gate status is undetermined in this environment.
Summary: The current PR fix is technically coherent and well-covered, but it is broad. Alternative candidates should explore whether a narrower GradientBrush-only fix can satisfy the original leak without touching global BindableObject / Element binding-context and dispatcher semantics.
🛠️ Fix — Analysis & Comparison
Fix Candidates
| # | Source | Approach | Test Result | Files Changed | Notes |
|---|---|---|---|---|---|
| 1 | try-fix | GradientBrush-only weak subscriptions and no GradientStop.Parent assignment |
✅ PASS focused leak/invalidation tests | 2 files | Smaller than PR, but behavior-breaking: drops parent/resource/dispatcher semantics and does not cover binding-source cleanup. |
| 2 | try-fix | GradientBrush weak subscriptions while preserving GradientStop.Parent; rely on existing weak RealParent |
❌ FAIL stronger inherited-context regression | 2 files | Fixes original brush leak, but stale inherited binding context/value remains after owner brush collection. |
| 3 | try-fix | Weak subscriptions plus lazy Element cleanup when weak parent is observed collected |
❌ FAIL no-dispatcher/no-access binding-source regression | 4 files | Passes original leak, lazy inherited cleanup, and adjacent BindableObjectUnitTests, but cannot release binding-held source without later access. |
| PR | PR #36521 | Weak subscriptions plus weak inherited binding-context storage, pending cleanup tokens, dispatcher-aware cleanup, and MultiBinding proxy handling |
10 files | Original PR; broader blast radius, but alternatives converge on its required ingredients. |
Cross-Pollination
| Model | Round | New Ideas? | Details |
|---|---|---|---|
| maui-expert-reviewer | 1 | Yes | Proposed lazy collected-parent cleanup after Candidate 2 failed inherited-context cleanup. |
| maui-expert-reviewer | 2 | No | NO NEW IDEAS: passing no-access source release requires weak inherited binding context or active dispatcher-aware cleanup, converging on PR #36521. |
Exhausted: Yes
Selected Fix: PR #36521 — The narrower candidates either break existing GradientStop.Parent semantics or fail inherited BindingContext/source-release regressions. Candidate 3 is the closest smaller alternative but still fails a memory-release scenario that the PR explicitly covers.
🏁 Report — Final Recommendation
Comparative Candidate Report — PR #36521
Candidate ranking
| Rank | Candidate | Regression result | Assessment |
|---|---|---|---|
| 1 | pr |
Best available fix. It fixes weak collection/per-stop event retention while preserving GradientStop.Parent, inherited binding context, dispatcher, binding replacement/removal, and MultiBinding semantics covered by the added regressions. |
|
| 1 | pr-plus-reviewer |
Same as pr |
Equivalent to pr; no actionable expert-review feedback was available to apply. |
| 3 | try-fix-1 |
✅ PASS focused leak/invalidation tests | Smaller, but behavior-breaking because it stops assigning GradientStop.Parent; this risks resource lookup, dispatcher discovery, and inherited-context behavior and does not cover binding-source cleanup. |
| 4 | try-fix-3 |
❌ FAIL CollectedBrushWithoutDispatcherReleasesBindingContextPropertyBindingSource |
Closest narrower alternative, but lazy cleanup cannot release a binding-held inherited source without later access or dispatcher availability. Must rank below passing/inconclusive non-failing candidates. |
| 5 | try-fix-2 |
❌ FAIL CollectedBrushClearsGradientStopInheritedBindingContext |
Weak subscriptions while preserving parent are insufficient because stale inherited context/binding values remain after the owning brush is collected. Must rank below candidates that did not fail regression tests. |
Comparative analysis
try-fix-1 demonstrates that the original event-subscription leak can be fixed with a much smaller GradientBrush-only diff, but it does so by removing the existing parent relationship. That trades a memory leak for a semantic change in GradientStop ownership and leaves the broader inherited-binding-source leak unaddressed.
try-fix-2 preserves the parent relationship and uses weak event proxies, but the stronger inherited-context regression fails. This shows the original PR is not merely over-engineering around event handlers: preserving existing parent semantics creates stale inherited binding context that must be cleaned up.
try-fix-3 adds lazy collected-parent cleanup and passes the original leak plus adjacent BindableObject tests, but it still fails the no-dispatcher/no-access binding-source regression. That failure is decisive because it is exactly the scenario where a dead owner must stop retaining an inherited source without relying on later app access.
The PR fix contains the ingredients the alternatives converged toward: weak event proxies, owner-aware parent cleanup, weak inherited binding-context storage for binding sources, dispatcher-aware pending cleanup, generation tokens, and MultiBinding proxy handling. Its blast radius is broader, but the narrower candidates either break existing behavior or fail strengthened regressions.
Winning candidate
Winner: pr
The raw PR fix wins because no reviewer patch was needed and every narrower try-fix candidate has either a known semantic regression or a failed strengthened regression. The gate remains inconclusive due the without-fix build issue, so this is a code-merit recommendation rather than a claim that CI is fully verified.
🧭 Next Steps — review latest findings
No alternative fix was selected for this run. Review the session findings and CI results before merging.
|
🔍 AI-generated review follow-up (GitHub Copilot CLI, on behalf of @kubaflo): The current-head MauiBot review |
|
🔍 AI-generated merge-readiness analysis (GitHub Copilot CLI, on behalf of @kubaflo) Good for merge from the automated review/CI perspective.
The current-head reviewer already reported gate passed and zero findings. No |
This comment has been minimized.
This comment has been minimized.
Tests Failure Analysis
Test Failure Review: Not ready - click to expandOverall verdict: Not ready. Six UITest failures are deterministic regressions vs base — red on this PR but green across all 5 recent
Coverage: 162 checks · 153 passing · 9 failing · 0 pending · 0 inaccessible · 1 unmapped · 15 unexplained build legs · 0 unaccounted failing checks · 2 aborted failing checks · 0 canceled-build checks · 0 device-test unverified · 8 unattributed · 6 regressed-vs-base. Deterministic ceiling: Not ready — 6 legs regressed vs base plus unexplained/aborted legs and unattributed failures. Builds (this PR): maui-pr-uitests 1511373, maui-pr-devicetests 1511380 (device tests confirmed clean). Base sampling (main, 5 recent builds per definition): 1503618, 1503332, 1503036, 1499834, 1499259. Recommended actionDo not merge yet. A human should investigate the 6 regressed-vs-base UITest failures (drag-and-drop, layout, indicator, FlexLayout crash) against this PR's GradientBrush change, and read the 15 unexplained legs and 2 cancelled macOS Controls checks before trusting a green result. |
|
🔍 AI-generated follow-up analysis (GitHub Copilot CLI, on behalf of @kubaflo) The
Main build 1511365 and device build 1511380 passed on the same merge, with all seven Helix jobs clean. No failure stack enters the changed GradientBrush/BindingContext cleanup. No PR code change is required; the draft remains good for merge from the automated code/CI perspective. |
|
🔍 AI-generated (GitHub Copilot CLI, on behalf of @kubaflo) Closing as over-scoped / too risky to merge as-is. The core
Changing the core binding/dispatcher infrastructure to support finalizer-time context clearing risks every Recommendation: re-do as a focused, self-contained fix in |
Note
Are you waiting for the changes in this PR to be merged?
It would be very helpful if you could test the resulting artifacts from this PR and let us know in a comment if this change resolves your issue. Thank you!
Note
🔍 AI-generated PR. This fix and its regression tests were produced by the Memory Leak Fixer workflow. Please review carefully before merging.
Fixes #36363
The leak
GradientBrush.UpdateGradientStopssubscribed the brush directly toGradientStopCollection.CollectionChangedand toPropertyChangedon every containedGradientStop. A shared or long-lived non-empty collection could therefore retain transient brushes through both the collection-level and per-stop event handlers.The fix
Route the collection subscription through
WeakNotifyCollectionChangedProxyand each stop subscription throughWeakNotifyPropertyChangedProxy. A privateGradientStopSubscriptionshelper owns the collection proxy and per-stop proxies while the brush is alive. It removes subscriptions when collections or stops change and handles reset/reentrant replacement safely. Live collection replacement uses an owner-aware cleanup path that also clears unused parents, while a separately named finalizer path clears collected weak-parent references.Collected-parent cleanup now keeps ordinary inherited context and the source used by a
BindingContextPropertybinding in element-owned weak references.BindingBase.Contextunwraps the weak holder for normal binding application, andMultiBindingpropagates the source to its proxy as inherited context rather than a strong local value. A finalizer therefore only atomically transitions element-owned fields; it never readsBindablePropertyContext, binding collections, or mutableBindingBasestate. This also releases a binding-held inherited source when no dispatcher exists, without requiring later application access.Objects created before a dispatcher exists now cache an available dispatcher when either a handler or parent is attached on a normal thread. Parent capture occurs while the hierarchy is still available; finalizer cleanup therefore reads only cached state and never resolves services from the finalizer thread. Later normal access uses a non-throwing dispatcher lookup that does not traverse the already-cleared parent hierarchy. The public
Dispatchergetter uses the same volatile-read and compare-exchange winner semantics, so concurrent background discovery cannot overwrite a dispatcher captured during handler or parent attachment. If a cached dispatcher has already been disposed, both finalizer and normal-access cleanup catchObjectDisposedExceptionfromIsDispatchRequiredorDispatch, reset single-flight scheduling when needed, and leave cleanup pending for later safe access.ParentandBindingContextreads therefore return safely instead of leaking dispatcher disposal.Each cleanup uses a unique pending-generation token. Dispatcher callbacks capture that exact generation, so a stale callback cannot clear a newer cleanup after reparenting. An atomic per-token single-flight flag allows only one accepted dispatcher callback to be queued; a rejected dispatch resets the flag so a later access can retry. Later dispatcher-safe
Parent,RealParent, orBindingContextaccess drains the pending work without flooding the dispatcher with duplicate no-op callbacks.Binding replacement and removal preserve or atomically claim the pending generation. Replacement bindings apply against a null inherited source while cleanup is pending; final removal clears the removed binding's stored specificity value so a manual override cannot later reveal stale data. Forced cleanup refreshes a hidden lower-specificity binding while preserving an effective manual
BindingContext.The handler delegates remain strongly cached by the live brush so weak forwarding continues after GC. Parent cleanup is owner- and occurrence-aware: a brush clears a stop's parent only when that brush is still the current parent and the same stop instance no longer appears in its current collection; this check uses reference identity rather than
GradientStopvalue equality. Clearing, removing, or replacing stops from an older sharing brush therefore cannot detach a stop currently owned by another live brush, and duplicate occurrences retain their parent until the last occurrence is removed.The public
GradientBrushtype remains finalizer-free and there are no public API additions. Binding-context propagation also returns safely whenGradientStopsisnullor contains null entries.Regression tests
GradientBrushMemoryTestscovers:GradientBrushDoesNotLeakWhenSharingGradientStops— a shared non-empty collection does not retain a transient brush.CollectedBrushDoesNotLeaveStaleGradientStopParent— a collected brush leaves no stale weak parent or misleadingRealParentwarning on a surviving stop.CollectedBrushClearsGradientStopInheritedBindingContext— collected-parent cleanup clears the inherited context, resets a bound stop property, and prevents later source changes from reaching it.CollectedBrushClearsGradientStopBindingContextPropertyBindingSource— cleanup clears inherited context held by a binding onBindingContextProperty, raises the normal context transition, and detaches the stale source.CollectedBrushWithoutDispatcherReleasesBindingContextPropertyBindingSource— a binding-held source is collectible without a dispatcher and without readingParentorBindingContext; after the weak source is collected, the test proves the stale0.25fstored binding value still requires normal cleanup.CollectedBrushWithoutDispatcherReleasesMultiBindingContextPropertyBindingSource—MultiBindingproxy state also remains weak and collectible without a dispatcher.CollectedBrushDispatchesGradientStopInheritedBindingContextCleanup— finalizer cleanup queues binding work and does not raiseBindingContextChangeduntil the dispatched action executes.CollectedBrushUsesDispatcherAttachedAfterGradientStopCreation— an element created without a dispatcher captures one from a later handler attachment and queues finalizer cleanup through it; the regression times out on the prior head and passes with the fix.CollectedBrushUsesDispatcherAvailableWhenParentIsAssigned— a child created without a dispatcher captures the dispatcher available through its new parent before that weak parent can be collected; the regression times out on the previous head and passes with the fix.CollectedInheritedContextStillClearsAppliedBindingValues— when both an ordinary inherited source and its parent are collected before finalizer cleanup, stale bound target values remain pending until normal access safely clears them; the regression verifies the reset and single context notification.BindingContextAccessBeforeDispatchedCleanupDoesNotRunCallbacks— a backgroundBindingContextread before queued cleanup executes does not run bindings or callbacks off-dispatcher.BoundBindingContextReturnsNullWhileInheritedCleanupIsPending— a collected publicParentdrives generic cleanup without fix-only test APIs; rejected dispatch remains retryable, accepted dispatch is single-flight across repeatedBindingContextreads, and stale bound values remain hidden.CleanupLeavesPendingWhenDispatcherIsDisposed—ObjectDisposedExceptionfrom eitherIsDispatchRequiredorDispatchis contained for both finalizer cleanup and normalParent/BindingContextaccess; cleanup remains pending and completes after the dispatcher becomes usable.FailedDispatchLeavesGradientStopInheritedBindingContextCleanupPending— dispatch rejection leaves cleanup pending; a later dispatcher-safe access completes it.ParentAccessBeforeSubscriptionFinalizerClearsInheritedBindingContext— readingParentbefore the subscription helper finalizes still clears the inherited context and binding source.ParentAccessAfterCollectedBrushClearsGradientStopInheritedBindingContext— readingParentafter finalization has already cleared the weak parent drains pending inherited-context cleanup.CollectingPreviousBrushPreservesCurrentGradientStopParent— collecting a previously owning brush after reparenting preserves the current live brush parent and binding context.CollectingPreviousBrushPreservesCurrentGradientStopBindingContextPropertyBindingSource— the binding-held source also follows the current brush after reparenting.ClearingManualBindingContextAfterCollectedBrushDoesNotRestoreStaleBindingValue— forced cleanup refreshes the hidden binding value without replacing a manual override.ReplacingBindingContextBindingWhileCleanupIsPendingUsesNullInheritedSource— binding replacement cannot unwrap or revive the old inherited source.RemovingBindingContextBindingWhileCleanupIsPendingClearsStoredBindingValue— final binding removal cannot leave stale data behind a manual value.StaleDispatchedCleanupDoesNotClearNewPendingBindingContextCleanup— a callback from an older cleanup generation cannot consume a newer pending cleanup.GradientStopChangesStillInvalidateAfterGc— a live brush keeps receiving stop invalidation after GC.SharedGradientStopsInvalidateEachLiveBrushAfterGc— multiple live brushes sharing one collection each invalidate.AliveBrushStillInvalidatesAfterSiblingBrushIsCollected— one sharing brush can be collected without suppressing invalidation for its live sibling.RemovingAndReplacingGradientStopsMovesSubscriptions— removed/old stops detach and replacement stops attach.ReplacingGradientStopsInvalidatesBrush— replacing non-empty stops withnullor an empty collection raises one explicit invalidation.NullGradientStopCollectionAllowsBindingContextChange— a later binding-context change remains safe whileGradientStopsisnull.NullGradientStopEntryAllowsBindingContextChange— null entries are ignored during inherited-context propagation.SharedGradientStopsPreserveExistingMostRecentlyAssignedParentBehavior— shared collections retain the existing single-parent/inherited-context behavior.DetachingSharedStopFromPreviousBrushPreservesCurrentParent— clear, remove, and collection replacement from an older brush preserve the current brush parent and binding context.DuplicateGradientStopsPreserveOccurrenceSubscriptions— duplicate occurrences retain one invalidation subscription per occurrence and keep their parent until the final occurrence is removed.DetachingValueEqualStopClearsRemovedParent— removal, item replacement, and collection replacement clear only the detached instance when an equal-valued stop remains.RemovingGradientStopAllowsReentrantReuse— reentrant reuse of a removed stop preserves the newly installed property-change subscription.ClearingGradientStopsAllowsReentrantReplacement— reset cleanup permits reentrant collection replacement and verifies that clear and replacement each invalidate once.ClearingAndReusingGradientStopsKeepsNewStopsSubscribed— later additions remain subscribed after clear/reuse.DispatcherExtensionsTest.DispatcherGetterPreservesDispatcherCapturedDuringHandlerAttachmentdeterministically verifies that a concurrent background lookup cannot overwrite the dispatcher captured during handler attachment.Known existing behavior: a
GradientStopis anElementwith oneParentand one inherited binding context. When a stop is shared by multiple brushes, the most recently assigned brush remains the current parent. This PR preserves that single-parent model while ensuring cleanup by a non-owning brush does not erase the current owner.All 40 focused
GradientBrushMemoryTestspass, and the focused cleanup/dispatcher filter passes 143 tests. The latest completeControls.Core.UnitTestsrun before this test-only hardening passed 5,706 tests with 30 existing tests skipped. The original review gate verified the leak regression red without the fix and green with it, and the Brush UI category completed successfully. The revised generic regression was also run against merge base0395a53bwith all production fix files absent: it compiled and failed behaviorally on the staleFooBarvalue, while the fixed head passes.Scope
Managed cross-platform changes in
GradientBrush.cs,Element.cs,BindableObject.cs,DispatcherExtensions.cs,BindingBase.cs, andMultiBinding.cs, with focused unit coverage inGradientBrushMemoryTests.cs,BindableObjectUnitTests.cs, andDispatcherExtensionsTests.cs, plus failed-dispatch support inDispatcherStub.cs. No existing test was muted, skipped, or weakened.