From d5ae1fc7feebe434fab88906de1302769faa64b3 Mon Sep 17 00:00:00 2001 From: Kumar Aditya Date: Mon, 27 Jul 2026 14:38:44 +0530 Subject: [PATCH] gh-102572: Fix KeyboardInterrupt in TaskGroup child task crashing the event loop A KeyboardInterrupt or SystemExit raised by a task in a TaskGroup was both set as the task's exception and re-raised into the event loop, stopping it before the group could re-raise the exception in the parent task as documented. Task now only re-raises KeyboardInterrupt/SystemExit into the event loop when the private flag Task._reraise_base_exceptions is true (the default); TaskGroup disables it for its child tasks since it delivers the exception to the parent task itself. --- Lib/asyncio/taskgroups.py | 3 + Lib/asyncio/tasks.py | 7 +- Lib/test/test_asyncio/test_taskgroups.py | 98 +++++++++++++++---- Lib/test/test_asyncio/test_tasks.py | 38 +++++++ ...-07-27-00-00-00.gh-issue-102572.hqCLgU.rst | 4 + Modules/_asynciomodule.c | 51 +++++++++- Modules/clinic/_asynciomodule.c.h | 53 +++++++++- 7 files changed, 228 insertions(+), 26 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-07-27-00-00-00.gh-issue-102572.hqCLgU.rst diff --git a/Lib/asyncio/taskgroups.py b/Lib/asyncio/taskgroups.py index e1ec025791a52e..c8cbd4b0581e29 100644 --- a/Lib/asyncio/taskgroups.py +++ b/Lib/asyncio/taskgroups.py @@ -201,6 +201,9 @@ def create_task(self, coro, **kwargs): raise RuntimeError(f"TaskGroup {self!r} is shutting down") task = self._loop.create_task(coro, **kwargs) + # gh-102572: the TaskGroup re-raises base exceptions in the parent task + task._reraise_base_exceptions = False + futures.future_add_to_awaited_by(task, self._parent_task) # Always schedule the done callback even if the task is diff --git a/Lib/asyncio/tasks.py b/Lib/asyncio/tasks.py index 9d20930dc300c6..120999c87eeaf1 100644 --- a/Lib/asyncio/tasks.py +++ b/Lib/asyncio/tasks.py @@ -81,6 +81,10 @@ class Task(futures._PyFuture): # Inherit Python Task implementation # status is still pending _log_destroy_pending = True + # If False, don't re-raise KeyboardInterrupt/SystemExit raised by the + # coroutine into the event loop (used by TaskGroup) + _reraise_base_exceptions = True + def __init__(self, coro, *, loop=None, name=None, context=None, eager_start=False): super().__init__(loop=loop) @@ -302,7 +306,8 @@ def __step_run_and_handle_result(self, exc): super().cancel() # I.e., Future.cancel(self). except (KeyboardInterrupt, SystemExit) as exc: super().set_exception(exc) - raise + if self._reraise_base_exceptions: + raise except BaseException as exc: super().set_exception(exc) else: diff --git a/Lib/test/test_asyncio/test_taskgroups.py b/Lib/test/test_asyncio/test_taskgroups.py index e1eaa60e4df85d..f7c5ea898ad013 100644 --- a/Lib/test/test_asyncio/test_taskgroups.py +++ b/Lib/test/test_asyncio/test_taskgroups.py @@ -608,31 +608,87 @@ async def runner(): get_error_types(cm.exception), {MyBaseExc, ZeroDivisionError} ) - async def _test_taskgroup_21(self): - # This test doesn't work as asyncio, currently, doesn't - # correctly propagate KeyboardInterrupt (or SystemExit) -- - # those cause the event loop itself to crash. - # (Compare to the previous (passing) test -- that one raises - # a plain exception but raises KeyboardInterrupt in nested(); - # this test does it the other way around.) + async def test_taskgroup_21(self): + # gh-102572: KeyboardInterrupt/SystemExit raised by a task in the + # group is re-raised in the parent task, not into the event loop. + for exc_type in (KeyboardInterrupt, SystemExit): + with self.subTest(exc_type=exc_type): + async def crash_soon(): + await asyncio.sleep(0.1) + raise exc_type + + async def nested(): + try: + await asyncio.sleep(10) + finally: + raise TypeError + + async def runner(): + async with taskgroups.TaskGroup() as g: + g.create_task(crash_soon()) + await nested() + + with self.assertRaises(exc_type): + await runner() + + # The event loop is still running normally. + await asyncio.sleep(0) - async def crash_soon(): - await asyncio.sleep(0.1) - raise KeyboardInterrupt + async def test_taskgroup_21b(self): + # gh-102572: as above, but raised in response to cancellation + # after a regular exception aborted the group. + loop = asyncio.get_running_loop() + for exc_type in (KeyboardInterrupt, SystemExit): + with self.subTest(exc_type=exc_type): + fut = loop.create_future() + base_exc = exc_type() + + async def coro(): + try: + fut.set_exception(TypeError()) + await loop.create_future() + except asyncio.CancelledError: + raise base_exc + + async def runner(): + async with taskgroups.TaskGroup() as g: + g.create_task(coro()) + await fut + + with self.assertRaises(exc_type) as cm: + await runner() + + self.assertIs(cm.exception, base_exc) + + # The event loop is still running normally. + await asyncio.sleep(0) - async def nested(): - try: - await asyncio.sleep(10) - finally: - raise TypeError + async def test_taskgroup_21c(self): + # gh-102572: as above, but raised before the first await. + for exc_type in (KeyboardInterrupt, SystemExit): + with self.subTest(exc_type=exc_type): + children = [] - async def runner(): - async with taskgroups.TaskGroup() as g: - g.create_task(crash_soon()) - await nested() + async def crasher(): + children.append(asyncio.current_task()) + raise exc_type - with self.assertRaises(KeyboardInterrupt): - await runner() + async def runner(): + async with taskgroups.TaskGroup() as g: + g.create_task(crasher()) + await asyncio.sleep(10) + + with self.assertRaises(exc_type): + await runner() + + # With an eager task factory the child crashed inside + # create_task() before the group registered it; retrieve + # its exception to avoid a "never retrieved" warning. + for child in children: + child.exception() + + # The event loop is still running normally. + await asyncio.sleep(0) async def test_taskgroup_21a(self): diff --git a/Lib/test/test_asyncio/test_tasks.py b/Lib/test/test_asyncio/test_tasks.py index 609ccdbfdb2b28..ef9bec6f605500 100644 --- a/Lib/test/test_asyncio/test_tasks.py +++ b/Lib/test/test_asyncio/test_tasks.py @@ -1953,6 +1953,44 @@ async def notmutch(): self.assertFalse(task.cancelled()) self.assertIs(task.exception(), base_exc) + def test_baseexception_during_cancel_no_reraise(self): + # gh-102572: with _reraise_base_exceptions disabled, a + # KeyboardInterrupt/SystemExit is only set as the task's + # exception and not re-raised into the event loop. + for exc_type in (KeyboardInterrupt, SystemExit): + with self.subTest(exc_type=exc_type): + def gen(): + when = yield + self.assertAlmostEqual(10.0, when) + yield 0 + + loop = self.new_test_loop(gen) + + async def sleeper(): + await asyncio.sleep(10) + + base_exc = exc_type() + + async def notmutch(): + try: + await sleeper() + except asyncio.CancelledError: + raise base_exc + + task = self.new_task(loop, notmutch()) + self.assertTrue(task._reraise_base_exceptions) + task._reraise_base_exceptions = False + test_utils.run_briefly(loop) + + task.cancel() + self.assertFalse(task.done()) + + test_utils.run_briefly(loop) + + self.assertTrue(task.done()) + self.assertFalse(task.cancelled()) + self.assertIs(task.exception(), base_exc) + def test_coroutine_non_gen_function(self): async def func(): return 'test' diff --git a/Misc/NEWS.d/next/Library/2026-07-27-00-00-00.gh-issue-102572.hqCLgU.rst b/Misc/NEWS.d/next/Library/2026-07-27-00-00-00.gh-issue-102572.hqCLgU.rst new file mode 100644 index 00000000000000..e786a0469ba40c --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-27-00-00-00.gh-issue-102572.hqCLgU.rst @@ -0,0 +1,4 @@ +Fix :class:`asyncio.TaskGroup` so that a :exc:`KeyboardInterrupt` or +:exc:`SystemExit` raised by a task in the group no longer crashes the event +loop. As already documented, the exception is now re-raised in the parent +task after the group has cancelled and waited for the remaining tasks. diff --git a/Modules/_asynciomodule.c b/Modules/_asynciomodule.c index 9c29eb8232ed3f..2f2005431738c6 100644 --- a/Modules/_asynciomodule.c +++ b/Modules/_asynciomodule.c @@ -61,6 +61,7 @@ typedef struct TaskObj { FutureObj_HEAD(task) unsigned task_must_cancel: 1; unsigned task_log_destroy_pending: 1; + unsigned task_reraise_base_exceptions: 1; int task_num_cancels_requested; PyObject *task_fut_waiter; PyObject *task_coro; @@ -2335,6 +2336,7 @@ _asyncio_Task___init___impl(TaskObj *self, PyObject *coro, PyObject *loop, #endif self->task_must_cancel = 0; self->task_log_destroy_pending = 1; + self->task_reraise_base_exceptions = 1; self->task_num_cancels_requested = 0; set_task_coro(self, coro); @@ -2461,6 +2463,47 @@ _asyncio_Task__log_destroy_pending_set_impl(TaskObj *self, PyObject *value) return 0; } +/*[clinic input] +@critical_section +@getter +_asyncio.Task._reraise_base_exceptions +[clinic start generated code]*/ + +static PyObject * +_asyncio_Task__reraise_base_exceptions_get_impl(TaskObj *self) +/*[clinic end generated code: output=ca3e80a29c03c0fa input=ef5a02100f61548c]*/ +{ + if (self->task_reraise_base_exceptions) { + Py_RETURN_TRUE; + } + else { + Py_RETURN_FALSE; + } +} + +/*[clinic input] +@critical_section +@setter +_asyncio.Task._reraise_base_exceptions +[clinic start generated code]*/ + +static int +_asyncio_Task__reraise_base_exceptions_set_impl(TaskObj *self, + PyObject *value) +/*[clinic end generated code: output=7373ae0cc01caeb8 input=5e777a0452e204ad]*/ +{ + if (value == NULL) { + PyErr_SetString(PyExc_AttributeError, "cannot delete attribute"); + return -1; + } + int is_true = PyObject_IsTrue(value); + if (is_true < 0) { + return -1; + } + self->task_reraise_base_exceptions = is_true; + return 0; +} + /*[clinic input] @critical_section @@ -2927,6 +2970,7 @@ static PyMethodDef TaskType_methods[] = { static PyGetSetDef TaskType_getsetlist[] = { _ASYNCIO_TASK__LOG_DESTROY_PENDING_GETSETDEF + _ASYNCIO_TASK__RERAISE_BASE_EXCEPTIONS_GETSETDEF _ASYNCIO_TASK__MUST_CANCEL_GETSETDEF _ASYNCIO_TASK__CORO_GETSETDEF _ASYNCIO_TASK__FUT_WAITER_GETSETDEF @@ -3151,10 +3195,11 @@ task_step_impl(asyncio_state *state, TaskObj *task, PyObject *exc) assert(o == Py_None); Py_DECREF(o); - if (PyErr_GivenExceptionMatches(exc, PyExc_KeyboardInterrupt) || - PyErr_GivenExceptionMatches(exc, PyExc_SystemExit)) + if (task->task_reraise_base_exceptions && + (PyErr_GivenExceptionMatches(exc, PyExc_KeyboardInterrupt) || + PyErr_GivenExceptionMatches(exc, PyExc_SystemExit))) { - /* We've got a KeyboardInterrupt or a SystemError; re-raise it */ + /* We've got a KeyboardInterrupt or a SystemExit; re-raise it */ PyErr_SetRaisedException(exc); goto fail; } diff --git a/Modules/clinic/_asynciomodule.c.h b/Modules/clinic/_asynciomodule.c.h index 8bbef6b5023125..a648bc947ea15d 100644 --- a/Modules/clinic/_asynciomodule.c.h +++ b/Modules/clinic/_asynciomodule.c.h @@ -1009,6 +1009,57 @@ _asyncio_Task__log_destroy_pending_set(PyObject *self, PyObject *value, void *Py return return_value; } +#if !defined(_asyncio_Task__reraise_base_exceptions_DOCSTR) +# define _asyncio_Task__reraise_base_exceptions_DOCSTR NULL +#endif +#if defined(_ASYNCIO_TASK__RERAISE_BASE_EXCEPTIONS_GETSETDEF) +# undef _ASYNCIO_TASK__RERAISE_BASE_EXCEPTIONS_GETSETDEF +# define _ASYNCIO_TASK__RERAISE_BASE_EXCEPTIONS_GETSETDEF {"_reraise_base_exceptions", (getter)_asyncio_Task__reraise_base_exceptions_get, (setter)_asyncio_Task__reraise_base_exceptions_set, _asyncio_Task__reraise_base_exceptions_DOCSTR}, +#else +# define _ASYNCIO_TASK__RERAISE_BASE_EXCEPTIONS_GETSETDEF {"_reraise_base_exceptions", (getter)_asyncio_Task__reraise_base_exceptions_get, NULL, _asyncio_Task__reraise_base_exceptions_DOCSTR}, +#endif + +static PyObject * +_asyncio_Task__reraise_base_exceptions_get_impl(TaskObj *self); + +static PyObject * +_asyncio_Task__reraise_base_exceptions_get(PyObject *self, void *Py_UNUSED(context)) +{ + PyObject *return_value = NULL; + + Py_BEGIN_CRITICAL_SECTION(self); + return_value = _asyncio_Task__reraise_base_exceptions_get_impl((TaskObj *)self); + Py_END_CRITICAL_SECTION(); + + return return_value; +} + +#if !defined(_asyncio_Task__reraise_base_exceptions_DOCSTR) +# define _asyncio_Task__reraise_base_exceptions_DOCSTR NULL +#endif +#if defined(_ASYNCIO_TASK__RERAISE_BASE_EXCEPTIONS_GETSETDEF) +# undef _ASYNCIO_TASK__RERAISE_BASE_EXCEPTIONS_GETSETDEF +# define _ASYNCIO_TASK__RERAISE_BASE_EXCEPTIONS_GETSETDEF {"_reraise_base_exceptions", (getter)_asyncio_Task__reraise_base_exceptions_get, (setter)_asyncio_Task__reraise_base_exceptions_set, _asyncio_Task__reraise_base_exceptions_DOCSTR}, +#else +# define _ASYNCIO_TASK__RERAISE_BASE_EXCEPTIONS_GETSETDEF {"_reraise_base_exceptions", NULL, (setter)_asyncio_Task__reraise_base_exceptions_set, NULL}, +#endif + +static int +_asyncio_Task__reraise_base_exceptions_set_impl(TaskObj *self, + PyObject *value); + +static int +_asyncio_Task__reraise_base_exceptions_set(PyObject *self, PyObject *value, void *Py_UNUSED(context)) +{ + int return_value; + + Py_BEGIN_CRITICAL_SECTION(self); + return_value = _asyncio_Task__reraise_base_exceptions_set_impl((TaskObj *)self, value); + Py_END_CRITICAL_SECTION(); + + return return_value; +} + #if !defined(_asyncio_Task__must_cancel_DOCSTR) # define _asyncio_Task__must_cancel_DOCSTR NULL #endif @@ -2234,4 +2285,4 @@ _asyncio_future_discard_from_awaited_by(PyObject *module, PyObject *const *args, exit: return return_value; } -/*[clinic end generated code: output=22e74568ff49f81f input=a9049054013a1b77]*/ +/*[clinic end generated code: output=5e0ec48d1dad2a42 input=a9049054013a1b77]*/