From c357cc2221c6e3e04c05898caedb7af80ac1f5ba Mon Sep 17 00:00:00 2001 From: drakeo338 Date: Tue, 13 Oct 2026 11:12:57 +0000 Subject: [PATCH 1/2] gh-159303: Run asyncio's default exception handler in the task context --- Lib/asyncio/base_events.py | 48 +++++++++---- Lib/test/test_asyncio/test_base_events.py | 70 +++++++++++++++++++ ...-10-13-12-00-00.gh-issue-159303.Ctx3Dh.rst | 5 ++ 3 files changed, 108 insertions(+), 15 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-10-13-12-00-00.gh-issue-159303.Ctx3Dh.rst diff --git a/Lib/asyncio/base_events.py b/Lib/asyncio/base_events.py index 90269c936555cb..749a69529877be 100644 --- a/Lib/asyncio/base_events.py +++ b/Lib/asyncio/base_events.py @@ -275,6 +275,28 @@ async def restore(self): self._proto.resume_writing() +def _get_exception_context(context): + """Return the Context of the task/future/handle in an error context. + + Return None if there is no such object or it has no usable context. + """ + thing = context.get("task") + if thing is None: + # Even though Futures don't have a context, + # Task is a subclass of Future, + # and sometimes the 'future' key holds a Task. + thing = context.get("future") + if thing is None: + # Handles also have a context. + thing = context.get("handle") + if thing is None or not hasattr(thing, "get_context"): + return None + ctx = thing.get_context() + if ctx is None or not hasattr(ctx, "run"): + return None + return ctx + + class Server(events.AbstractServer): def __init__(self, loop, sockets, protocol_factory, ssl_context, backlog, @@ -1941,7 +1963,14 @@ def call_exception_handler(self, context): """ if self._exception_handler is None: try: - self.default_exception_handler(context) + ctx = _get_exception_context(context) + if ctx is not None: + # Run in a copy: the context may already be entered + # (e.g. when reporting from inside the task itself), + # and Context.run() does not allow re-entering. + ctx.copy().run(self.default_exception_handler, context) + else: + self.default_exception_handler(context) except (SystemExit, KeyboardInterrupt): raise except BaseException: @@ -1952,20 +1981,9 @@ def call_exception_handler(self, context): exc_info=True) else: try: - ctx = None - thing = context.get("task") - if thing is None: - # Even though Futures don't have a context, - # Task is a subclass of Future, - # and sometimes the 'future' key holds a Task. - thing = context.get("future") - if thing is None: - # Handles also have a context. - thing = context.get("handle") - if thing is not None and hasattr(thing, "get_context"): - ctx = thing.get_context() - if ctx is not None and hasattr(ctx, "run"): - ctx.run(self._exception_handler, self, context) + ctx = _get_exception_context(context) + if ctx is not None: + ctx.copy().run(self._exception_handler, self, context) else: self._exception_handler(self, context) except (SystemExit, KeyboardInterrupt): diff --git a/Lib/test/test_asyncio/test_base_events.py b/Lib/test/test_asyncio/test_base_events.py index e11f77ef10c0c9..778cce71048189 100644 --- a/Lib/test/test_asyncio/test_base_events.py +++ b/Lib/test/test_asyncio/test_base_events.py @@ -1,6 +1,7 @@ """Tests for base_events.py""" import concurrent.futures +import contextvars import errno import math import platform @@ -599,6 +600,75 @@ def test_subprocess_shell_invalid_args(self): self.loop.run_until_complete, self.loop.subprocess_shell, asyncio.SubprocessProtocol, 'exit 0', bufsize=4096) + def test_default_exc_handler_runs_in_task_context(self): + var = contextvars.ContextVar('var', default='outer') + seen = [] + + def default_handler(context): + seen.append(var.get()) + + async def coro(): + pass + + self.loop._process_events = mock.Mock() + task_ctx = contextvars.copy_context() + task_ctx.run(var.set, 'task') + task = self.loop.create_task(coro(), context=task_ctx) + self.loop.run_until_complete(task) + with mock.patch.object(self.loop, 'default_exception_handler', + default_handler): + self.loop.call_exception_handler({ + 'message': 'msg', 'task': task, 'exception': ValueError()}) + ctx = contextvars.copy_context() + ctx.run(var.set, 'handle') + handle = asyncio.Handle(lambda: None, (), self.loop, ctx) + self.loop.call_exception_handler({ + 'message': 'msg', 'handle': handle}) + # No context available: runs in the current context. + self.loop.call_exception_handler({'message': 'msg'}) + self.assertEqual(seen, ['task', 'handle', 'outer']) + + def test_default_exc_handler_from_inside_task(self): + # The task's context is already entered when reporting from + # inside the task; the original message must still be logged. + self.loop._process_events = mock.Mock() + var = contextvars.ContextVar('var', default='outer') + seen = [] + + def default_handler(context): + seen.append((var.get(), context['message'])) + + async def coro(): + var.set('task') + self.loop.call_exception_handler({ + 'message': 'msg', 'task': asyncio.current_task()}) + + with mock.patch.object(self.loop, 'default_exception_handler', + default_handler): + with mock.patch('asyncio.base_events.logger') as log: + self.loop.run_until_complete(coro()) + self.assertEqual(seen, [('task', 'msg')]) + self.assertFalse(log.error.called) + + def test_custom_exc_handler_from_inside_task(self): + self.loop._process_events = mock.Mock() + var = contextvars.ContextVar('var', default='outer') + seen = [] + + def handler(loop, context): + seen.append((var.get(), context['message'])) + + async def coro(): + var.set('task') + self.loop.call_exception_handler({ + 'message': 'msg', 'task': asyncio.current_task()}) + + self.loop.set_exception_handler(handler) + with mock.patch('asyncio.base_events.logger') as log: + self.loop.run_until_complete(coro()) + self.assertEqual(seen, [('task', 'msg')]) + self.assertFalse(log.error.called) + def test_default_exc_handler_callback(self): self.loop._process_events = mock.Mock() diff --git a/Misc/NEWS.d/next/Library/2026-10-13-12-00-00.gh-issue-159303.Ctx3Dh.rst b/Misc/NEWS.d/next/Library/2026-10-13-12-00-00.gh-issue-159303.Ctx3Dh.rst new file mode 100644 index 00000000000000..17a88d64ad3d01 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-13-12-00-00.gh-issue-159303.Ctx3Dh.rst @@ -0,0 +1,5 @@ +:meth:`asyncio.loop.call_exception_handler` now runs the default exception +handler in the context of the task, future or handle that caused the error, +as it already did for a custom exception handler. Both handlers now run in a +copy of that context, so reporting an error from inside the task itself no +longer fails with :exc:`RuntimeError`. From a6373a717cffbca94ed7093703bdc779e47faa75 Mon Sep 17 00:00:00 2001 From: drakeo338 Date: Tue, 13 Oct 2026 12:10:54 +0000 Subject: [PATCH 2/2] gh-159303: Document the exception handler context change --- Doc/library/asyncio-eventloop.rst | 15 ++++++++++++++- ...2026-10-13-12-00-00.gh-issue-159303.Ctx3Dh.rst | 2 +- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/Doc/library/asyncio-eventloop.rst b/Doc/library/asyncio-eventloop.rst index 393f7a5fab4af5..f805633d611b85 100644 --- a/Doc/library/asyncio-eventloop.rst +++ b/Doc/library/asyncio-eventloop.rst @@ -1493,7 +1493,7 @@ Allows customizing how exceptions are handled in the event loop. about context). If the handler is called on behalf of a :class:`~asyncio.Task` or - :class:`~asyncio.Handle`, it is run in the + :class:`~asyncio.Handle`, it is run in a copy of the :class:`contextvars.Context` of that task or callback handle. .. versionchanged:: 3.12 @@ -1501,6 +1501,12 @@ Allows customizing how exceptions are handled in the event loop. The handler may be called in the :class:`~contextvars.Context` of the task or handle where the exception originated. + .. versionchanged:: next + + The handler now runs in a copy of that context. Previously, reporting + an exception from inside the task's own context raised + :exc:`RuntimeError` and the handler was skipped. + .. method:: loop.get_exception_handler() Return the current exception handler, or ``None`` if no custom @@ -1519,6 +1525,13 @@ Allows customizing how exceptions are handled in the event loop. *context* parameter has the same meaning as in :meth:`call_exception_handler`. + .. versionchanged:: next + + When no exception handler is set, :meth:`call_exception_handler` + runs this method in a copy of the :class:`contextvars.Context` of + the task or handle where the exception originated, as for a custom + handler. + .. method:: loop.call_exception_handler(context) Call the current event loop exception handler. diff --git a/Misc/NEWS.d/next/Library/2026-10-13-12-00-00.gh-issue-159303.Ctx3Dh.rst b/Misc/NEWS.d/next/Library/2026-10-13-12-00-00.gh-issue-159303.Ctx3Dh.rst index 17a88d64ad3d01..d6087129bac464 100644 --- a/Misc/NEWS.d/next/Library/2026-10-13-12-00-00.gh-issue-159303.Ctx3Dh.rst +++ b/Misc/NEWS.d/next/Library/2026-10-13-12-00-00.gh-issue-159303.Ctx3Dh.rst @@ -1,5 +1,5 @@ :meth:`asyncio.loop.call_exception_handler` now runs the default exception -handler in the context of the task, future or handle that caused the error, +handler in the context of the task or handle that caused the error, as it already did for a custom exception handler. Both handlers now run in a copy of that context, so reporting an error from inside the task itself no longer fails with :exc:`RuntimeError`.