Skip to content

Commit d5dbbf4

Browse files
authored
pythongh-124958: fix asyncio.TaskGroup and _PyFuture refcycles (python#124959)
1 parent 45df264 commit d5dbbf4

File tree

5 files changed

+147
-15
lines changed

5 files changed

+147
-15
lines changed

Lib/asyncio/futures.py

+2-4
Original file line numberDiff line numberDiff line change
@@ -190,8 +190,7 @@ def result(self):
190190
the future is done and has an exception set, this exception is raised.
191191
"""
192192
if self._state == _CANCELLED:
193-
exc = self._make_cancelled_error()
194-
raise exc
193+
raise self._make_cancelled_error()
195194
if self._state != _FINISHED:
196195
raise exceptions.InvalidStateError('Result is not ready.')
197196
self.__log_traceback = False
@@ -208,8 +207,7 @@ def exception(self):
208207
InvalidStateError.
209208
"""
210209
if self._state == _CANCELLED:
211-
exc = self._make_cancelled_error()
212-
raise exc
210+
raise self._make_cancelled_error()
213211
if self._state != _FINISHED:
214212
raise exceptions.InvalidStateError('Exception is not set.')
215213
self.__log_traceback = False

Lib/asyncio/taskgroups.py

+32-9
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,20 @@ async def __aenter__(self):
6666
return self
6767

6868
async def __aexit__(self, et, exc, tb):
69+
tb = None
70+
try:
71+
return await self._aexit(et, exc)
72+
finally:
73+
# Exceptions are heavy objects that can have object
74+
# cycles (bad for GC); let's not keep a reference to
75+
# a bunch of them. It would be nicer to use a try/finally
76+
# in __aexit__ directly but that introduced some diff noise
77+
self._parent_task = None
78+
self._errors = None
79+
self._base_error = None
80+
exc = None
81+
82+
async def _aexit(self, et, exc):
6983
self._exiting = True
7084

7185
if (exc is not None and
@@ -122,7 +136,10 @@ async def __aexit__(self, et, exc, tb):
122136
assert not self._tasks
123137

124138
if self._base_error is not None:
125-
raise self._base_error
139+
try:
140+
raise self._base_error
141+
finally:
142+
exc = None
126143

127144
if self._parent_cancel_requested:
128145
# If this flag is set we *must* call uncancel().
@@ -133,8 +150,14 @@ async def __aexit__(self, et, exc, tb):
133150

134151
# Propagate CancelledError if there is one, except if there
135152
# are other errors -- those have priority.
136-
if propagate_cancellation_error is not None and not self._errors:
137-
raise propagate_cancellation_error
153+
try:
154+
if propagate_cancellation_error is not None and not self._errors:
155+
try:
156+
raise propagate_cancellation_error
157+
finally:
158+
exc = None
159+
finally:
160+
propagate_cancellation_error = None
138161

139162
if et is not None and not issubclass(et, exceptions.CancelledError):
140163
self._errors.append(exc)
@@ -146,14 +169,14 @@ async def __aexit__(self, et, exc, tb):
146169
if self._parent_task.cancelling():
147170
self._parent_task.uncancel()
148171
self._parent_task.cancel()
149-
# Exceptions are heavy objects that can have object
150-
# cycles (bad for GC); let's not keep a reference to
151-
# a bunch of them.
152172
try:
153-
me = BaseExceptionGroup('unhandled errors in a TaskGroup', self._errors)
154-
raise me from None
173+
raise BaseExceptionGroup(
174+
'unhandled errors in a TaskGroup',
175+
self._errors,
176+
) from None
155177
finally:
156-
self._errors = None
178+
exc = None
179+
157180

158181
def create_task(self, coro, *, name=None, context=None):
159182
"""Create a new task in this group and return it.

Lib/test/test_asyncio/test_futures.py

+22
Original file line numberDiff line numberDiff line change
@@ -659,6 +659,28 @@ def __del__(self):
659659
fut = self._new_future(loop=self.loop)
660660
fut.set_result(Evil())
661661

662+
def test_future_cancelled_result_refcycles(self):
663+
f = self._new_future(loop=self.loop)
664+
f.cancel()
665+
exc = None
666+
try:
667+
f.result()
668+
except asyncio.CancelledError as e:
669+
exc = e
670+
self.assertIsNotNone(exc)
671+
self.assertListEqual(gc.get_referrers(exc), [])
672+
673+
def test_future_cancelled_exception_refcycles(self):
674+
f = self._new_future(loop=self.loop)
675+
f.cancel()
676+
exc = None
677+
try:
678+
f.exception()
679+
except asyncio.CancelledError as e:
680+
exc = e
681+
self.assertIsNotNone(exc)
682+
self.assertListEqual(gc.get_referrers(exc), [])
683+
662684

663685
@unittest.skipUnless(hasattr(futures, '_CFuture'),
664686
'requires the C _asyncio module')

Lib/test/test_asyncio/test_taskgroups.py

+90-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Adapted with permission from the EdgeDB project;
22
# license: PSFL.
33

4-
4+
import gc
55
import asyncio
66
import contextvars
77
import contextlib
@@ -11,7 +11,6 @@
1111

1212
from test.test_asyncio.utils import await_without_task
1313

14-
1514
# To prevent a warning "test altered the execution environment"
1615
def tearDownModule():
1716
asyncio.set_event_loop_policy(None)
@@ -899,6 +898,95 @@ async def outer():
899898

900899
await outer()
901900

901+
async def test_exception_refcycles_direct(self):
902+
"""Test that TaskGroup doesn't keep a reference to the raised ExceptionGroup"""
903+
tg = asyncio.TaskGroup()
904+
exc = None
905+
906+
class _Done(Exception):
907+
pass
908+
909+
try:
910+
async with tg:
911+
raise _Done
912+
except ExceptionGroup as e:
913+
exc = e
914+
915+
self.assertIsNotNone(exc)
916+
self.assertListEqual(gc.get_referrers(exc), [])
917+
918+
919+
async def test_exception_refcycles_errors(self):
920+
"""Test that TaskGroup deletes self._errors, and __aexit__ args"""
921+
tg = asyncio.TaskGroup()
922+
exc = None
923+
924+
class _Done(Exception):
925+
pass
926+
927+
try:
928+
async with tg:
929+
raise _Done
930+
except* _Done as excs:
931+
exc = excs.exceptions[0]
932+
933+
self.assertIsInstance(exc, _Done)
934+
self.assertListEqual(gc.get_referrers(exc), [])
935+
936+
937+
async def test_exception_refcycles_parent_task(self):
938+
"""Test that TaskGroup deletes self._parent_task"""
939+
tg = asyncio.TaskGroup()
940+
exc = None
941+
942+
class _Done(Exception):
943+
pass
944+
945+
async def coro_fn():
946+
async with tg:
947+
raise _Done
948+
949+
try:
950+
async with asyncio.TaskGroup() as tg2:
951+
tg2.create_task(coro_fn())
952+
except* _Done as excs:
953+
exc = excs.exceptions[0].exceptions[0]
954+
955+
self.assertIsInstance(exc, _Done)
956+
self.assertListEqual(gc.get_referrers(exc), [])
957+
958+
async def test_exception_refcycles_propagate_cancellation_error(self):
959+
"""Test that TaskGroup deletes propagate_cancellation_error"""
960+
tg = asyncio.TaskGroup()
961+
exc = None
962+
963+
try:
964+
async with asyncio.timeout(-1):
965+
async with tg:
966+
await asyncio.sleep(0)
967+
except TimeoutError as e:
968+
exc = e.__cause__
969+
970+
self.assertIsInstance(exc, asyncio.CancelledError)
971+
self.assertListEqual(gc.get_referrers(exc), [])
972+
973+
async def test_exception_refcycles_base_error(self):
974+
"""Test that TaskGroup deletes self._base_error"""
975+
class MyKeyboardInterrupt(KeyboardInterrupt):
976+
pass
977+
978+
tg = asyncio.TaskGroup()
979+
exc = None
980+
981+
try:
982+
async with tg:
983+
raise MyKeyboardInterrupt
984+
except MyKeyboardInterrupt as e:
985+
exc = e
986+
987+
self.assertIsNotNone(exc)
988+
self.assertListEqual(gc.get_referrers(exc), [])
989+
902990

903991
if __name__ == "__main__":
904992
unittest.main()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix refcycles in exceptions raised from :class:`asyncio.TaskGroup` and the python implementation of :class:`asyncio.Future`

0 commit comments

Comments
 (0)