From 2defc0ea92f8df15554cbbe25357ca65fb06f56c Mon Sep 17 00:00:00 2001 From: Alex Tate <0xalextate@gmail.com> Date: Wed, 19 Oct 2022 11:07:53 -0700 Subject: [PATCH 1/6] Bugfix addressing infinite loop while handling self-referencing chained exception in TestResult._clean_tracebacks() --- Lib/unittest/result.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/unittest/result.py b/Lib/unittest/result.py index 3da7005e603f4a..f3bed509547b10 100644 --- a/Lib/unittest/result.py +++ b/Lib/unittest/result.py @@ -214,7 +214,7 @@ def _clean_tracebacks(self, exctype, value, tb, test): if value is not None: for c in (value.__cause__, value.__context__): - if c is not None: + if c is not None and c is not value: excs.append((type(c), c, c.__traceback__)) return ret From bfe8a4a74a54e7d295807b304670f38265729b8d Mon Sep 17 00:00:00 2001 From: "blurb-it[bot]" <43283697+blurb-it[bot]@users.noreply.github.com> Date: Wed, 19 Oct 2022 18:31:54 +0000 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=93=9C=F0=9F=A4=96=20Added=20by=20blu?= =?UTF-8?q?rb=5Fit.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../next/Library/2022-10-19-18-31-53.gh-issue-98458.vwyq7O.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 Misc/NEWS.d/next/Library/2022-10-19-18-31-53.gh-issue-98458.vwyq7O.rst diff --git a/Misc/NEWS.d/next/Library/2022-10-19-18-31-53.gh-issue-98458.vwyq7O.rst b/Misc/NEWS.d/next/Library/2022-10-19-18-31-53.gh-issue-98458.vwyq7O.rst new file mode 100644 index 00000000000000..f74195cc8e7dc6 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2022-10-19-18-31-53.gh-issue-98458.vwyq7O.rst @@ -0,0 +1 @@ +Fix infinite loop in unittest when a self-referencing chained exception is raised From c1004097eb5976c23645117b0c1653690726e735 Mon Sep 17 00:00:00 2001 From: Alex Tate <0xalextate@gmail.com> Date: Thu, 1 Dec 2022 14:53:39 -0800 Subject: [PATCH 3/6] 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) --- Lib/test/test_unittest/test_result.py | 33 +++++++++++++++++++++++++++ Lib/unittest/result.py | 4 +++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_unittest/test_result.py b/Lib/test/test_unittest/test_result.py index e71d114751d94d..0d8b27460d0fae 100644 --- a/Lib/test/test_unittest/test_result.py +++ b/Lib/test/test_unittest/test_result.py @@ -275,6 +275,39 @@ def get_exc_info(): self.assertEqual(len(dropped), 1) self.assertIn("raise self.failureException(msg)", dropped[0]) + def test_addFailure_filter_traceback_frames_chained_exception_cycle(self): + class Foo(unittest.TestCase): + def test_1(self): + pass + + def get_exc_info(): + try: + # Create two directionally opposed cycles + # __cause__ in one direction, __context__ in the other + # Only one is needed since they serve the same role in _clean_tracebacks() + # But this way we cover both possibilities if the code changes + A, B, C = Exception("A"), Exception("B"), Exception("C") + edges = [(C, B), (B, A), (A, C)] + for ex1, ex2 in edges: + ex1.__cause__ = ex2 + ex2.__context__ = ex1 + raise C + except: + return sys.exc_info() + + exc_info_tuple = get_exc_info() + + test = Foo('test_1') + result = unittest.TestResult() + result.startTest(test) + result.addFailure(test, exc_info_tuple) + result.stopTest(test) + + formatted_exc = result.failures[0][1] + self.assertEqual(formatted_exc.count("Exception: A\n"), 1) + self.assertEqual(formatted_exc.count("Exception: B\n"), 1) + self.assertEqual(formatted_exc.count("Exception: C\n"), 1) + # "addError(test, err)" # ... # "Called when the test case test raises an unexpected exception err diff --git a/Lib/unittest/result.py b/Lib/unittest/result.py index f3bed509547b10..c0470fb26e67ce 100644 --- a/Lib/unittest/result.py +++ b/Lib/unittest/result.py @@ -196,6 +196,7 @@ def _clean_tracebacks(self, exctype, value, tb, test): ret = None first = True excs = [(exctype, value, tb)] + seen = {id(value)} # Handle loops in __cause__ or __context__. while excs: (exctype, value, tb) = excs.pop() # Skip test runner traceback levels @@ -214,8 +215,9 @@ def _clean_tracebacks(self, exctype, value, tb, test): if value is not None: for c in (value.__cause__, value.__context__): - if c is not None and c is not value: + if c is not None and id(c) not in seen: excs.append((type(c), c, c.__traceback__)) + seen.add(id(c)) return ret def _is_relevant_tb_level(self, tb): From 8388d827c97b3f910417c4e8498feff3b5f470c5 Mon Sep 17 00:00:00 2001 From: Alex Tate <0xalextate@gmail.com> Date: Sat, 3 Dec 2022 09:37:23 -0800 Subject: [PATCH 4/6] Requested change: removing unnecessary comment lines --- Lib/test/test_unittest/test_result.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/Lib/test/test_unittest/test_result.py b/Lib/test/test_unittest/test_result.py index 0d8b27460d0fae..d231bba7b93fe4 100644 --- a/Lib/test/test_unittest/test_result.py +++ b/Lib/test/test_unittest/test_result.py @@ -284,8 +284,6 @@ def get_exc_info(): try: # Create two directionally opposed cycles # __cause__ in one direction, __context__ in the other - # Only one is needed since they serve the same role in _clean_tracebacks() - # But this way we cover both possibilities if the code changes A, B, C = Exception("A"), Exception("B"), Exception("C") edges = [(C, B), (B, A), (A, C)] for ex1, ex2 in edges: From 08fd9844ce79544c1ba148b0653054f993413e5b Mon Sep 17 00:00:00 2001 From: Alex Tate <0xalextate@gmail.com> Date: Sat, 3 Dec 2022 10:04:46 -0800 Subject: [PATCH 5/6] Requested change: clarify comment in _clean_tracebacks() --- Lib/unittest/result.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/unittest/result.py b/Lib/unittest/result.py index c0470fb26e67ce..5ca4c23238b419 100644 --- a/Lib/unittest/result.py +++ b/Lib/unittest/result.py @@ -196,7 +196,7 @@ def _clean_tracebacks(self, exctype, value, tb, test): ret = None first = True excs = [(exctype, value, tb)] - seen = {id(value)} # Handle loops in __cause__ or __context__. + seen = {id(value)} # Detect loops in chained exceptions. while excs: (exctype, value, tb) = excs.pop() # Skip test runner traceback levels From 6b868dfc093ce2e6683e847cded6b9654435b72d Mon Sep 17 00:00:00 2001 From: Alex Tate <0xalextate@gmail.com> Date: Sat, 3 Dec 2022 10:05:23 -0800 Subject: [PATCH 6/6] Requested change: adding a test for a single chained exception that holds a self-loop in its __cause__ and __context__ attributes --- Lib/test/test_unittest/test_result.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/Lib/test/test_unittest/test_result.py b/Lib/test/test_unittest/test_result.py index d231bba7b93fe4..efd9c902350506 100644 --- a/Lib/test/test_unittest/test_result.py +++ b/Lib/test/test_unittest/test_result.py @@ -275,6 +275,31 @@ def get_exc_info(): self.assertEqual(len(dropped), 1) self.assertIn("raise self.failureException(msg)", dropped[0]) + def test_addFailure_filter_traceback_frames_chained_exception_self_loop(self): + class Foo(unittest.TestCase): + def test_1(self): + pass + + def get_exc_info(): + try: + loop = Exception("Loop") + loop.__cause__ = loop + loop.__context__ = loop + raise loop + except: + return sys.exc_info() + + exc_info_tuple = get_exc_info() + + test = Foo('test_1') + result = unittest.TestResult() + result.startTest(test) + result.addFailure(test, exc_info_tuple) + result.stopTest(test) + + formatted_exc = result.failures[0][1] + self.assertEqual(formatted_exc.count("Exception: Loop\n"), 1) + def test_addFailure_filter_traceback_frames_chained_exception_cycle(self): class Foo(unittest.TestCase): def test_1(self):