From 8ec2f60f79ca08731268b67496fda0a16ec7e26f Mon Sep 17 00:00:00 2001 From: John Belmonte Date: Sat, 23 Nov 2024 22:39:35 -0800 Subject: [PATCH 01/15] add TaskGroup.stop() ISSUE: #108951 --- Doc/library/asyncio-task.rst | 66 +++++++----------------- Lib/asyncio/taskgroups.py | 34 ++++++++++++ Lib/test/test_asyncio/test_taskgroups.py | 65 +++++++++++++++++++++++ 3 files changed, 118 insertions(+), 47 deletions(-) diff --git a/Doc/library/asyncio-task.rst b/Doc/library/asyncio-task.rst index f27e858cf420f4..15f5a2a99c91ad 100644 --- a/Doc/library/asyncio-task.rst +++ b/Doc/library/asyncio-task.rst @@ -342,6 +342,25 @@ and reliable way to wait for all tasks in the group to finish. Close the given coroutine if the task group is not active. + .. method:: stop() + + Stop the task group + + :meth:`~asyncio.Task.cancel` will be called on any tasks in the group that + aren't yet done, as well as the parent (body) of the group. This will + cause the task group context manager to exit *without* a + :exc:`asyncio.CancelledError` being raised. + + If :meth:`stop` is called before entering the task group, the group will be + stopped upon entry. This is useful for patterns where one piece of + code passes an unused TaskGroup instance to another in order to have + the ability to stop anything run within the group. + + :meth:`stop` is idempotent and may be called after the task group has + already exited. + + .. versionadded:: 3.14 + Example:: async def main(): @@ -414,53 +433,6 @@ reported by :meth:`asyncio.Task.cancelling`. Improved handling of simultaneous internal and external cancellations and correct preservation of cancellation counts. -Terminating a Task Group ------------------------- - -While terminating a task group is not natively supported by the standard -library, termination can be achieved by adding an exception-raising task -to the task group and ignoring the raised exception: - -.. code-block:: python - - import asyncio - from asyncio import TaskGroup - - class TerminateTaskGroup(Exception): - """Exception raised to terminate a task group.""" - - async def force_terminate_task_group(): - """Used to force termination of a task group.""" - raise TerminateTaskGroup() - - async def job(task_id, sleep_time): - print(f'Task {task_id}: start') - await asyncio.sleep(sleep_time) - print(f'Task {task_id}: done') - - async def main(): - try: - async with TaskGroup() as group: - # spawn some tasks - group.create_task(job(1, 0.5)) - group.create_task(job(2, 1.5)) - # sleep for 1 second - await asyncio.sleep(1) - # add an exception-raising task to force the group to terminate - group.create_task(force_terminate_task_group()) - except* TerminateTaskGroup: - pass - - asyncio.run(main()) - -Expected output: - -.. code-block:: text - - Task 1: start - Task 2: start - Task 1: done - Sleeping ======== diff --git a/Lib/asyncio/taskgroups.py b/Lib/asyncio/taskgroups.py index 9fa772ca9d02cc..beb381b68b20b2 100644 --- a/Lib/asyncio/taskgroups.py +++ b/Lib/asyncio/taskgroups.py @@ -36,6 +36,7 @@ def __init__(self): self._errors = [] self._base_error = None self._on_completed_fut = None + self._stop_on_enter = False def __repr__(self): info = [''] @@ -62,6 +63,8 @@ async def __aenter__(self): raise RuntimeError( f'TaskGroup {self!r} cannot determine the parent task') self._entered = True + if self._stop_on_enter: + self.stop() return self @@ -147,6 +150,10 @@ async def _aexit(self, et, exc): # If there are no pending cancellations left, # don't propagate CancelledError. propagate_cancellation_error = None + # If Cancelled would actually be raised out of the TaskGroup, + # suppress it-- this is significant when using stop(). + if not self._errors: + return True # Propagate CancelledError if there is one, except if there # are other errors -- those have priority. @@ -273,3 +280,30 @@ def _on_task_done(self, task): self._abort() self._parent_cancel_requested = True self._parent_task.cancel() + + def stop(self): + """Stop the task group + + `cancel()` will be called on any tasks in the group that aren't yet + done, as well as the parent (body) of the group. This will cause the + task group context manager to exit *without* a Cancelled exception + being raised. + + If `stop()` is called before entering the task group, the group will be + stopped upon entry. This is useful for patterns where one piece of + code passes an unused TaskGroup instance to another in order to have + the ability to stop anything run within the group. + + `stop()` is idempotent and may be called after the task group has + already exited. + """ + if not self._entered: + self._stop_on_enter = True + return + if self._exiting and not self._tasks: + return + if not self._aborting: + self._abort() + if self._parent_task and not self._parent_cancel_requested: + self._parent_cancel_requested = True + self._parent_task.cancel() diff --git a/Lib/test/test_asyncio/test_taskgroups.py b/Lib/test/test_asyncio/test_taskgroups.py index 1b4de96a572fb9..aa68de4bc012f8 100644 --- a/Lib/test/test_asyncio/test_taskgroups.py +++ b/Lib/test/test_asyncio/test_taskgroups.py @@ -3,10 +3,12 @@ import sys import gc + import asyncio import contextvars import contextlib from asyncio import taskgroups +import math import unittest import warnings @@ -997,6 +999,69 @@ class MyKeyboardInterrupt(KeyboardInterrupt): self.assertIsNotNone(exc) self.assertListEqual(gc.get_referrers(exc), no_other_refs()) + async def test_taskgroup_stop_children(self): + async with asyncio.TaskGroup() as tg: + tg.create_task(asyncio.sleep(math.inf)) + tg.create_task(asyncio.sleep(math.inf)) + await asyncio.sleep(0) + tg.stop() + + async def test_taskgroup_stop_body(self): + count = 0 + async with asyncio.TaskGroup() as tg: + tg.stop() + count += 1 + await asyncio.sleep(0) + count += 1 + self.assertEqual(count, 1) + + async def test_taskgroup_stop_idempotent(self): + count = 0 + async with asyncio.TaskGroup() as tg: + tg.stop() + tg.stop() + count += 1 + await asyncio.sleep(0) + count += 1 + self.assertEqual(count, 1) + + async def test_taskgroup_stop_after_exit(self): + async with asyncio.TaskGroup() as tg: + await asyncio.sleep(0) + tg.stop() + + async def test_taskgroup_stop_before_enter(self): + tg = asyncio.TaskGroup() + tg.stop() + count = 0 + async with tg: + count += 1 + await asyncio.sleep(0) + count += 1 + self.assertEqual(count, 1) + + async def test_taskgroup_stop_before_exception(self): + async def raise_exc(parent_tg: asyncio.TaskGroup): + parent_tg.stop() + raise RuntimeError + + with self.assertRaises(ExceptionGroup): + async with asyncio.TaskGroup() as tg: + tg.create_task(raise_exc(tg)) + await asyncio.sleep(1) + + async def test_taskgroup_stop_after_exception(self): + async def raise_exc(parent_tg: asyncio.TaskGroup): + try: + raise RuntimeError + finally: + parent_tg.stop() + + with self.assertRaises(ExceptionGroup): + async with asyncio.TaskGroup() as tg: + tg.create_task(raise_exc(tg)) + await asyncio.sleep(1) + if __name__ == "__main__": unittest.main() From 907f1d0d1d1b9657bb22559011ed7df11c33a373 Mon Sep 17 00:00:00 2001 From: "blurb-it[bot]" <43283697+blurb-it[bot]@users.noreply.github.com> Date: Sun, 24 Nov 2024 07:18:46 +0000 Subject: [PATCH 02/15] =?UTF-8?q?=F0=9F=93=9C=F0=9F=A4=96=20Added=20by=20b?= =?UTF-8?q?lurb=5Fit.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../next/Library/2024-11-24-07-18-40.gh-issue-108951.jyKygP.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 Misc/NEWS.d/next/Library/2024-11-24-07-18-40.gh-issue-108951.jyKygP.rst diff --git a/Misc/NEWS.d/next/Library/2024-11-24-07-18-40.gh-issue-108951.jyKygP.rst b/Misc/NEWS.d/next/Library/2024-11-24-07-18-40.gh-issue-108951.jyKygP.rst new file mode 100644 index 00000000000000..9c12797306a584 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2024-11-24-07-18-40.gh-issue-108951.jyKygP.rst @@ -0,0 +1 @@ +Add :meth:`~asyncio.TaskGroup.stop`. From 2cfa1e6d1c2b5089694e3e59934660b6346e7e5e Mon Sep 17 00:00:00 2001 From: John Belmonte Date: Sat, 23 Nov 2024 23:24:49 -0800 Subject: [PATCH 03/15] minor doc fixes --- Doc/library/asyncio-task.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Doc/library/asyncio-task.rst b/Doc/library/asyncio-task.rst index 15f5a2a99c91ad..1f7943cd819ce5 100644 --- a/Doc/library/asyncio-task.rst +++ b/Doc/library/asyncio-task.rst @@ -344,16 +344,16 @@ and reliable way to wait for all tasks in the group to finish. .. method:: stop() - Stop the task group + Stop the task group. :meth:`~asyncio.Task.cancel` will be called on any tasks in the group that aren't yet done, as well as the parent (body) of the group. This will - cause the task group context manager to exit *without* a + cause the task group context manager to exit *without* :exc:`asyncio.CancelledError` being raised. If :meth:`stop` is called before entering the task group, the group will be stopped upon entry. This is useful for patterns where one piece of - code passes an unused TaskGroup instance to another in order to have + code passes an unused :class:`asyncio.TaskGroup` instance to another in order to have the ability to stop anything run within the group. :meth:`stop` is idempotent and may be called after the task group has From bce1fb1027754c80a0e844df8af960ffa167cbfb Mon Sep 17 00:00:00 2001 From: John Belmonte Date: Sun, 24 Nov 2024 10:43:31 -0800 Subject: [PATCH 04/15] make tests more explicit --- Lib/test/test_asyncio/test_taskgroups.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/Lib/test/test_asyncio/test_taskgroups.py b/Lib/test/test_asyncio/test_taskgroups.py index aa68de4bc012f8..f1b96269b6781d 100644 --- a/Lib/test/test_asyncio/test_taskgroups.py +++ b/Lib/test/test_asyncio/test_taskgroups.py @@ -3,12 +3,10 @@ import sys import gc - import asyncio import contextvars import contextlib from asyncio import taskgroups -import math import unittest import warnings @@ -1000,11 +998,13 @@ class MyKeyboardInterrupt(KeyboardInterrupt): self.assertListEqual(gc.get_referrers(exc), no_other_refs()) async def test_taskgroup_stop_children(self): - async with asyncio.TaskGroup() as tg: - tg.create_task(asyncio.sleep(math.inf)) - tg.create_task(asyncio.sleep(math.inf)) - await asyncio.sleep(0) - tg.stop() + # (asserting that TimeoutError is not raised) + async with asyncio.timeout(1): + async with asyncio.TaskGroup() as tg: + tg.create_task(asyncio.sleep(10)) + tg.create_task(asyncio.sleep(10)) + await asyncio.sleep(0) + tg.stop() async def test_taskgroup_stop_body(self): count = 0 @@ -1028,6 +1028,7 @@ async def test_taskgroup_stop_idempotent(self): async def test_taskgroup_stop_after_exit(self): async with asyncio.TaskGroup() as tg: await asyncio.sleep(0) + # (asserting that exception is not raised) tg.stop() async def test_taskgroup_stop_before_enter(self): From 7754aad934b75ecb05bbadc0b9333c85b8852e77 Mon Sep 17 00:00:00 2001 From: John Belmonte Date: Mon, 25 Nov 2024 04:02:16 +0900 Subject: [PATCH 05/15] use versionadded:: next Co-authored-by: sobolevn --- Doc/library/asyncio-task.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Doc/library/asyncio-task.rst b/Doc/library/asyncio-task.rst index 1f7943cd819ce5..43f5fa3264152e 100644 --- a/Doc/library/asyncio-task.rst +++ b/Doc/library/asyncio-task.rst @@ -359,7 +359,7 @@ and reliable way to wait for all tasks in the group to finish. :meth:`stop` is idempotent and may be called after the task group has already exited. - .. versionadded:: 3.14 + .. versionadded:: next Example:: From 452042d83b1bbdfa453a4442cd515f8c80ce0d95 Mon Sep 17 00:00:00 2001 From: John Belmonte Date: Fri, 29 Nov 2024 15:57:10 -0800 Subject: [PATCH 06/15] stop() -> cancel() --- Doc/library/asyncio-task.rst | 12 ++++---- Lib/asyncio/taskgroups.py | 22 +++++++------- Lib/test/test_asyncio/test_taskgroups.py | 30 +++++++++---------- ...-11-24-07-18-40.gh-issue-108951.jyKygP.rst | 2 +- 4 files changed, 33 insertions(+), 33 deletions(-) diff --git a/Doc/library/asyncio-task.rst b/Doc/library/asyncio-task.rst index 43f5fa3264152e..22874e9c9e7d3e 100644 --- a/Doc/library/asyncio-task.rst +++ b/Doc/library/asyncio-task.rst @@ -342,21 +342,21 @@ and reliable way to wait for all tasks in the group to finish. Close the given coroutine if the task group is not active. - .. method:: stop() + .. method:: cancel() - Stop the task group. + Cancel the task group. :meth:`~asyncio.Task.cancel` will be called on any tasks in the group that aren't yet done, as well as the parent (body) of the group. This will cause the task group context manager to exit *without* :exc:`asyncio.CancelledError` being raised. - If :meth:`stop` is called before entering the task group, the group will be - stopped upon entry. This is useful for patterns where one piece of + If :meth:`cancel` is called before entering the task group, the group will be + cancelled upon entry. This is useful for patterns where one piece of code passes an unused :class:`asyncio.TaskGroup` instance to another in order to have - the ability to stop anything run within the group. + the ability to cancel anything run within the group. - :meth:`stop` is idempotent and may be called after the task group has + :meth:`cancel` is idempotent and may be called after the task group has already exited. .. versionadded:: next diff --git a/Lib/asyncio/taskgroups.py b/Lib/asyncio/taskgroups.py index beb381b68b20b2..6b9cb8aed4facc 100644 --- a/Lib/asyncio/taskgroups.py +++ b/Lib/asyncio/taskgroups.py @@ -36,7 +36,7 @@ def __init__(self): self._errors = [] self._base_error = None self._on_completed_fut = None - self._stop_on_enter = False + self._cancel_on_enter = False def __repr__(self): info = [''] @@ -63,8 +63,8 @@ async def __aenter__(self): raise RuntimeError( f'TaskGroup {self!r} cannot determine the parent task') self._entered = True - if self._stop_on_enter: - self.stop() + if self._cancel_on_enter: + self.cancel() return self @@ -281,24 +281,24 @@ def _on_task_done(self, task): self._parent_cancel_requested = True self._parent_task.cancel() - def stop(self): - """Stop the task group + def cancel(self): + """Cancel the task group `cancel()` will be called on any tasks in the group that aren't yet done, as well as the parent (body) of the group. This will cause the - task group context manager to exit *without* a Cancelled exception + task group context manager to exit *without* `asyncio.CancelledError` being raised. - If `stop()` is called before entering the task group, the group will be - stopped upon entry. This is useful for patterns where one piece of + If `cancel()` is called before entering the task group, the group will be + cancelled upon entry. This is useful for patterns where one piece of code passes an unused TaskGroup instance to another in order to have - the ability to stop anything run within the group. + the ability to cancel anything run within the group. - `stop()` is idempotent and may be called after the task group has + `cancel()` is idempotent and may be called after the task group has already exited. """ if not self._entered: - self._stop_on_enter = True + self._cancel_on_enter = True return if self._exiting and not self._tasks: return diff --git a/Lib/test/test_asyncio/test_taskgroups.py b/Lib/test/test_asyncio/test_taskgroups.py index f1b96269b6781d..0b1f97ed79bffe 100644 --- a/Lib/test/test_asyncio/test_taskgroups.py +++ b/Lib/test/test_asyncio/test_taskgroups.py @@ -997,43 +997,43 @@ class MyKeyboardInterrupt(KeyboardInterrupt): self.assertIsNotNone(exc) self.assertListEqual(gc.get_referrers(exc), no_other_refs()) - async def test_taskgroup_stop_children(self): + async def test_taskgroup_cancel_children(self): # (asserting that TimeoutError is not raised) async with asyncio.timeout(1): async with asyncio.TaskGroup() as tg: tg.create_task(asyncio.sleep(10)) tg.create_task(asyncio.sleep(10)) await asyncio.sleep(0) - tg.stop() + tg.cancel() - async def test_taskgroup_stop_body(self): + async def test_taskgroup_cancel_body(self): count = 0 async with asyncio.TaskGroup() as tg: - tg.stop() + tg.cancel() count += 1 await asyncio.sleep(0) count += 1 self.assertEqual(count, 1) - async def test_taskgroup_stop_idempotent(self): + async def test_taskgroup_cancel_idempotent(self): count = 0 async with asyncio.TaskGroup() as tg: - tg.stop() - tg.stop() + tg.cancel() + tg.cancel() count += 1 await asyncio.sleep(0) count += 1 self.assertEqual(count, 1) - async def test_taskgroup_stop_after_exit(self): + async def test_taskgroup_cancel_after_exit(self): async with asyncio.TaskGroup() as tg: await asyncio.sleep(0) # (asserting that exception is not raised) - tg.stop() + tg.cancel() - async def test_taskgroup_stop_before_enter(self): + async def test_taskgroup_cancel_before_enter(self): tg = asyncio.TaskGroup() - tg.stop() + tg.cancel() count = 0 async with tg: count += 1 @@ -1041,9 +1041,9 @@ async def test_taskgroup_stop_before_enter(self): count += 1 self.assertEqual(count, 1) - async def test_taskgroup_stop_before_exception(self): + async def test_taskgroup_cancel_before_exception(self): async def raise_exc(parent_tg: asyncio.TaskGroup): - parent_tg.stop() + parent_tg.cancel() raise RuntimeError with self.assertRaises(ExceptionGroup): @@ -1051,12 +1051,12 @@ async def raise_exc(parent_tg: asyncio.TaskGroup): tg.create_task(raise_exc(tg)) await asyncio.sleep(1) - async def test_taskgroup_stop_after_exception(self): + async def test_taskgroup_cancel_after_exception(self): async def raise_exc(parent_tg: asyncio.TaskGroup): try: raise RuntimeError finally: - parent_tg.stop() + parent_tg.cancel() with self.assertRaises(ExceptionGroup): async with asyncio.TaskGroup() as tg: diff --git a/Misc/NEWS.d/next/Library/2024-11-24-07-18-40.gh-issue-108951.jyKygP.rst b/Misc/NEWS.d/next/Library/2024-11-24-07-18-40.gh-issue-108951.jyKygP.rst index 9c12797306a584..1a27bef702d583 100644 --- a/Misc/NEWS.d/next/Library/2024-11-24-07-18-40.gh-issue-108951.jyKygP.rst +++ b/Misc/NEWS.d/next/Library/2024-11-24-07-18-40.gh-issue-108951.jyKygP.rst @@ -1 +1 @@ -Add :meth:`~asyncio.TaskGroup.stop`. +Add :meth:`~asyncio.TaskGroup.cancel`. From f077241b9a3ff4b1538e639ae8097fec7eeb5b31 Mon Sep 17 00:00:00 2001 From: John Belmonte Date: Fri, 29 Nov 2024 16:09:21 -0800 Subject: [PATCH 07/15] document some ways to use cancel() --- Doc/library/asyncio-task.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Doc/library/asyncio-task.rst b/Doc/library/asyncio-task.rst index 22874e9c9e7d3e..a662d1e5531ff4 100644 --- a/Doc/library/asyncio-task.rst +++ b/Doc/library/asyncio-task.rst @@ -359,6 +359,14 @@ and reliable way to wait for all tasks in the group to finish. :meth:`cancel` is idempotent and may be called after the task group has already exited. + Ways to use :meth:`cancel`: + + * call it from the task group body based on some condition or event + * pass the task group instance to child tasks via :meth:`create_task`, allowing a child + task to conditionally cancel the entire entire group + * pass the task group instance or bound :meth:`cancel` method to some other task *before* + opening the task group, allowing remote cancellation + .. versionadded:: next Example:: From 8345647a8a9ae2dbedfaaedd453389bde2e00a68 Mon Sep 17 00:00:00 2001 From: John Belmonte Date: Sat, 30 Nov 2024 16:12:43 -0800 Subject: [PATCH 08/15] fix cases of exception in task group body before/after cancel() --- Lib/asyncio/taskgroups.py | 8 ++++---- Lib/test/test_asyncio/test_taskgroups.py | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/Lib/asyncio/taskgroups.py b/Lib/asyncio/taskgroups.py index 6b9cb8aed4facc..1986df5b55ae09 100644 --- a/Lib/asyncio/taskgroups.py +++ b/Lib/asyncio/taskgroups.py @@ -150,10 +150,6 @@ async def _aexit(self, et, exc): # If there are no pending cancellations left, # don't propagate CancelledError. propagate_cancellation_error = None - # If Cancelled would actually be raised out of the TaskGroup, - # suppress it-- this is significant when using stop(). - if not self._errors: - return True # Propagate CancelledError if there is one, except if there # are other errors -- those have priority. @@ -184,6 +180,10 @@ async def _aexit(self, et, exc): finally: exc = None + # If we wanted to raise an error, it would have been done explicitly + # above. Otherwise, either there is no error or we want to suppress + # the original error. + return True def create_task(self, coro, *, name=None, context=None): """Create a new task in this group and return it. diff --git a/Lib/test/test_asyncio/test_taskgroups.py b/Lib/test/test_asyncio/test_taskgroups.py index 0b1f97ed79bffe..3d67c4f14a9a5c 100644 --- a/Lib/test/test_asyncio/test_taskgroups.py +++ b/Lib/test/test_asyncio/test_taskgroups.py @@ -1063,6 +1063,20 @@ async def raise_exc(parent_tg: asyncio.TaskGroup): tg.create_task(raise_exc(tg)) await asyncio.sleep(1) + async def test_taskgroup_body_cancel_before_exception(self): + with self.assertRaises(ExceptionGroup): + async with asyncio.TaskGroup() as tg: + tg.cancel() + raise RuntimeError + + async def test_taskgroup_body_cancel_after_exception(self): + with self.assertRaises(ExceptionGroup): + async with asyncio.TaskGroup() as tg: + try: + raise RuntimeError + finally: + tg.cancel() + if __name__ == "__main__": unittest.main() From 7235c20989391bb99e37863fd7bcebdce7156e0a Mon Sep 17 00:00:00 2001 From: John Belmonte Date: Sat, 30 Nov 2024 17:56:44 -0800 Subject: [PATCH 09/15] add test for create_task() following cancel() --- Lib/test/test_asyncio/test_taskgroups.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Lib/test/test_asyncio/test_taskgroups.py b/Lib/test/test_asyncio/test_taskgroups.py index 3d67c4f14a9a5c..f1585fb60aaf38 100644 --- a/Lib/test/test_asyncio/test_taskgroups.py +++ b/Lib/test/test_asyncio/test_taskgroups.py @@ -1041,6 +1041,14 @@ async def test_taskgroup_cancel_before_enter(self): count += 1 self.assertEqual(count, 1) + async def test_taskgroup_cancel_before_create_task(self): + async with asyncio.TaskGroup() as tg: + tg.cancel() + # TODO: This behavior is not ideal. We'd rather have no exception + # raised, and the child task run until the first await. + with self.assertRaises(RuntimeError): + tg.create_task(asyncio.sleep(1)) + async def test_taskgroup_cancel_before_exception(self): async def raise_exc(parent_tg: asyncio.TaskGroup): parent_tg.cancel() From e36a1af800b89b0273510bc35144279afa6bf9d5 Mon Sep 17 00:00:00 2001 From: John Belmonte Date: Mon, 20 Apr 2026 19:07:57 -0700 Subject: [PATCH 10/15] apply comment revision Co-authored-by: Guido van Rossum --- Lib/asyncio/taskgroups.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Lib/asyncio/taskgroups.py b/Lib/asyncio/taskgroups.py index 1986df5b55ae09..2daf24619d34d3 100644 --- a/Lib/asyncio/taskgroups.py +++ b/Lib/asyncio/taskgroups.py @@ -180,9 +180,8 @@ async def _aexit(self, et, exc): finally: exc = None - # If we wanted to raise an error, it would have been done explicitly - # above. Otherwise, either there is no error or we want to suppress - # the original error. + # Suppress any remaining exception (exceptions deserving to be raised + # were raised above). return True def create_task(self, coro, *, name=None, context=None): From 5ae5ee9e97fdf91088028b3f4922dd9215e37e4e Mon Sep 17 00:00:00 2001 From: John Belmonte Date: Mon, 20 Apr 2026 19:17:46 -0700 Subject: [PATCH 11/15] expand news entry --- .../next/Library/2024-11-24-07-18-40.gh-issue-108951.jyKygP.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Misc/NEWS.d/next/Library/2024-11-24-07-18-40.gh-issue-108951.jyKygP.rst b/Misc/NEWS.d/next/Library/2024-11-24-07-18-40.gh-issue-108951.jyKygP.rst index 1a27bef702d583..b603e4c66e6b00 100644 --- a/Misc/NEWS.d/next/Library/2024-11-24-07-18-40.gh-issue-108951.jyKygP.rst +++ b/Misc/NEWS.d/next/Library/2024-11-24-07-18-40.gh-issue-108951.jyKygP.rst @@ -1 +1 @@ -Add :meth:`~asyncio.TaskGroup.cancel`. +Add :meth:`~asyncio.TaskGroup.cancel` which cancels unfinished tasks and exits the group without error." From c83d853ea220fe2537f247ac7b1853324b18bf1e Mon Sep 17 00:00:00 2001 From: Guido van Rossum Date: Mon, 20 Apr 2026 19:54:42 -0700 Subject: [PATCH 12/15] Remove stray quote in NEWS --- .../next/Library/2024-11-24-07-18-40.gh-issue-108951.jyKygP.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Misc/NEWS.d/next/Library/2024-11-24-07-18-40.gh-issue-108951.jyKygP.rst b/Misc/NEWS.d/next/Library/2024-11-24-07-18-40.gh-issue-108951.jyKygP.rst index b603e4c66e6b00..1696a2dd1728ed 100644 --- a/Misc/NEWS.d/next/Library/2024-11-24-07-18-40.gh-issue-108951.jyKygP.rst +++ b/Misc/NEWS.d/next/Library/2024-11-24-07-18-40.gh-issue-108951.jyKygP.rst @@ -1 +1 @@ -Add :meth:`~asyncio.TaskGroup.cancel` which cancels unfinished tasks and exits the group without error." +Add :meth:`~asyncio.TaskGroup.cancel` which cancels unfinished tasks and exits the group without error. From 1ce0b40293f55fce1c543e59e527d7c9ee5c70ee Mon Sep 17 00:00:00 2001 From: John Belmonte Date: Wed, 22 Apr 2026 19:56:00 -0700 Subject: [PATCH 13/15] suppress lint error for removed doc ID terminating-a-task-group --- Doc/tools/removed-ids.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Doc/tools/removed-ids.txt b/Doc/tools/removed-ids.txt index 7bffbb8d86197d..5e3ef2efe271fd 100644 --- a/Doc/tools/removed-ids.txt +++ b/Doc/tools/removed-ids.txt @@ -3,3 +3,5 @@ # Remove from here in 3.16 c-api/allocation.html: deprecated-aliases c-api/file.html: deprecated-api + +library/asyncio-task.html: terminating-a-task-group From e8617b4293ba577e9c2986c1a32d2f584738c171 Mon Sep 17 00:00:00 2001 From: John Belmonte Date: Wed, 22 Apr 2026 20:47:15 -0700 Subject: [PATCH 14/15] move test methods back to BaseTestTaskGroup (caused by bad merge) --- Lib/test/test_asyncio/test_taskgroups.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/Lib/test/test_asyncio/test_taskgroups.py b/Lib/test/test_asyncio/test_taskgroups.py index fff056dea011ff..d9e96e2703bf6d 100644 --- a/Lib/test/test_asyncio/test_taskgroups.py +++ b/Lib/test/test_asyncio/test_taskgroups.py @@ -1102,17 +1102,6 @@ async def throw_error(): # cancellation happens here and error is more understandable await asyncio.sleep(0) - -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 - async def test_taskgroup_cancel_children(self): # (asserting that TimeoutError is not raised) async with asyncio.timeout(1): @@ -1202,5 +1191,16 @@ async def test_taskgroup_body_cancel_after_exception(self): tg.cancel() +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() From 8710b95065d5f529a39e1d116c4d1d428945e86b Mon Sep 17 00:00:00 2001 From: John Belmonte Date: Wed, 22 Apr 2026 21:19:20 -0700 Subject: [PATCH 15/15] add race test --- Lib/test/test_asyncio/test_taskgroups.py | 37 ++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/Lib/test/test_asyncio/test_taskgroups.py b/Lib/test/test_asyncio/test_taskgroups.py index d9e96e2703bf6d..8925884b9dcf73 100644 --- a/Lib/test/test_asyncio/test_taskgroups.py +++ b/Lib/test/test_asyncio/test_taskgroups.py @@ -1190,6 +1190,43 @@ async def test_taskgroup_body_cancel_after_exception(self): finally: tg.cancel() + async def test_taskgroup_cancel_one_winner(self): + async def race(*fns): + outcome = None + async def run(fn): + nonlocal outcome + outcome = await fn() + tg.cancel() + + async with asyncio.TaskGroup() as tg: + for fn in fns: + tg.create_task(run(fn)) + return outcome + + event = asyncio.Event() + record = [] + async def fn_1(): + record.append("1 started") + await event.wait() + record.append("1 finished") + return 1 + + async def fn_2(): + record.append("2 started") + await event.wait() + record.append("2 finished") + return 2 + + async def fn_3(): + record.append("3 started") + event.set() + await asyncio.sleep(10) + record.append("3 finished") + return 3 + + self.assertEqual(await race(fn_1, fn_2, fn_3), 1) + self.assertListEqual(record, ["1 started", "2 started", "3 started", "1 finished"]) + class TestTaskGroup(BaseTestTaskGroup, unittest.IsolatedAsyncioTestCase): loop_factory = asyncio.EventLoop