Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Lib/asyncio/taskgroups.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion Lib/asyncio/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
98 changes: 77 additions & 21 deletions Lib/test/test_asyncio/test_taskgroups.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand Down
38 changes: 38 additions & 0 deletions Lib/test/test_asyncio/test_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
51 changes: 48 additions & 3 deletions Modules/_asynciomodule.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down
53 changes: 52 additions & 1 deletion Modules/clinic/_asynciomodule.c.h

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading