Skip to content

Commit 242dd3a

Browse files
Fix: Infinite loop in DataGrid visual parent walk with unit tests
Refactored DataGrid focus handling to use VisualParentWalker.EnumerateUniqueAncestors, preventing infinite loops on cyclic parent chains. Added a new test project with xUnit, internal visibility for testing, and a unit test verifying correct termination on cycles. Updated solution and dependencies accordingly.
1 parent 256512a commit 242dd3a

7 files changed

Lines changed: 157 additions & 12 deletions

File tree

Spice86.DataGrid.slnx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
<Solution>
22
<Project Path="src/Spice86.DataGrid/Avalonia.Controls.DataGrid/Avalonia.Controls.DataGrid.csproj" />
3+
<Project Path="src/Spice86.DataGrid/Spice86.DataGrid.Tests/Spice86.DataGrid.Tests.csproj" />
34
</Solution>

src/Spice86.DataGrid/Avalonia.Controls.DataGrid/Avalonia.Controls.DataGrid.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,8 @@
2121
<ItemGroup>
2222
<PackageReference Include="Avalonia" />
2323
</ItemGroup>
24+
25+
<ItemGroup>
26+
<InternalsVisibleTo Include="Spice86.DataGrid.Tests" />
27+
</ItemGroup>
2428
</Project>

src/Spice86.DataGrid/Avalonia.Controls.DataGrid/DataGrid.cs

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3996,6 +3996,14 @@ private void DataGrid_KeyUp(object sender, KeyEventArgs e)
39963996
}
39973997

39983998
//TODO: Make override?
3999+
// Selects the logical Parent first (so popups walk out of their host), then falls back
4000+
// to the visual parent. Declared as a static method so it can be passed as a delegate
4001+
// without capturing 'this'.
4002+
internal static Visual GetVisualParentForFocusWalk(Visual visual)
4003+
{
4004+
return (visual?.Parent as Visual) ?? visual?.GetVisualParent();
4005+
}
4006+
39994007
private void DataGrid_LostFocus(object sender, RoutedEventArgs e)
40004008
{
40014009
_focusedObject = null;
@@ -4006,27 +4014,22 @@ private void DataGrid_LostFocus(object sender, RoutedEventArgs e)
40064014
Visual focusedObject = AvaloniaInternalCompatibilityHelper.GetFocusedElement(this);
40074015
DataGridColumn editingColumn = null;
40084016

4009-
while (focusedObject != null)
4017+
// Walk up the visual tree using a cycle-safe enumerator to avoid infinite loops
4018+
// when the visual parent chain contains a cycle (can happen with popups / detached visuals).
4019+
foreach (Visual ancestor in VisualParentWalker.EnumerateUniqueAncestors<Visual>(focusedObject, GetVisualParentForFocusWalk))
40104020
{
4011-
if (focusedObject == this)
4021+
if (ancestor == this)
40124022
{
40134023
focusLeftDataGrid = false;
40144024
break;
40154025
}
40164026

4017-
// Walk up the visual tree. If we hit the root, try using the framework element's
4018-
// parent. We do this because Popups behave differently with respect to the visual tree,
4019-
// and it could have a parent even if the VisualTreeHelper doesn't find it.
4020-
var parent = focusedObject.Parent as Visual;
4021-
if (parent == null)
4022-
{
4023-
parent = focusedObject.GetVisualParent();
4024-
}
4025-
else
4027+
// If the ancestor has a logical Parent (Popups behave differently w.r.t. the visual tree),
4028+
// the DataGrid will not receive the routed event for this focus change.
4029+
if (ancestor?.Parent as Visual != null)
40264030
{
40274031
dataGridWillReceiveRoutedEvent = false;
40284032
}
4029-
focusedObject = parent;
40304033
}
40314034

40324035
if (EditingRow != null && EditingColumnIndex != -1)
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
#nullable enable
2+
3+
using System;
4+
using System.Collections.Generic;
5+
6+
namespace Avalonia.Controls.Utils
7+
{
8+
/// <summary>
9+
/// Walks a parent chain while guarding against cycles, so a cyclic visual-parent graph
10+
/// (which can happen with popups / detached visuals) cannot cause an infinite loop.
11+
/// </summary>
12+
internal static class VisualParentWalker
13+
{
14+
/// <summary>
15+
/// Enumerates <paramref name="start"/> and its ancestors as produced by <paramref name="getParent"/>,
16+
/// stopping when the chain ends (null) or when a node is encountered a second time (cycle).
17+
/// </summary>
18+
public static IEnumerable<T> EnumerateUniqueAncestors<T>(T? start, Func<T, T?> getParent)
19+
where T : class
20+
{
21+
if (start is null)
22+
{
23+
yield break;
24+
}
25+
26+
HashSet<T> visited = new HashSet<T>(ReferenceEqualityComparer.Instance);
27+
T? current = start;
28+
while (current is not null)
29+
{
30+
if (!visited.Add(current))
31+
{
32+
// Cycle detected - stop walking.
33+
yield break;
34+
}
35+
36+
yield return current;
37+
current = getParent(current);
38+
}
39+
}
40+
}
41+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
<Project>
22
<ItemGroup>
33
<PackageVersion Include="Avalonia" Version="12.0.2" />
4+
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
5+
<PackageVersion Include="xunit" Version="2.9.1" />
6+
<PackageVersion Include="xunit.runner.visualstudio" Version="2.5.11" />
47
</ItemGroup>
58
</Project>
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<PropertyGroup>
3+
<TargetFramework>net10.0</TargetFramework>
4+
<IsTestProject>true</IsTestProject>
5+
<Nullable>enable</Nullable>
6+
<WarningsAsErrors />
7+
</PropertyGroup>
8+
9+
<ItemGroup>
10+
<PackageReference Include="Microsoft.NET.Test.Sdk" />
11+
<PackageReference Include="xunit" />
12+
<PackageReference Include="xunit.runner.visualstudio">
13+
<PrivateAssets>all</PrivateAssets>
14+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
15+
</PackageReference>
16+
</ItemGroup>
17+
18+
<ItemGroup>
19+
<ProjectReference Include="../Avalonia.Controls.DataGrid/Avalonia.Controls.DataGrid.csproj" />
20+
</ItemGroup>
21+
</Project>
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Threading;
4+
using System.Threading.Tasks;
5+
using Avalonia.Controls.Utils;
6+
using Xunit;
7+
8+
namespace Spice86.DataGrid.Tests;
9+
10+
/// <summary>
11+
/// True logic test for the cycle-safe visual parent walk used by DataGrid_LostFocus.
12+
///
13+
/// DataGrid_LostFocus walks up the focused element's parent chain. If that chain contains
14+
/// a cycle (which can happen with popups / detached visuals), a naive walk loops forever
15+
/// and freezes the UI. The fix is <see cref="VisualParentWalker.EnumerateUniqueAncestors"/>,
16+
/// which uses a HashSet to break out of cycles.
17+
///
18+
/// - RED (without fix): EnumerateUniqueAncestors loops forever on a cyclic chain -> test times out -> FAIL
19+
/// - GREEN (with fix): EnumerateUniqueAncestors visits each node once then stops -> test PASSES
20+
/// </summary>
21+
public class VisualParentWalkerCycleTests
22+
{
23+
private sealed class Node
24+
{
25+
public Node? Parent;
26+
}
27+
28+
[Fact]
29+
public async Task EnumerateUniqueAncestors_OnCyclicChain_TerminatesAndVisitsEachNodeOnce()
30+
{
31+
// ==================== ARRANGE ====================
32+
// Build a cyclic parent chain: a -> b -> c -> a (cycle).
33+
Node a = new Node();
34+
Node b = new Node();
35+
Node c = new Node();
36+
a.Parent = b;
37+
b.Parent = c;
38+
c.Parent = a;
39+
40+
List<Node> visited = new List<Node>();
41+
42+
// ==================== ACT ====================
43+
// Run the walk on a worker thread with a hard timeout.
44+
// Without the HashSet guard, this loops forever and the task never completes.
45+
Task task = Task.Run(() =>
46+
{
47+
foreach (Node node in VisualParentWalker.EnumerateUniqueAncestors<Node>(a, n => n.Parent))
48+
{
49+
visited.Add(node);
50+
51+
// Hard safety net so a buggy (cycle-blind) implementation cannot
52+
// run away with unbounded memory before the timeout fires.
53+
if (visited.Count > 1000)
54+
{
55+
break;
56+
}
57+
}
58+
});
59+
60+
await Task.Delay(TimeSpan.FromSeconds(2));
61+
bool completed = task.IsCompletedSuccessfully;
62+
63+
// ==================== ASSERT ====================
64+
Assert.True(completed,
65+
"RED: VisualParentWalker.EnumerateUniqueAncestors did not terminate on a cyclic " +
66+
"parent chain within 2s. Without the HashSet<T> visited guard, the walk loops " +
67+
"forever and freezes the UI in DataGrid_LostFocus.");
68+
69+
// Each node in the cycle must be visited exactly once, in order.
70+
Assert.Equal(new[] { a, b, c }, visited);
71+
}
72+
}

0 commit comments

Comments
 (0)