Fix FormattedString memory leak caused by shared Span - #36294
Conversation
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 36294Or
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 36294" |
This comment has been minimized.
This comment has been minimized.
9bc7961 to
886c885
Compare
f29f0ed to
efd1d14
Compare
|
Verified the AI summary and incorporated the necessary updates. |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Pull request overview
This PR addresses a memory leak in FormattedString caused by strong event subscriptions to Span.PropertyChanged/PropertyChanging when a Span instance is shared or long-lived. The fix replaces direct event subscriptions with per-occurrence weak subscription tokens so shared spans no longer keep discarded FormattedString instances alive.
Changes:
- Replace direct
Spanevent subscriptions inFormattedStringwith per-occurrence weak subscription tokens (and cleanup logic). - Add unit tests covering duplicate-span add/remove symmetry and
PropertyChangingbehavior. - Add a memory-focused unit test that verifies a
FormattedStringcan be garbage collected even when it contains a sharedSpan.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| src/Controls/src/Core/FormattedString.cs | Introduces per-occurrence weak subscription tokens for span property-change events to prevent shared-span retention of FormattedString. |
| src/Controls/tests/Core.UnitTests/FormattedStringTests.cs | Adds tests for duplicate-span subscription symmetry, PropertyChanging behavior, and a GC regression test for the shared-span leak. |
| sealed class SpanSubscriptions | ||
| { | ||
| readonly WeakReference<FormattedString> _owner; | ||
| readonly List<SpanSubscription> _subscriptions = new(); | ||
|
|
||
| public SpanSubscriptions(FormattedString owner) => _owner = new(owner); | ||
|
|
||
| ~SpanSubscriptions() => Clear(); | ||
|
|
||
| public void Add(Span span) => _subscriptions.Add(new SpanSubscription(_owner, span)); |
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.
| bo.Parent?.RemoveLogicalChild(bo); | ||
| bo.PropertyChanging -= OnItemPropertyChanging; | ||
| bo.PropertyChanged -= OnItemPropertyChanged; | ||
| _spanSubscriptions.Remove(bo); |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[major] Logic and Correctness — This now removes one event subscription per removed Span, but the logical-child cleanup in the same branch still removes through bo.Parent. For duplicate occurrences, the first removal clears span.Parent, so the second removal reaches this subscription cleanup while leaving the remaining logical child in this FormattedString; for a Span shared by two FormattedString instances, removing it from the first removes the logical child from the second instead. Please remove the occurrence from this FormattedString's logical children (and add a regression assertion) so subscription cleanup and logical-tree cleanup stay symmetric.
MauiBot
left a comment
There was a problem hiding this comment.
AI Review Summary
@Dhivya-SF4094 — new AI review results are available based on this last commit:
a76e6ed. 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: ✅ PASSED
Platform: ANDROID · Base: main · Merge base: 0395a53b
| Test | Without Fix (expect FAIL) | With Fix (expect PASS) |
|---|---|---|
🧪 FormattedStringTests FormattedStringTests |
✅ FAIL — 109s | ✅ PASS — 83s |
🔴 Without fix — 🧪 FormattedStringTests: FAIL ✅ · 109s
Determining projects to restore...
Restored /home/vsts/work/1/s/src/Graphics/src/Graphics/Graphics.csproj (in 878 ms).
Restored /home/vsts/work/1/s/src/TestUtils/src/TestUtils/TestUtils.csproj (in 2.81 sec).
Restored /home/vsts/work/1/s/src/Essentials/src/Essentials.csproj (in 4.49 sec).
Restored /home/vsts/work/1/s/src/Core/src/Core.csproj (in 5.09 sec).
Restored /home/vsts/work/1/s/src/Core/maps/src/Maps.csproj (in 2.58 sec).
Restored /home/vsts/work/1/s/src/Controls/src/Xaml/Controls.Xaml.csproj (in 60 ms).
Restored /home/vsts/work/1/s/src/Controls/src/Core/Controls.Core.csproj (in 34 ms).
Restored /home/vsts/work/1/s/src/Controls/Maps/src/Controls.Maps.csproj (in 42 ms).
Restored /home/vsts/work/1/s/src/Controls/tests/Core.UnitTests/Controls.Core.UnitTests.csproj (in 1.42 sec).
1 of 10 projects are up-to-date for restore.
##vso[build.updatebuildnumber]10.0.100-ci+azdo.14656892
Graphics -> /home/vsts/work/1/s/artifacts/bin/Graphics/Debug/net10.0/Microsoft.Maui.Graphics.dll
##vso[build.updatebuildnumber]10.0.100-ci+azdo.14656892
Essentials -> /home/vsts/work/1/s/artifacts/bin/Essentials/Debug/net10.0/Microsoft.Maui.Essentials.dll
##vso[build.updatebuildnumber]10.0.100-ci+azdo.14656892
Core -> /home/vsts/work/1/s/artifacts/bin/Core/Debug/net10.0/Microsoft.Maui.dll
##vso[build.updatebuildnumber]10.0.100-ci+azdo.14656892
Maps -> /home/vsts/work/1/s/artifacts/bin/Maps/Debug/net10.0/Microsoft.Maui.Maps.dll
Controls.BindingSourceGen -> /home/vsts/work/1/s/artifacts/bin/Controls.BindingSourceGen/Debug/netstandard2.0/Microsoft.Maui.Controls.BindingSourceGen.dll
##vso[build.updatebuildnumber]10.0.100-ci+azdo.14656892
Controls.Core -> /home/vsts/work/1/s/artifacts/bin/Controls.Core/Debug/net10.0/Microsoft.Maui.Controls.dll
##vso[build.updatebuildnumber]10.0.100-ci+azdo.14656892
Controls.Xaml -> /home/vsts/work/1/s/artifacts/bin/Controls.Xaml/Debug/net10.0/Microsoft.Maui.Controls.Xaml.dll
##vso[build.updatebuildnumber]10.0.100-ci+azdo.14656892
Controls.Maps -> /home/vsts/work/1/s/artifacts/bin/Controls.Maps/Debug/net10.0/Microsoft.Maui.Controls.Maps.dll
TestUtils -> /home/vsts/work/1/s/artifacts/bin/TestUtils/Debug/netstandard2.0/Microsoft.Maui.TestUtils.dll
Controls.Core.UnitTests -> /home/vsts/work/1/s/artifacts/bin/Controls.Core.UnitTests/Debug/net10.0/Microsoft.Maui.Controls.Core.UnitTests.dll
Test run for /home/vsts/work/1/s/artifacts/bin/Controls.Core.UnitTests/Debug/net10.0/Microsoft.Maui.Controls.Core.UnitTests.dll (.NETCoreApp,Version=v10.0)
VSTest version 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.17] Discovering: Microsoft.Maui.Controls.Core.UnitTests
[xUnit.net 00:00:01.46] Discovered: Microsoft.Maui.Controls.Core.UnitTests
[xUnit.net 00:00:01.48] Starting: Microsoft.Maui.Controls.Core.UnitTests
[xUnit.net 00:00:02.50] FormattedStringDoesNotLeak [FAIL]
[xUnit.net 00:00:02.50] FormattedString should not be alive!
[xUnit.net 00:00:02.51] Stack Trace:
[xUnit.net 00:00:02.51] /_/src/Controls/tests/Core.UnitTests/FormattedStringTests.cs(208,0): at Microsoft.Maui.Controls.Core.UnitTests.FormattedStringTests.FormattedStringDoesNotLeak()
[xUnit.net 00:00:02.51] --- End of stack trace from previous location ---
Passed NullSpansNotAllowed [18 ms]
Passed SpanChangesUnsubscribes [3 ms]
Passed AddingSpanTriggersSpansPropertyChange [< 1 ms]
Passed ImplicitStringConversion [4 ms]
Passed SpanChangingTriggersSpansPropertyChanging [< 1 ms]
Passed DuplicateSpanKeepsOneSubscriptionAfterSingleRemove [< 1 ms]
Passed SpanChangeTriggersSpansPropertyChange [< 1 ms]
Passed DuplicateSpanChangesUnsubscribes [< 1 ms]
Failed FormattedStringDoesNotLeak [916 ms]
Error Message:
FormattedString should not be alive!
Stack Trace:
at Microsoft.Maui.Controls.Core.UnitTests.FormattedStringTests.FormattedStringDoesNotLeak() in /_/src/Controls/tests/Core.UnitTests/FormattedStringTests.cs:line 208
--- End of stack trace from previous location ---
[xUnit.net 00:00:02.53] Finished: Microsoft.Maui.Controls.Core.UnitTests
Passed ImplicitStringConversionNull [< 1 ms]
Passed SpanChangingUnsubscribesAfterRemoval [< 1 ms]
Test Run Failed.
Total tests: 11
Passed: 10
Failed: 1
Total time: 3.0675 Seconds
🟢 With fix — 🧪 FormattedStringTests: PASS ✅ · 83s
Determining projects to restore...
All projects are up-to-date for restore.
##vso[build.updatebuildnumber]10.0.100-ci+azdo.14656892
Graphics -> /home/vsts/work/1/s/artifacts/bin/Graphics/Debug/net10.0/Microsoft.Maui.Graphics.dll
##vso[build.updatebuildnumber]10.0.100-ci+azdo.14656892
Essentials -> /home/vsts/work/1/s/artifacts/bin/Essentials/Debug/net10.0/Microsoft.Maui.Essentials.dll
##vso[build.updatebuildnumber]10.0.100-ci+azdo.14656892
Core -> /home/vsts/work/1/s/artifacts/bin/Core/Debug/net10.0/Microsoft.Maui.dll
##vso[build.updatebuildnumber]10.0.100-ci+azdo.14656892
Maps -> /home/vsts/work/1/s/artifacts/bin/Maps/Debug/net10.0/Microsoft.Maui.Maps.dll
Controls.BindingSourceGen -> /home/vsts/work/1/s/artifacts/bin/Controls.BindingSourceGen/Debug/netstandard2.0/Microsoft.Maui.Controls.BindingSourceGen.dll
##vso[build.updatebuildnumber]10.0.100-ci+azdo.14656892
Controls.Core -> /home/vsts/work/1/s/artifacts/bin/Controls.Core/Debug/net10.0/Microsoft.Maui.Controls.dll
##vso[build.updatebuildnumber]10.0.100-ci+azdo.14656892
##vso[build.updatebuildnumber]10.0.100-ci+azdo.14656892
Controls.Maps -> /home/vsts/work/1/s/artifacts/bin/Controls.Maps/Debug/net10.0/Microsoft.Maui.Controls.Maps.dll
Controls.Xaml -> /home/vsts/work/1/s/artifacts/bin/Controls.Xaml/Debug/net10.0/Microsoft.Maui.Controls.Xaml.dll
TestUtils -> /home/vsts/work/1/s/artifacts/bin/TestUtils/Debug/netstandard2.0/Microsoft.Maui.TestUtils.dll
Controls.Core.UnitTests -> /home/vsts/work/1/s/artifacts/bin/Controls.Core.UnitTests/Debug/net10.0/Microsoft.Maui.Controls.Core.UnitTests.dll
Test run for /home/vsts/work/1/s/artifacts/bin/Controls.Core.UnitTests/Debug/net10.0/Microsoft.Maui.Controls.Core.UnitTests.dll (.NETCoreApp,Version=v10.0)
VSTest version 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.15] Discovering: Microsoft.Maui.Controls.Core.UnitTests
[xUnit.net 00:00:01.17] Discovered: Microsoft.Maui.Controls.Core.UnitTests
[xUnit.net 00:00:01.19] Starting: Microsoft.Maui.Controls.Core.UnitTests
Passed NullSpansNotAllowed [12 ms]
Passed SpanChangesUnsubscribes [4 ms]
Passed AddingSpanTriggersSpansPropertyChange [< 1 ms]
Passed ImplicitStringConversion [3 ms]
Passed SpanChangingTriggersSpansPropertyChanging [< 1 ms]
Passed DuplicateSpanKeepsOneSubscriptionAfterSingleRemove [< 1 ms]
Passed SpanChangeTriggersSpansPropertyChange [< 1 ms]
Passed DuplicateSpanChangesUnsubscribes [< 1 ms]
Passed FormattedStringDoesNotLeak [28 ms]
[xUnit.net 00:00:01.34] Finished: Microsoft.Maui.Controls.Core.UnitTests
Passed ImplicitStringConversionNull [< 1 ms]
Passed SpanChangingUnsubscribesAfterRemoval [1 ms]
Test Run Successful.
Total tests: 11
Passed: 11
Total time: 1.8909 Seconds
📁 Fix files reverted (1 files)
src/Controls/src/Core/FormattedString.cs
📋 Pre-Flight — Context & Validation
Issue: Unknown - GitHub metadata unavailable (gh unauthenticated)
PR: #36294 - Local squashed PR branch over origin/main
Platforms Affected: android requested for testing; implementation is platform-neutral Controls core
Files Changed: 1 implementation, 1 test
Key Findings
- Local PR diff changes
src/Controls/src/Core/FormattedString.csto prevent long-lived/sharedSpaninstances from retainingFormattedStringthroughPropertyChanging/PropertyChangedsubscriptions. - Local tests added in
src/Controls/tests/Core.UnitTests/FormattedStringTests.cscover duplicate span occurrences,PropertyChanging, unsubscription after removal, and memory collection. - GitHub PR description, linked issue, comments, prior reviews, and required checks could not be fetched because the GitHub CLI is unauthenticated in this environment.
- Existing MAUI weak-event proxy infrastructure (
WeakNotifyPropertyChangingProxy,WeakNotifyPropertyChangedProxy) is a meaningful alternative to the PR's custom token-wrapper implementation.
Code Review Summary
Verdict: NEEDS_DISCUSSION
Confidence: low
Errors: 0 | Warnings: 0 | Suggestions: 1
Key code review findings:
- ℹ
src/Controls/src/Core/FormattedString.cs: Consider using existing MAUI weak-event proxy infrastructure instead of custom nested subscription tokens.
Fix Candidates
| # | Source | Approach | Test Result | Files Changed | Notes |
|---|---|---|---|---|---|
| PR | PR #36294 | Per-occurrence weak subscription token wrapping Span.PropertyChanging/PropertyChanged with weak owner reference |
✅ PASSED (Gate provided) | src/Controls/src/Core/FormattedString.cs, src/Controls/tests/Core.UnitTests/FormattedStringTests.cs |
Original PR |
🔬 Code Review — Deep Analysis
Code Review - PR #36294
Independent Assessment
What this changes: Replaces FormattedString's direct subscriptions to each Span's PropertyChanging and PropertyChanged events with per-occurrence weak subscription tokens. The PR also adds unit tests for duplicate span occurrences, PropertyChanging propagation/unsubscription, and collection of a FormattedString attached to a long-lived shared Span.
Inferred motivation: A shared or long-lived Span can strongly retain every FormattedString that subscribed with instance-method delegates, causing FormattedString leaks.
Reconciliation with PR Narrative
Author claims: GitHub PR metadata could not be fetched because gh is unauthenticated in this environment.
Agreement/disagreement: The local code diff and tests support the inferred leak fix. The test FormattedStringDoesNotLeak specifically proves the long-lived-span retention path.
Prior Review Reconciliation
No prior review surfaces could be queried because gh is unauthenticated. No prior error findings were available locally.
Blast Radius Assessment
- Runs for all instances: Yes. Every
FormattedStringnow allocates aSpanSubscriptionshelper and every addedSpanuses a weak subscription token. - Startup impact: No direct startup path; behavior runs when
FormattedStringinstances are constructed/modified. - Static/shared state: No new static state in the PR fix.
CI Status
- Required-check result: undetermined
- Classification: undetermined
- Action taken: GitHub CLI is unauthenticated (
gh auth loginrequired), so CI checks could not be queried. Confidence capped at low for CI evidence, but gate result was provided externally as passed.
Findings
💡 Suggestion - Prefer existing weak-event proxy infrastructure
The PR's custom SpanSubscription token is reasonable, but MAUI already has WeakNotifyPropertyChangingProxy and WeakNotifyPropertyChangedProxy patterns in WeakEventProxy.cs. A proxy-based implementation may be easier to maintain and align with existing Controls infrastructure.
Failure-Mode Probing
- Shared long-lived
Span: The PR avoids retainingFormattedStringbecauseSpanSubscriptiononly holds a weak owner reference. - Duplicate span occurrences: The PR preserves per-occurrence subscription behavior, so one removal leaves one active subscription and removing both removes both.
- Removed span mutation: Explicit
Removeunsubscribes one matching occurrence, so removed spans should not notify once all occurrences are removed. - Long-lived span never fires after owner collection: Small subscription tokens can remain until finalizer cleanup, but the owner
FormattedStringis no longer strongly retained.
Verdict: NEEDS_DISCUSSION
Confidence: low
Summary: The local code looks directionally correct and the provided gate result says tests pass, but remote PR narrative, prior reviews, and CI checks were unavailable due GitHub CLI authentication. Existing MAUI weak-event proxies provide a credible alternative worth testing against the same unit gate.
🛠️ Fix — Analysis & Comparison
Fix Candidates
| # | Source | Approach | Test Result | Files Changed | Notes |
|---|---|---|---|---|---|
| 1 | maui-expert-reviewer + code-review loop | Reuse existing WeakNotifyPropertyChangingProxy and WeakNotifyPropertyChangedProxy per span occurrence |
✅ PASS — 11/11 focused tests | 1 file | Preserves duplicate occurrence semantics and aligns with MAUI weak-event infrastructure |
| 2 | maui-expert-reviewer + learned comparison | Reuse existing weak proxies but register once per distinct Span with occurrence counting |
✅ PASS — 11/11 focused tests | 1 file | Fewer subscriptions, but may change exact duplicate-notification count semantics |
| PR | PR #36294 | Custom per-occurrence SpanSubscription token with weak owner reference |
✅ PASSED (Gate provided) | 2 files | Original PR; tests already proved fail-without-fix/pass-with-fix |
Cross-Pollination / Expert Review
| Model | Round | New Ideas? | Details |
|---|---|---|---|
| maui-expert-reviewer | 1 | Yes | Suggested existing weak-event proxy registrations, counted distinct-span registrations, and a static/shared span observer registry. |
| local test loop | 2 | No implemented new low-risk idea | Static/shared registry was not implemented because both proxy-based candidates passed and the registry adds static state, thread-safety risk, and broader blast radius without clear benefit for this localized leak. |
Candidate Details
try-fix-1
- Approach: Existing weak-event proxy per span occurrence.
- Test result: ✅ PASS —
FormattedStringTestsfiltered run passed 11 tests. - Diff:
CustomAgentLogsTmp/PRState/36294/PRAgent/try-fix-1/fix.diff - Assessment: Best alternative. It is demonstrably more consistent with existing MAUI weak-event infrastructure while preserving the PR's per-occurrence behavior.
try-fix-2
- Approach: Existing weak-event proxy per distinct span with occurrence counting.
- Test result: ✅ PASS —
FormattedStringTestsfiltered run passed 11 tests. - Diff:
CustomAgentLogsTmp/PRState/36294/PRAgent/try-fix-2/fix.diff - Assessment: Technically viable but less clearly compatible because it may reduce duplicate notifications from one-per-occurrence to one-per-distinct-span.
Exhausted: Yes — low-risk alternatives were tested; the remaining shared-registry idea is meaningfully different but not preferable because it introduces static/shared state and a wider failure surface.
Selected Fix: Candidate #1 — uses established MAUI weak-event proxy infrastructure and preserves the PR's observable duplicate-span semantics. It is the best alternative to consider if maintainability/style is prioritized over the PR's smaller custom wrapper.
📝 Recommended PR Title & Description
Assessment: ✏️ Recommend updating — the current description accurately explains the weak subscription leak fix, but the winning pr-plus-reviewer candidate also changes logical-child cleanup and adds regression coverage that the current metadata does not mention.
Recommended title
FormattedString: Fix memory leak caused by shared Span
Recommended description
### Issue Details:
A shared, long-lived Span keeps a strong event reference to every FormattedString that uses it. As a result, even when those FormattedString instances are no longer used, the garbage collector cannot free them, causing a memory leak that grows over time.
### Root Cause
- Element.Parent already stores its parent as a weak reference, so the logical parent relationship (span.Parent = formattedString) was not causing the memory leak.
- The actual leak came from the event subscriptions made when a Span was added to a FormattedString. In FormattedString.OnCollectionChanged, the FormattedString subscribed to the Span's PropertyChanged and PropertyChanging events.
- These event handlers held strong references back to the FormattedString. As a result, if a Span was long-lived or shared, it kept every FormattedString it had been added to alive, preventing them from being garbage collected.
- The same removal path also needs to clean up logical children from the FormattedString being modified, not from the Span's current Parent, because duplicate/shared Span scenarios can make Parent point somewhere else or become null.
### Description of Change
**FormattedString.cs**
- Replaced the direct Span event subscriptions with per-occurrence weak subscription tokens.
- Added two private nested types:
- SpanSubscription — represents a single subscription to one Span. It holds the owning FormattedString only through a WeakReference<FormattedString>, and subscribes to the span's PropertyChanging/PropertyChanged events. When an event fires, it forwards to the owner if still alive; if the owner has already been collected, it unsubscribes itself.
- SpanSubscriptions — manages a List<SpanSubscription> for the FormattedString. Each Add creates an independent subscription token, and Remove unsubscribes and removes a single matching token. This gives correct add/remove symmetry even when the same Span is added multiple times.
- When a Span is added, a new SpanSubscription is created and tracked. When a Span is removed or the collection is cleared, the corresponding subscription is unsubscribed and removed from the list.
- Logical-child cleanup now removes the old Span occurrence from the current FormattedString instead of routing through span.Parent, so duplicate/shared Span removal does not leave stale logical children or remove from another FormattedString.
- Because the WeakReference breaks the strong path from the span back to the FormattedString, a shared/long-lived Span no longer keeps discarded FormattedString instances alive.
- Added a finalizer on SpanSubscriptions that unsubscribes any remaining subscriptions as a safety net, following the same cleanup pattern used by Border.
**FormattedStringTests.cs**
- Added regression coverage for duplicate Span subscription removal, preserving one active subscription after a single duplicate removal, PropertyChanging cleanup, and collection of a FormattedString that used a shared long-lived Span.
- Added logical-child regression coverage for duplicate Span removal and shared Span removal across two FormattedString instances.
### Issues Fixed:
Fixes #36289
### Platforms Tested
- [x] Android
### Screenshots
| Before | After |
|---------|--------|
| <img width="634" height="375" src="https://github.com/user-attachments/assets/400c456e-c8ae-4c07-8856-a7d9ad9cd470"> | <img width="634" height="375" src="https://github.com/user-attachments/assets/62c8f19a-4a2b-4272-81ef-2f96df036e16"> |
🏁 Report — Final Recommendation
Comparative Fix Report — PR #36294
Candidate ranking
| Rank | Candidate | Regression result | Assessment |
|---|---|---|---|
| 1 | pr-plus-reviewer |
✅ Passed expanded focused regression suite in sandbox: 13/13 FormattedStringTests |
Best candidate. Keeps the PR's per-occurrence weak subscription behavior and applies the expert reviewer's logical-child cleanup fix (RemoveLogicalChild(bo) on this FormattedString) with targeted duplicate/shared Span assertions. |
| 2 | try-fix-1 |
✅ Passed original focused suite: 11/11; |
Good weak-event implementation because it reuses MAUI's existing WeakNotifyPropertyChangingProxy/WeakNotifyPropertyChangedProxy per span occurrence, but it does not address the expert reviewer's logical-child cleanup issue. |
| 3 | pr |
✅ Gate passed; |
Correctly breaks the shared-span event-retention leak with custom weak subscription tokens and preserves duplicate occurrence subscription semantics, but it is not merge-ready as-is because duplicate/shared spans can leave stale logical children or remove from the wrong FormattedString. |
| 4 | try-fix-2 |
✅ Passed original focused suite: 11/11; |
Viable leak fix, but weaker than try-fix-1 and pr because distinct-span occurrence counting may change duplicate notification behavior from one callback per occurrence to one callback per distinct span. |
Comparison details
pr replaces direct event subscriptions with custom per-occurrence subscription tokens that hold only a WeakReference<FormattedString> back to the owner. This addresses the reported memory leak and the supplied gate result confirms the tests fail without the fix and pass with the fix. The expert reviewer found a separate correctness issue in the same removal path: logical-child cleanup still routes through bo.Parent, which is not occurrence-safe for duplicate/shared Span instances.
pr-plus-reviewer applies the expert feedback by removing the old span occurrence from the current FormattedString's logical children instead of whatever bo.Parent currently points to. This is the only candidate that addresses both the memory leak and the logical-tree cleanup symmetry issue. It also adds regression coverage for duplicate span removal and shared span removal across two FormattedString instances.
try-fix-1 is the best STEP 5a alternative because it reuses existing MAUI weak-event proxy infrastructure and preserves per-occurrence subscription semantics. However, its diff only changes the weak-event subscription implementation and leaves the same bo.Parent?.RemoveLogicalChild(bo) cleanup in place, so it does not incorporate the expert reviewer's required fix.
try-fix-2 also uses existing weak-event proxies, but tracks one subscription per distinct Span with an occurrence count. It passed the original focused tests, but it is less behavior-preserving because duplicate span changes may produce one notification per distinct span rather than one per occurrence. Like try-fix-1, it does not fix the logical-child cleanup issue.
Winner
Winner: pr-plus-reviewer
pr-plus-reviewer is the single best candidate because it preserves the raw PR's successful weak subscription fix while correcting the reviewer-confirmed logical-child asymmetry. The STEP 5a alternatives are useful implementation references, especially try-fix-1, but neither addresses the expert review finding; try-fix-2 additionally carries a duplicate-notification behavior risk.
🧭 Next Steps — review latest findings
No alternative fix was selected for this run. Review the session findings and CI results before merging.
<!-- Please let the below note in for people that find this PR --> > [!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](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from this PR and let us know in a comment if this change resolves your issue. Thank you! ### Issue Details: A shared, long-lived Span keeps a strong event reference to every FormattedString that uses it. As a result, even when those FormattedString instances are no longer used, the garbage collector cannot free them, causing a memory leak that grows over time. ### Root Cause - Element.Parent already stores its parent as a weak reference, so the logical parent relationship (span.Parent = formattedString) was not causing the memory leak. - The actual leak came from the event subscriptions made when a Span was added to a FormattedString. In FormattedString.OnCollectionChanged, the FormattedString subscribed to the Span's PropertyChanged and PropertyChanging events. - These event handlers held strong references back to the FormattedString. As a result, if a Span was long-lived or shared, it kept every FormattedString it had been added to alive, preventing them from being garbage collected. ### Description of Change **FormattedString.cs** - Replaced the direct `Span` event subscriptions with per-occurrence weak subscription tokens. - Added two private nested types: - `SpanSubscription` — represents a single subscription to one `Span`. It holds the owning `FormattedString` only through a `WeakReference<FormattedString>`, and subscribes to the span's `PropertyChanging`/`PropertyChanged` events. When an event fires, it forwards to the owner if still alive; if the owner has already been collected, it unsubscribes itself. - `SpanSubscriptions` — manages a `List<SpanSubscription>` for the `FormattedString`. Each `Add` creates an independent subscription token, and `Remove` unsubscribes and removes a single matching token. This gives correct add/remove symmetry even when the same `Span` is added multiple times. - When a `Span` is added, a new `SpanSubscription` is created and tracked. When a `Span` is removed or the collection is cleared, the corresponding subscription is unsubscribed and removed from the list. - Because the `WeakReference` breaks the strong path from the span back to the `FormattedString`, a shared/long-lived `Span` no longer keeps discarded `FormattedString` instances alive. - Added a finalizer on `SpanSubscriptions` that unsubscribes any remaining subscriptions as a safety net, following the same cleanup pattern used by [Border](https://github.com/dotnet/maui/blob/main/src/Controls/src/Core/Border/Border.cs#L29). ### Issues Fixed: Fixes #36289 ### Screenshots | Before | After | |---------|--------| | <img width="634" height="375" src="https://github.com/user-attachments/assets/400c456e-c8ae-4c07-8856-a7d9ad9cd470"> | <img width="634" height="375" src="https://github.com/user-attachments/assets/62c8f19a-4a2b-4272-81ef-2f96df036e16"> |
<!-- Please let the below note in for people that find this PR --> > [!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](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from this PR and let us know in a comment if this change resolves your issue. Thank you! ### Issue Details: A shared, long-lived Span keeps a strong event reference to every FormattedString that uses it. As a result, even when those FormattedString instances are no longer used, the garbage collector cannot free them, causing a memory leak that grows over time. ### Root Cause - Element.Parent already stores its parent as a weak reference, so the logical parent relationship (span.Parent = formattedString) was not causing the memory leak. - The actual leak came from the event subscriptions made when a Span was added to a FormattedString. In FormattedString.OnCollectionChanged, the FormattedString subscribed to the Span's PropertyChanged and PropertyChanging events. - These event handlers held strong references back to the FormattedString. As a result, if a Span was long-lived or shared, it kept every FormattedString it had been added to alive, preventing them from being garbage collected. ### Description of Change **FormattedString.cs** - Replaced the direct `Span` event subscriptions with per-occurrence weak subscription tokens. - Added two private nested types: - `SpanSubscription` — represents a single subscription to one `Span`. It holds the owning `FormattedString` only through a `WeakReference<FormattedString>`, and subscribes to the span's `PropertyChanging`/`PropertyChanged` events. When an event fires, it forwards to the owner if still alive; if the owner has already been collected, it unsubscribes itself. - `SpanSubscriptions` — manages a `List<SpanSubscription>` for the `FormattedString`. Each `Add` creates an independent subscription token, and `Remove` unsubscribes and removes a single matching token. This gives correct add/remove symmetry even when the same `Span` is added multiple times. - When a `Span` is added, a new `SpanSubscription` is created and tracked. When a `Span` is removed or the collection is cleared, the corresponding subscription is unsubscribed and removed from the list. - Because the `WeakReference` breaks the strong path from the span back to the `FormattedString`, a shared/long-lived `Span` no longer keeps discarded `FormattedString` instances alive. - Added a finalizer on `SpanSubscriptions` that unsubscribes any remaining subscriptions as a safety net, following the same cleanup pattern used by [Border](https://github.com/dotnet/maui/blob/main/src/Controls/src/Core/Border/Border.cs#L29). ### Issues Fixed: Fixes #36289 ### Screenshots | Before | After | |---------|--------| | <img width="634" height="375" src="https://github.com/user-attachments/assets/400c456e-c8ae-4c07-8856-a7d9ad9cd470"> | <img width="634" height="375" src="https://github.com/user-attachments/assets/62c8f19a-4a2b-4272-81ef-2f96df036e16"> |
<!-- Please let the below note in for people that find this PR --> > [!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](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from this PR and let us know in a comment if this change resolves your issue. Thank you! ### Issue Details: A shared, long-lived Span keeps a strong event reference to every FormattedString that uses it. As a result, even when those FormattedString instances are no longer used, the garbage collector cannot free them, causing a memory leak that grows over time. ### Root Cause - Element.Parent already stores its parent as a weak reference, so the logical parent relationship (span.Parent = formattedString) was not causing the memory leak. - The actual leak came from the event subscriptions made when a Span was added to a FormattedString. In FormattedString.OnCollectionChanged, the FormattedString subscribed to the Span's PropertyChanged and PropertyChanging events. - These event handlers held strong references back to the FormattedString. As a result, if a Span was long-lived or shared, it kept every FormattedString it had been added to alive, preventing them from being garbage collected. ### Description of Change **FormattedString.cs** - Replaced the direct `Span` event subscriptions with per-occurrence weak subscription tokens. - Added two private nested types: - `SpanSubscription` — represents a single subscription to one `Span`. It holds the owning `FormattedString` only through a `WeakReference<FormattedString>`, and subscribes to the span's `PropertyChanging`/`PropertyChanged` events. When an event fires, it forwards to the owner if still alive; if the owner has already been collected, it unsubscribes itself. - `SpanSubscriptions` — manages a `List<SpanSubscription>` for the `FormattedString`. Each `Add` creates an independent subscription token, and `Remove` unsubscribes and removes a single matching token. This gives correct add/remove symmetry even when the same `Span` is added multiple times. - When a `Span` is added, a new `SpanSubscription` is created and tracked. When a `Span` is removed or the collection is cleared, the corresponding subscription is unsubscribed and removed from the list. - Because the `WeakReference` breaks the strong path from the span back to the `FormattedString`, a shared/long-lived `Span` no longer keeps discarded `FormattedString` instances alive. - Added a finalizer on `SpanSubscriptions` that unsubscribes any remaining subscriptions as a safety net, following the same cleanup pattern used by [Border](https://github.com/dotnet/maui/blob/main/src/Controls/src/Core/Border/Border.cs#L29). ### Issues Fixed: Fixes #36289 ### Screenshots | Before | After | |---------|--------| | <img width="634" height="375" src="https://github.com/user-attachments/assets/400c456e-c8ae-4c07-8856-a7d9ad9cd470"> | <img width="634" height="375" src="https://github.com/user-attachments/assets/62c8f19a-4a2b-4272-81ef-2f96df036e16"> |
<!-- Please let the below note in for people that find this PR --> > [!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](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from this PR and let us know in a comment if this change resolves your issue. Thank you! ### Issue Details: A shared, long-lived Span keeps a strong event reference to every FormattedString that uses it. As a result, even when those FormattedString instances are no longer used, the garbage collector cannot free them, causing a memory leak that grows over time. ### Root Cause - Element.Parent already stores its parent as a weak reference, so the logical parent relationship (span.Parent = formattedString) was not causing the memory leak. - The actual leak came from the event subscriptions made when a Span was added to a FormattedString. In FormattedString.OnCollectionChanged, the FormattedString subscribed to the Span's PropertyChanged and PropertyChanging events. - These event handlers held strong references back to the FormattedString. As a result, if a Span was long-lived or shared, it kept every FormattedString it had been added to alive, preventing them from being garbage collected. ### Description of Change **FormattedString.cs** - Replaced the direct `Span` event subscriptions with per-occurrence weak subscription tokens. - Added two private nested types: - `SpanSubscription` — represents a single subscription to one `Span`. It holds the owning `FormattedString` only through a `WeakReference<FormattedString>`, and subscribes to the span's `PropertyChanging`/`PropertyChanged` events. When an event fires, it forwards to the owner if still alive; if the owner has already been collected, it unsubscribes itself. - `SpanSubscriptions` — manages a `List<SpanSubscription>` for the `FormattedString`. Each `Add` creates an independent subscription token, and `Remove` unsubscribes and removes a single matching token. This gives correct add/remove symmetry even when the same `Span` is added multiple times. - When a `Span` is added, a new `SpanSubscription` is created and tracked. When a `Span` is removed or the collection is cleared, the corresponding subscription is unsubscribed and removed from the list. - Because the `WeakReference` breaks the strong path from the span back to the `FormattedString`, a shared/long-lived `Span` no longer keeps discarded `FormattedString` instances alive. - Added a finalizer on `SpanSubscriptions` that unsubscribes any remaining subscriptions as a safety net, following the same cleanup pattern used by [Border](https://github.com/dotnet/maui/blob/main/src/Controls/src/Core/Border/Border.cs#L29). ### Issues Fixed: Fixes #36289 ### Screenshots | Before | After | |---------|--------| | <img width="634" height="375" src="https://github.com/user-attachments/assets/400c456e-c8ae-4c07-8856-a7d9ad9cd470"> | <img width="634" height="375" src="https://github.com/user-attachments/assets/62c8f19a-4a2b-4272-81ef-2f96df036e16"> |
<!-- Please let the below note in for people that find this PR --> > [!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](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from this PR and let us know in a comment if this change resolves your issue. Thank you! ### Issue Details: A shared, long-lived Span keeps a strong event reference to every FormattedString that uses it. As a result, even when those FormattedString instances are no longer used, the garbage collector cannot free them, causing a memory leak that grows over time. ### Root Cause - Element.Parent already stores its parent as a weak reference, so the logical parent relationship (span.Parent = formattedString) was not causing the memory leak. - The actual leak came from the event subscriptions made when a Span was added to a FormattedString. In FormattedString.OnCollectionChanged, the FormattedString subscribed to the Span's PropertyChanged and PropertyChanging events. - These event handlers held strong references back to the FormattedString. As a result, if a Span was long-lived or shared, it kept every FormattedString it had been added to alive, preventing them from being garbage collected. ### Description of Change **FormattedString.cs** - Replaced the direct `Span` event subscriptions with per-occurrence weak subscription tokens. - Added two private nested types: - `SpanSubscription` — represents a single subscription to one `Span`. It holds the owning `FormattedString` only through a `WeakReference<FormattedString>`, and subscribes to the span's `PropertyChanging`/`PropertyChanged` events. When an event fires, it forwards to the owner if still alive; if the owner has already been collected, it unsubscribes itself. - `SpanSubscriptions` — manages a `List<SpanSubscription>` for the `FormattedString`. Each `Add` creates an independent subscription token, and `Remove` unsubscribes and removes a single matching token. This gives correct add/remove symmetry even when the same `Span` is added multiple times. - When a `Span` is added, a new `SpanSubscription` is created and tracked. When a `Span` is removed or the collection is cleared, the corresponding subscription is unsubscribed and removed from the list. - Because the `WeakReference` breaks the strong path from the span back to the `FormattedString`, a shared/long-lived `Span` no longer keeps discarded `FormattedString` instances alive. - Added a finalizer on `SpanSubscriptions` that unsubscribes any remaining subscriptions as a safety net, following the same cleanup pattern used by [Border](https://github.com/dotnet/maui/blob/main/src/Controls/src/Core/Border/Border.cs#L29). ### Issues Fixed: Fixes #36289 ### Screenshots | Before | After | |---------|--------| | <img width="634" height="375" src="https://github.com/user-attachments/assets/400c456e-c8ae-4c07-8856-a7d9ad9cd470"> | <img width="634" height="375" src="https://github.com/user-attachments/assets/62c8f19a-4a2b-4272-81ef-2f96df036e16"> |
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!
Issue Details:
A shared, long-lived Span keeps a strong event reference to every FormattedString that uses it. As a result, even when those FormattedString instances are no longer used, the garbage collector cannot free them, causing a memory leak that grows over time.
Root Cause
Description of Change
FormattedString.cs
Replaced the direct
Spanevent subscriptions with per-occurrence weak subscription tokens.Added two private nested types:
SpanSubscription— represents a single subscription to oneSpan. It holds the owningFormattedStringonly through aWeakReference<FormattedString>, and subscribes to the span'sPropertyChanging/PropertyChangedevents. When an event fires, it forwards to the owner if still alive; if the owner has already been collected, it unsubscribes itself.SpanSubscriptions— manages aList<SpanSubscription>for theFormattedString. EachAddcreates an independent subscription token, andRemoveunsubscribes and removes a single matching token. This gives correct add/remove symmetry even when the sameSpanis added multiple times.When a
Spanis added, a newSpanSubscriptionis created and tracked. When aSpanis removed or the collection is cleared, the corresponding subscription is unsubscribed and removed from the list.Because the
WeakReferencebreaks the strong path from the span back to theFormattedString, a shared/long-livedSpanno longer keeps discardedFormattedStringinstances alive.Added a finalizer on
SpanSubscriptionsthat unsubscribes any remaining subscriptions as a safety net, following the same cleanup pattern used by Border.Issues Fixed:
Fixes #36289
Screenshots