Skip to content

Commit d5ae1fc

Browse files
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.
1 parent 5062427 commit d5ae1fc

7 files changed

Lines changed: 228 additions & 26 deletions

File tree

Lib/asyncio/taskgroups.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,9 @@ def create_task(self, coro, **kwargs):
201201
raise RuntimeError(f"TaskGroup {self!r} is shutting down")
202202
task = self._loop.create_task(coro, **kwargs)
203203

204+
# gh-102572: the TaskGroup re-raises base exceptions in the parent task
205+
task._reraise_base_exceptions = False
206+
204207
futures.future_add_to_awaited_by(task, self._parent_task)
205208

206209
# Always schedule the done callback even if the task is

Lib/asyncio/tasks.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ class Task(futures._PyFuture): # Inherit Python Task implementation
8181
# status is still pending
8282
_log_destroy_pending = True
8383

84+
# If False, don't re-raise KeyboardInterrupt/SystemExit raised by the
85+
# coroutine into the event loop (used by TaskGroup)
86+
_reraise_base_exceptions = True
87+
8488
def __init__(self, coro, *, loop=None, name=None, context=None,
8589
eager_start=False):
8690
super().__init__(loop=loop)
@@ -302,7 +306,8 @@ def __step_run_and_handle_result(self, exc):
302306
super().cancel() # I.e., Future.cancel(self).
303307
except (KeyboardInterrupt, SystemExit) as exc:
304308
super().set_exception(exc)
305-
raise
309+
if self._reraise_base_exceptions:
310+
raise
306311
except BaseException as exc:
307312
super().set_exception(exc)
308313
else:

Lib/test/test_asyncio/test_taskgroups.py

Lines changed: 77 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -608,31 +608,87 @@ async def runner():
608608
get_error_types(cm.exception), {MyBaseExc, ZeroDivisionError}
609609
)
610610

611-
async def _test_taskgroup_21(self):
612-
# This test doesn't work as asyncio, currently, doesn't
613-
# correctly propagate KeyboardInterrupt (or SystemExit) --
614-
# those cause the event loop itself to crash.
615-
# (Compare to the previous (passing) test -- that one raises
616-
# a plain exception but raises KeyboardInterrupt in nested();
617-
# this test does it the other way around.)
611+
async def test_taskgroup_21(self):
612+
# gh-102572: KeyboardInterrupt/SystemExit raised by a task in the
613+
# group is re-raised in the parent task, not into the event loop.
614+
for exc_type in (KeyboardInterrupt, SystemExit):
615+
with self.subTest(exc_type=exc_type):
616+
async def crash_soon():
617+
await asyncio.sleep(0.1)
618+
raise exc_type
619+
620+
async def nested():
621+
try:
622+
await asyncio.sleep(10)
623+
finally:
624+
raise TypeError
625+
626+
async def runner():
627+
async with taskgroups.TaskGroup() as g:
628+
g.create_task(crash_soon())
629+
await nested()
630+
631+
with self.assertRaises(exc_type):
632+
await runner()
633+
634+
# The event loop is still running normally.
635+
await asyncio.sleep(0)
618636

619-
async def crash_soon():
620-
await asyncio.sleep(0.1)
621-
raise KeyboardInterrupt
637+
async def test_taskgroup_21b(self):
638+
# gh-102572: as above, but raised in response to cancellation
639+
# after a regular exception aborted the group.
640+
loop = asyncio.get_running_loop()
641+
for exc_type in (KeyboardInterrupt, SystemExit):
642+
with self.subTest(exc_type=exc_type):
643+
fut = loop.create_future()
644+
base_exc = exc_type()
645+
646+
async def coro():
647+
try:
648+
fut.set_exception(TypeError())
649+
await loop.create_future()
650+
except asyncio.CancelledError:
651+
raise base_exc
652+
653+
async def runner():
654+
async with taskgroups.TaskGroup() as g:
655+
g.create_task(coro())
656+
await fut
657+
658+
with self.assertRaises(exc_type) as cm:
659+
await runner()
660+
661+
self.assertIs(cm.exception, base_exc)
662+
663+
# The event loop is still running normally.
664+
await asyncio.sleep(0)
622665

623-
async def nested():
624-
try:
625-
await asyncio.sleep(10)
626-
finally:
627-
raise TypeError
666+
async def test_taskgroup_21c(self):
667+
# gh-102572: as above, but raised before the first await.
668+
for exc_type in (KeyboardInterrupt, SystemExit):
669+
with self.subTest(exc_type=exc_type):
670+
children = []
628671

629-
async def runner():
630-
async with taskgroups.TaskGroup() as g:
631-
g.create_task(crash_soon())
632-
await nested()
672+
async def crasher():
673+
children.append(asyncio.current_task())
674+
raise exc_type
633675

634-
with self.assertRaises(KeyboardInterrupt):
635-
await runner()
676+
async def runner():
677+
async with taskgroups.TaskGroup() as g:
678+
g.create_task(crasher())
679+
await asyncio.sleep(10)
680+
681+
with self.assertRaises(exc_type):
682+
await runner()
683+
684+
# With an eager task factory the child crashed inside
685+
# create_task() before the group registered it; retrieve
686+
# its exception to avoid a "never retrieved" warning.
687+
for child in children:
688+
child.exception()
689+
690+
# The event loop is still running normally.
691+
await asyncio.sleep(0)
636692

637693
async def test_taskgroup_21a(self):
638694

Lib/test/test_asyncio/test_tasks.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1953,6 +1953,44 @@ async def notmutch():
19531953
self.assertFalse(task.cancelled())
19541954
self.assertIs(task.exception(), base_exc)
19551955

1956+
def test_baseexception_during_cancel_no_reraise(self):
1957+
# gh-102572: with _reraise_base_exceptions disabled, a
1958+
# KeyboardInterrupt/SystemExit is only set as the task's
1959+
# exception and not re-raised into the event loop.
1960+
for exc_type in (KeyboardInterrupt, SystemExit):
1961+
with self.subTest(exc_type=exc_type):
1962+
def gen():
1963+
when = yield
1964+
self.assertAlmostEqual(10.0, when)
1965+
yield 0
1966+
1967+
loop = self.new_test_loop(gen)
1968+
1969+
async def sleeper():
1970+
await asyncio.sleep(10)
1971+
1972+
base_exc = exc_type()
1973+
1974+
async def notmutch():
1975+
try:
1976+
await sleeper()
1977+
except asyncio.CancelledError:
1978+
raise base_exc
1979+
1980+
task = self.new_task(loop, notmutch())
1981+
self.assertTrue(task._reraise_base_exceptions)
1982+
task._reraise_base_exceptions = False
1983+
test_utils.run_briefly(loop)
1984+
1985+
task.cancel()
1986+
self.assertFalse(task.done())
1987+
1988+
test_utils.run_briefly(loop)
1989+
1990+
self.assertTrue(task.done())
1991+
self.assertFalse(task.cancelled())
1992+
self.assertIs(task.exception(), base_exc)
1993+
19561994
def test_coroutine_non_gen_function(self):
19571995
async def func():
19581996
return 'test'
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Fix :class:`asyncio.TaskGroup` so that a :exc:`KeyboardInterrupt` or
2+
:exc:`SystemExit` raised by a task in the group no longer crashes the event
3+
loop. As already documented, the exception is now re-raised in the parent
4+
task after the group has cancelled and waited for the remaining tasks.

Modules/_asynciomodule.c

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ typedef struct TaskObj {
6161
FutureObj_HEAD(task)
6262
unsigned task_must_cancel: 1;
6363
unsigned task_log_destroy_pending: 1;
64+
unsigned task_reraise_base_exceptions: 1;
6465
int task_num_cancels_requested;
6566
PyObject *task_fut_waiter;
6667
PyObject *task_coro;
@@ -2335,6 +2336,7 @@ _asyncio_Task___init___impl(TaskObj *self, PyObject *coro, PyObject *loop,
23352336
#endif
23362337
self->task_must_cancel = 0;
23372338
self->task_log_destroy_pending = 1;
2339+
self->task_reraise_base_exceptions = 1;
23382340
self->task_num_cancels_requested = 0;
23392341
set_task_coro(self, coro);
23402342

@@ -2461,6 +2463,47 @@ _asyncio_Task__log_destroy_pending_set_impl(TaskObj *self, PyObject *value)
24612463
return 0;
24622464
}
24632465

2466+
/*[clinic input]
2467+
@critical_section
2468+
@getter
2469+
_asyncio.Task._reraise_base_exceptions
2470+
[clinic start generated code]*/
2471+
2472+
static PyObject *
2473+
_asyncio_Task__reraise_base_exceptions_get_impl(TaskObj *self)
2474+
/*[clinic end generated code: output=ca3e80a29c03c0fa input=ef5a02100f61548c]*/
2475+
{
2476+
if (self->task_reraise_base_exceptions) {
2477+
Py_RETURN_TRUE;
2478+
}
2479+
else {
2480+
Py_RETURN_FALSE;
2481+
}
2482+
}
2483+
2484+
/*[clinic input]
2485+
@critical_section
2486+
@setter
2487+
_asyncio.Task._reraise_base_exceptions
2488+
[clinic start generated code]*/
2489+
2490+
static int
2491+
_asyncio_Task__reraise_base_exceptions_set_impl(TaskObj *self,
2492+
PyObject *value)
2493+
/*[clinic end generated code: output=7373ae0cc01caeb8 input=5e777a0452e204ad]*/
2494+
{
2495+
if (value == NULL) {
2496+
PyErr_SetString(PyExc_AttributeError, "cannot delete attribute");
2497+
return -1;
2498+
}
2499+
int is_true = PyObject_IsTrue(value);
2500+
if (is_true < 0) {
2501+
return -1;
2502+
}
2503+
self->task_reraise_base_exceptions = is_true;
2504+
return 0;
2505+
}
2506+
24642507

24652508
/*[clinic input]
24662509
@critical_section
@@ -2927,6 +2970,7 @@ static PyMethodDef TaskType_methods[] = {
29272970

29282971
static PyGetSetDef TaskType_getsetlist[] = {
29292972
_ASYNCIO_TASK__LOG_DESTROY_PENDING_GETSETDEF
2973+
_ASYNCIO_TASK__RERAISE_BASE_EXCEPTIONS_GETSETDEF
29302974
_ASYNCIO_TASK__MUST_CANCEL_GETSETDEF
29312975
_ASYNCIO_TASK__CORO_GETSETDEF
29322976
_ASYNCIO_TASK__FUT_WAITER_GETSETDEF
@@ -3151,10 +3195,11 @@ task_step_impl(asyncio_state *state, TaskObj *task, PyObject *exc)
31513195
assert(o == Py_None);
31523196
Py_DECREF(o);
31533197

3154-
if (PyErr_GivenExceptionMatches(exc, PyExc_KeyboardInterrupt) ||
3155-
PyErr_GivenExceptionMatches(exc, PyExc_SystemExit))
3198+
if (task->task_reraise_base_exceptions &&
3199+
(PyErr_GivenExceptionMatches(exc, PyExc_KeyboardInterrupt) ||
3200+
PyErr_GivenExceptionMatches(exc, PyExc_SystemExit)))
31563201
{
3157-
/* We've got a KeyboardInterrupt or a SystemError; re-raise it */
3202+
/* We've got a KeyboardInterrupt or a SystemExit; re-raise it */
31583203
PyErr_SetRaisedException(exc);
31593204
goto fail;
31603205
}

Modules/clinic/_asynciomodule.c.h

Lines changed: 52 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)