]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
[3.13] gh-128552: fix refcycles in eager task creation (#128553) (#128585)
authorThomas Grainger <tagrain@gmail.com>
Wed, 8 Jan 2025 12:46:43 +0000 (12:46 +0000)
committerGitHub <noreply@github.com>
Wed, 8 Jan 2025 12:46:43 +0000 (18:16 +0530)
gh-128552: fix refcycles in eager task creation (#128553)

(cherry picked from commit 61b9811ac6843e22b5896ef96030d421b79cd892)

Lib/asyncio/base_events.py
Lib/asyncio/taskgroups.py
Lib/test/test_asyncio/test_taskgroups.py
Misc/NEWS.d/next/Library/2025-01-06-18-41-08.gh-issue-128552.fV-f8j.rst [new file with mode: 0644]

index 91434042685239b8c23d69182accce54bdce5dfa..ca29792dc89825d79d711c5e775ddc33e5348d00 100644 (file)
@@ -477,7 +477,12 @@ class BaseEventLoop(events.AbstractEventLoop):
 
             task.set_name(name)
 
-        return task
+        try:
+            return task
+        finally:
+            # gh-128552: prevent a refcycle of
+            # task.exception().__traceback__->BaseEventLoop.create_task->task
+            del task
 
     def set_task_factory(self, factory):
         """Set a task factory that will be used by loop.create_task().
index 9fa772ca9d02cc39f65272b4149bd6934d9a5067..8af199d6dcc41acd8dfa78b33ee53df45700ef7a 100644 (file)
@@ -205,7 +205,12 @@ class TaskGroup:
         else:
             self._tasks.add(task)
             task.add_done_callback(self._on_task_done)
-        return task
+        try:
+            return task
+        finally:
+            # gh-128552: prevent a refcycle of
+            # task.exception().__traceback__->TaskGroup.create_task->task
+            del task
 
     # Since Python 3.8 Tasks propagate all exceptions correctly,
     # except for KeyboardInterrupt and SystemExit which are
index 138f59ebf57ef71f2276f1ed54e4f87e3aef3912..e52061aac923cc953bf880bc2bd7869cdb7151de 100644 (file)
@@ -1,6 +1,8 @@
 # Adapted with permission from the EdgeDB project;
 # license: PSFL.
 
+import weakref
+import sys
 import gc
 import asyncio
 import contextvars
@@ -28,7 +30,25 @@ def get_error_types(eg):
     return {type(exc) for exc in eg.exceptions}
 
 
-class TestTaskGroup(unittest.IsolatedAsyncioTestCase):
+def set_gc_state(enabled):
+    was_enabled = gc.isenabled()
+    if enabled:
+        gc.enable()
+    else:
+        gc.disable()
+    return was_enabled
+
+
+@contextlib.contextmanager
+def disable_gc():
+    was_enabled = set_gc_state(enabled=False)
+    try:
+        yield
+    finally:
+        set_gc_state(enabled=was_enabled)
+
+
+class BaseTestTaskGroup:
 
     async def test_taskgroup_01(self):
 
@@ -822,15 +842,15 @@ class TestTaskGroup(unittest.IsolatedAsyncioTestCase):
         with self.assertRaisesRegex(RuntimeError, "has not been entered"):
             tg.create_task(coro)
 
-    def test_coro_closed_when_tg_closed(self):
+    async def test_coro_closed_when_tg_closed(self):
         async def run_coro_after_tg_closes():
             async with taskgroups.TaskGroup() as tg:
                 pass
             coro = asyncio.sleep(0)
             with self.assertRaisesRegex(RuntimeError, "is finished"):
                 tg.create_task(coro)
-        loop = asyncio.get_event_loop()
-        loop.run_until_complete(run_coro_after_tg_closes())
+
+        await run_coro_after_tg_closes()
 
     async def test_cancelling_level_preserved(self):
         async def raise_after(t, e):
@@ -955,6 +975,30 @@ class TestTaskGroup(unittest.IsolatedAsyncioTestCase):
         self.assertIsInstance(exc, _Done)
         self.assertListEqual(gc.get_referrers(exc), [])
 
+
+    async def test_exception_refcycles_parent_task_wr(self):
+        """Test that TaskGroup deletes self._parent_task and create_task() deletes task"""
+        tg = asyncio.TaskGroup()
+        exc = None
+
+        class _Done(Exception):
+            pass
+
+        async def coro_fn():
+            async with tg:
+                raise _Done
+
+        with disable_gc():
+            try:
+                async with asyncio.TaskGroup() as tg2:
+                    task_wr = weakref.ref(tg2.create_task(coro_fn()))
+            except* _Done as excs:
+                exc = excs.exceptions[0].exceptions[0]
+
+        self.assertIsNone(task_wr())
+        self.assertIsInstance(exc, _Done)
+        self.assertListEqual(gc.get_referrers(exc), [])
+
     async def test_exception_refcycles_propagate_cancellation_error(self):
         """Test that TaskGroup deletes propagate_cancellation_error"""
         tg = asyncio.TaskGroup()
@@ -988,5 +1032,16 @@ class TestTaskGroup(unittest.IsolatedAsyncioTestCase):
         self.assertListEqual(gc.get_referrers(exc), [])
 
 
+class TestTaskGroup(BaseTestTaskGroup, unittest.IsolatedAsyncioTestCase):
+    loop_factory = asyncio.EventLoop
+
+class TestEagerTaskTaskGroup(BaseTestTaskGroup, unittest.IsolatedAsyncioTestCase):
+    @staticmethod
+    def loop_factory():
+        loop = asyncio.EventLoop()
+        loop.set_task_factory(asyncio.eager_task_factory)
+        return loop
+
+
 if __name__ == "__main__":
     unittest.main()
diff --git a/Misc/NEWS.d/next/Library/2025-01-06-18-41-08.gh-issue-128552.fV-f8j.rst b/Misc/NEWS.d/next/Library/2025-01-06-18-41-08.gh-issue-128552.fV-f8j.rst
new file mode 100644 (file)
index 0000000..83816f7
--- /dev/null
@@ -0,0 +1 @@
+Fix cyclic garbage introduced by :meth:`asyncio.loop.create_task` and :meth:`asyncio.TaskGroup.create_task` holding a reference to the created task if it is eager.