Skip to content

gh-159303: Run asyncio's default exception handler in the task context - #159307

Open
drakeo338 wants to merge 3 commits into
python:mainfrom
drakeo338:claude/159303-fix
Open

drakeo338 wants to merge 3 commits into
python:mainfrom
drakeo338:claude/159303-fix

Conversation

@drakeo338

@drakeo338 drakeo338 commented Oct 13, 2026 •

Copy link
Copy Markdown

Fixes gh-159303.

With no custom handler set, call_exception_handler() called default_exception_handler() in the caller's context. gh-96704 made a custom handler run in the task's or handle's context, and its PR left the default handler for later. This moves the lookup into a helper and uses it for both.

Both handlers run in ctx.copy(). Without the copy, reporting from inside the task itself ('task': asyncio.current_task()) raises RuntimeError: cannot enter context, and a custom handler is silently skipped today.

Three tests in test_base_events:

  • the first fails on main (the default handler sees the caller's context);
  • the third fails on main (the custom handler is skipped);
  • the second passes on main and fails only if the copy is dropped.

The docs note both changes. The issue's repro now prints req-1 twice. ./python -m test --fast-ci -j8 test_asyncio passes.

Prepared with AI assistance (Claude), then reviewed and tested before submission.

@read-the-docs-community

read-the-docs-community Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Documentation build overview

📚 cpython-previews | 🛠️ Build #35509497 | 📁 Comparing 340c4e6 against main (50082b8)

  🔍 Preview build  

166 files changed · + 1 added · ± 165 modified

+ Added

± Modified

@OsmnvAslan OsmnvAslan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked this out locally: test_base_events passes, and the two new tests fail on main as described. The move into _get_exception_context() is a straight refactor of the existing lookup.

One behaviour change worth documenting: a custom handler used to run directly in the task's context, so a ContextVar.set() inside the handler was visible to the task afterwards. With ctx.copy() that write is now discarded (I confirmed both behaviours with a small script on main and on this branch). I think that's the right call, but a sentence in the versionchanged note would avoid surprising anyone relying on it.

@drakeo338

Copy link
Copy Markdown
Author

Thanks for checking it locally. Added in 340c4e6: the versionchanged note now says that a ContextVar.set() made inside the handler is no longer visible to the task afterwards, since the handler runs in a copy of the context.

Comment on lines +1506 to +1508
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

Copy link
Copy Markdown
Contributor

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 RuntimeError from ctx.run() is caught and passed to default_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.

Comment on lines +1530 to +1535
.. 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This note describes how call_exception_handler() behaves rather than default_exception_handler() itself (calling loop.default_exception_handler() directly is unaffected). Consider moving it under loop.call_exception_handler instead, e.g. "When no custom handler is set, the default handler now runs in a copy of the context of the task or handle where the exception originated."

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):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This covers the 'task' and 'handle' keys, but not 'future', which is the path the issue's repro actually goes through: "Task exception was never retrieved" is reported from the task finalizer with 'future': task. Could you add that case here too, e.g.

            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 support.gc_collect()) would also be nice, but the direct call is enough.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

asyncio: default exception handler does not run in the task's context

3 participants