galaxy: add cycle guard to HasMatchingDescendant

This commit is contained in:
Joseph Doherty
2026-05-28 15:30:08 -04:00
parent 1a1d14a9fd
commit f0ec068430
2 changed files with 83 additions and 2 deletions
@@ -163,10 +163,17 @@ public static class GalaxyBrowseProjector
return false;
}
// Defend against pathological cycles in Galaxy data (e.g. a corrupt A→B→A chain).
// BuildContainedPath uses the same visited-id pattern; mirror it so this walk
// terminates even when ChildrenByParent forms a cycle.
HashSet<int> visited = new() { parent.Object.GobjectId };
Stack<GalaxyObjectView> stack = new();
foreach (GalaxyObjectView child in children)
{
stack.Push(child);
if (visited.Add(child.Object.GobjectId))
{
stack.Push(child);
}
}
while (stack.Count > 0)
{
@@ -180,7 +187,10 @@ public static class GalaxyBrowseProjector
{
foreach (GalaxyObjectView grandchild in grandchildren)
{
stack.Push(grandchild);
if (visited.Add(grandchild.Object.GobjectId))
{
stack.Push(grandchild);
}
}
}
}
@@ -223,6 +223,77 @@ public sealed class GalaxyBrowseProjectorTests
Assert.Equal("Plant.Line_A", result.Children[0].TagName);
}
/// <summary>
/// Verifies <see cref="GalaxyBrowseProjector"/> terminates when the Galaxy data
/// contains a cyclic parent chain (A→B→C→A). Without the visited-id guard in
/// <c>HasMatchingDescendant</c>, the depth-first walk would loop forever; the
/// 5-second xUnit timeout asserts termination.
/// </summary>
[Fact(Timeout = 5000)]
public async Task Project_CyclicDescendants_DoesNotInfiniteLoop()
{
await Task.Yield();
// Construct a 3-node cycle: A(10)→B(11)→C(12)→A. Each node's ParentGobjectId
// points to the next, so GalaxyHierarchyIndex.ChildrenByParent has
// [12] = [A], [10] = [B], [11] = [C].
// None of them are historized, so HistorizedOnly=true forces the projector to
// call HasMatchingDescendant on every direct child, exercising the cycle walk.
GalaxyObject a = new()
{
GobjectId = 10,
ParentGobjectId = 12,
ContainedName = "A",
BrowseName = "A",
TagName = "A",
};
GalaxyObject b = new()
{
GobjectId = 11,
ParentGobjectId = 10,
ContainedName = "B",
BrowseName = "B",
TagName = "B",
};
GalaxyObject c = new()
{
GobjectId = 12,
ParentGobjectId = 11,
ContainedName = "C",
BrowseName = "C",
TagName = "C",
};
IReadOnlyList<GalaxyObject> objects = new[] { a, b, c };
GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty with
{
Status = GalaxyCacheStatus.Healthy,
Sequence = 1,
LastSuccessAt = DateTimeOffset.UtcNow,
Objects = objects,
Index = GalaxyHierarchyIndex.Build(objects),
DashboardSummary = DashboardGalaxySummary.Unknown with
{
Status = DashboardGalaxyStatus.Healthy,
ObjectCount = objects.Count,
},
ObjectCount = objects.Count,
};
// Browse children of A (id=10). Its direct child B fails HistorizedOnly, so the
// projector falls back to HasMatchingDescendant(B), which walks B→C→A→B…
// without the visited-id guard. With the guard, the walk terminates and returns
// an empty page (no historized descendants exist anywhere in the cycle).
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
entry,
new BrowseChildrenRequest { ParentGobjectId = 10, HistorizedOnly = true },
browseSubtreeGlobs: null,
offset: 0,
pageSize: 10);
Assert.Empty(result.Children);
Assert.Equal(0, result.TotalChildCount);
}
private static GalaxyHierarchyCacheEntry CreateEntry()
{
IReadOnlyList<GalaxyObject> objects = CreateObjects();