diff --git a/Lib/concurrent/futures/_base.py b/Lib/concurrent/futures/_base.py index e728b8e0a91f744..505723b1e44dc5f 100644 --- a/Lib/concurrent/futures/_base.py +++ b/Lib/concurrent/futures/_base.py @@ -723,7 +723,11 @@ def __iter__(self): def __next__(self): value, exc = next(self.gen) if exc is not None: - raise exc + try: + raise exc + finally: + # avoid a ref cycle where exc is captured in its traceback + exc = None return value def close(self): diff --git a/Lib/concurrent/futures/interpreter.py b/Lib/concurrent/futures/interpreter.py index fd3d45144b49a71..be26b40628ee434 100644 --- a/Lib/concurrent/futures/interpreter.py +++ b/Lib/concurrent/futures/interpreter.py @@ -88,7 +88,11 @@ def run(self, task): if exc is None: # The exception must have been not shareable. raise # re-raise - raise exc from wrapper + try: + raise exc from wrapper + finally: + # avoid a ref cycle where exc is captured in its traceback + exc = None class BrokenInterpreterPool(_thread.BrokenThreadPool): diff --git a/Lib/test/test_concurrent_futures/executor.py b/Lib/test/test_concurrent_futures/executor.py index ff7bd0db0c2199c..0af0879e3ff0636 100644 --- a/Lib/test/test_concurrent_futures/executor.py +++ b/Lib/test/test_concurrent_futures/executor.py @@ -1,6 +1,8 @@ +import gc import itertools import threading import time +import traceback import weakref from concurrent import futures from operator import add @@ -80,7 +82,41 @@ def test_map_exception(self): i = self.executor.map(divmod, [5, 5, 5, 5], [2, 3, 0, 5]) self.assertEqual(next(i), (2, 1)) self.assertEqual(next(i), (1, 2)) - self.assertRaises(ZeroDivisionError, next, i) + + exception = None + try: + i.__next__() + except Exception as e: + exception = e + self.assertTrue( + isinstance(exception, ZeroDivisionError), + msg="should raise a ZeroDivisionError", + ) + + # pause needed for free-threading builds on Ubuntu (ARM) and Windows + time.sleep(1) + + self.assertFalse( + gc.get_referrers(exception), + msg="the exception should not have any referrers", + ) + + self.assertFalse( + [ + (var, val) + # go through the frames of the exception's traceback + for frame, _ in traceback.walk_tb(exception.__traceback__) + # skipping the current frame + if frame is not exception.__traceback__.tb_frame + # go through the locals captured in that frame + for var, val in frame.f_locals.items() + # check if one of them is an exception + if isinstance(val, Exception) + # check if it is captured in its own traceback + and frame is val.__traceback__.tb_frame + ], + msg=f"the exception's traceback should not contain an exception captured in its own traceback", + ) self.assertEqual(next(i), (1, 0)) self.assertRaises(StopIteration, next, i) self.assertRaises(StopIteration, next, i) @@ -212,7 +248,7 @@ def test_map_buffersize_when_buffer_is_full(self): self.assertEqual( next(ints), buffersize, - msg="should have fetched only `buffersize` elements from `ints`.", + msg="should have fetched only `buffersize` elements from `ints`", ) @warnings_helper.ignore_fork_in_thread_deprecation_warnings()