You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
AI-generated by the Daily Memory Leak Hunter — dotnet/maui workflow. The finding below was confirmed empirically against the shipped Microsoft.Maui.Controls 10.0.0 package.
Description
Assigning a long-lived ICommand to BackButtonBehavior.Command installs a normal CanExecuteChanged handler. If the behavior is discarded without first clearing Command, the command retains the behavior and everything reachable from it. There is no lifecycle teardown for this subscription.
src/Controls/src/Core/Shell/BackButtonBehavior.cs:129-139 removes the handler only when the property changes, then adds the strong handler for the new command. No unload/detach path clears it.
usingSystem;usingSystem.Collections.Generic;usingSystem.Collections.ObjectModel;usingSystem.Linq;usingSystem.Runtime.CompilerServices;usingSystem.Windows.Input;usingMicrosoft.Maui.Controls;usingXunit;publicsealedclassLeakTest{constintN=30;[Fact]publicvoidBackButtonBehavior_Command_Leaks(){varcommand=newTestCommand();varcontrol=CreateBackButtonBehaviorCohort(null,mitigate:false);varleaky=CreateBackButtonBehaviorCohort(command,mitigate:false);varmitigation=CreateBackButtonBehaviorCohort(command,mitigate:true);AssertCohorts(control,leaky,mitigation,command);}[Fact]publicvoidTableView_Root_Leaks(){varroot=newTableRoot();varcontrol=CreateTableViewCohort(null,mitigate:false);varleaky=CreateTableViewCohort(root,mitigate:false);varmitigation=CreateTableViewCohort(root,mitigate:true);AssertCohorts(control,leaky,mitigation,root);}[Fact]publicvoidIndicatorView_ItemsSource_Leaks(){varitems=newObservableCollection<int>();varcontrol=CreateIndicatorViewCohort(null,mitigate:false);varleaky=CreateIndicatorViewCohort(items,mitigate:false);varmitigation=CreateIndicatorViewCohort(items,mitigate:true);AssertCohorts(control,leaky,mitigation,items);}[Fact]publicvoidPicker_ItemsSource_Leaks(){varitems=newObservableCollection<int>();varcontrol=CreatePickerCohort(null,mitigate:false);varleaky=CreatePickerCohort(items,mitigate:false);varmitigation=CreatePickerCohort(items,mitigate:true);AssertCohorts(control,leaky,mitigation,items);}staticvoidAssertCohorts(WeakReference[]control,WeakReference[]leaky,WeakReference[]mitigation,objectroot){ForceGc();Assert.Equal(0,CountAlive(control));Assert.Equal(N,CountAlive(leaky));Assert.Equal(0,CountAlive(mitigation));GC.KeepAlive(root);}[MethodImpl(MethodImplOptions.NoInlining)]staticWeakReference[]CreateBackButtonBehaviorCohort(TestCommand?command,boolmitigate){varreferences=newWeakReference[N];for(vari=0;i<N;i++){varpayload=newPayload();varbehavior=newBackButtonBehavior{BindingContext=payload};if(commandis not null)behavior.Command=command;if(mitigate)behavior.Command=null;references[i]=newWeakReference(payload);}returnreferences;}[MethodImpl(MethodImplOptions.NoInlining)]staticWeakReference[]CreateTableViewCohort(TableRoot?root,boolmitigate){varreferences=newWeakReference[N];for(vari=0;i<N;i++){varpayload=newPayload();varview=newTableView{BindingContext=payload};if(rootis not null)view.Root=root;if(mitigate)view.Root=null!;references[i]=newWeakReference(payload);}returnreferences;}[MethodImpl(MethodImplOptions.NoInlining)]staticWeakReference[]CreateIndicatorViewCohort(ObservableCollection<int>?items,boolmitigate){varreferences=newWeakReference[N];for(vari=0;i<N;i++){varpayload=newPayload();varview=newIndicatorView{BindingContext=payload};if(itemsis not null)view.ItemsSource=items;if(mitigate)view.ItemsSource=null;references[i]=newWeakReference(payload);}returnreferences;}[MethodImpl(MethodImplOptions.NoInlining)]staticWeakReference[]CreatePickerCohort(ObservableCollection<int>?items,boolmitigate){varreferences=newWeakReference[N];for(vari=0;i<N;i++){varpayload=newPayload();varpicker=newPicker{BindingContext=payload};if(itemsis not null)picker.ItemsSource=items;if(mitigate)picker.ItemsSource=null;references[i]=newWeakReference(payload);}returnreferences;}staticintCountAlive(IEnumerable<WeakReference>references)=>references.Count(reference =>reference.IsAlive);staticvoidForceGc(){for(vari=0;i<7;i++){GC.Collect();GC.WaitForPendingFinalizers();GC.Collect();}}sealedclassPayload{readonlybyte[]_bytes=newbyte[1024*1024];}sealedclassTestCommand:ICommand{publiceventEventHandler?CanExecuteChanged;publicboolCanExecute(object?parameter)=>true;publicvoidExecute(object?parameter){}publicvoidRaiseCanExecuteChanged()=>CanExecuteChanged?.Invoke(this,EventArgs.Empty);}}
Run:
cd leakprobe
dotnet test --filter BackButtonBehavior_Command_Leaks --logger "console;verbosity=normal"
Observed results
Cohort
Alive after full GC
Retained payload
Control (no command)
0 / 30
0 MB
Mitigation (Command = null)
0 / 30
0 MB
Leaky (shared command remains assigned)
30 / 30
30 MB
Impact and condition
This is purely managed code and affects all platforms. It occurs when a command outlives a discarded behavior and Command is not explicitly cleared.
Suggested fix
Use the existing weak command-subscription pattern (for example WeakCommandSubscription) or add deterministic behavior teardown that unsubscribes from CanExecuteChanged. This is a framework lifecycle leak; applications can mitigate it today by clearing Command before discarding the behavior.
Important
AI-generated by the Daily Memory Leak Hunter — dotnet/maui workflow. The finding below was confirmed empirically against the shipped
Microsoft.Maui.Controls10.0.0 package.Description
Assigning a long-lived
ICommandtoBackButtonBehavior.Commandinstalls a normalCanExecuteChangedhandler. If the behavior is discarded without first clearingCommand, the command retains the behavior and everything reachable from it. There is no lifecycle teardown for this subscription.Retention path
shared ICommand -> CanExecuteChanged invocation list -> BackButtonBehavior.CanExecuteChanged -> BackButtonBehavior -> BindingContext payloadsrc/Controls/src/Core/Shell/BackButtonBehavior.cs:129-139removes the handler only when the property changes, then adds the strong handler for the new command. No unload/detach path clears it.Repro
leakprobe.csproj:LeakTest.cs:Run:
Observed results
Command = null)Impact and condition
This is purely managed code and affects all platforms. It occurs when a command outlives a discarded behavior and
Commandis not explicitly cleared.Suggested fix
Use the existing weak command-subscription pattern (for example
WeakCommandSubscription) or add deterministic behavior teardown that unsubscribes fromCanExecuteChanged. This is a framework lifecycle leak; applications can mitigate it today by clearingCommandbefore discarding the behavior.