Skip to content

gh-102201: Preserve exception __context__ chain in ExitStack/AsyncExitStack#152151

Open
iamsharduld wants to merge 1 commit into
python:mainfrom
iamsharduld:local-gh-102201-exitstack-context
Open

gh-102201: Preserve exception __context__ chain in ExitStack/AsyncExitStack#152151
iamsharduld wants to merge 1 commit into
python:mainfrom
iamsharduld:local-gh-102201-exitstack-context

Conversation

@iamsharduld

@iamsharduld iamsharduld commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

ExitStack/AsyncExitStack try to reproduce the exception __context__ chaining of equivalent nested with statements. That works while an exception is being handled, but when __exit__ runs after the body completed normally (no active exception) and several callbacks raise during unwind, only the last exception propagates — the earlier ones are dropped from the __context__ chain (and from the traceback).

import contextlib

def fail(n):
    raise RuntimeError(n)

with contextlib.ExitStack() as es:
    es.callback(fail, 'A')
    es.callback(fail, 'B')
    es.callback(fail, 'C')
# Before: RuntimeError('A').__context__ is None      -> B and C lost
# After:  A.__context__ is B, B.__context__ is C     -> matches nested `with`

As described on the issue, the interpreter only chains an exception raised by a callback onto the exception that is currently being handled. When the body exited normally there isn't one, so ExitStack has nothing to chain onto, and after the fact it can't tell "__context__ is None because there was nothing to chain" apart from "__context__ is None because the callback cleared it".

This makes the missing exception active again: when __exit__ runs with no exception being handled and a previous callback has already raised, the most recent exception is briefly re-raised (saving and restoring its __traceback__) so the interpreter chains the next callback's exception onto it, exactly as nested with statements do. _fix_exception_context is unchanged and still handles the case where an exception is being handled.

I made the real previous exception active rather than introducing a private sentinel (the other option floated on the issue), because a sentinel is observable from callbacks via sys.exception(), a bare raise, or raise ... from sys.exception(), which would leak an internal exception object into user code. With this approach those all reference the real previous exception (or raise RuntimeError: No active exception to reraise when nothing is active), matching nested with.

Tests cover the chaining, the bare-raise and raise ... from sys.exception() cases, and the existing explicit __context__ = None / issue 20317 behaviour is preserved, for both ExitStack and AsyncExitStack.

Fixes #102201.

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.

(Async)ExitStack can lose __context__ when its __exit__ runs without an active exception

1 participant