Skip to content

Commit 9bcc68b

Browse files
gh-98458: unittest: bugfix for infinite loop while handling chained exceptions that contain cycles (GH-98459)
* Bugfix addressing infinite loop while handling self-referencing chained exception in TestResult._clean_tracebacks() * Bugfix extended to properly handle exception cycles in _clean_tracebacks. The "seen" set follows the approach used in the TracebackException class (thank you @iritkatriel for pointing it out) * adds a test for a single chained exception that holds a self-loop in its __cause__ and __context__ attributes (cherry picked from commit 72ec518) Co-authored-by: AlexTate <0xalextate@gmail.com>
1 parent 7aa87bb commit 9bcc68b

File tree

3 files changed

+60
-1
lines changed

3 files changed

+60
-1
lines changed

Lib/unittest/result.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ def _clean_tracebacks(self, exctype, value, tb, test):
196196
ret = None
197197
first = True
198198
excs = [(exctype, value, tb)]
199+
seen = {id(value)} # Detect loops in chained exceptions.
199200
while excs:
200201
(exctype, value, tb) = excs.pop()
201202
# Skip test runner traceback levels
@@ -214,8 +215,9 @@ def _clean_tracebacks(self, exctype, value, tb, test):
214215

215216
if value is not None:
216217
for c in (value.__cause__, value.__context__):
217-
if c is not None:
218+
if c is not None and id(c) not in seen:
218219
excs.append((type(c), c, c.__traceback__))
220+
seen.add(id(c))
219221
return ret
220222

221223
def _is_relevant_tb_level(self, tb):

Lib/unittest/test/test_result.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,62 @@ def get_exc_info():
275275
self.assertEqual(len(dropped), 1)
276276
self.assertIn("raise self.failureException(msg)", dropped[0])
277277

278+
def test_addFailure_filter_traceback_frames_chained_exception_self_loop(self):
279+
class Foo(unittest.TestCase):
280+
def test_1(self):
281+
pass
282+
283+
def get_exc_info():
284+
try:
285+
loop = Exception("Loop")
286+
loop.__cause__ = loop
287+
loop.__context__ = loop
288+
raise loop
289+
except:
290+
return sys.exc_info()
291+
292+
exc_info_tuple = get_exc_info()
293+
294+
test = Foo('test_1')
295+
result = unittest.TestResult()
296+
result.startTest(test)
297+
result.addFailure(test, exc_info_tuple)
298+
result.stopTest(test)
299+
300+
formatted_exc = result.failures[0][1]
301+
self.assertEqual(formatted_exc.count("Exception: Loop\n"), 1)
302+
303+
def test_addFailure_filter_traceback_frames_chained_exception_cycle(self):
304+
class Foo(unittest.TestCase):
305+
def test_1(self):
306+
pass
307+
308+
def get_exc_info():
309+
try:
310+
# Create two directionally opposed cycles
311+
# __cause__ in one direction, __context__ in the other
312+
A, B, C = Exception("A"), Exception("B"), Exception("C")
313+
edges = [(C, B), (B, A), (A, C)]
314+
for ex1, ex2 in edges:
315+
ex1.__cause__ = ex2
316+
ex2.__context__ = ex1
317+
raise C
318+
except:
319+
return sys.exc_info()
320+
321+
exc_info_tuple = get_exc_info()
322+
323+
test = Foo('test_1')
324+
result = unittest.TestResult()
325+
result.startTest(test)
326+
result.addFailure(test, exc_info_tuple)
327+
result.stopTest(test)
328+
329+
formatted_exc = result.failures[0][1]
330+
self.assertEqual(formatted_exc.count("Exception: A\n"), 1)
331+
self.assertEqual(formatted_exc.count("Exception: B\n"), 1)
332+
self.assertEqual(formatted_exc.count("Exception: C\n"), 1)
333+
278334
# "addError(test, err)"
279335
# ...
280336
# "Called when the test case test raises an unexpected exception err
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix infinite loop in unittest when a self-referencing chained exception is raised

0 commit comments

Comments
 (0)