Skip to content
Open
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
15 changes: 14 additions & 1 deletion Doc/library/asyncio-eventloop.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1493,14 +1493,20 @@ 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

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
Expand All @@ -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.
Expand Down
48 changes: 33 additions & 15 deletions Lib/asyncio/base_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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):
Expand Down
70 changes: 70 additions & 0 deletions Lib/test/test_asyncio/test_base_events.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Tests for base_events.py"""

import concurrent.futures
import contextvars
import errno
import math
import platform
Expand Down Expand Up @@ -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()

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
:meth:`asyncio.loop.call_exception_handler` now runs the default exception
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`.
Loading