Skip to content

[leak-fix] Fix Picker.ItemsSource memory leak (Fixes #36272) - #36274

Closed
kubaflo wants to merge 146 commits into
dotnet:inflight/currentfrom
kubaflo:promote/picker-itemssource-leak
Closed

[leak-fix] Fix Picker.ItemsSource memory leak (Fixes #36272)#36274
kubaflo wants to merge 146 commits into
dotnet:inflight/currentfrom
kubaflo:promote/picker-itemssource-leak

Conversation

@kubaflo

@kubaflo kubaflo commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

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 an AI-driven memory-leak workflow and verified locally. Please review carefully before merging.

Fixes #36272

The leak

Picker.ItemsSource subscribes the Picker to the assigned collection CollectionChanged event with a plain, non-weak delegate (src/Controls/src/Core/Picker/Picker.cs), detaching only when ItemsSource is reassigned. A shared or long-lived collection, such as a view-model ObservableCollection, therefore strongly roots every Picker bound to it and, through it, the parent visual tree or page.

The fix

Route the ItemsSource CollectionChanged subscription through a WeakNotifyCollectionChangedProxy owned by a private, lazily-created ItemsSourceCollectionChangedSubscription helper. The helper finalizer unsubscribes the proxy when the owner is collected, so a shared collection no longer roots the Picker without adding a public Picker finalizer or changing the public API surface. The same helper and proxy are reused across clear, reassignment, and handler detach/reattach cycles; deterministic unsubscribe suppresses finalization while inactive, and the next subscription re-registers it.

Collection updates remain active before the first handler and pause only after explicit handler detachment. The first handler attach avoids a redundant rebuild for observable sources, while non-observable sources are still refreshed in case they changed in place. Reattachment rebuilds display items through selected-item-aware reconciliation, preserving a selected item that still exists and clearing selection when the selected item was removed while detached instead of selecting the item now occupying its stale index. Reattachment also applies a pending SelectedIndex before normal reconciliation when an empty source is populated while detached; mapper-order coverage verifies the automatic bindable-property update sends SelectedIndex after Items.

Regression tests

  • PickerItemsSourceDoesNotLeak verifies the Picker, subscription helper, and weak proxy are collected while the shared collection remains alive.
  • PickerItemsSourceChangesStillApplyAfterGc verifies a live Picker continues processing observable collection changes after forced GC.
  • PickerItemsSourceClearReusesCollectionChangedSubscription verifies clearing and replacing ItemsSource intentionally reuses the same detached helper and proxy, exercising finalizer suppression/re-registration while ignoring the old source.
  • ReusedPickerItemsSourceSubscriptionDoesNotLeak verifies a helper re-registered after reuse still finalizes and detaches when its Picker is collected.
  • PickerItemsSourceChangesPauseWhileHandlerIsDetached verifies updates pause after explicit handler detachment and resynchronize on reattach.
  • PickerDoesNotResyncObservableItemsSourceOnFirstHandlerAttach verifies the first attach does not redundantly rebuild an already synchronized observable source.
  • PickerResyncsNonObservableItemsSourceOnFirstHandlerAttach preserves refresh behavior for mutable non-observable sources.
  • PickerItemsSourceReplacementWhileHandlerIsDetachedUsesReplacementOnReattach verifies replacing the source while detached ignores the old source, catches up with changes to the replacement on reattach, and continues observing only the replacement.
  • PickerReconcilesRemovedSelectionWhenHandlerReattaches verifies removing the selected item while detached clears selection on reattach rather than selecting the stale index.
  • PickerPreservesSelectionWhenItemInsertedBeforeItWhileHandlerDetached verifies inserting an item before the selected item while detached preserves SelectedItem and shifts SelectedIndex on reattach.
  • PickerAppliesPendingSelectedIndexWhenHandlerReattaches verifies a pending index is applied after an empty source receives items while detached and that the handler maps Items before the resolved SelectedIndex.
  • PickerItemsSourceReassignmentMovesCollectionChangedSubscription verifies reassignment releases the old source while the replacement remains active.
  • PickerItemsSourceReassignmentIgnoresOldCollectionChanges verifies old source mutations are ignored and replacement source mutations still update Picker.Items.
  • PickerItemsSourceCollectionChangedHandlerIsLazy verifies the cached handler is allocated only for observable sources.

All GC-sensitive helper methods use [MethodImpl(MethodImplOptions.NoInlining)] to keep lifetime assertions deterministic.

Verified locally against this PR base (inflight/current), Controls.Core.UnitTests:

State Result
Without fix (current inflight/current) ❌ FAIL — Picker should not be alive!
With fix ✅ all 73 PickerTests passed

Scope

Managed cross-platform change for all platforms. No public API additions.

Route the ItemsSource CollectionChanged subscription through a WeakNotifyCollectionChangedProxy, torn down via a ~Picker finalizer, so a shared/long-lived collection no longer roots every Picker it is assigned to. Adds a Controls.Core.UnitTests regression test that fails without this fix.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

🚀 Dogfood this PR with:

⚠️ WARNING: Do not do this without first carefully reviewing the code of this PR to satisfy yourself it is safe.

curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 36274

Or

  • Run remotely in PowerShell:
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 36274"

@kubaflo

This comment has been minimized.

@github-actions github-actions Bot added s/agent-review-in-progress AI review is currently running for this PR area-controls-picker Picker labels Jul 1, 2026
@MauiBot MauiBot added s/agent-gate-passed AI verified tests catch the bug (fail without fix, pass with fix) s/agent-fix-pr-picked AI could not beat the PR fix - PR is the best among all candidates s/agent-reviewed PR was reviewed by AI agent workflow (full 4-phase review) labels Jul 1, 2026
MauiBot

This comment was marked as outdated.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Fixes a managed memory-leak in Microsoft.Maui.Controls.Picker where a long-lived ItemsSource (e.g., a VM-owned ObservableCollection) could keep Picker instances alive via a strong CollectionChanged subscription.

Changes:

  • Route Picker.ItemsSource collection notifications through WeakNotifyCollectionChangedProxy instead of a direct event subscription.
  • Add a ~Picker() finalizer to ensure the weak proxy unsubscribes, preventing the proxy itself from being leaked by a long-lived collection.
  • Add a regression unit test PickerItemsSourceDoesNotLeak under the memory test category.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated no comments.

Show a summary per file
File Description
src/Controls/src/Core/Picker/Picker.cs Replaces strong CollectionChanged subscription with WeakNotifyCollectionChangedProxy and adds ~Picker() to unsubscribe on finalization.
src/Controls/tests/Core.UnitTests/PickerTests.cs Adds a memory regression test ensuring a Picker is collectible even when its shared ItemsSource remains alive.
src/Controls/src/Core/PublicAPI/netstandard/PublicAPI.Unshipped.txt Records the new Picker finalizer in the netstandard API surface.
src/Controls/src/Core/PublicAPI/net/PublicAPI.Unshipped.txt Records the new Picker finalizer in the net API surface.
src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt Records the new Picker finalizer in the Android API surface.
src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt Records the new Picker finalizer in the iOS API surface.
src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt Records the new Picker finalizer in the MacCatalyst API surface.
src/Controls/src/Core/PublicAPI/net-windows/PublicAPI.Unshipped.txt Records the new Picker finalizer in the Windows API surface.
src/Controls/src/Core/PublicAPI/net-tizen/PublicAPI.Unshipped.txt Records the new Picker finalizer in the Tizen API surface.

@MauiBot

This comment has been minimized.

MauiBot

This comment was marked as outdated.

Copilot AI review requested due to automatic review settings July 8, 2026 13:40
@kubaflo

kubaflo commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator Author

🔍 AI-generated review follow-up (GitHub Copilot CLI, on behalf of @kubaflo)

@MauiBot Review 4698928434 evaluated current head 6ce9a004596 and found 0 errors, 0 warnings, and 0 suggestions. It confirms the prior detached-handler selection, pending-index, reassignment, and lazy-subscription concerns are fixed. The gate passes 73/73 PR-focused tests, local focused Picker validation passes 120/120, and the submitted private helper/proxy implementation was selected over both broader alternatives.

No code change or no-op commit is warranted. The [leak-fix] title prefix is intentionally retained for the coordinated leak-fix campaign and monitoring. The branch is ready for re-review.

@kubaflo

This comment has been minimized.

@kubaflo

kubaflo commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines will not run the associated pipelines, because the pull request was updated after the run command was issued. Review the pull request again and issue a new run command.

@kubaflo

kubaflo commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines will not run the associated pipelines, because the pull request was updated after the run command was issued. Review the pull request again and issue a new run command.

@kubaflo

kubaflo commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines will not run the associated pipelines, because the pull request was updated after the run command was issued. Review the pull request again and issue a new run command.

@kubaflo

This comment has been minimized.

@MauiBot

This comment has been minimized.

@MauiBot

This comment has been minimized.

Reconcile the force-rebased target while preserving only the validated Picker leak fix and regression coverage.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: bbcdaa9d-d75a-4e3b-b375-cf827a689644
@kubaflo

kubaflo commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

🔍 AI-generated update (GitHub Copilot CLI, on behalf of @kubaflo): Resolved the conflicts against the current inflight/current tip in merge commit 4e7af4566da. The reconciled PR remains limited to Picker.cs and PickerTests.cs (435 additions, 11 deletions), GitHub now reports it as mergeable, and all 73 focused PickerTests pass.

@kubaflo

This comment has been minimized.

MauiBot

This comment was marked as outdated.

@kubaflo

kubaflo commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author

🔍 AI-generated review follow-up (GitHub Copilot CLI, on behalf of @kubaflo) @MauiBot Review 4709220883 analyzed current head 4e7af4566da and reports 0 errors, 0 warnings, 0 suggestions, and no inline findings, with the submitted PR selected as the winning implementation. The gate is inconclusive only because its synthetic without-fix checkout used main and removed the unrelated BooleanBoxes source while leaving BooleanBoxesTests.cs, causing compile errors. The review separately records the relevant actual-base evidence against inflight/current: without the fix the Picker remains alive, while all 73 focused PickerTests pass with the fix. No code change is warranted; full merge CI is being queued.

@kubaflo

kubaflo commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).

@kubaflo

This comment has been minimized.

MauiBot

This comment was marked as outdated.

@kubaflo

kubaflo commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author

🔍 AI-generated review follow-up (GitHub Copilot CLI, on behalf of @kubaflo)

MauiBot review #36274 (review) was submitted against the current head 4e7af4566da7fec2ab40090df4cd34832adbb016. It reports Gate PASSED, 73/73 Picker unit tests passed, 54/54 deep Picker UI tests passed, the submitted PR selected as the winning candidate, and 0 errors / 0 warnings / 0 suggestions. No inline findings or unresolved threads were created.

The NEEDS_DISCUSSION/low-confidence label is solely because the reviewer environment could not authenticate gh to inspect required checks; it did not identify a code defect. No code change or thread resolution is required, and I am not posting another /review -b improved-reviewer because this is already a clean current-head review with no new commit.

@kubaflo

This comment has been minimized.

@kubaflo

kubaflo commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author

🔍 AI-generated merge-readiness analysis (GitHub Copilot CLI, on behalf of @kubaflo)

The PR code is good for merge; CI is blocked by the broken inflight/current base branch, not by the Picker changes.

The PR builds 1511588, 1511590, and 1511589 have the same failure set as independent builds of exact base commit 431890bd254c03ea3c391a9f2e82cfdbcbd02d0d: 1511352, 1511355, and 1511354.

The matching base failures are solution-wide SimpleTemplateTest.cs compile errors, PublicAPI errors, and identical Helix/integration failures. None touch Picker.cs or PickerTests.cs. The current-head review separately reported zero findings, 73/73 Picker unit tests passed, and 54/54 deep Picker UI tests passed.

No Picker code change is required. Green CI is impossible until inflight/current itself is repaired or the PR is moved to a healthy base.

@github-actions

Copy link
Copy Markdown
Contributor

Tests Failure Analysis

@kubaflo — test-failure review results are available based on commit 4e7af45.
To request a fresh review after new comments, commits, or CI runs, comment /review tests.

Overall Not ready Failures 173 Regressed vs base 1 Baseline 20 on base

Test Failure Review: Not ready - click to expand

Overall verdict: Not ready. Exactly one failure is a deterministic regression vs the base branch (inflight/current, 5 recent base builds sampled per definition) — a NullReferenceException in DatePickerOpenedAndClosedEventsAreRaised that is green on every sampled base build and red only on this PR — which is directly plausible given this PR rewires Picker.ItemsSource. The remaining ~172 failures could not be attributed deterministically (leg-only flaky-on-base, unexplained build legs, or device-test results that XHarness cannot confirm), so they are neither provably PR-caused nor safely dismissible; 20 distinct failures also appear on base.

  • ✗ PR-related — DatePicker event regression (~1 test): DatePickerOpenedAndClosedEventsAreRaised throws System.NullReferenceException, green on all 5 sampled base builds and red only here — a deterministic regression that overlaps this PR's Picker.ItemsSource change area.
  • i Uncertain — Unexplained/aborted build & device legs (~73 legs + 3 device-test-unverified): 73 failed build legs (Integration Tests, Helix, RunOniOS, PublishTestResults) yielded no extractable failure, 3 green device-test checks could not be confirmed Failed==0, and a Helix work item (com.microsoft.maui.controls.devicetests-Signed) crashed before completing.
  • i Uncertain — Flaky/indeterminate tests, also-red on base (~90 tests): a large iOS FlyoutHeaderContentAndFooterAllMeasureCorrectly measurement family plus Windows visual-snapshot tests (e.g. VerifyEditorWhenHeightRequestSet) and the Android platform-tools;35.0.2 provisioning error are all also red on base but marked indeterminate, so they cannot be dismissed as pre-existing.

Coverage: 72 checks · 22 passing · 50 failing · 0 pending · 0 inaccessible · 1 unmapped · 73 unexplained build legs · 0 unaccounted failing checks · 0 aborted failing checks · 0 canceled-build checks · 3 device-test unverified · 172 unattributed · 1 regressed-vs-base. Deterministic ceiling: Not ready — 1 leg is a deterministic regression vs base (DatePickerOpenedAndClosedEventsAreRaised) and 73 failed legs produced no extractable failure.

Builds (this PR): maui-pr 1511588, maui-pr-devicetests 1511590, maui-pr-uitests 1511589. Base sampling (inflight/current, 5 recent builds per definition): 1511352, 1511355, 1511354.

Recommended action

Investigate the DatePickerOpenedAndClosedEventsAreRaised NullReferenceException — it is a real regression vs base and overlaps this PR's Picker.ItemsSource change; then have a human confirm the 3 unverified device-test checks and the 73 unexplained build legs before merging.

@kubaflo

kubaflo commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author

🔍 AI-generated follow-up analysis (GitHub Copilot CLI, on behalf of @kubaflo)

The automated test-analysis comment’s DatePickerOpenedAndClosedEventsAreRaised classification is a false correlation; no Picker code change is required.

  • This exact Windows NullReferenceException test failure predates this PR and is tracked by [ci-scan-net11] [maui-pr-uitests] DatePickerOpenedAndClosedEventsAreRaised NullReferenceException on Windows (WinUI) #35967 from builds 1466520 and 1466210. It was closed after a later pass, confirming intermittent behavior rather than a product fix.
  • In this PR’s UI run 41647014, the failure artifact shows the DatePicker calendar already open and the Opened label successfully updated. The exception occurs afterward in generic Appium lookup code while App.Tap("16") calls FindElement(...).Click() and transiently receives no element, despite the visible 16 calendar item.
  • The PR diff changes only Picker.cs and PickerTests.cs. DatePicker is a separate control and handler and does not use Picker.ItemsSource or the changed subscription path.

Five recent base passes do not turn a previously documented intermittent Appium lookup failure into a deterministic regression. This failure is unrelated to #36274; the earlier merge-readiness verdict remains unchanged.

@kubaflo

This comment has been minimized.

@MauiBot MauiBot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

AI Review Summary

@kubaflo — new AI review results are available based on this last commit: 91554e0. To request a fresh review after new comments or commits, comment /review rerun.

Gate Passed Confidence Low Platform Android


🗂️ Review Sessions — click to expand
🚦 Gate — Test Before & After Fix

Gate Result: ✅ PASSED

Platform: ANDROID · Base: inflight/current · Merge base: 431890bd

Test Without Fix (expect FAIL) With Fix (expect PASS)
🧪 PickerTests PickerTests ✅ FAIL — 160s ✅ PASS — 127s
🔴 Without fix — 🧪 PickerTests: FAIL ✅ · 160s

Error-relevant lines (filtered from the build log):

   at InvokeStub_PickerTests.TestItemsSourceCollectionChangedRemoveAtEndSelected(Object, Span`1)
   at System.Reflection.MethodBaseInvoker.InvokeWithOneArg(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
     at Microsoft.Maui.Controls.Core.UnitTests.PickerTests.PickerItemsSourceReassignmentIgnoresOldCollectionChanges() in /_/src/Controls/tests/Core.UnitTests/PickerTests.cs:line 1322
     at Microsoft.Maui.Controls.Core.UnitTests.PickerTests.PickerRetainsSelectionWhenItemsAreInsertedBeforeAndAfterSelection() in /_/src/Controls/tests/Core.UnitTests/PickerTests.cs:line 1492
     at Microsoft.Maui.Controls.Core.UnitTests.PickerTests.PickerRetainsSelectionWhenSelectedItemIsMoved() in /_/src/Controls/tests/Core.UnitTests/PickerTests.cs:line 1546
     at Microsoft.Maui.Controls.Core.UnitTests.PickerTests.PickerReconcilesRemovedSelectionWhenHandlerReattaches() in /_/src/Controls/tests/Core.UnitTests/PickerTests.cs:line 1176
     at Microsoft.Maui.Controls.Core.UnitTests.PickerTests.GetItemsSourceSubscriptionObjects(Picker picker) in /_/src/Controls/tests/Core.UnitTests/PickerTests.cs:line 1390
   at Microsoft.Maui.Controls.Core.UnitTests.PickerTests.PickerItemsSourceClearReusesCollectionChangedSubscription() in /_/src/Controls/tests/Core.UnitTests/PickerTests.cs:line 1253
     at Microsoft.Maui.Controls.Core.UnitTests.PickerTests.PickerPreservesSelectionWhenItemInsertedBeforeItWhileHandlerDetached() in /_/src/Controls/tests/Core.UnitTests/PickerTests.cs:line 1200
   at Microsoft.Maui.Controls.Core.UnitTests.PickerTests.GetItemsSourceSubscriptionReferences(Picker picker) in /_/src/Controls/tests/Core.UnitTests/PickerTests.cs:line 1382
   at Microsoft.Maui.Controls.Core.UnitTests.PickerTests.CreatePickerReferences(ObservableCollection`1 itemsSource) in /_/src/Controls/tests/Core.UnitTests/PickerTests.cs:line 1352
   at Microsoft.Maui.Controls.Core.UnitTests.PickerTests.PickerItemsSourceDoesNotLeak() in /_/src/Controls/tests/Core.UnitTests/PickerTests.cs:line 1056
     at Microsoft.Maui.Controls.Core.UnitTests.PickerTests.PickerPreservesSelectedItemAfterInsertingItemBeforeSelection() in /_/src/Controls/tests/Core.UnitTests/PickerTests.cs:line 1046
     at Microsoft.Maui.Controls.Core.UnitTests.PickerTests.TestItemsSourceCollectionChangedAppend() in /_/src/Controls/tests/Core.UnitTests/PickerTests.cs:line 477
     at Microsoft.Maui.Controls.Core.UnitTests.PickerTests.TestSelectedItemAssignedItemsSourceCollectionChangedRemove() in /_/src/Controls/tests/Core.UnitTests/PickerTests.cs:line 827
     at Microsoft.Maui.Controls.Core.UnitTests.PickerTests.SelectedItemSetAfterPendingSelectedIndexClearsPendingIndex() in /_/src/Controls/tests/Core.UnitTests/PickerTests.cs:line 275
     at Microsoft.Maui.Controls.Core.UnitTests.PickerTests.PickerItemsSourceCollectionChangedHandlerIsLazy() in /_/src/Controls/tests/Core.UnitTests/PickerTests.cs:line 1333
     at Microsoft.Maui.Controls.Core.UnitTests.PickerTests.TestSelectedIndexAssignedItemsSourceCollectionChangedAppend() in /_/src/Controls/tests/Core.UnitTests/PickerTests.cs:line 696
   at Microsoft.Maui.Controls.Core.UnitTests.PickerTests.CreateReusedPickerReferences(ObservableCollection`1 itemsSource) in /_/src/Controls/tests/Core.UnitTests/PickerTests.cs:line 1366
   at Microsoft.Maui.Controls.Core.UnitTests.PickerTests.ReusedPickerItemsSourceSubscriptionDoesNotLeak() in /_/src/Controls/tests/Core.UnitTests/PickerTests.cs:line 1278
     at Microsoft.Maui.Controls.Core.UnitTests.PickerTests.TestItemsSourceCollectionChangedInsert() in /_/src/Controls/tests/Core.UnitTests/PickerTests.cs:line 519
     at Microsoft.Maui.Controls.Core.UnitTests.PickerTests.PickerItemsSourceChangesStillApplyAfterGc() in /_/src/Controls/tests/Core.UnitTests/PickerTests.cs:line 1074
     at Microsoft.Maui.Controls.Core.UnitTests.PickerTests.TestItemsSourceCollectionChangedRemove() in /_/src/Controls/tests/Core.UnitTests/PickerTests.cs:line 569
🟢 With fix — 🧪 PickerTests: PASS ✅ · 127s

(no coded error found; showing last 1200 chars)

ceClampsAfterItemsSource [< 1 ms]
  Passed SelectedItemSetAfterPendingSelectedIndexClearsPendingIndex [< 1 ms]
  Passed TestItemsSourceEnums [4 ms]
  Passed PickerItemsSourceCollectionChangedHandlerIsLazy [< 1 ms]
  Passed TestEmptyCollectionResetItems [< 1 ms]
  Passed PickerResyncsNonObservableItemsSourceOnFirstHandlerAttach [< 1 ms]
  Passed PickerRetainsSelectionWhenDuplicateSelectedItemIsRemoved [< 1 ms]
  Passed SelectedIndexSetBeforeInlineItemsSurvivesHandlerAttachmentUntilIndexBecomesValid [114 ms]
  Passed TestSetSelectedIndexOnNullRows [< 1 ms]
  Passed TestSelectedIndexAssignedItemsSourceCollectionChangedAppend [< 1 ms]
  Passed TestSelectedItemDefault [< 1 ms]
  Passed ReusedPickerItemsSourceSubscriptionDoesNotLeak [34 ms]
  Passed TestItemsSourceCollectionChangedInsert [1 ms]
  Passed PickerItemsSourceChangesStillApplyAfterGc [28 ms]
  Passed PendingSelectedIndexCanBeClearedBeforeItemsLoad [< 1 ms]
  Passed TestUnsubscribeINotifyCollectionChanged [< 1 ms]
[xUnit.net 00:00:02.96]   Finished:    Microsoft.Maui.Controls.Core.UnitTests
  Passed TestItemsSourceCollectionChangedRemove [< 1 ms]

Test Run Successful.
Total tests: 73
     Passed: 73
 Total time: 3.7711 Seconds

📁 Fix files reverted (1 files)
  • src/Controls/src/Core/Picker/Picker.cs

📱 UI Tests — Picker

Detected UI test categories: Picker

⚠️ Deep UI tests — The deep UI run for 1 category (Picker) started (the HostApp built and dotnet test launched) but produced no results — the app failed to launch or the test host crashed before a .trx was written (an app-launch/Appium failure or an emulator flake). These categories exited quickly, well under their time budget, so raising the budget will not help — re-run the review to try again. If it recurs, check the build-output.log + logcat in the drop-deep-uitests artifact for an app-startup crash.


📋 Pre-Flight — Context & Validation

Issue: #36272 - Picker.ItemsSource: a shared/long-lived collection leaks every Picker via a non-weak CollectionChanged subscription (no teardown on unload)
PR: #36274 - [leak-fix] Fix Picker.ItemsSource memory leak (Fixes #36272)
Platforms Affected: all platforms (managed cross-platform Controls code); requested test platform: android, but regression coverage is Controls.Core.UnitTests
Files Changed: 1 implementation, 1 test

Key Findings

  • Picker.ItemsSource previously subscribed directly to INotifyCollectionChanged.CollectionChanged, so a long-lived/shared observable collection could strongly retain each assigned Picker until reassignment or collection disposal.
  • The PR's current fix routes the observable subscription through a private weak-proxy helper and adds memory/lifecycle unit tests for leak, reassignment, handler detach/reattach, and selection reconciliation.
  • GitHub CLI is unauthenticated in this environment, so PR metadata came from the public GitHub API and required-check classification remains undetermined.

Code Review Summary

Verdict: NEEDS_DISCUSSION
Confidence: low
Errors: 0 | Warnings: 0 | Suggestions: 0

Key code review findings:

  • ℹ No actionable current code defects were found by the code-review pass.
  • ℹ Prior error-level findings seen in PR discussion appear addressed in the current two-file diff: no PublicAPI changes remain, handler reattach reconciles selection, and pending SelectedIndex is mapped after item rebuild.
  • ℹ CI/required checks could not be classified because gh is unauthenticated; confidence is capped low.

Fix Candidates

# Source Approach Test Result Files Changed Notes
PR PR #36274 Private lazily-created ItemsSourceCollectionChangedSubscription owns a WeakNotifyCollectionChangedProxy, unsubscribes deterministically on source changes/detach, and finalizes stale subscriptions so the source does not root Picker. ✅ PASSED (Gate) src/Controls/src/Core/Picker/Picker.cs, src/Controls/tests/Core.UnitTests/PickerTests.cs Original PR; gate supplied by caller.

🔬 Code Review — Deep Analysis

Code Review — PR #36274

Independent Assessment

What this changes: Picker.ItemsSource now uses a private weak CollectionChanged subscription helper so long-lived observable sources do not keep Picker instances alive. It also adjusts handler attach/detach resync behavior and adds Picker unit/memory tests.

Inferred motivation: Fix a memory leak while preserving item/selection synchronization across handler lifecycle transitions.

Reconciliation with PR Narrative

Author claims: Fixes #36272 via a lazy weak-proxy helper, no public API change, with regression coverage for leak, reassignment, detach/reattach, and selection reconciliation.

Agreement/disagreement: The claims match the current two-file diff.

Prior Review Reconciliation

Prior ❌ Error Finding Source Status Evidence
Duplicate PublicAPI entries / public finalizer mismatch Copilot inline ✅ Fixed Current diff only changes Picker.cs and PickerTests.cs; no PublicAPI files.
Detached handler reattach could select stale item MauiBot [major] ✅ Fixed ResyncItemsAndReconcileSelection() is used on reattach; tests cover removed selected item at PickerTests.cs:1156-1178.
Pending SelectedIndex not mapped after Items update MauiBot [major] ✅ Fixed/covered PickerAppliesPendingSelectedIndexWhenHandlerReattaches asserts mapper order at PickerTests.cs:1204-1244; stub mapper records updates at PickerTests.cs:1403-1423.

Blast Radius Assessment

  • Runs for all instances: Only Picker instances with ItemsSource; weak helper allocated only for observable sources.
  • Startup impact: No.
  • Static/shared state: No.

CI Status

  • Required-check result: unavailable/undetermined — gh pr checks --required failed because GitHub CLI is unauthenticated.
  • Public check-run result: failing snapshot via public API; failures not attributable from available public metadata.
  • Classification: undetermined.
  • Action taken: public API fallback; confidence capped low; no GitHub comments posted.

Findings

No high-confidence ❌ Error, ⚠️ Warning, or 💡 Suggestion findings in the current PR diff.

Failure-Mode Probing

  • Shared collection outlives Picker: weak proxy/helper avoids rooting; memory tests cover this.
  • Handler detaches then source mutates: subscription pauses, reattach resyncs and reconciles selection.
  • ItemsSource reassignment: old source unsubscribed/collectible; replacement remains active.
  • Non-observable source changes before first attach: first attach resyncs.
  • Null/default source: subscribe path no-ops; clear path safely unlocks and clears.

Verdict: NEEDS_DISCUSSION

Confidence: low
Summary: Code review found no current actionable code defects, but CI is red/undetermined and required-check status is unavailable without gh auth. Skill rules prohibit LGTM until CI is green or failures are classified PR-unrelated.


🛠️ Fix — Analysis & Comparison

Fix Candidates

# Source Approach Test Result Files Changed Notes
1 maui-expert-reviewer Reuse existing ListProxy as the weak subscription owner for Picker.ItemsSource. ❌ Fail 1 implementation file 9 passed / 3 failed. Failed because current PR regression tests intentionally assert the private helper/proxy shape.
2 maui-expert-reviewer Use a static per-source weak broker keyed by INotifyCollectionChanged, with weak Picker subscribers and behavioral test rewrites. ✅ Pass 1 implementation file, 1 test file 12 passed / 0 failed, but more complex/global and not demonstrably better than PR.
3 maui-expert-reviewer Use direct strong CollectionChanged subscription scoped only to handler lifetime, with reattach resync. ❌ Fail 1 implementation file 5 passed / 7 failed. Handlerless observable changes stale and lifecycle boundary is too weak.
4 maui-expert-reviewer Store weak proxy directly on Picker and make Picker finalizable for cleanup. ❌ Fail 1 implementation file Build failed with Public API analyzer RS0016 for Picker.~Picker().
PR PR #36274 Private lazily-created ItemsSourceCollectionChangedSubscription owns WeakNotifyCollectionChangedProxy, finalizes cleanup, pauses on handler detach, and resyncs/reconciles on reattach. ✅ PASSED (Gate) 1 implementation file, 1 test file Original PR; gate result supplied by caller.

Cross-Pollination

Model/Reviewer Round New Ideas? Details
maui-expert-reviewer 1 Yes Suggested ListProxy ownership to reuse existing weak collection proxy infrastructure.
maui-expert-reviewer 2 Yes After ListProxy failed, suggested per-source weak broker with weak Picker references.
maui-expert-reviewer 3 Yes After broker passed but was not better, suggested handler-lifetime direct subscription as a simpler lifecycle-scoped alternative.
maui-expert-reviewer 4 Yes Suggested direct Picker-owned weak-proxy finalization; testing confirmed PublicAPI/analyzer downside.
maui-expert-reviewer 5 No Remaining meaningful axes were exhausted: proxy owner (ListProxy, broker, Picker, private helper) and lifetime boundary (weak event vs handler lifetime).

Exhausted: Yes
Selected Fix: PR #36274 — It is the best candidate. It passes the supplied gate, keeps the fix localized to Picker, avoids static/global broker bookkeeping, preserves handlerless and detach/reattach synchronization semantics, and avoids adding a public Picker finalizer/API surface.

Candidate Notes

  • try-fix-1/content.md records the ListProxy attempt and failure analysis.
  • try-fix-2/content.md records the broker attempt; it passed focused tests but required test rewrites and was not better.
  • try-fix-3/content.md records the handler-lifetime attempt and behavioral failures.
  • try-fix-4/content.md records the Picker-finalizer attempt and PublicAPI analyzer failure.

📝 Recommended PR Title & Description

Assessment: ✏️ Recommend updating — the current description is accurate and thorough, but the title uses a workflow/noise prefix instead of the repo's [Scope] Component: What changed style.

Recommended title

[Controls] Picker: Fix ItemsSource memory leak

Recommended description

> [!NOTE]
> 🔍 **AI-generated PR.** This fix and its regression tests were produced by an AI-driven memory-leak workflow and verified locally. Please review carefully before merging.

Fixes #36272

### The leak

`Picker.ItemsSource` subscribes the `Picker` to the assigned collection `CollectionChanged` event with a plain, non-weak delegate (`src/Controls/src/Core/Picker/Picker.cs`), detaching only when `ItemsSource` is reassigned. A shared or long-lived collection, such as a view-model `ObservableCollection`, therefore strongly roots every `Picker` bound to it and, through it, the parent visual tree or page.

### The fix

Route the `ItemsSource` `CollectionChanged` subscription through a `WeakNotifyCollectionChangedProxy` owned by a private, lazily-created `ItemsSourceCollectionChangedSubscription` helper. The helper finalizer unsubscribes the proxy when the owner is collected, so a shared collection no longer roots the `Picker` without adding a public `Picker` finalizer or changing the public API surface. The same helper and proxy are reused across clear, reassignment, and handler detach/reattach cycles; deterministic unsubscribe suppresses finalization while inactive, and the next subscription re-registers it.

Collection updates remain active before the first handler and pause only after explicit handler detachment. The first handler attach avoids a redundant rebuild for observable sources, while non-observable sources are still refreshed in case they changed in place. Reattachment rebuilds display items through selected-item-aware reconciliation, preserving a selected item that still exists and clearing selection when the selected item was removed while detached instead of selecting the item now occupying its stale index. Reattachment also applies a pending `SelectedIndex` before normal reconciliation when an empty source is populated while detached; mapper-order coverage verifies the automatic bindable-property update sends `SelectedIndex` after `Items`.

### Regression tests

- `PickerItemsSourceDoesNotLeak` verifies the `Picker`, subscription helper, and weak proxy are collected while the shared collection remains alive.
- `PickerItemsSourceChangesStillApplyAfterGc` verifies a live `Picker` continues processing observable collection changes after forced GC.
- `PickerItemsSourceClearReusesCollectionChangedSubscription` verifies clearing and replacing `ItemsSource` intentionally reuses the same detached helper and proxy, exercising finalizer suppression/re-registration while ignoring the old source.
- `ReusedPickerItemsSourceSubscriptionDoesNotLeak` verifies a helper re-registered after reuse still finalizes and detaches when its `Picker` is collected.
- `PickerItemsSourceChangesPauseWhileHandlerIsDetached` verifies updates pause after explicit handler detachment and resynchronize on reattach.
- `PickerDoesNotResyncObservableItemsSourceOnFirstHandlerAttach` verifies the first attach does not redundantly rebuild an already synchronized observable source.
- `PickerResyncsNonObservableItemsSourceOnFirstHandlerAttach` preserves refresh behavior for mutable non-observable sources.
- `PickerItemsSourceReplacementWhileHandlerIsDetachedUsesReplacementOnReattach` verifies replacing the source while detached ignores the old source, catches up with changes to the replacement on reattach, and continues observing only the replacement.
- `PickerReconcilesRemovedSelectionWhenHandlerReattaches` verifies removing the selected item while detached clears selection on reattach rather than selecting the stale index.
- `PickerPreservesSelectionWhenItemInsertedBeforeItWhileHandlerDetached` verifies inserting an item before the selected item while detached preserves `SelectedItem` and shifts `SelectedIndex` on reattach.
- `PickerAppliesPendingSelectedIndexWhenHandlerReattaches` verifies a pending index is applied after an empty source receives items while detached and that the handler maps `Items` before the resolved `SelectedIndex`.
- `PickerItemsSourceReassignmentMovesCollectionChangedSubscription` verifies reassignment releases the old source while the replacement remains active.
- `PickerItemsSourceReassignmentIgnoresOldCollectionChanges` verifies old source mutations are ignored and replacement source mutations still update `Picker.Items`.
- `PickerItemsSourceCollectionChangedHandlerIsLazy` verifies the cached handler is allocated only for observable sources.

All GC-sensitive helper methods use `[MethodImpl(MethodImplOptions.NoInlining)]` to keep lifetime assertions deterministic.

**Verified locally against this PR base (`inflight/current`), `Controls.Core.UnitTests`:**

| State | Result |
|---|---|
| Without fix (current `inflight/current`) | ❌ FAIL — `Picker should not be alive!` |
| With fix | ✅ all 73 `PickerTests` passed |

### Scope

Managed cross-platform change for all platforms. No public API additions.

🏁 Report — Final Recommendation

Comparative Fix Report — PR #36274

Candidate ranking

Rank Candidate Regression result Assessment
1 pr ✅ Gate passed Best overall. Localized private helper/proxy design fixes the leak without public API changes, static brokers, or handler-lifetime assumptions, and preserves the strongest regression coverage.
2 pr-plus-reviewer Not rerun; test-only sandbox edits Applies safe reviewer feedback, but only changes tests. Removing the identity assertions improves refactor tolerance while weakening intentional coverage of the suppress/re-register lifecycle invariant. No production benefit over pr.
3 try-fix-2 ✅ Passed focused tests Valid independent broker approach, but more complex and introduces static per-source bookkeeping. Self-review noted residual broker/weak-reference slots can remain for long-lived sources until explicit unsubscribe or pruning.
4 try-fix-1 ❌ Failed focused tests Uses ListProxy as weak subscription owner. Failed 3 regression tests and did not satisfy the current helper/proxy lifetime coverage.
5 try-fix-3 ❌ Failed focused tests Handler-lifetime strong subscription failed 7 regression tests and leaves handlerless observable updates stale. Handler lifetime is too weak a boundary for the managed ItemsSource mirror.
6 try-fix-4 ❌ Build failed Picker-owned weak proxy/finalizer failed Public API analyzer RS0016 for Picker.~Picker() and broadens finalization impact onto the public control type.

Comparative analysis

The raw PR fix is the best candidate. It addresses the root cause directly: a shared or long-lived observable collection no longer strongly roots each Picker through a direct CollectionChanged subscription. The solution stays private to Picker, uses the existing WeakNotifyCollectionChangedProxy infrastructure, keeps the weak proxy delegate alive while the Picker is live, and avoids adding a public Picker finalizer or global/static subscription broker.

pr-plus-reviewer is useful as review feedback but not as the winning candidate. The expert findings are test-maintainability concerns, not production correctness defects. The applied sandbox changes add issue-link comments and remove one pair of private identity assertions, but the removed assertions were deliberately covering finalizer suppression/re-registration reuse. That tradeoff makes the candidate less protective for this specific leak fix.

try-fix-2 is the only non-PR candidate that passed focused tests, but it is less attractive than the PR: it introduces static shared broker state keyed by source collections and retains small weak-reference bookkeeping for long-lived sources until cleanup/pruning. The PR's per-picker private helper has a smaller blast radius and simpler ownership.

The remaining try-fix candidates failed regression tests or build validation and must rank below passing candidates. try-fix-1 did not satisfy the regression suite, try-fix-3 regressed handlerless update behavior, and try-fix-4 hit the public API analyzer with a public-control finalizer.

Winner

pr wins. It passed the supplied gate, has no high-confidence production-code findings from expert review, and is simpler and more localized than the passing alternative.


🧭 Next Steps — review latest findings

No alternative fix was selected for this run. Review the session findings and CI results before merging.

@MauiBot MauiBot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Expert Review — 3 findings

See inline comments for details.


// Implementation/lifecycle invariant: reuse exercises finalizer suppression and
// re-registration on the same helper instead of masking lifecycle bugs with a new proxy.
Assert.Same(originalReferences.Subscription, reusedReferences.Subscription);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔍 AI-Generated Review (multi-model)

[moderate] Regression Prevention and Test CoverageAssert.Same(originalReferences.Subscription, reusedReferences.Subscription) / Assert.Same(originalReferences.Proxy, reusedReferences.Proxy) assert on the identity of private implementation objects (ItemsSourceCollectionChangedSubscription, WeakNotifyCollectionChangedProxy) rather than on observable behavior. The comment above (lines 1263-1264) acknowledges this is deliberate, but it still means a functionally-equivalent refactor (e.g., allocating a fresh subscription/proxy per ItemsSource reassignment instead of reusing one) would fail this test even though nothing user-visible changed. The reuse invariant this is guarding (exercising the GC.SuppressFinalize/GC.ReRegisterForFinalize cycle) is valuable, but consider expressing it as a behavioral assertion (e.g., verifying no additional allocation/leak across many reassign cycles) instead of Assert.Same on private fields, so the test survives internal refactors that preserve behavior.

static (object Subscription, object Proxy) GetItemsSourceSubscriptionObjects(Picker picker)
{
const BindingFlags flags = BindingFlags.NonPublic | BindingFlags.Instance;
var subscriptionField = typeof(Picker).GetField("_itemsSourceCollectionChangedSubscription", flags);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔍 AI-Generated Review (multi-model)

[moderate] Regression Prevention and Test Coverage — This helper (and the standalone reflection lookup of _collectionChangedEventHandler in PickerItemsSourceCollectionChangedHandlerIsLazy, ~line 1332) uses BindingFlags.NonPublic to reach into Picker._itemsSourceCollectionChangedSubscription, Picker._collectionChangedEventHandler, and the nested subscription's private _proxy field. Four tests (PickerItemsSourceClearReusesCollectionChangedSubscription, ReusedPickerItemsSourceSubscriptionDoesNotLeak, PickerItemsSourceReassignmentMovesCollectionChangedSubscription, PickerItemsSourceCollectionChangedHandlerIsLazy) are coupled to these exact private field names via string literals. A future internal rename (e.g., renaming _proxy or inlining the subscription wrapper) with no behavioral change will break these tests only through the Assert.NotNull(...Field) guard rather than a meaningful behavioral assertion. Consider verifying the 'lazy allocation' and 'no leak' invariants purely through observable behavior (collection updates propagating to picker.Items, and WeakReference-based collection of the Picker/ItemsSource, which the memory tests already do) instead of reflecting into implementation-private fields.

}

[Fact, Category(TestCategory.Memory)]
public async Task PickerItemsSourceDoesNotLeak()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔍 AI-Generated Review (multi-model)

[minor] Regression Prevention and Test Coverage — None of the new regression tests added for this fix (this method through ~line 1340) reference the originating issue in a // https://github.com/dotnet/maui/issues/NNNNN comment, unlike the established convention elsewhere in this same file (see lines 1004, 1427, 1450, 1473, 1503, 1527) where fix-specific tests are annotated with the issue link. Adding that cross-reference to at least the primary leak-repro tests (PickerItemsSourceDoesNotLeak, PickerItemsSourceChangesStillApplyAfterGc) would help future contributors locate the original repro and avoid silently reintroducing the leak.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-controls-picker Picker s/agent-fix-pr-picked AI could not beat the PR fix - PR is the best among all candidates s/agent-gate-passed AI verified tests catch the bug (fail without fix, pass with fix) s/agent-reviewed PR was reviewed by AI agent workflow (full 4-phase review)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Picker.ItemsSource: a shared/long-lived collection leaks every Picker via a non-weak CollectionChanged subscription (no teardown on unload)