From f0ec068430b27ba97bc2659aa2e111e431e4312c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 28 May 2026 15:30:08 -0400 Subject: [PATCH] galaxy: add cycle guard to HasMatchingDescendant --- .../Galaxy/GalaxyBrowseProjector.cs | 14 +++- .../Galaxy/GalaxyBrowseProjectorTests.cs | 71 +++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseProjector.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseProjector.cs index a9b7501..2d69391 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseProjector.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseProjector.cs @@ -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 visited = new() { parent.Object.GobjectId }; Stack 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); + } } } } diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyBrowseProjectorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyBrowseProjectorTests.cs index 98fee57..50a7d62 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyBrowseProjectorTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyBrowseProjectorTests.cs @@ -223,6 +223,77 @@ public sealed class GalaxyBrowseProjectorTests Assert.Equal("Plant.Line_A", result.Children[0].TagName); } + /// + /// Verifies terminates when the Galaxy data + /// contains a cyclic parent chain (A→B→C→A). Without the visited-id guard in + /// HasMatchingDescendant, the depth-first walk would loop forever; the + /// 5-second xUnit timeout asserts termination. + /// + [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 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 objects = CreateObjects();