Skip to content

[mypyc] Fix exception swallowing in async try/finally blocks with await #19353

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

Open
wants to merge 4 commits into
base: master
Choose a base branch
from

Conversation

Chainfire
Copy link

@Chainfire Chainfire commented Jun 28, 2025

EDIT: This text is obsolete and replaced by my next post, scroll down.


[mypyc] Test cases and compilation error for mypyc-try-finally-await

Any await that causes a context switch inside a finally block swallows any exception (re-)raised in the preceding try or except blocks. As the exception 'never happened', this also changes control flow.

This commit adds several tests (which intentionally fail under mypyc but run fine under cpython) for this bug, and triggers a compiler error if this pattern is detected in the code. # type: ignore[mypyc-try-finally-await, unused-ignore] can be used on the try line to bypass the error.

This also newly causes the testAsyncReturn test to fail, as it should.

See mypyc/mypyc#1114


I have made several attempts to fix this, but as I am unfamiliar with the code base and this does not seem to be a quick simple fix, I have given up on that for now.

I found it more important to create a compiler error that detects this case so my code stops silently and unexpectedly swallowing exceptions and changing control flow.


I've run the full pytest suite under Python 3.12.10, and the only additional failures are one of the test cases I added and testAsyncReturn (as noted above). Mypyc compiles itself and mypy and some small projects of my own.

Any await that causes a context switch inside a finally block
swallows any exception (re-)raised in the preceding try or except
blocks. As the exception 'never happened', this also changes
control flow.

This commit adds several tests (which fail) for this bug, and
triggers a compiler error if this pattern is detected in the code.
`# type: ignore[mypyc-try-finally-await, unused-ignore]` can be
used on the try line to bypass the error.

This also newly causes the testAsyncReturn test to fail, as it
should.

See mypyc/mypyc#1114
@sterliakov sterliakov added the topic-mypyc mypyc bugs label Jun 28, 2025
Copy link
Contributor

According to mypy_primer, this change doesn't affect type check results on a corpus of open source code. ✅

When a try/finally block in an async function contains an await statement
in the finally block, exceptions raised in the try block are silently
swallowed if a context switch occurs. This happens because mypyc stores
exception information in registers that don't survive across await points.

The Problem:
- mypyc's transform_try_finally_stmt uses error_catch_op to save exceptions
  to a register, then reraise_exception_op to restore from that register
- When await causes a context switch, register values are lost
- The exception information is gone, causing silent exception swallowing

The Solution:
- Add new transform_try_finally_stmt_async for async-aware exception handling
- Use sys.exc_info() to preserve exceptions across context switches instead
  of registers
- Check error indicator first to handle new exceptions raised in finally
- Route to async version when finally block contains await expressions

Implementation Details:
- transform_try_finally_stmt_async uses get_exc_info_op/restore_exc_info_op
  which work with sys.exc_info() that survives context switches
- Proper exception priority: new exceptions in finally replace originals
- Added has_await_in_block helper to detect await expressions

Test Coverage:
Added comprehensive async exception handling tests:
- testAsyncTryExceptFinallyAwait: 8 test cases covering various scenarios
  - Simple try/finally with exception and await
  - Exception caught but not re-raised
  - Exception caught and re-raised
  - Different exception raised in except
  - Try/except inside finally block
  - Try/finally inside finally block
  - Control case without await
  - Normal flow without exceptions
- testAsyncContextManagerExceptionHandling: Verifies async with still works
  - Basic exception propagation
  - Exception in __aexit__ replacing original

  See mypyc/mypyc#1114
@Chainfire Chainfire force-pushed the try-finally-await branch from 8c9600d to 589694f Compare June 28, 2025 11:26
@Chainfire
Copy link
Author

I couldn't let it go so I reverted the compiler error and created a fix instead, based on how async context managers work. With ample help from artificial friends.


[mypyc] Fix exception swallowing in async try/finally blocks with await

When a try/finally block in an async function contains an await statement
in the finally block, exceptions raised in the try block are silently
swallowed if a context switch occurs. This happens because mypyc stores
exception information in registers that don't survive across await points.

The Problem:

  • mypyc's transform_try_finally_stmt uses error_catch_op to save exceptions
    to a register, then reraise_exception_op to restore from that register
  • When await causes a context switch, register values are lost
  • The exception information is gone, causing silent exception swallowing

The Solution:

  • Add new transform_try_finally_stmt_async for async-aware exception handling
  • Use sys.exc_info() to preserve exceptions across context switches instead
    of registers
  • Check error indicator first to handle new exceptions raised in finally
  • Route to async version when finally block contains await expressions

Implementation Details:

  • transform_try_finally_stmt_async uses get_exc_info_op/restore_exc_info_op
    which work with sys.exc_info() that survives context switches
  • Proper exception priority: new exceptions in finally replace originals
  • Added has_await_in_block helper to detect await expressions

Test Coverage:
Added comprehensive async exception handling tests:

  • testAsyncTryExceptFinallyAwait: 8 test cases covering various scenarios

    • Simple try/finally with exception and await
    • Exception caught but not re-raised
    • Exception caught and re-raised
    • Different exception raised in except
    • Try/except inside finally block
    • Try/finally inside finally block
    • Control case without await
    • Normal flow without exceptions
  • testAsyncContextManagerExceptionHandling: Verifies async with still works

    • Basic exception propagation
    • Exception in aexit replacing original

    See await inside finally swallows exceptions mypyc/mypyc#1114

@Chainfire Chainfire changed the title [mypyc] Test cases and compilation error for mypyc-try-finally-await [mypyc] Fix exception swallowing in async try/finally blocks with await Jun 28, 2025
@JukkaL
Copy link
Collaborator

JukkaL commented Jul 1, 2025

Thanks for the PR! There may be an easier way to fix this. Here's my attempt (it doesn't quite work yet though):

diff --git a/mypyc/irbuild/nonlocalcontrol.py b/mypyc/irbuild/nonlocalcontrol.py
index 0ac9bd3ce..d92592663 100644
--- a/mypyc/irbuild/nonlocalcontrol.py
+++ b/mypyc/irbuild/nonlocalcontrol.py
@@ -184,14 +184,14 @@ class FinallyNonlocalControl(CleanupNonlocalControl):
     leave and the return register is decrefed if it isn't null.
     """

-    def __init__(self, outer: NonlocalControl, saved: Value) -> None:
+    def __init__(self, outer: NonlocalControl, saved: Value | AssignmentTarget) -> None:
         super().__init__(outer)
         self.saved = saved

     def gen_cleanup(self, builder: IRBuilder, line: int) -> None:
         # Restore the old exc_info
         target, cleanup = BasicBlock(), BasicBlock()
-        builder.add(Branch(self.saved, target, cleanup, Branch.IS_ERROR))
+        builder.add(Branch(builder.read(self.saved), target, cleanup, Branch.IS_ERROR))
         builder.activate_block(cleanup)
-        builder.call_c(restore_exc_info_op, [self.saved], line)
+        builder.call_c(restore_exc_info_op, [builder.read(self.saved)], line)
         builder.goto_and_activate(target)
diff --git a/mypyc/irbuild/statement.py b/mypyc/irbuild/statement.py
index 5c32d8f1a..409b000b4 100644
--- a/mypyc/irbuild/statement.py
+++ b/mypyc/irbuild/statement.py
@@ -591,31 +591,33 @@ def try_finally_entry_blocks(
     main_entry: BasicBlock,
     finally_block: BasicBlock,
     ret_reg: Register | AssignmentTarget | None,
-) -> Value:
-    old_exc = Register(exc_rtuple)
-
+) -> Register | AssignmentTarget:
     # Entry block for non-exceptional flow
     builder.activate_block(main_entry)
+
+    old_exc = builder.maybe_spill_assignable(Register(exc_rtuple))
+
     if ret_reg:
         builder.assign(ret_reg, builder.add(LoadErrorValue(builder.ret_types[-1])), -1)
     builder.goto(return_entry)

     builder.activate_block(return_entry)
-    builder.add(Assign(old_exc, builder.add(LoadErrorValue(exc_rtuple))))
+    builder.assign(old_exc, builder.add(LoadErrorValue(exc_rtuple)), -1)
+
     builder.goto(finally_block)

     # Entry block for errors
     builder.activate_block(err_handler)
     if ret_reg:
         builder.assign(ret_reg, builder.add(LoadErrorValue(builder.ret_types[-1])), -1)
-    builder.add(Assign(old_exc, builder.call_c(error_catch_op, [], -1)))
+    builder.assign(old_exc, builder.call_c(error_catch_op, [], -1), -1)
     builder.goto(finally_block)

     return old_exc


 def try_finally_body(
-    builder: IRBuilder, finally_block: BasicBlock, finally_body: GenFunc, old_exc: Value
+    builder: IRBuilder, finally_block: BasicBlock, finally_body: GenFunc, old_exc: Register | AssignmentTarget
 ) -> tuple[BasicBlock, FinallyNonlocalControl]:
     cleanup_block = BasicBlock()
     # Compile the finally block with the nonlocal control flow overridden to restore exc_info
@@ -633,7 +635,7 @@ def try_finally_resolve_control(
     builder: IRBuilder,
     cleanup_block: BasicBlock,
     finally_control: FinallyNonlocalControl,
-    old_exc: Value,
+    old_exc: Value | AssignmentTarget,
     ret_reg: Register | AssignmentTarget | None,
 ) -> BasicBlock:
     """Resolve the control flow out of a finally block.
@@ -642,7 +644,7 @@ def try_finally_resolve_control(
     exceptions, break/continue (soon), or just continuing on.
     """
     reraise, rest = BasicBlock(), BasicBlock()
-    builder.add(Branch(old_exc, rest, reraise, Branch.IS_ERROR))
+    builder.add(Branch(builder.read(old_exc), rest, reraise, Branch.IS_ERROR))

     # Reraise the exception if there was one
     builder.activate_block(reraise)

One of the new tests added in this PR fails with the above changes, however, but maybe there's a way to fix it (I wonder if the fix in #19361 is relevant here):

  << test_async_try_except_finally_await >> (diff)
  Traceback (most recent call last): (diff)
    File "driver.py", line 48, in <module> (diff)
      raise failures[-1][1][1] (diff)
    File "driver.py", line 19, in <module> (diff)
      test_func() (diff)
      ~~~~~~~~~^^ (diff)
    File "run-async.test", line 1052, in test_async_try_except_finally_await (diff)
      result = asyncio.run(async_try_except_no_reraise()) (diff)
    File "/Library/Frameworks/Python.framework/Versions/3.13/lib/python3.13/asyncio/runners.py", line 194, in run (diff)
      return runner.run(main) (diff)
             ~~~~~~~~~~^^^^^^ (diff)
    File "/Library/Frameworks/Python.framework/Versions/3.13/lib/python3.13/asyncio/runners.py", line 118, in run (diff)
      return self._loop.run_until_complete(task) (diff)
             ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^ (diff)
    File "/Library/Frameworks/Python.framework/Versions/3.13/lib/python3.13/asyncio/base_events.py", line 721, in run_until_complete (diff)
      return future.result() (diff)
             ~~~~~~~~~~~~~^^ (diff)
    File "native.py", line None, in async_try_except_no_reraise (diff)
  AttributeError: attribute '__mypyc_temp__5' of 'async_try_except_no_reraise_gen' undefined (diff)

Do you think that my fix above looks like a promising alternative? If yes, maybe you can figure out why we are getting the AttributeError (feel free to use my suggestion above in your PR)?

@Chainfire
Copy link
Author

Chainfire commented Jul 1, 2025

Well, you're the expert here. I've attempted several different approaches that I now don't even remember the details of, the one I PR'd is the only one I could get to work. If I made an oversight that would've made it fixable much easier in a different way, then that is for you to determine.

I did apply your patch over my other patch to see if it solves the AttributeError, but it doesn't - and many more tests are failing now.

The only place I've seen the AttributeError so far is if a spilled value is read while it has been created but not been assigned to or holds NULL (not Py_None). Perhaps that is a hint ?

Either way, I spent nearly two full days on this patch because I have code that just needs to work. I do not have time right now to spend another two days trying to get a different approach to work.

@JukkaL
Copy link
Collaborator

JukkaL commented Jul 1, 2025

I will spend some time later this week and see I can get my approach to work so that all your tests pass, as it could be more compact. But it's quite possible that it's a dead end, and since your PR here works and fixes the issue, we can always get back to it merge it.

This part of the codebase is quite tricky, so this is quite impressive work. I also like the detailed test coverage.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
topic-mypyc mypyc bugs
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants