Skip to content

Commit 1d225ed

Browse files
committed
graph: check for cycles in visit methods
Avoid silently returning a subset of nodes.
1 parent ee9db51 commit 1d225ed

File tree

2 files changed

+27
-2
lines changed

2 files changed

+27
-2
lines changed

src/taskgraph/graph.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,14 +97,18 @@ def _visit(self, reverse):
9797
indegree[dependent] -= 1
9898
if indegree[dependent] == 0:
9999
queue.append(dependent)
100+
loopy_nodes = {node for node, degree in indegree.items() if degree > 0}
101+
if loopy_nodes:
102+
raise Exception(
103+
f"Dependency loop detected involving the following nodes: {loopy_nodes}"
104+
)
100105

101106
def visit_postorder(self):
102107
"""
103108
Generate a sequence of nodes in postorder, such that every node is
104109
visited *after* any nodes it links to.
105110
106-
Behavior is undefined (read: it will hang) if the graph contains a
107-
cycle.
111+
Raises an exception if the graph contains a cycle.
108112
"""
109113
return self._visit(False)
110114

test/test_graph.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
import unittest
77

8+
import pytest
9+
810
from taskgraph.graph import Graph
911

1012

@@ -63,6 +65,11 @@ class TestGraph(unittest.TestCase):
6365
},
6466
)
6567

68+
loopy = Graph(
69+
{"A", "B", "C"},
70+
{tuple(x) for x in "ABL BCL CAL".split()}, # codespell:ignore
71+
)
72+
6673
def test_transitive_closure_empty(self):
6774
"transitive closure of an empty set is an empty graph"
6875
g = Graph({"a", "b", "c"}, {("a", "b", "L"), ("a", "c", "L")})
@@ -123,6 +130,10 @@ def test_transitive_closure_linear(self):
123130
"transitive closure of a linear graph includes all nodes in the line"
124131
self.assertEqual(self.linear.transitive_closure({"1"}), self.linear)
125132

133+
def test_transitive_closure_loopy(self):
134+
"transitive closure of a loop is the whole loop"
135+
self.assertEqual(self.loopy.transitive_closure({"A"}), self.loopy)
136+
126137
def test_visit_postorder_empty(self):
127138
"postorder visit of an empty graph is empty"
128139
self.assertEqual(list(Graph(set(), set()).visit_postorder()), [])
@@ -154,6 +165,11 @@ def test_visit_postorder_disjoint(self):
154165
"postorder visit of a disjoint graph satisfies invariant"
155166
self.assert_postorder(self.disjoint.visit_postorder(), self.disjoint.nodes)
156167

168+
def test_visit_postorder_loopy(self):
169+
with pytest.raises(Exception) as excinfo:
170+
list(self.loopy.visit_postorder())
171+
assert "Dependency loop detected" in str(excinfo.value)
172+
157173
def assert_preorder(self, seq, all_nodes):
158174
seen = set()
159175
for e in seq:
@@ -179,6 +195,11 @@ def test_visit_preorder_disjoint(self):
179195
"preorder visit of a disjoint graph satisfies invariant"
180196
self.assert_preorder(self.disjoint.visit_preorder(), self.disjoint.nodes)
181197

198+
def test_visit_preorder_loopy(self):
199+
with pytest.raises(Exception) as excinfo:
200+
list(self.loopy.visit_preorder())
201+
assert "Dependency loop detected" in str(excinfo.value)
202+
182203
def test_links_dict(self):
183204
"link dict for a graph with multiple edges is correct"
184205
self.assertEqual(

0 commit comments

Comments
 (0)