Reduce allocations on AnimationManager#35612
Conversation
<!-- 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! ## What Restrict the agentic-labeler to apply **exactly one `area-*` label** per item, while still allowing multiple `platform/*` labels. ## Why Backfilling the 26 items affected by the `max:1` bug (fixed in dotnet#35540) revealed that the labeler occasionally applies multiple `area-*` labels for ambiguous cases: - **dotnet#35501** got both `area-layout` and `area-safearea` - **dotnet#35490** got both `area-navigation` and `area-controls-tabbedpage` The intended behavior is exactly one best-fit `area-*` per item (a label-quota distinction not expressible via `safe-outputs.add-labels.max:` — that field counts total labels, not labels per prefix). The fix has to live in the agent's instructions. ## Changes ### `.github/skills/agentic-labeler/SKILL.md` - Scope section: "Exactly one `area-*`" / "One or more `platform/*`". - Area rules section: renamed heading, changed "pick one or more" → "apply exactly one". - New **tie-breaking heuristics** for the area-* selection: - Specific control beats generic area (`area-controls-tabbedpage` over `area-navigation`) - Sub-area beats parent area (`area-safearea` over `area-layout`) - Subject-matter focus beats incidental touch - When genuinely tied, prefer the user-visible feature - Mixed-PR rule clarified: infra-primary PRs get only `area-infrastructure` (no second product area). ### `.github/workflows/agentic-labeler.md` - Added explicit reinforcement in the workflow prompt: "Apply exactly one `area-*` label … and one or more `platform/*` labels". - Fixed two stale `max: 1` comments left over from dotnet#35540 (the cap is now `max: 10`). ### `.github/workflows/agentic-labeler.lock.yml` - Regenerated via `gh aw compile`. Diff is frontmatter-hash + heredoc rotations only — no semantic change to the compiled config. ## Validation - Reviewed all 21 existing eval scenarios in `tests/eval.yaml` — none assert multiple `area-*` labels, so no test updates needed. - The `max: 10` cap in `safe-outputs` is preserved as a blast-radius safeguard (one area + several platforms still fit comfortably). ## Follow-ups (not in this PR) If accuracy of the "one area" rule drops below ~95% in eval runs, consider adding a deterministic post-step that strips extra `area-*` labels per a known precedence list (Option B from the design discussion). Co-authored-by: bot <bot@test> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
## Description
Extends the `maui-copilot` DevDiv pipeline (pipeline 27723) with a
3-stage architecture that runs real UI tests on platform-pool agents and
reports results directly in the AI summary PR comment.
### Pipeline Workflow
```
┌─────────────────────────────────────────────────────────┐
│ Stage 1: ReviewPR │
│ │
│ STEP 1: Branch Setup (checkout + cherry-pick PR) │
│ STEP 2: Detect UI Test Categories │
│ STEP 3: Run Detected UI Tests (in-process, fast) │
│ STEP 4: Regression Cross-Reference │
│ STEP 5: Gate — verify tests fail/pass before/after fix │
│ STEP 6: Code Review — deep analysis via Copilot agent │
│ │
│ Outputs → CopilotLogs artifact + detectedCategories │
└──────────────────────┬──────────────────────────────────┘
│
┌──────────────────────▼──────────────────────────────────┐
│ Stage 2: RunDeepUITests (platform-pool agent) │
│ │
│ iOS: AcesShared Tahoe + iOS 26.4 │
│ Android: ubuntu-22.04 + KVM + AVD │
│ │
│ Runs BuildAndRunHostApp.ps1 per detected category │
│ Outputs → drop-deep-uitests artifact (TRX + diffs) │
└──────────────────────┬──────────────────────────────────┘
│
┌──────────────────────▼──────────────────────────────────┐
│ Stage 3: PostResults │
│ │
│ 1. Download CopilotLogs (review content files) │
│ 2. Download drop-deep-uitests (TRX results) │
│ 3. Merge deep results into uitests/content.md │
│ 4. Post full AI Summary comment on PR │
│ 5. Apply labels (s/agent-reviewed, etc.) │
│ │
│ One comment with everything — no patching needed │
└─────────────────────────────────────────────────────────┘
```
### What's New
**Deep UI Test Execution (Stage 2)**
- Runs detected UI test categories on proper platform-pool agents (not
in-process on Linux)
- **iOS**: AcesShared Tahoe agents with iOS 26.4 simulator, iPhone 11
Pro (matching `ios-26` baselines from PR dotnet#35061)
- **Android**: ubuntu-22.04 with KVM, AVD boot with `-partition-size
2048`, `ignoreHiddenApiPolicyError` capability
- TRX results + snapshot-diff PNGs published as `drop-deep-uitests`
artifact
**Unified Comment Posting (Stage 3)**
- Comment posting and label application deferred to Stage 3 (after deep
tests complete)
- Single AI summary comment includes ALL results: code review + deep
test results
- Nested collapsible `<details>` for failed tests with full error +
stack trace
- Dynamic section title: `🧪 UI Tests — CollectionView, TabbedPage`
- Artifact download link for snapshot-diff PNGs
**Android Emulator Improvements**
- AVD boot step with proper partition size, ADB key pre-authorization,
boot wait
- `DEVICE_UDID` pass-through prevents double emulator boot
- Disk cleanup on hosted ubuntu agents (frees ~22GB)
- KVM enablement + `appium:ignoreHiddenApiPolicyError` for API 30
**iOS Simulator Improvements**
- Tahoe pool demand ensures macOS 26.x agents
- Explicit iOS 26.4 download via latest Xcode
- Auto-creates iPhone 11 Pro for baseline resolution match
### Validation
Tested across 30+ pipeline iterations on 6 PRs:
| PR | iOS | Android |
|---|---|---|
| 35358 (ViewBaseTests) | **112/112 ALL PASS** ✅ | **118/119 PASS** ✅ |
| 35359 (TabbedPage) | 44/50 (1 real failure) | 74/75 (1 real failure) |
| 35356 (CollectionView) | **415/417 PASS** ✅ | 593/619 (26 real
failures) |
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…35589) > [!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! Backport of dotnet#35460 to `main`. /cc @PureWeen Co-authored-by: HarishKumarSF4517 <harish.kumar@syncfusion.com>
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 35612Or
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 35612" |
|
Hey there @@pictos! Thank you so much for your PR! Someone from the team will get assigned to your PR shortly and we'll get it reviewed. |
|
/review -b feature/refactor-copilot-yml |
MauiBot
left a comment
There was a problem hiding this comment.
Expert Review — 3 findings
See inline comments for details.
300a2cb to
7418c16
Compare
|
/review -b feature/refactor-copilot-yml |
| @@ -130,7 +136,13 @@ public void Dispose() | |||
| void ForceFinishAnimations() | |||
| { | |||
| var animations = new List<Animation>(_animations); | |||
There was a problem hiding this comment.
If the _animations were an immutable object:
-readonly List<Animation> _animations = new();
+ImmutableArray<Animation> _animations = [];the allocations would move from "iterations" to "modifications" (adding/removing elements). Not sure if possible, perhaps it's worth a try.
There was a problem hiding this comment.
Thanks for your suggestion, I'll not do it on this PR. The main goal was to remove the lambdas allocations. Removing the List allocation would be a bonus, but looks like it's needed, all my attempts to remove resulted in IndexOutOfRangeExceptions. To improve would need a deeper look inside the animation engine.
|
@kubaflo this one is ready for review now |
### Description of Change https://github.com/GitOps-microsoft/GitOps.PullRequestIssueManagement/pull/262 (internal Microsoft link) changed the `${issueAuthor}` placeholder to include the `@` character. Remove the one we added so we don't duplicate it. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
kubaflo
left a comment
There was a problem hiding this comment.
What do you think about the ai's suggestions?
|
@kubaflo I believe the review was made on a previous commit. It says
but I didn't remove the |
|
/review -b feature/refactor-copilot-yml |
|
I'm not sure about the ArrayPool usage here. The minimum length array returned by the pool is of 16 and it grows in power of 2, so it seems an overkill, memory-wise, because I don't believe there will be too many items inside that list. Also, That change would make sense in a future work, with more data around it to validate the benefits, and I can't do it right now. I would like to merge it as it's, focusing on the |
6ccab60 to
cf8a3f0
Compare
|
/review -b feature/refactor-copilot-yml |
|
I don't like the |
This comment has been minimized.
This comment has been minimized.
Superseded by a newer MauiBot review run.
kubaflo
left a comment
There was a problem hiding this comment.
AI Review Summary
@pictos — new AI review results are available based on this last commit:
cf8a3f0.
create array instead of list
Review Sessions — click to expand
Gate — Test Before & After Fix
Gate Result: ⚠️ SKIPPED
No tests were detected in this PR.
Recommendation: Add tests to verify the fix using the write-tests-agent.
UI Tests
Full UI test matrix will run (no specific categories detected from PR changes).
Pre-Flight — Context & Validation
Issue: Unknown - GitHub API unavailable; no linked issue could be fetched
PR: #35612 - Unknown title (local branch: pr-review-35612)
Platforms Affected: android (per prompt); also shared Core animation behavior
Files Changed: 12 implementation/pipeline files, 0 test files
Key Findings
- GitHub PR metadata/comments could not be fetched because
ghis unauthenticated in this environment; analysis used the checked-out PR branch andorigin/main...HEADdiff. - Functional MAUI change is in
src/Core/src/Animations/AnimationManager.cs: replacenew List<Animation>(_animations).ForEach(...)with array snapshot +foreachin normal ticks and force-finish path. - Gate was already skipped: no tests detected in this PR. Nearby targeted coverage exists in
TickerSystemEnabledTestsand was used for try-fix regression checks. - Expert review found the PR fix is small and plausible but may only mitigate the symptom;
_animationsremains an unsynchronized mutable list.
Code Review Summary
Verdict: NEEDS_DISCUSSION
Confidence: medium
Errors: 1 | Warnings: 1 | Suggestions: 1
Key code review findings:
- ❌ No regression test coverage for the mutation/concurrency scenario; gate was skipped.
⚠️ src/Core/src/Animations/AnimationManager.cs: array snapshot avoidsList<T>.ForEachchecks but does not make_animationsaccess atomic under concurrent mutation.- 💡 Minor extra blank line in
ForceFinishAnimationsin the PR diff.
Fix Candidates
| # | Source | Approach | Test Result | Files Changed | Notes |
|---|---|---|---|---|---|
| PR | PR #35612 | Array snapshot + foreach in AnimationManager |
src/Core/src/Animations/AnimationManager.cs |
Original PR; no detected tests |
Code Review — Deep Analysis
Code Review — PR #35612
Independent Assessment
What this changes: The functional MAUI change updates AnimationManager to snapshot _animations into an array and iterate with foreach instead of constructing a List<Animation> snapshot and invoking List<T>.ForEach in OnFire and ForceFinishAnimations.
Inferred motivation: Avoid mutation/version-check failures or allocation overhead while animations are removed or force-finished during ticker callbacks, especially around Android animation-disable paths.
Reconciliation with PR Narrative
Author claims: GitHub API access was unavailable in this environment, so PR title/body/comments and linked issue could not be fetched. Local commit history indicates this PR also contains Copilot CI pipeline hardening/refactoring and one functional animation manager change.
Agreement/disagreement: Local code review agrees the array snapshot is a tactical improvement over List<T>.ForEach, but the MAUI expert review found it may not address all concurrent access scenarios for _animations.
Findings
❌ Error — No regression test coverage
Gate was already skipped because no tests were detected in the PR. The animation-manager change has existing nearby coverage in src/Controls/tests/Core.UnitTests/TickerSystemEnabledTests.cs, but the PR does not add a targeted regression for the mutation/concurrency scenario.
⚠️ Warning — Snapshot change may mask, not fix, concurrent list access
src/Core/src/Animations/AnimationManager.cs still uses a mutable List<Animation> without synchronization. Replacing a List<T> snapshot with an array snapshot avoids List<T>.ForEach version checks and reduces overhead, but snapshot construction is still not atomic if _animations is concurrently mutated.
💡 Suggestion — Minor formatting cleanup
The PR version of ForceFinishAnimations contains an extra blank line before End().
Devil's Advocate
The platform-specific Android callbacks likely run on the main thread in normal cases, so the immediate crash may be fully mitigated by avoiding List<T>.ForEach. Existing animation-disable tests pass with multiple alternative approaches. However, the base Ticker uses System.Timers.Timer, and AnimationManager itself does not enforce single-threaded access.
Verdict: NEEDS_DISCUSSION
Confidence: medium
Summary: The PR's functional fix is plausible and small, but it lacks a regression test and may be a symptom-level mitigation rather than a root-cause fix for unsynchronized _animations access. Alternative candidates show a lock-based approach is more robust, while allocation-free approaches pass existing tests but are narrower.
Fix — Analysis & Comparison
Fix Candidates
| # | Source | Approach | Test Result | Files Changed | Notes |
|---|---|---|---|---|---|
| 1 | try-fix | Lock-protected _animations list; snapshot under lock and invoke callbacks outside lock |
✅ PASS | 1 file | Most robust; addresses concurrent access window instead of only snapshot iteration |
| 2 | try-fix | Reverse in-place iteration without snapshot allocation | ✅ PASS | 1 file | Simpler/faster for single-threaded mutation; does not address cross-thread access |
| 3 | try-fix | Two-phase tick then backward remove | ✅ PASS | 1 file | Clear algorithm but changes cleanup timing and does not address cross-thread access |
| PR | PR #35612 | Array snapshot + foreach replacing List<T>.ForEach |
1 functional file | Small tactical original PR fix; no detected tests |
Cross-Pollination
| Model | Round | New Ideas? | Details |
|---|---|---|---|
| maui-expert-reviewer | 1 | Yes | Suggested lock, reverse iteration, two-phase remove, and source dispatch/thread-affinity ideas. |
| maui-expert-reviewer | 2 | No | NO NEW IDEAS. Immutable-array and ticker reentrancy variants were considered not worth implementing because they are either more complex than locking or narrower than Candidate 1. |
Exhausted: Yes
Selected Fix: Candidate #1 — Lock-protected animation list. It is larger than the PR's array snapshot but is the only tested candidate that closes the non-atomic snapshot/concurrent mutation window. Candidate #2 and #3 pass the existing regression tests but are not demonstrably better for Android/threading robustness.
Test Summary
All implemented candidates were tested with the targeted existing regression suite:
dotnet test src/Controls/tests/Core.UnitTests/Controls.Core.UnitTests.csproj --filter FullyQualifiedName~TickerSystemEnabledTests --verbosity minimal
The first candidate's initial --no-restore run failed because assets were missing; the retry with restore passed. Subsequent candidates used --no-restore and passed.
Report — Final Recommendation
Comparative Candidate Report
Inputs
- PR:
pr— array snapshot plusforeachinAnimationManager. - Reviewer-enhanced PR:
pr-plus-reviewer— identical topr; expert review produced no actionable inline findings. - try-fix-1: lock-protected
_animationslist; snapshots are taken under a private lock and callbacks run outside the lock. - try-fix-2: reverse in-place iteration without a snapshot.
- try-fix-3: two-phase tick then cleanup/removal.
Gate verification for the PR was skipped because no tests were detected. The try-fix candidates were evaluated with the targeted existing TickerSystemEnabledTests suite; all three try-fix candidates passed. No candidate failed regression tests, so no candidate is demoted under the failed-regression rule.
Ranking
try-fix-1— Winner. This is the only tested candidate that addresses the likely Android race at the shared mutable_animationslist instead of only changing iteration mechanics. It preserves snapshot callback semantics by copying under lock and invokes animation callbacks outside the lock to avoid deadlocks/reentrant callback hazards.pr/pr-plus-reviewer. The submitted PR is small, plausible, and low-risk. It preserves existing behavior and improves the snapshot implementation, but it does not make_animationsaccess atomic and had no detected regression tests in the PR.pr-plus-reviewerties withprbecause expert review had no actionable changes.try-fix-2. Reverse iteration is allocation-efficient and passed the targeted suite, but it changes iteration semantics and still leaves cross-thread access unsynchronized.try-fix-3. The two-phase algorithm is clear and passed the targeted suite, but it changes same-frame cleanup timing and still does not address concurrent access.
Decision
Select try-fix-1. It has the strongest correctness story for Android because it fixes the shared-list synchronization gap while preserving the PR's snapshot-style callback isolation. The PR fix remains acceptable as a narrow tactical improvement, but it is less complete for the suspected root cause.
Future Action — alternative fix proposed (try-fix-1)
Automated review — alternative fix proposed
The expert-reviewer evaluation compared the PR fix against automatically generated candidates and selected try-fix-1 as the strongest fix.
Why: try-fix-1 won because it passed the targeted regression suite and is the only candidate that addresses the shared _animations concurrency window while preserving snapshot callback semantics. The PR and pr-plus-reviewer are lower-risk but narrower; try-fix-2 and try-fix-3 pass tests but do not address cross-thread access.
Please consider applying the candidate diff below (or use it as guidance). Once you push an update, this workflow will re-trigger and re-evaluate.
Candidate diff (try-fix-1)
diff --git a/src/Core/src/Animations/AnimationManager.cs b/src/Core/src/Animations/AnimationManager.cs
index 2de13b3294..9bf205ef5d 100644
--- a/src/Core/src/Animations/AnimationManager.cs
+++ b/src/Core/src/Animations/AnimationManager.cs
@@ -7,6 +7,7 @@ namespace Microsoft.Maui.Animations
public class AnimationManager : IAnimationManager, IDisposable
{
readonly List<Animation> _animations = new();
+ readonly object _animationsLock = new();
long _lastUpdate;
bool _disposedValue;
@@ -40,8 +41,12 @@ namespace Microsoft.Maui.Animations
return;
}
- if (!_animations.Contains(animation))
- _animations.Add(animation);
+ lock (_animationsLock)
+ {
+ if (!_animations.Contains(animation))
+ _animations.Add(animation);
+ }
+
if (!Ticker.IsRunning && AutoStartTicker)
Start();
}
@@ -49,12 +54,22 @@ namespace Microsoft.Maui.Animations
/// <inheritdoc/>
public void Remove(Animation animation)
{
- _animations.TryRemove(animation);
+ lock (_animationsLock)
+ _animations.TryRemove(animation);
- if (_animations.Count == 0)
+ if (AnimationsCount == 0)
End();
}
+ int AnimationsCount
+ {
+ get
+ {
+ lock (_animationsLock)
+ return _animations.Count;
+ }
+ }
+
void Start()
{
_lastUpdate = GetCurrentTick();
@@ -84,17 +99,22 @@ namespace Microsoft.Maui.Animations
var milliseconds = TimeSpan.FromMilliseconds(now - _lastUpdate).TotalMilliseconds;
_lastUpdate = now;
- var animations = new List<Animation>(_animations);
- animations.ForEach(OnAnimationTick);
+ Animation[] animations;
+ lock (_animationsLock)
+ animations = _animations.ToArray();
+
+ foreach (var animation in animations)
+ OnAnimationTick(animation);
- if (_animations.Count == 0)
+ if (AnimationsCount == 0)
End();
void OnAnimationTick(Animation animation)
{
if (animation.HasFinished)
{
- _animations.TryRemove(animation);
+ lock (_animationsLock)
+ _animations.TryRemove(animation);
animation.RemoveFromParent();
return;
}
@@ -103,7 +123,8 @@ namespace Microsoft.Maui.Animations
if (animation.HasFinished)
{
- _animations.TryRemove(animation);
+ lock (_animationsLock)
+ _animations.TryRemove(animation);
animation.RemoveFromParent();
}
}
@@ -129,14 +150,20 @@ namespace Microsoft.Maui.Animations
void ForceFinishAnimations()
{
- var animations = new List<Animation>(_animations);
- animations.ForEach(ForceFinish);
+ Animation[] animations;
+ lock (_animationsLock)
+ animations = _animations.ToArray();
+
+ foreach (var animation in animations)
+ ForceFinish(animation);
+
End();
void ForceFinish(Animation animation)
{
animation.ForceFinish();
- _animations.TryRemove(animation);
+ lock (_animationsLock)
+ _animations.TryRemove(animation);
animation.RemoveFromParent();
}
}
There was a problem hiding this comment.
This class is clearly not thread safe.
Therefore I don't see a reason to copy _animations into a temporary array.
We can simply use our dear old for(var i = 0; i < _animations.Count; i++ and when the internal invocation removes the element, we can simply i--.
This also gives us the opportunity of providing the current i to the action inside the loop so that instead of TryRemove we can remove directly the element at the given index.
Take this AI
|
/review -b feature/enhanced-reviewer |
<!-- !!!!!!! MAIN IS THE ONLY ACTIVE BRANCH. MAKE SURE THIS PR IS TARGETING MAIN. !!!!!!! --> ### Description of Change In order to provide a good reference, I repeated the flow twice and collect the results. With the `main`, we do have this memory footprint: <img width="1436" height="138" alt="image" src="https://github.com/user-attachments/assets/f0b57463-7a8f-4767-9f55-301d5696d4bf" /> Those `System.Action` and `DisplayClass` allocations are caused by the usage of `ForEach` method, they fancy but they come with a cost. Since they're inside a loop the compiler couldn't cache them (my theory) which causes a lot of allocations. To remove all those allocations I rewrite the code using the boring `foreach` loop. And you can see the memory footprint below: <img width="1440" height="113" alt="image" src="https://github.com/user-attachments/assets/e9ab701b-29c6-42cc-9e86-906df4cc503e" /> Where the only allocation now is related with the `List` that is created inside the method and I wasn't able to remove on this PR. I'll let it for the future me or contributors. ### Issues Fixed <!-- Please make sure that there is a bug logged for the issue being fixed. The bug should describe the problem and how to reproduce it. --> Fixes #35654 <!-- Are you targeting main? All PRs should target the main branch unless otherwise noted. --> From IA review: <img width="658" height="198" alt="image" src="https://github.com/user-attachments/assets/44b9be3a-c6ee-4ae9-a5d0-2bb5d9bcd603" /> The 19 → 26 difference is just sampling noise. GCAllocationTick fires every ~100 KB of allocations, not on every individual allocation. The improved run had 2,017 sampled events vs 2,000 in the baseline (17 more), which means a slightly different sampling window was captured. That's enough to shift counts on any type by a few ticks. What actually changed: ✅ Action<Animation> (30 → 0): gone — the ForEach(delegate) no longer creates a delegate on each call ✅ <>c__DisplayClass20_0 (? → 0): the compiler-generated closure class is gone too ➡️ Animation[] (19 → 26): same root cause (new List<Animation> backing array), just sampling variance ---------
Description of Change
In order to provide a good reference, I repeated the flow twice and collect the results. With the
main, we do have this memory footprint:Those
System.ActionandDisplayClassallocations are caused by the usage ofForEachmethod, they fancy but they come with a cost. Since they're inside a loop the compiler couldn't cache them (my theory) which causes a lot of allocations.To remove all those allocations I rewrite the code using the boring
foreachloop. And you can see the memory footprint below:Where the only allocation now is related with the
Listthat is created inside the method and I wasn't able to remove on this PR. I'll let it for the future me or contributors.Issues Fixed
Fixes #35654
From IA review:
The 19 → 26 difference is just sampling noise. GCAllocationTick fires every ~100 KB of allocations, not on every individual allocation. The improved run had 2,017 sampled events vs 2,000 in the baseline (17 more), which means a slightly different sampling window was captured. That's enough to shift counts on any type by a few ticks.
What actually changed:
✅ Action (30 → 0): gone — the ForEach(delegate) no longer creates a delegate on each call
✅ <>c__DisplayClass20_0 (? → 0): the compiler-generated closure class is gone too
➡️ Animation[] (19 → 26): same root cause (new List backing array), just sampling variance