Skip to content

Commit 901fedf

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 f63fdf3 commit 901fedf

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
@@ -283,13 +283,6 @@ def __hash__(self) -> int:
283283
# This is a catch-all for remaining uncategorized errors.
284284
MISC: Final[ErrorCode] = ErrorCode("misc", "Miscellaneous other checks", "General")
285285

286-
# Mypyc-specific error codes
287-
MYPYC_TRY_FINALLY_AWAIT: Final[ErrorCode] = ErrorCode(
288-
"mypyc-try-finally-await",
289-
"Async try/finally blocks with await in finally are not supported by mypyc",
290-
"General",
291-
)
292-
293286
OVERLOAD_CANNOT_MATCH: Final[ErrorCode] = ErrorCode(
294287
"overload-cannot-match",
295288
"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,
@@ -104,6 +102,7 @@
104102
get_exc_info_op,
105103
get_exc_value_op,
106104
keep_propagating_op,
105+
no_err_occurred_op,
107106
raise_exception_op,
108107
reraise_exception_op,
109108
restore_exc_info_op,
@@ -718,6 +717,104 @@ def transform_try_finally_stmt(
718717
builder.activate_block(out_block)
719718

720719

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

741838
# Check if we're in an async function with a finally block that contains await
839+
use_async_version = False
742840
if t.finally_body and builder.fn_info.is_coroutine:
743841
detector = AwaitDetector()
744842
t.finally_body.accept(detector)
745843

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

769848
if t.finally_body:
770849

@@ -776,7 +855,10 @@ def transform_try_body() -> None:
776855

777856
body = t.finally_body
778857

779-
transform_try_finally_stmt(builder, transform_try_body, lambda: builder.accept(body), t.line)
858+
if use_async_version:
859+
transform_try_finally_stmt_async(builder, transform_try_body, lambda: builder.accept(body), t.line)
860+
else:
861+
transform_try_finally_stmt(builder, transform_try_body, lambda: builder.accept(body), t.line)
780862
else:
781863
transform_try_except_stmt(builder, t)
782864

mypyc/test-data/run-async.test

Lines changed: 13 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -948,9 +948,6 @@ test_async_with_mixed_return()
948948
def run(x: object) -> object: ...
949949

950950
[case testAsyncTryExceptFinallyAwait]
951-
# Comprehensive test for bug where exceptions are swallowed in async functions
952-
# when the finally block contains an await statement
953-
954951
import asyncio
955952
from testutil import assertRaises
956953

@@ -959,14 +956,14 @@ class TestError(Exception):
959956

960957
# Test 0: Simplest case - just try/finally with raise and await
961958
async def simple_try_finally_await() -> None:
962-
try: # type: ignore[mypyc-try-finally-await]
959+
try:
963960
raise ValueError("simple error")
964961
finally:
965962
await asyncio.sleep(0)
966963

967964
# Test 1: Raise inside try, catch in except, don't re-raise
968965
async def async_try_except_no_reraise() -> int:
969-
try: # type: ignore[mypyc-try-finally-await]
966+
try:
970967
raise ValueError("test error")
971968
return 1 # Never reached
972969
except ValueError:
@@ -977,7 +974,7 @@ async def async_try_except_no_reraise() -> int:
977974

978975
# Test 2: Raise inside try, catch in except, re-raise
979976
async def async_try_except_reraise() -> int:
980-
try: # type: ignore[mypyc-try-finally-await]
977+
try:
981978
raise ValueError("test error")
982979
return 1 # Never reached
983980
except ValueError:
@@ -988,7 +985,7 @@ async def async_try_except_reraise() -> int:
988985

989986
# Test 3: Raise inside try, catch in except, raise different error
990987
async def async_try_except_raise_different() -> int:
991-
try: # type: ignore[mypyc-try-finally-await]
988+
try:
992989
raise ValueError("original error")
993990
return 1 # Never reached
994991
except ValueError:
@@ -999,7 +996,7 @@ async def async_try_except_raise_different() -> int:
999996

1000997
# Test 4: Another try/except block inside finally
1001998
async def async_try_except_inside_finally() -> int:
1002-
try: # type: ignore[mypyc-try-finally-await]
999+
try:
10031000
raise ValueError("outer error")
10041001
return 1 # Never reached
10051002
finally:
@@ -1012,12 +1009,12 @@ async def async_try_except_inside_finally() -> int:
10121009

10131010
# Test 5: Another try/finally block inside finally
10141011
async def async_try_finally_inside_finally() -> int:
1015-
try: # type: ignore[mypyc-try-finally-await]
1012+
try:
10161013
raise ValueError("outer error")
10171014
return 1 # Never reached
10181015
finally:
10191016
await asyncio.sleep(0)
1020-
try: # type: ignore[mypyc-try-finally-await]
1017+
try:
10211018
raise RuntimeError("inner error")
10221019
finally:
10231020
await asyncio.sleep(0)
@@ -1033,7 +1030,7 @@ async def async_exception_no_await_in_finally() -> None:
10331030

10341031
# Test function with no exception to check normal flow
10351032
async def async_no_exception_with_await_in_finally() -> int:
1036-
try: # type: ignore[mypyc-try-finally-await]
1033+
try:
10371034
return 1 # Normal return
10381035
finally:
10391036
await asyncio.sleep(0)
@@ -1084,20 +1081,17 @@ async def sleep(t: float) -> None: ...
10841081
def run(x: object) -> object: ...
10851082

10861083
[case testAsyncContextManagerExceptionHandling]
1087-
# Test async context managers with exceptions
1088-
# Async context managers use try/finally internally but seem to work
1089-
# correctly
1090-
10911084
import asyncio
1085+
from typing import Optional, Type
10921086
from testutil import assertRaises
10931087

10941088
# Test 1: Basic async context manager that doesn't suppress exceptions
10951089
class AsyncContextManager:
10961090
async def __aenter__(self) -> 'AsyncContextManager':
10971091
return self
10981092

1099-
async def __aexit__(self, exc_type: type[BaseException] | None,
1100-
exc_val: BaseException | None,
1093+
async def __aexit__(self, exc_type: Optional[Type[BaseException]],
1094+
exc_val: Optional[BaseException],
11011095
exc_tb: object) -> None:
11021096
# This await in __aexit__ is like await in finally
11031097
await asyncio.sleep(0)
@@ -1123,8 +1117,8 @@ class AsyncContextManagerRaisesInExit:
11231117
async def __aenter__(self) -> 'AsyncContextManagerRaisesInExit':
11241118
return self
11251119

1126-
async def __aexit__(self, exc_type: type[BaseException] | None,
1127-
exc_val: BaseException | None,
1120+
async def __aexit__(self, exc_type: Optional[Type[BaseException]],
1121+
exc_val: Optional[BaseException],
11281122
exc_tb: object) -> None:
11291123
# This await in __aexit__ is like await in finally
11301124
await asyncio.sleep(0)

0 commit comments

Comments
 (0)