gh-111375: Fix handling of exceptions within @contextmanager-decorated functions#111676
gh-111375: Fix handling of exceptions within @contextmanager-decorated functions#111676pR0Ps wants to merge 11 commits into
@contextmanager-decorated functions#111676Conversation
ericsnowcurrently
left a comment
There was a problem hiding this comment.
Thanks for working on this. Mostly your solution fundamentally seems correct and specifically a good approach. I just have concerns about 2 points:
- relying on ctypes
- deleting
self.exc_contextinstead of setting it back toNone
|
A Python core developer has requested some changes be made to your pull request before we can consider merging it. If you could please address their requests along with any other requests in other reviews from core developers that would be appreciated. Once you have made the requested changes, please leave a comment on this pull request containing the phrase |
|
The function |
582be48 to
79766c8
Compare
Hmm, yeah, now that you mention it, I don't know why this is currently working. Consider: import sys
try:
raise TypeError()
except TypeError:
try:
raise ValueError()
except:
assert isinstance(sys.exception(), ValueError)
sys._set_exception(None) # !!!
assert sys.exception() is None # !!!
assert isinstance(sys.exception(), TypeError)This works (using the latest commit on this branch), yet it does look like it shouldn't. It seems like Unless I'm misunderstanding something? |
79766c8 to
b115754
Compare
Try nesting it in yet another try-except? |
I have not been able to break it, no matter how much nesting above or below is used. This also runs without asserting: import sys
try:
raise RuntimeError()
except:
try:
raise TypeError()
except:
try:
raise ValueError()
except:
assert type(sys.exception()) == ValueError
assert type(sys.exception().__context__) == TypeError
assert type(sys.exception().__context__.__context__) == RuntimeError
sys._set_exception(None) # !!!
assert sys.exception() is None # !!!
try:
raise IndexError()
except:
assert type(sys.exception()) == IndexError
assert sys.exception().__context__ is None
try:
raise SyntaxError()
except:
assert type(sys.exception()) == SyntaxError
assert type(sys.exception().__context__) == IndexError
assert sys.exception().__context__.__context__ is None
assert type(sys.exception()) == IndexError
assert sys.exception().__context__ is None
assert sys.exception() is None # !!!
assert type(sys.exception()) == TypeError
assert type(sys.exception().__context__) == RuntimeError
assert isinstance(sys.exception(), RuntimeError)
assert sys.exception() is None |
|
The piece I was missing was that The following shows the issue @iritkatriel raised (I've added the state of the import sys
def gen():
# exc_info: None
yield
# exc_info: NULL --> ZeroDivisionError('division by zero')
assert type(sys.exception()) == ZeroDivisionError
sys._set_exception(None)
# exc_info: None --> ZeroDivisionError('division by zero')
assert sys.exception() is None # 💣💣💣
yield
g = gen()
next(g)
try:
1/0
except:
next(g)This breaks because generators push a new This maybe brings up another "is it a bug or just a documentation issue" question (expand me)In
and
This doesn't convey the nuance here. Passing // exc_info state: None -> Exception
PyObject *obj = PyErr_GetHandledException();
// obj: Exception
/* <snip some code> */
PyErr_SetHandledException(obj)
// exc_info state: Exception -> ExceptionMaybe the documentation should just be expanded to cover this edge case, but it seems to me that it's actually a bug that I've just pushed a commit that fixes this issue by making sure that Open to feedback/suggestions. Also, since I think I've addressed everything at this point: I have made the requested changes; please review again. |
|
Thanks for the analysis! Yes, I think you found the issue.
I agree, and wouldn't mind seeing this fixed. Would it break anything? If you change the exception stack from |
I've messed around with this some more and I think you're right in that it's exactly the same from the perspective of everything just using It doesn't seem to break anything (nothing in the test suite at least). The danger here is that it now makes it possible for As far as I can tell, the only place the interpreter uses // <in a coroutine with the caller handling an Exception>
// exc_info state: None --> Exception
PyObject *value_err = make_some_ValueError();
PyErr_SetHandledException(value_err);
// PREVIOUS: ValueError --> Exception
// NEW: None -> ValueErrorConsidering all the above, I'm reconsidering the change to While looking into this, I also realized that the change to Sorry for all the back and forth on this - I'm discovering how all this works as I go. Assuming that the exception stack having duplicates in it is fine, the remaining issue (documentation and otherwise) is that passing in Since it's desired behavior to have One way to do this that looks like it might work is to use the difference between I've implemented the above in my latest commit and it seems to work (all test cases pass). However, I'm definitely a bit out of my depth here so I'm not sure if this is something that can/should be changed or if I've overlooked an edge case with it. |
Good point. This is all useful analysis. Can you start by making some doc enhancements with this point you feel are missing? (If we end up making code changes we can change the docs as well, but it's useful to have the current state documented in any case). |
Sure, should I do them in this PR or submit a new one? |
|
Maybe a new one. |
|
In parallel to that, I think I've resolved all the issues that were brought up here so @bedevere-bot : I have made the requested changes; please review again. EDIT: ok, no idea how to get the label to change back to awaiting review so I've just manually requested a re-revew. Sorry for the spam |
|
Anything else I need to do for this? |
|
As far as I recall, the fix described in #111676 (comment) needed to be reverted so we still have a problem with the None/NULL exc_info. No? |
|
I think the changes I made in the following commit of 36b6177 solved that problem. Previously, a To support this change, I modified the sections of code that add exceptions to the exception stack so that they always insert |
This makes sense, but since it's a deep subtle change I think we should split it into two PRs. That would make it easier in the future to see what happened. Both PRs can be attached to this issue. The first PR would get rid of the |
Previously, both `NULL` and `Py_None` would be used interchangeably to indicate that an exception is no longer being handled. By ensuring that only `NULL` is used, this opens up the possibility to use `Py_None` to indicate a cleared exception. The difference here would be that clearing would indicate that no exception is currently being handled vs. handling would indicate that the next exception in the stack is currently being handled. This functionality will be used to patch up some edge cases in how the exception context interacts with exceptions thrown into coroutines. This is implemented in this commit by changing code that could add `Py_None` to the exception stack to indicate that an exception is no longer being handled to add `NULL` instead. An assert was also added to ensure that `Py_None` is no longer added to the exception stack. See pythongh-111676 for context.
Previously, both `NULL` and `Py_None` would be used interchangeably to indicate that an exception is no longer being handled. By ensuring that only `NULL` is used, this opens up the possibility to use `Py_None` to indicate a cleared exception. The difference here would be that clearing would indicate that no exception is currently being handled vs. handling would indicate that the next exception in the stack is currently being handled. This functionality will be used to patch up some edge cases in how the exception context interacts with exceptions thrown into coroutines. This is implemented in this commit by changing code that could add `Py_None` to the exception stack to indicate that an exception is no longer being handled to add `NULL` instead. An assert was also added to ensure that `Py_None` is no longer added to the exception stack. See pythongh-111676 for context.
edd0f5b to
a6f948b
Compare
I've created #113302 that fixes the Apologies for this, I know rebasing PRs during development is generally frowned upon, but figured the alternative of having the same functionality implemented in different ways across multiple branches, having merge conflicts with |
5e7f10b to
887eb54
Compare
887eb54 to
c0e7400
Compare
|
This PR is stale because it has been open for 30 days with no activity. |
…nction Exposing this function allows Python code to clear/set the current exception context as returned by `sys.exception()`. It is prefixed by an underscore since this functionality is not intended to be accessed by user-written code. In order to allow `sys._set_exception(None)` to clear the current exception `_PyErr_GetTopmostException` was changed to consider `Py_None` a valid exception instead of an indication to keep traversing the stack like `NULL` is. Additionally, `PyErr_SetHandledException` was updated to add `Py_None` to the exception stack instead of `NULL`. Put together, these changes allow `PyErr_SetHandledException(NULL)` to mask the entire exception chain and "clear" the current exception state as documented in `Doc/c-api/exceptions.rst`. Furthermore, since `sys._set_exception()` uses `PyErr_SetHandledException`, this allows `sys._set_exception(None)` to clear the current exception context instead of just exposing the next exception in the stack.
Previously, when using a `try...yield...except` construct within a `@contextmanager` function, an exception raised by the `yield` wouldn't be properly cleared when handled the `except` block. Instead, calling `sys.exception()` would continue to return the exception, leading to confusing tracebacks and logs. This was happening due to the way that the `@contextmanager` decorator drives its decorated generator function. When an exception occurs, it uses `.throw(exc)` to throw the exception into the generator. However, even if the generator handles this exception, because the exception was thrown into it in the context of the `@contextmanager` decorator handling it (and not being finished yet), `sys.exception()` was not being reset. In order to fix this, the exception context as the `@contextmanager` decorator is `__enter__`'d is stored and set as the current exception context just before throwing the new exception into the generator. Doing this means that after the generator handles the thrown exception, `sys.exception()` reverts back to what it was when the `@contextmanager` function was started.
Co-authored-by: Irit Katriel <1055913+iritkatriel@users.noreply.github.com>
Co-authored-by: Irit Katriel <1055913+iritkatriel@users.noreply.github.com>
Since this is only used for a contextlib-specific hack, having it exposed in the sys module (even with an underscore) might not be a good idea.
This is done as a separate commit to make reviewing the next commit (ie. the actual implementation) easier. If accepted, this commit should be squashed.
This solves the exception context leaking into the generator by allowing an alternate exception context to be passed in. It also removes the need for an exposed function that directly manipulates the exception state since the details are handled in the C implementation.
5da75d0 to
725892c
Compare
|
I have implemented the change where the exception context is passed in via Because it's been a while and things have changed a bunch in
|
Previously, when using a
try...yield...exceptconstruct within a@contextmanagerfunction, an exception raised by theyieldwouldn't be properly cleared when handled by theexceptblock. Instead, callingsys.exception()would continue to return the exception, leading to confusing tracebacks and logs.This was happening due to the way that the
@contextmanagerdecorator drives its decorated generator function. When an exception occurs, it uses.throw(exc)to throw the exception into the generator. However, even if the generator handles this exception, because the exception was thrown into it in the context of the@contextmanagerdecorator handling it (and not being finished yet),sys.exception()was not being reset.For an example of this and more information, see #111375
In order to fix this, the exception context as the
@contextmanagerdecorator is__enter__'d is stored and set as the current exception context just before throwing the new exception into the generator. Doing this means that after the generator handles the thrown exception,sys.exception()reverts back to what it was when the@contextmanagerfunction was started.Potential reviewers based on activity in the linked issue: @iritkatriel @ericsnowcurrently @ncoghlan
@contextmanagerfunction doesn't clearsys.exception()#111375