-
Notifications
You must be signed in to change notification settings - Fork 834
Dev/xygu/20251216/hotreload-fwktemplate-leak #22161
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Conversation
|
todo: tests |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR addresses memory leaks in the Uno Platform's template system by introducing weak references for delegate storage and optimizing template pooling. The changes prevent FrameworkTemplate instances from keeping view factories alive unnecessarily and avoid creating unnecessary pool entries when pooling is disabled.
Key Changes:
- Introduces a new
WeakDelegate<TDelegate>wrapper class to hold delegates with weak references to their targets - Updates
FrameworkTemplateto store its view factory usingWeakDelegateinstead of direct delegate references - Optimizes
FrameworkTemplatePoolto skip creating pool entries when pooling is disabled
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 7 comments.
| File | Description |
|---|---|
src/Uno.UI/Helpers/WeakDelegate.cs |
New helper class implementing weak delegate pattern to prevent memory leaks by holding delegate targets weakly |
src/Uno.UI/UI/Xaml/FrameworkTemplate.cs |
Updates view factory storage to use WeakDelegate wrapper and fixes typo in XAML scope comment |
src/Uno.UI/UI/Xaml/FrameworkTemplatePool.cs |
Refactors pool management to use TryGetTemplatePool and skip pool entry creation when pooling is disabled, plus fixes error message typo |
Critical Issues Found:
- Inverted logic in
TryGetTemplatePoolat line 320 - returns null when pooling IS enabled instead of when disabled - Non-existent API -
HasSingleTargetdoesn't exist on theDelegateclass; should useGetInvocationList().Length == 1 - Missing issue reference in PR description - the PR must link to a GitHub issue per repository guidelines
|
π€ Your WebAssembly Skia Sample App stage site is ready! Visit it here: https://unowasmprstaging.z20.web.core.windows.net/pr-22161/wasm-skia-net9/index.html |
|
π€ Your Docs stage site is ready! Visit it here: https://unodocsprstaging.z13.web.core.windows.net/pr-22161/docs/index.html |
|
|
src/Uno.UI/Helpers/WeakDelegate.cs
Outdated
| public TDelegate? Delegate => Instance switch | ||
| { | ||
| // for instanced method delegate, only try to create if the instance reference is still alive | ||
| { Target: { } t } => System.Delegate.CreateDelegate(typeof(TDelegate), t, Method), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We are re-creating delegate on each access ... we need to validate the performance of this! (Not sure if the CreateDelegate involves reflection).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
invoking a delegate is quite fast, almost comparable to direct call
that said, since we are rebuilding it on every invocation, there is a concern here
it would argue that, for its current sole usage, template builder, the bulk of work would be in the builder itself.
and for now, we can rest assured that this wont impact RELEASE with static delegate being cached
32fa54f to
c158701
Compare
|
π€ Your Docs stage site is ready! Visit it here: https://unodocsprstaging.z13.web.core.windows.net/pr-22161/docs/index.html |
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 7 comments.
| /// </remarks> | ||
| public TDelegate? Delegate => | ||
| _staticDelegate ?? | ||
| // for instanced method delegate, only try to create if the instance reference is still alive |
Copilot
AI
Dec 19, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comment states "for instanced method delegate" but should use the standard terminology "instance method delegate" (without the 'd').
| // for instanced method delegate, only try to create if the instance reference is still alive | |
| // for instance method delegate, only try to create if the instance reference is still alive |
| ο»Ώ#nullable enable | ||
|
|
||
| using System; | ||
| using System.Diagnostics.CodeAnalysis; | ||
| using System.Reflection; | ||
|
|
||
| namespace Uno.UI.Helpers; | ||
|
|
||
| internal interface IDelegate<TDelegate> where TDelegate : Delegate | ||
| { | ||
| public object? Target { get; } | ||
| public MethodInfo Method { get; } | ||
| public TDelegate? Delegate { get; } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Represents a plain wrapper over a normal delegate instance. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// Solely used to conform to the <see cref="IDelegate{TDelegate}"/> interface, | ||
| /// so <see cref="WeakDelegate{TDelegate}"/> and literal delegate can be used interchangeably. | ||
| /// </remarks> | ||
| /// <typeparam name="TDelegate">The type of delegate encapsulated by this instance. Must derive from <see cref="Delegate"/>.</typeparam> | ||
| internal class LiteralDelegate<TDelegate> : IDelegate<TDelegate> | ||
| where TDelegate : Delegate | ||
| { | ||
| public object? Target => Delegate!.Target; | ||
| public MethodInfo Method => Delegate!.Method; | ||
| public TDelegate? Delegate { get; } | ||
|
|
||
| public LiteralDelegate(TDelegate d) | ||
| { | ||
| Delegate = d; | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Represents a delegate that references its <see cref="Delegate.Target"/> weakly, allowing the target to be eventually garbage collected. | ||
| /// </summary> | ||
| /// <remarks>Use <see cref="WeakDelegate{TDelegate}"/> to hold a reference to a delegate without preventing its | ||
| /// target object from being collected by the garbage collector. This is useful for scenarios where you want to | ||
| /// avoid memory leaks caused by strong references. Only delegates with a single target are supported; | ||
| /// multicast delegates are not allowed.</remarks> | ||
| /// <typeparam name="TDelegate">The type of delegate to be referenced. Must derive from <see cref="Delegate"/>.</typeparam> | ||
| internal class WeakDelegate<TDelegate> : IDelegate<TDelegate> | ||
| where TDelegate : Delegate | ||
| { | ||
| public WeakReference? Instance { get; init; } | ||
| public MethodInfo Method { get; init; } | ||
|
|
||
| // static method delegate doesn't capture any target instance, so we can just reuse it | ||
| private TDelegate? _staticDelegate; | ||
|
|
||
| public WeakDelegate(TDelegate d) | ||
| { | ||
| if (!d.HasSingleTarget) | ||
| { | ||
| throw new NotImplementedException("Multi-cast delegate not supported"); | ||
| } | ||
|
|
||
| if (d.Target is { } t) | ||
| { | ||
| Instance = new WeakReference(t); | ||
| Method = d.Method; | ||
| } | ||
| else | ||
| { | ||
| _staticDelegate = d; | ||
| Method = d.Method; // still used outside of this class for logging | ||
| } | ||
| } | ||
|
|
||
| public object? Target => Instance?.Target; | ||
|
|
||
| /// <summary> | ||
| /// Gets the delegate instance that targets the referenced object and method, | ||
| /// may be null if the target is no longer available. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// DO NOT store/cache the returned delegate, as it keeps a strong reference to the target just like the original delegate. | ||
| /// </remarks> | ||
| public TDelegate? Delegate => | ||
| _staticDelegate ?? | ||
| // for instanced method delegate, only try to create if the instance reference is still alive | ||
| (Instance!.Target is { } t | ||
| ? System.Delegate.CreateDelegate(typeof(TDelegate), t, Method) as TDelegate | ||
| : null); | ||
| } | ||
|
|
||
| internal class DelegateHelper | ||
| { | ||
| public static LiteralDelegate<TDelegate> CreateLiteral<TDelegate>(TDelegate d) | ||
| where TDelegate : Delegate | ||
| { | ||
| return new(d); | ||
| } | ||
|
|
||
| public static WeakDelegate<TDelegate> CreateWeak<TDelegate>(TDelegate d) | ||
| where TDelegate : Delegate | ||
| { | ||
| return new(d); | ||
| } | ||
| } |
Copilot
AI
Dec 19, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new WeakDelegate class lacks test coverage. Given that this is a critical component for preventing memory leaks and involves complex logic around weak references, delegate recreation, and handling both static and instance methods, consider adding unit tests to verify its behavior, especially edge cases like delegate recreation after garbage collection and proper handling of static vs instance delegates.
| // But if the factory doesn't have a target (no capture), then we use the literal delegate that has no overhead. | ||
| var inner = factory.Target is not null | ||
| ? DelegateHelper.CreateWeak(factory) | ||
| : DelegateHelper.CreateLiteral(factory) as IDelegate<TDelegate>; |
Copilot
AI
Dec 19, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The cast as IDelegate<TDelegate> is redundant and potentially misleading. DelegateHelper.CreateLiteral(factory) always returns LiteralDelegate<TDelegate> which implements IDelegate<TDelegate>, so the cast will never fail. Consider removing the cast or assigning the result directly to inner without the cast.
| : DelegateHelper.CreateLiteral(factory) as IDelegate<TDelegate>; | |
| : DelegateHelper.CreateLiteral(factory); |
| private readonly static DataTemplate InnerContentPresenterTemplate = | ||
| new DataTemplate(owner: null, factory: InnerContentPresenterTemplateImpl); | ||
|
|
||
| // note: Lambda functions even with `static` and none of variable captures |
Copilot
AI
Dec 19, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Grammatical error in comment: "none of variable captures" should be "no variable captures" or "none of the variable captures".
| // note: Lambda functions even with `static` and none of variable captures | |
| // note: Lambda functions even with `static` and no variable captures |
|
|
||
| public WeakDelegate(TDelegate d) | ||
| { | ||
| if (!d.HasSingleTarget) |
Copilot
AI
Dec 19, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The property HasSingleTarget does not exist on the Delegate type in .NET. To check if a delegate is multicast, use GetInvocationList().Length == 1 instead.
| if (!d.HasSingleTarget) | |
| if (d.GetInvocationList().Length != 1) |
| { | ||
| if (!d.HasSingleTarget) | ||
| { | ||
| throw new NotImplementedException("Multi-cast delegate not supported"); |
Copilot
AI
Dec 19, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The exception message says "Multi-cast delegate not supported" but uses NotImplementedException. For unsupported operations, use NotSupportedException instead, which is the standard exception type for operations that are not supported by design.
| throw new NotImplementedException("Multi-cast delegate not supported"); | |
| throw new NotSupportedException("Multi-cast delegate not supported"); |
| public TDelegate? Delegate => | ||
| _staticDelegate ?? | ||
| // for instanced method delegate, only try to create if the instance reference is still alive | ||
| (Instance!.Target is { } t |
Copilot
AI
Dec 19, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Potential null reference issue: Instance!.Target may throw NullReferenceException if Instance is null. This can occur when the delegate is static (has no target), in which case Instance is never initialized. Add a null check: Instance?.Target instead of Instance!.Target.
| (Instance!.Target is { } t | |
| (Instance?.Target is { } t |
|
π€ Your WebAssembly Skia Sample App stage site is ready! Visit it here: https://unowasmprstaging.z20.web.core.windows.net/pr-22161/wasm-skia-net9/index.html |
|
π€ Your Docs stage site is ready! Visit it here: https://unodocsprstaging.z13.web.core.windows.net/pr-22161/docs/index.html |
|
π€ Your WebAssembly Skia Sample App stage site is ready! Visit it here: https://unowasmprstaging.z20.web.core.windows.net/pr-22161/wasm-skia-net9/index.html |
|
π€ Your Docs stage site is ready! Visit it here: https://unodocsprstaging.z13.web.core.windows.net/pr-22161/docs/index.html |
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.
| // We want to keep a weak reference to the factory target, so that templates do not prevent those objects from being GC'd. | ||
| // But only so, if the target is not an instance of the closure class, which we use IWeakReferenceProvider to determine since | ||
| // the known top-level xaml classes (Pages, ResourceDictionary,...) usually implement this interface. | ||
| // If the factory doesn't have a target (no capture), then we use the literal delegate that has no overhead. |
Copilot
AI
Dec 19, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comment explaining the weak reference logic is somewhat confusing. Consider rephrasing for clarity. The current logic is: when the target implements IWeakReferenceProvider (indicating it's a top-level XAML class like Page or ResourceDictionary), use a weak reference to avoid memory leaks. When the target doesn't implement this interface (indicating it's likely a compiler-generated closure class), use a literal (strong) reference to keep the closure alive. The phrase "if the target is not an instance of the closure class" could be clearer as "if the target is a top-level XAML class (not a closure)".
| // We want to keep a weak reference to the factory target, so that templates do not prevent those objects from being GC'd. | |
| // But only so, if the target is not an instance of the closure class, which we use IWeakReferenceProvider to determine since | |
| // the known top-level xaml classes (Pages, ResourceDictionary,...) usually implement this interface. | |
| // If the factory doesn't have a target (no capture), then we use the literal delegate that has no overhead. | |
| // When the factory target is a top-level XAML class (e.g. Page, ResourceDictionary) that implements IWeakReferenceProvider, | |
| // we wrap the delegate in a weak reference so that the template does not keep that object alive and cause memory leaks. | |
| // When the target does not implement IWeakReferenceProvider (typically a compiler-generated closure class), we keep a strong | |
| // (literal) reference so the closure stays alive. If the factory doesn't have a target (no capture), we also use the literal | |
| // delegate without additional overhead. |
| // Same target method (instance or static) (possible if the delegate was created from a | ||
| // method group, which are *not* cached by the C# compiler (required by | ||
| // the C# spec as of version 6.0) | ||
| || ( | ||
| ReferenceEquals(left?._viewFactory?.Target, right?._viewFactory?.Target) | ||
| && left?._viewFactory?.Method == right?._viewFactory?.Method | ||
| ); | ||
| || (left?._hashCode == right?._hashCode) | ||
| ; | ||
|
|
||
| public int GetHashCode(FrameworkTemplate obj) => obj._hashCode; | ||
| } |
Copilot
AI
Dec 19, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comment about "Same delegate" is misleading with the new implementation. Since _viewFactory is now wrapped in IDelegate<T> instances, the reference equality check will always fail (each template gets its own wrapper instance). The equality now relies solely on the hashCode comparison. Consider updating the comment to reflect this change, or remove the _viewFactory comparison entirely since it will never match.
| ο»Ώ#nullable enable | ||
|
|
||
| using System; | ||
| using System.Diagnostics.CodeAnalysis; |
Copilot
AI
Dec 19, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The System.Diagnostics.CodeAnalysis namespace is imported but not used. Consider removing this unused import.
| using System.Diagnostics.CodeAnalysis; |
|
π€ Your WebAssembly Skia Sample App stage site is ready! Visit it here: https://unowasmprstaging.z20.web.core.windows.net/pr-22161/wasm-skia-net9/index.html |
|
π€ Your Docs stage site is ready! Visit it here: https://unodocsprstaging.z13.web.core.windows.net/pr-22161/docs/index.html |
|
|
GitHub Issue: closes unoplatform/uno-private#1670
PR Type:
What is the current behavior? π€
What is the new behavior? π
PR Checklist β
Please check if your PR fulfills the following requirements:
Screenshots Compare Test Runresults.Other information βΉοΈ
view-factory method are usually generated with
staticmodifier, except when hot-reload is enable (which implies in DEBUG only)