Repository navigation
gh-159303: Run asyncio's default exception handler in the task context #159307
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1493,14 +1493,22 @@ 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. Because the handler | ||
| runs in a copy, a :meth:`~contextvars.ContextVar.set` made inside the | ||
| handler is no longer visible to the task afterwards. | ||
|
|
||
| .. method:: loop.get_exception_handler() | ||
|
|
||
| Return the current exception handler, or ``None`` if no custom | ||
|
|
@@ -1519,6 +1527,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. | ||
|
Comment on lines
+1530
to
+1535
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This note describes how |
||
|
|
||
| .. method:: loop.call_exception_handler(context) | ||
|
|
||
| Call the current event loop exception handler. | ||
|
|
||
| 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 | ||
|
|
@@ -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): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This covers the self.loop.call_exception_handler({
'message': 'msg', 'future': task, 'exception': ValueError()})(and extend the expected list accordingly). An end-to-end variant along the lines of the issue's repro (a failing task dropped without retrieving its exception, then |
||
| 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() | ||
|
|
||
|
|
||
| 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`. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Small accuracy nit: on main the handler isn't really skipped silently. The
RuntimeErrorfromctx.run()is caught and passed todefault_exception_handler()as "Unhandled error in exception handler", with the original context nested under'context'. Maybe something like:The handler now runs in a copy of that context. Previously, reporting an exception from inside the task's own context failed with :exc:`RuntimeError`, and the handler was not called; the failure was logged as an unhandled error in the exception handler instead.