diff --git a/Lib/asyncio/tasks.py b/Lib/asyncio/tasks.py index f432cf0afa895a..25afc9e0bcbcac 100644 --- a/Lib/asyncio/tasks.py +++ b/Lib/asyncio/tasks.py @@ -268,7 +268,10 @@ def __step(self, exc=None): raise exceptions.InvalidStateError( f'__step(): already done: {self!r}, {exc!r}') if self._must_cancel: - if not isinstance(exc, exceptions.CancelledError): + # gh-108549: do not swallow SystemExit and KeyboardInterrupt + if not isinstance(exc, (exceptions.CancelledError, + SystemExit, KeyboardInterrupt) + ): exc = self._make_cancelled_error() self._must_cancel = False self._fut_waiter = None diff --git a/Lib/test/test_asyncio/test_tasks.py b/Lib/test/test_asyncio/test_tasks.py index 9c111da8c27f16..cc747c80c86723 100644 --- a/Lib/test/test_asyncio/test_tasks.py +++ b/Lib/test/test_asyncio/test_tasks.py @@ -1891,6 +1891,28 @@ async def notmuch(): self.loop.run_until_complete(task), 'ko') + def test_step_dont_swallow_systemexit_or_keyboardinterrupt(self): + # see gh-108549: do not swallow SystemExit and KeyboardInterrupt + # in Task.__step when the current task must be cancelled. + async def sub_task(exc): + raise exc + + async def current_task(exc): + try: + await asyncio.create_task(sub_task(exc)) + except exc: + pass + except BaseException as e: + self.fail(f'{exc} is expected, instead of {type(e)}') + + for exc in (SystemExit, KeyboardInterrupt): + with self.subTest(exc): + t = self.new_task(self.loop, current_task(exc)) + self.assertRaises(exc, self.loop.run_until_complete, t) + t.cancel() + test_utils.run_briefly(self.loop) + self.assertTrue(not t.cancelled()) + def test_step_result_future(self): # If coroutine returns future, task waits on this future. diff --git a/Misc/NEWS.d/next/Library/2026-08-24-14-28-47.gh-issue-108549.XZ34WD.rst b/Misc/NEWS.d/next/Library/2026-08-24-14-28-47.gh-issue-108549.XZ34WD.rst new file mode 100644 index 00000000000000..c6b7605f1b0eb5 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-08-24-14-28-47.gh-issue-108549.XZ34WD.rst @@ -0,0 +1,3 @@ +Fix :class:`asyncio.Task`, when the task has a pending cancellation, +replace **exc** exception with a :exc:`asyncio.CancelledError` unless it is +:exc:`SystemExit` or :exc:`KeyboardInterrupt`, which must propagate unchanged. diff --git a/Modules/_asynciomodule.c b/Modules/_asynciomodule.c index a380f8ac72b32f..8c90b0b1517ae4 100644 --- a/Modules/_asynciomodule.c +++ b/Modules/_asynciomodule.c @@ -3051,8 +3051,13 @@ task_step_impl(asyncio_state *state, TaskObj *task, PyObject *exc) if (task->task_must_cancel) { assert(exc != Py_None); - if (!exc || !PyErr_GivenExceptionMatches(exc, state->asyncio_CancelledError)) { - /* exc was not a CancelledError */ + /* Replace exc with a CancelledError unless it already is one, or + it is SystemExit/KeyboardInterrupt, which must propagate + unchanged (gh-108549). */ + if (!exc || + (!PyErr_GivenExceptionMatches(exc, state->asyncio_CancelledError) && + !PyErr_GivenExceptionMatches(exc, PyExc_KeyboardInterrupt) && + !PyErr_GivenExceptionMatches(exc, PyExc_SystemExit))) { exc = create_cancelled_error(state, (FutureObj*)task); if (!exc) {