Skip to content

Commit 22a533f

Browse files
[subgraph] Extract cycle detection to shared utility
- Create src/subgraph/utils/cycleDetection.ts with validation functions - Add validateNoSubgraphCycles and validateSubgraphAddition utilities - Integrate cycle detection into LGraph.convertToSubgraph() - Add comprehensive tests for cycle detection functionality - Prevent creating subgraphs that would cause infinite recursion
1 parent 83795d5 commit 22a533f

File tree

5 files changed

+255
-3
lines changed

5 files changed

+255
-3
lines changed

src/LGraph.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { type GraphOrSubgraph, Subgraph } from "./subgraph/Subgraph"
3737
import { SubgraphInput } from "./subgraph/SubgraphInput"
3838
import { SubgraphOutput } from "./subgraph/SubgraphOutput"
3939
import { getBoundaryLinks, groupResolvedByOutput, mapSubgraphInputsAndLinks, mapSubgraphOutputsAndLinks, multiClone, splitPositionables } from "./subgraph/subgraphUtils"
40+
import { validateNoSubgraphCycles } from "./subgraph/utils/cycleDetection"
4041
import { Alignment, LGraphEventMode } from "./types/globalEnums"
4142
import { getAllNestedItems } from "./utils/collections"
4243

@@ -1412,6 +1413,9 @@ export class LGraph implements LinkNetwork, BaseLGraph, Serialisable<Serialisabl
14121413

14131414
const clonedNodes = multiClone(nodes)
14141415

1416+
// Validate that we won't create circular references
1417+
validateNoSubgraphCycles(Array.from(nodes), this)
1418+
14151419
// Inputs, outputs, and links
14161420
const links = internalLinks.map(x => x.asSerialisable())
14171421
const inputs = mapSubgraphInputsAndLinks(resolvedInputLinks, links)
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import type { LGraphNode } from "@/LGraphNode"
2+
import type { SubgraphNode } from "@/subgraph/SubgraphNode"
3+
4+
import { RecursionError } from "@/infrastructure/RecursionError"
5+
6+
/**
7+
* Validates that a set of nodes doesn't contain circular references during subgraph creation.
8+
* This prevents creating subgraphs that would cause infinite recursion during execution.
9+
* @param nodes Nodes to check for cycles
10+
* @param targetSubgraph Optional: the subgraph these nodes will be added to
11+
* @throws RecursionError if cycle detected
12+
*/
13+
export function validateNoSubgraphCycles(
14+
nodes: LGraphNode[],
15+
targetSubgraph?: any, // Using any to avoid circular dependency
16+
): void {
17+
// Check if any of the nodes being added is a subgraph that contains the target
18+
for (const node of nodes) {
19+
if (node.isSubgraphNode?.()) {
20+
const subgraphNode = node as SubgraphNode
21+
if (targetSubgraph && containsSubgraph(subgraphNode.subgraph, targetSubgraph)) {
22+
throw new RecursionError(
23+
`Circular reference detected: Cannot add node ${node.id} (${node.title || "untitled"}) ` +
24+
`because its subgraph would contain its parent subgraph`,
25+
)
26+
}
27+
}
28+
}
29+
}
30+
31+
/**
32+
* Checks if a subgraph contains another subgraph (directly or indirectly).
33+
* @param container The potential container subgraph
34+
* @param target The target subgraph to search for
35+
* @returns true if container contains target
36+
*/
37+
function containsSubgraph(container: any, target: any): boolean {
38+
if (container === target) return true
39+
40+
// Check all nodes in the container
41+
for (const node of container.nodes.values()) {
42+
if (node.isSubgraphNode?.()) {
43+
const subgraphNode = node as SubgraphNode
44+
if (subgraphNode.subgraph === target || containsSubgraph(subgraphNode.subgraph, target)) {
45+
return true
46+
}
47+
}
48+
}
49+
50+
return false
51+
}
52+
53+
/**
54+
* Validates that adding nodes to a subgraph won't create cycles.
55+
* @param nodes Nodes being added
56+
* @param targetSubgraph The subgraph they're being added to
57+
* @throws RecursionError if adding would create a cycle
58+
*/
59+
export function validateSubgraphAddition(
60+
nodes: LGraphNode[],
61+
targetSubgraph: any,
62+
): void {
63+
validateNoSubgraphCycles(nodes, targetSubgraph)
64+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* LGraph.convertToSubgraph Tests
3+
*
4+
* Tests for converting nodes to subgraphs with cycle detection.
5+
*/
6+
7+
import { describe, expect, it } from "vitest"
8+
9+
import { RecursionError } from "@/infrastructure/RecursionError"
10+
import { LGraph } from "@/litegraph"
11+
12+
import { createTestSubgraph, createTestSubgraphNode } from "./subgraph/fixtures/subgraphHelpers"
13+
14+
describe("LGraph.convertToSubgraph", () => {
15+
describe("cycle detection", () => {
16+
it("should prevent creating subgraphs that would contain themselves", () => {
17+
const parentGraph = new LGraph()
18+
19+
// Create a subgraph
20+
const childSubgraph = createTestSubgraph({ name: "Child" })
21+
const subgraphNode = createTestSubgraphNode(childSubgraph)
22+
parentGraph.add(subgraphNode)
23+
24+
// Try to convert the subgraph node back into the child subgraph
25+
// This would create childSubgraph -> subgraphNode -> childSubgraph
26+
const nodesToConvert = new Set([subgraphNode])
27+
28+
expect(() => {
29+
// This should be caught by our cycle detection
30+
childSubgraph.convertToSubgraph(nodesToConvert)
31+
}).toThrow(RecursionError)
32+
33+
expect(() => {
34+
childSubgraph.convertToSubgraph(nodesToConvert)
35+
}).toThrow(/Circular reference detected.*because its subgraph would contain its parent subgraph/)
36+
})
37+
38+
it("should prevent complex nested cycles", () => {
39+
const rootGraph = new LGraph()
40+
41+
// Create subgraph A
42+
const subgraphA = createTestSubgraph({ name: "Subgraph A" })
43+
44+
// Create subgraph B that contains A
45+
const subgraphB = createTestSubgraph({ name: "Subgraph B" })
46+
const nodeAinB = createTestSubgraphNode(subgraphA)
47+
subgraphB.add(nodeAinB)
48+
49+
// Add B to root
50+
const nodeBinRoot = createTestSubgraphNode(subgraphB)
51+
rootGraph.add(nodeBinRoot)
52+
53+
// Now try to convert nodeBinRoot into subgraphA
54+
// This would create A -> B -> A cycle
55+
const nodesToConvert = new Set([nodeBinRoot])
56+
57+
expect(() => {
58+
subgraphA.convertToSubgraph(nodesToConvert)
59+
}).toThrow(RecursionError)
60+
})
61+
62+
it("should allow valid nodes when no cycles exist", () => {
63+
const parentGraph = new LGraph()
64+
65+
// Create a subgraph that has no relationship to parentGraph
66+
const independentSubgraph = createTestSubgraph({ name: "Independent" })
67+
const subgraphNode = createTestSubgraphNode(independentSubgraph)
68+
69+
// This should not throw because there's no cycle
70+
expect(() => {
71+
parentGraph.convertToSubgraph(new Set([subgraphNode]))
72+
}).not.toThrow(RecursionError)
73+
})
74+
})
75+
})

test/subgraph/SubgraphMemory.test.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
import { describe, expect, it, vi } from "vitest"
22

33
import { LGraph } from "@/litegraph"
4-
import { SubgraphNode } from "@/subgraph/SubgraphNode"
54

65
import { subgraphTest } from "./fixtures/subgraphFixtures"
76
import {
8-
createEventCapture,
97
createTestSubgraph,
108
createTestSubgraphNode,
119
} from "./fixtures/subgraphHelpers"
@@ -427,4 +425,4 @@ describe("SubgraphMemory - Performance and Scale", () => {
427425
expect(rootGraph.nodes.length).toBe(0)
428426
}
429427
})
430-
})
428+
})
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/**
2+
* Cycle Detection Utility Tests
3+
*
4+
* Tests for cycle detection during subgraph creation to prevent
5+
* infinite recursion issues.
6+
*/
7+
8+
import { describe, expect, it } from "vitest"
9+
10+
import { RecursionError } from "@/infrastructure/RecursionError"
11+
import { LGraph, LGraphNode } from "@/litegraph"
12+
import { validateNoSubgraphCycles, validateSubgraphAddition } from "@/subgraph/utils/cycleDetection"
13+
14+
import { createTestSubgraph, createTestSubgraphNode } from "../fixtures/subgraphHelpers"
15+
16+
describe("Cycle Detection Utilities", () => {
17+
describe("validateNoSubgraphCycles", () => {
18+
it("should allow adding regular nodes without issues", () => {
19+
const node1 = new LGraphNode("Test Node 1")
20+
const node2 = new LGraphNode("Test Node 2")
21+
22+
expect(() => {
23+
validateNoSubgraphCycles([node1, node2])
24+
}).not.toThrow()
25+
})
26+
27+
it("should allow adding subgraph nodes that don't create cycles", () => {
28+
const parentGraph = new LGraph()
29+
const childSubgraph = createTestSubgraph({ name: "Child Subgraph" })
30+
const subgraphNode = createTestSubgraphNode(childSubgraph)
31+
32+
expect(() => {
33+
validateNoSubgraphCycles([subgraphNode], parentGraph)
34+
}).not.toThrow()
35+
})
36+
37+
it("should detect direct self-reference cycles", () => {
38+
const subgraph = createTestSubgraph({ name: "Self Reference" })
39+
const subgraphNode = createTestSubgraphNode(subgraph)
40+
41+
expect(() => {
42+
validateNoSubgraphCycles([subgraphNode], subgraph)
43+
}).toThrow(RecursionError)
44+
45+
expect(() => {
46+
validateNoSubgraphCycles([subgraphNode], subgraph)
47+
}).toThrow(/Circular reference detected.*because its subgraph would contain its parent subgraph/)
48+
})
49+
50+
it("should detect indirect cycles through nested subgraphs", () => {
51+
// Create subgraph A
52+
const subgraphA = createTestSubgraph({ name: "Subgraph A" })
53+
54+
// Create subgraph B that contains A
55+
const subgraphB = createTestSubgraph({ name: "Subgraph B" })
56+
const nodeAinB = createTestSubgraphNode(subgraphA)
57+
subgraphB.add(nodeAinB)
58+
59+
// Now try to add B to A (would create A -> B -> A cycle)
60+
const nodeBinA = createTestSubgraphNode(subgraphB)
61+
62+
expect(() => {
63+
validateNoSubgraphCycles([nodeBinA], subgraphA)
64+
}).toThrow(RecursionError)
65+
})
66+
67+
it("should handle mixed node types correctly", () => {
68+
const parentGraph = new LGraph()
69+
const subgraph = createTestSubgraph({ name: "Mixed Test" })
70+
71+
const regularNode = new LGraphNode("Regular Node")
72+
const subgraphNode = createTestSubgraphNode(subgraph)
73+
74+
// Should allow mixed nodes when no cycle exists
75+
expect(() => {
76+
validateNoSubgraphCycles([regularNode, subgraphNode], parentGraph)
77+
}).not.toThrow()
78+
})
79+
80+
it("should include node information in error messages", () => {
81+
const subgraph = createTestSubgraph({ name: "Error Test" })
82+
const subgraphNode = createTestSubgraphNode(subgraph)
83+
subgraphNode.id = 123
84+
subgraphNode.title = "My Subgraph Instance"
85+
86+
expect(() => {
87+
validateNoSubgraphCycles([subgraphNode], subgraph)
88+
}).toThrow(/Cannot add node 123 \(My Subgraph Instance\)/)
89+
})
90+
})
91+
92+
describe("validateSubgraphAddition", () => {
93+
it("should validate nodes being added to a subgraph", () => {
94+
const targetSubgraph = createTestSubgraph({ name: "Target" })
95+
const node = new LGraphNode()
96+
97+
expect(() => {
98+
validateSubgraphAddition([node], targetSubgraph)
99+
}).not.toThrow()
100+
})
101+
102+
it("should detect cycles when adding to subgraph", () => {
103+
const subgraph = createTestSubgraph({ name: "Cyclic" })
104+
const subgraphNode = createTestSubgraphNode(subgraph)
105+
106+
expect(() => {
107+
validateSubgraphAddition([subgraphNode], subgraph)
108+
}).toThrow(RecursionError)
109+
})
110+
})
111+
})

0 commit comments

Comments
 (0)