Skip to content

Commit 589694f

Browse files
committed
[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 mypyc/mypyc#1114
1 parent aa53441 commit 589694f

File tree

3 files changed

+119
-50
lines changed

3 files changed

+119
-50
lines changed

mypy/errorcodes.py

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -282,13 +282,6 @@ def __hash__(self) -> int:
282282
# This is a catch-all for remaining uncategorized errors.
283283
MISC: Final[ErrorCode] = ErrorCode("misc", "Miscellaneous other checks", "General")
284284

285-
# Mypyc-specific error codes
286-
MYPYC_TRY_FINALLY_AWAIT: Final[ErrorCode] = ErrorCode(
287-
"mypyc-try-finally-await",
288-
"Async try/finally blocks with await in finally are not supported by mypyc",
289-
"General",
290-
)
291-
292285
OVERLOAD_CANNOT_MATCH: Final[ErrorCode] = ErrorCode(
293286
"overload-cannot-match",
294287
"Warn if an @overload signature can never be matched",

mypyc/irbuild/statement.py

Lines changed: 106 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@
1313
from typing import Callable
1414

1515
import mypy.nodes
16-
import mypy.traverser
17-
from mypy.errorcodes import MYPYC_TRY_FINALLY_AWAIT
1816
from mypy.nodes import (
1917
ARG_NAMED,
2018
ARG_POS,
@@ -103,6 +101,7 @@
103101
get_exc_info_op,
104102
get_exc_value_op,
105103
keep_propagating_op,
104+
no_err_occurred_op,
106105
raise_exception_op,
107106
reraise_exception_op,
108107
restore_exc_info_op,
@@ -712,6 +711,104 @@ def transform_try_finally_stmt(
712711
builder.activate_block(out_block)
713712

714713

714+
def transform_try_finally_stmt_async(
715+
builder: IRBuilder, try_body: GenFunc, finally_body: GenFunc, line: int = -1
716+
) -> None:
717+
"""Async-aware try/finally handling for when finally contains await.
718+
719+
This version uses a modified approach that preserves exceptions across await."""
720+
721+
# We need to handle returns properly, so we'll use TryFinallyNonlocalControl
722+
# to track return values, similar to the regular try/finally implementation
723+
724+
err_handler, main_entry, return_entry, finally_entry = (
725+
BasicBlock(), BasicBlock(), BasicBlock(), BasicBlock()
726+
)
727+
728+
# Track if we're returning from the try block
729+
control = TryFinallyNonlocalControl(return_entry)
730+
builder.builder.push_error_handler(err_handler)
731+
builder.nonlocal_control.append(control)
732+
builder.goto_and_activate(BasicBlock())
733+
try_body()
734+
builder.goto(main_entry)
735+
builder.nonlocal_control.pop()
736+
builder.builder.pop_error_handler()
737+
ret_reg = control.ret_reg
738+
739+
# Normal case - no exception or return
740+
builder.activate_block(main_entry)
741+
builder.goto(finally_entry)
742+
743+
# Return case
744+
builder.activate_block(return_entry)
745+
builder.goto(finally_entry)
746+
747+
# Exception case - need to catch to clear the error indicator
748+
builder.activate_block(err_handler)
749+
# Catch the error to clear Python's error indicator
750+
old_exc = builder.call_c(error_catch_op, [], line)
751+
# We're not going to use old_exc since it won't survive await
752+
# The exception is now in sys.exc_info()
753+
builder.goto(finally_entry)
754+
755+
# Finally block
756+
builder.activate_block(finally_entry)
757+
758+
# Execute finally body
759+
finally_body()
760+
761+
# After finally, we need to handle exceptions carefully:
762+
# 1. If finally raised a new exception, it's in the error indicator - let it propagate
763+
# 2. If finally didn't raise, check if we need to reraise the original from sys.exc_info()
764+
# 3. If there was a return, return that value
765+
# 4. Otherwise, normal exit
766+
767+
# First, check if there's a current exception in the error indicator
768+
# (this would be from the finally block)
769+
no_current_exc = builder.call_c(no_err_occurred_op, [], line)
770+
finally_raised = BasicBlock()
771+
check_original = BasicBlock()
772+
builder.add(Branch(no_current_exc, check_original, finally_raised, Branch.BOOL))
773+
774+
# Finally raised an exception - let it propagate naturally
775+
builder.activate_block(finally_raised)
776+
builder.call_c(keep_propagating_op, [], NO_TRACEBACK_LINE_NO)
777+
builder.add(Unreachable())
778+
779+
# No exception from finally, check if we need to handle return or original exception
780+
builder.activate_block(check_original)
781+
782+
# Check if we have a return value
783+
if ret_reg:
784+
return_block, check_old_exc = BasicBlock(), BasicBlock()
785+
builder.add(Branch(builder.read(ret_reg), check_old_exc, return_block, Branch.IS_ERROR))
786+
787+
builder.activate_block(return_block)
788+
builder.nonlocal_control[-1].gen_return(builder, builder.read(ret_reg), -1)
789+
790+
builder.activate_block(check_old_exc)
791+
792+
# Check if we need to reraise the original exception from sys.exc_info
793+
exc_info = builder.call_c(get_exc_info_op, [], line)
794+
exc_type = builder.add(TupleGet(exc_info, 0, line))
795+
796+
# Check if exc_type is None
797+
none_obj = builder.none_object()
798+
has_exc = builder.binary_op(exc_type, none_obj, "is not", line)
799+
800+
reraise_block, exit_block = BasicBlock(), BasicBlock()
801+
builder.add(Branch(has_exc, reraise_block, exit_block, Branch.BOOL))
802+
803+
# Reraise the original exception
804+
builder.activate_block(reraise_block)
805+
builder.call_c(reraise_exception_op, [], NO_TRACEBACK_LINE_NO)
806+
builder.add(Unreachable())
807+
808+
# Normal exit
809+
builder.activate_block(exit_block)
810+
811+
715812
# A simple visitor to detect await expressions
716813
class AwaitDetector(mypy.traverser.TraverserVisitor):
717814
def __init__(self) -> None:
@@ -733,32 +830,14 @@ def transform_try_stmt(builder: IRBuilder, t: TryStmt) -> None:
733830
builder.error("Exception groups and except* cannot be compiled yet", t.line)
734831

735832
# Check if we're in an async function with a finally block that contains await
833+
use_async_version = False
736834
if t.finally_body and builder.fn_info.is_coroutine:
737835
detector = AwaitDetector()
738836
t.finally_body.accept(detector)
739837

740838
if detector.has_await:
741-
# Check if this error is suppressed with # type: ignore
742-
error_ignored = False
743-
if builder.module_name in builder.graph:
744-
mypyfile = builder.graph[builder.module_name].tree
745-
if mypyfile and t.line in mypyfile.ignored_lines:
746-
ignored_codes = mypyfile.ignored_lines[t.line]
747-
# Empty list means ignore all errors on this line
748-
# Otherwise check for specific error code
749-
if not ignored_codes or "mypyc-try-finally-await" in ignored_codes:
750-
error_ignored = True
751-
752-
if not error_ignored:
753-
builder.error(
754-
"try/(except/)finally blocks in async functions with 'await' in "
755-
"the finally block are not supported by mypyc. Exceptions "
756-
"(re-)raised in the try or except blocks will be silently "
757-
"swallowed if a context switch occurs. Ignore with "
758-
"'# type: ignore[mypyc-try-finally-await, unused-ignore]', if you "
759-
"really know what you're doing.",
760-
t.line
761-
)
839+
# Use the async version that handles exceptions correctly
840+
use_async_version = True
762841

763842
if t.finally_body:
764843

@@ -770,7 +849,10 @@ def transform_try_body() -> None:
770849

771850
body = t.finally_body
772851

773-
transform_try_finally_stmt(builder, transform_try_body, lambda: builder.accept(body), t.line)
852+
if use_async_version:
853+
transform_try_finally_stmt_async(builder, transform_try_body, lambda: builder.accept(body), t.line)
854+
else:
855+
transform_try_finally_stmt(builder, transform_try_body, lambda: builder.accept(body), t.line)
774856
else:
775857
transform_try_except_stmt(builder, t)
776858

mypyc/test-data/run-async.test

Lines changed: 13 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -645,9 +645,6 @@ def test_async_def_contains_two_nested_functions() -> None:
645645
def run(x: object) -> object: ...
646646

647647
[case testAsyncTryExceptFinallyAwait]
648-
# Comprehensive test for bug where exceptions are swallowed in async functions
649-
# when the finally block contains an await statement
650-
651648
import asyncio
652649
from testutil import assertRaises
653650

@@ -656,14 +653,14 @@ class TestError(Exception):
656653

657654
# Test 0: Simplest case - just try/finally with raise and await
658655
async def simple_try_finally_await() -> None:
659-
try: # type: ignore[mypyc-try-finally-await]
656+
try:
660657
raise ValueError("simple error")
661658
finally:
662659
await asyncio.sleep(0)
663660

664661
# Test 1: Raise inside try, catch in except, don't re-raise
665662
async def async_try_except_no_reraise() -> int:
666-
try: # type: ignore[mypyc-try-finally-await]
663+
try:
667664
raise ValueError("test error")
668665
return 1 # Never reached
669666
except ValueError:
@@ -674,7 +671,7 @@ async def async_try_except_no_reraise() -> int:
674671

675672
# Test 2: Raise inside try, catch in except, re-raise
676673
async def async_try_except_reraise() -> int:
677-
try: # type: ignore[mypyc-try-finally-await]
674+
try:
678675
raise ValueError("test error")
679676
return 1 # Never reached
680677
except ValueError:
@@ -685,7 +682,7 @@ async def async_try_except_reraise() -> int:
685682

686683
# Test 3: Raise inside try, catch in except, raise different error
687684
async def async_try_except_raise_different() -> int:
688-
try: # type: ignore[mypyc-try-finally-await]
685+
try:
689686
raise ValueError("original error")
690687
return 1 # Never reached
691688
except ValueError:
@@ -696,7 +693,7 @@ async def async_try_except_raise_different() -> int:
696693

697694
# Test 4: Another try/except block inside finally
698695
async def async_try_except_inside_finally() -> int:
699-
try: # type: ignore[mypyc-try-finally-await]
696+
try:
700697
raise ValueError("outer error")
701698
return 1 # Never reached
702699
finally:
@@ -709,12 +706,12 @@ async def async_try_except_inside_finally() -> int:
709706

710707
# Test 5: Another try/finally block inside finally
711708
async def async_try_finally_inside_finally() -> int:
712-
try: # type: ignore[mypyc-try-finally-await]
709+
try:
713710
raise ValueError("outer error")
714711
return 1 # Never reached
715712
finally:
716713
await asyncio.sleep(0)
717-
try: # type: ignore[mypyc-try-finally-await]
714+
try:
718715
raise RuntimeError("inner error")
719716
finally:
720717
await asyncio.sleep(0)
@@ -730,7 +727,7 @@ async def async_exception_no_await_in_finally() -> None:
730727

731728
# Test function with no exception to check normal flow
732729
async def async_no_exception_with_await_in_finally() -> int:
733-
try: # type: ignore[mypyc-try-finally-await]
730+
try:
734731
return 1 # Normal return
735732
finally:
736733
await asyncio.sleep(0)
@@ -781,20 +778,17 @@ async def sleep(t: float) -> None: ...
781778
def run(x: object) -> object: ...
782779

783780
[case testAsyncContextManagerExceptionHandling]
784-
# Test async context managers with exceptions
785-
# Async context managers use try/finally internally but seem to work
786-
# correctly
787-
788781
import asyncio
782+
from typing import Optional, Type
789783
from testutil import assertRaises
790784

791785
# Test 1: Basic async context manager that doesn't suppress exceptions
792786
class AsyncContextManager:
793787
async def __aenter__(self) -> 'AsyncContextManager':
794788
return self
795789

796-
async def __aexit__(self, exc_type: type[BaseException] | None,
797-
exc_val: BaseException | None,
790+
async def __aexit__(self, exc_type: Optional[Type[BaseException]],
791+
exc_val: Optional[BaseException],
798792
exc_tb: object) -> None:
799793
# This await in __aexit__ is like await in finally
800794
await asyncio.sleep(0)
@@ -820,8 +814,8 @@ class AsyncContextManagerRaisesInExit:
820814
async def __aenter__(self) -> 'AsyncContextManagerRaisesInExit':
821815
return self
822816

823-
async def __aexit__(self, exc_type: type[BaseException] | None,
824-
exc_val: BaseException | None,
817+
async def __aexit__(self, exc_type: Optional[Type[BaseException]],
818+
exc_val: Optional[BaseException],
825819
exc_tb: object) -> None:
826820
# This await in __aexit__ is like await in finally
827821
await asyncio.sleep(0)

0 commit comments

Comments
 (0)