Skip to content

Commit f63fdf3

Browse files
committed
[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 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
1 parent cbe28b2 commit f63fdf3

File tree

3 files changed

+270
-2
lines changed

3 files changed

+270
-2
lines changed

mypy/errorcodes.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,13 @@ 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+
286293
OVERLOAD_CANNOT_MATCH: Final[ErrorCode] = ErrorCode(
287294
"overload-cannot-match",
288295
"Warn if an @overload signature can never be matched",

mypyc/irbuild/statement.py

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
from collections.abc import Sequence
1313
from typing import Callable
1414

15+
import mypy.nodes
16+
import mypy.traverser
17+
from mypy.errorcodes import MYPYC_TRY_FINALLY_AWAIT
1518
from mypy.nodes import (
1619
ARG_NAMED,
1720
ARG_POS,
@@ -679,7 +682,7 @@ def try_finally_resolve_control(
679682

680683

681684
def transform_try_finally_stmt(
682-
builder: IRBuilder, try_body: GenFunc, finally_body: GenFunc
685+
builder: IRBuilder, try_body: GenFunc, finally_body: GenFunc, line: int = -1
683686
) -> None:
684687
"""Generalized try/finally handling that takes functions to gen the bodies.
685688
@@ -715,6 +718,17 @@ def transform_try_finally_stmt(
715718
builder.activate_block(out_block)
716719

717720

721+
# A simple visitor to detect await expressions
722+
class AwaitDetector(mypy.traverser.TraverserVisitor):
723+
def __init__(self) -> None:
724+
super().__init__()
725+
self.has_await = False
726+
727+
def visit_await_expr(self, o: mypy.nodes.AwaitExpr) -> None:
728+
self.has_await = True
729+
super().visit_await_expr(o)
730+
731+
718732
def transform_try_stmt(builder: IRBuilder, t: TryStmt) -> None:
719733
# Our compilation strategy for try/except/else/finally is to
720734
# treat try/except/else and try/finally as separate language
@@ -723,6 +737,35 @@ def transform_try_stmt(builder: IRBuilder, t: TryStmt) -> None:
723737
# body of a try/finally block.
724738
if t.is_star:
725739
builder.error("Exception groups and except* cannot be compiled yet", t.line)
740+
741+
# Check if we're in an async function with a finally block that contains await
742+
if t.finally_body and builder.fn_info.is_coroutine:
743+
detector = AwaitDetector()
744+
t.finally_body.accept(detector)
745+
746+
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+
)
768+
726769
if t.finally_body:
727770

728771
def transform_try_body() -> None:
@@ -733,7 +776,7 @@ def transform_try_body() -> None:
733776

734777
body = t.finally_body
735778

736-
transform_try_finally_stmt(builder, transform_try_body, lambda: builder.accept(body))
779+
transform_try_finally_stmt(builder, transform_try_body, lambda: builder.accept(body), t.line)
737780
else:
738781
transform_try_except_stmt(builder, t)
739782

@@ -824,6 +867,7 @@ def finally_body() -> None:
824867
builder,
825868
lambda: transform_try_except(builder, try_body, [(None, None, except_body)], None, line),
826869
finally_body,
870+
line
827871
)
828872

829873

mypyc/test-data/run-async.test

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -946,3 +946,220 @@ test_async_with_mixed_return()
946946

947947
[file asyncio/__init__.pyi]
948948
def run(x: object) -> object: ...
949+
950+
[case testAsyncTryExceptFinallyAwait]
951+
# Comprehensive test for bug where exceptions are swallowed in async functions
952+
# when the finally block contains an await statement
953+
954+
import asyncio
955+
from testutil import assertRaises
956+
957+
class TestError(Exception):
958+
pass
959+
960+
# Test 0: Simplest case - just try/finally with raise and await
961+
async def simple_try_finally_await() -> None:
962+
try: # type: ignore[mypyc-try-finally-await]
963+
raise ValueError("simple error")
964+
finally:
965+
await asyncio.sleep(0)
966+
967+
# Test 1: Raise inside try, catch in except, don't re-raise
968+
async def async_try_except_no_reraise() -> int:
969+
try: # type: ignore[mypyc-try-finally-await]
970+
raise ValueError("test error")
971+
return 1 # Never reached
972+
except ValueError:
973+
return 2 # Should return this
974+
finally:
975+
await asyncio.sleep(0)
976+
return 3 # Should not reach this
977+
978+
# Test 2: Raise inside try, catch in except, re-raise
979+
async def async_try_except_reraise() -> int:
980+
try: # type: ignore[mypyc-try-finally-await]
981+
raise ValueError("test error")
982+
return 1 # Never reached
983+
except ValueError:
984+
raise # Re-raise the exception
985+
finally:
986+
await asyncio.sleep(0)
987+
return 2 # Should not reach this
988+
989+
# Test 3: Raise inside try, catch in except, raise different error
990+
async def async_try_except_raise_different() -> int:
991+
try: # type: ignore[mypyc-try-finally-await]
992+
raise ValueError("original error")
993+
return 1 # Never reached
994+
except ValueError:
995+
raise RuntimeError("different error")
996+
finally:
997+
await asyncio.sleep(0)
998+
return 2 # Should not reach this
999+
1000+
# Test 4: Another try/except block inside finally
1001+
async def async_try_except_inside_finally() -> int:
1002+
try: # type: ignore[mypyc-try-finally-await]
1003+
raise ValueError("outer error")
1004+
return 1 # Never reached
1005+
finally:
1006+
await asyncio.sleep(0)
1007+
try:
1008+
raise RuntimeError("inner error")
1009+
except RuntimeError:
1010+
pass # Catch inner error
1011+
return 2 # What happens after finally with inner exception handled?
1012+
1013+
# Test 5: Another try/finally block inside finally
1014+
async def async_try_finally_inside_finally() -> int:
1015+
try: # type: ignore[mypyc-try-finally-await]
1016+
raise ValueError("outer error")
1017+
return 1 # Never reached
1018+
finally:
1019+
await asyncio.sleep(0)
1020+
try: # type: ignore[mypyc-try-finally-await]
1021+
raise RuntimeError("inner error")
1022+
finally:
1023+
await asyncio.sleep(0)
1024+
return 2 # Should not reach this
1025+
1026+
# Control case: No await in finally - should work correctly
1027+
async def async_exception_no_await_in_finally() -> None:
1028+
"""Control case: This works correctly - exception propagates"""
1029+
try:
1030+
raise TestError("This exception will propagate!")
1031+
finally:
1032+
pass # No await here
1033+
1034+
# Test function with no exception to check normal flow
1035+
async def async_no_exception_with_await_in_finally() -> int:
1036+
try: # type: ignore[mypyc-try-finally-await]
1037+
return 1 # Normal return
1038+
finally:
1039+
await asyncio.sleep(0)
1040+
return 2 # Should not reach this
1041+
1042+
def test_async_try_except_finally_await() -> None:
1043+
# Test 0: Simplest case - just try/finally with exception
1044+
# Expected: ValueError propagates
1045+
with assertRaises(ValueError):
1046+
asyncio.run(simple_try_finally_await())
1047+
1048+
# Test 1: Exception caught, not re-raised
1049+
# Expected: return 2 (from except block)
1050+
result = asyncio.run(async_try_except_no_reraise())
1051+
assert result == 2, f"Expected 2, got {result}"
1052+
1053+
# Test 2: Exception caught and re-raised
1054+
# Expected: ValueError propagates
1055+
with assertRaises(ValueError):
1056+
asyncio.run(async_try_except_reraise())
1057+
1058+
# Test 3: Exception caught, different exception raised
1059+
# Expected: RuntimeError propagates
1060+
with assertRaises(RuntimeError):
1061+
asyncio.run(async_try_except_raise_different())
1062+
1063+
# Test 4: Try/except inside finally
1064+
# Expected: ValueError propagates (outer exception)
1065+
with assertRaises(ValueError):
1066+
asyncio.run(async_try_except_inside_finally())
1067+
1068+
# Test 5: Try/finally inside finally
1069+
# Expected: RuntimeError propagates (inner error)
1070+
with assertRaises(RuntimeError):
1071+
asyncio.run(async_try_finally_inside_finally())
1072+
1073+
# Control case: No await in finally (should work correctly)
1074+
with assertRaises(TestError):
1075+
asyncio.run(async_exception_no_await_in_finally())
1076+
1077+
# Test normal flow (no exception)
1078+
# Expected: return 1
1079+
result = asyncio.run(async_no_exception_with_await_in_finally())
1080+
assert result == 1, f"Expected 1, got {result}"
1081+
1082+
[file asyncio/__init__.pyi]
1083+
async def sleep(t: float) -> None: ...
1084+
def run(x: object) -> object: ...
1085+
1086+
[case testAsyncContextManagerExceptionHandling]
1087+
# Test async context managers with exceptions
1088+
# Async context managers use try/finally internally but seem to work
1089+
# correctly
1090+
1091+
import asyncio
1092+
from testutil import assertRaises
1093+
1094+
# Test 1: Basic async context manager that doesn't suppress exceptions
1095+
class AsyncContextManager:
1096+
async def __aenter__(self) -> 'AsyncContextManager':
1097+
return self
1098+
1099+
async def __aexit__(self, exc_type: type[BaseException] | None,
1100+
exc_val: BaseException | None,
1101+
exc_tb: object) -> None:
1102+
# This await in __aexit__ is like await in finally
1103+
await asyncio.sleep(0)
1104+
# Don't suppress the exception (return None/False)
1105+
1106+
async def func_with_async_context_manager() -> str:
1107+
async with AsyncContextManager():
1108+
raise ValueError("Exception inside async with")
1109+
return "should not reach" # Never reached
1110+
return "should not reach either" # Never reached
1111+
1112+
async def test_basic_exception() -> str:
1113+
try:
1114+
await func_with_async_context_manager()
1115+
return "func_a returned normally - bug!"
1116+
except ValueError:
1117+
return "caught ValueError - correct!"
1118+
except Exception as e:
1119+
return f"caught different exception: {type(e).__name__}"
1120+
1121+
# Test 2: Async context manager that raises a different exception in __aexit__
1122+
class AsyncContextManagerRaisesInExit:
1123+
async def __aenter__(self) -> 'AsyncContextManagerRaisesInExit':
1124+
return self
1125+
1126+
async def __aexit__(self, exc_type: type[BaseException] | None,
1127+
exc_val: BaseException | None,
1128+
exc_tb: object) -> None:
1129+
# This await in __aexit__ is like await in finally
1130+
await asyncio.sleep(0)
1131+
# Raise a different exception - this should replace the original exception
1132+
raise RuntimeError("Exception in __aexit__")
1133+
1134+
async def func_with_raising_context_manager() -> str:
1135+
async with AsyncContextManagerRaisesInExit():
1136+
raise ValueError("Original exception")
1137+
return "should not reach" # Never reached
1138+
return "should not reach either" # Never reached
1139+
1140+
async def test_exception_in_aexit() -> str:
1141+
try:
1142+
await func_with_raising_context_manager()
1143+
return "func returned normally - unexpected!"
1144+
except RuntimeError:
1145+
return "caught RuntimeError - correct!"
1146+
except ValueError:
1147+
return "caught ValueError - original exception not replaced!"
1148+
except Exception as e:
1149+
return f"caught different exception: {type(e).__name__}"
1150+
1151+
def test_async_context_manager_exception_handling() -> None:
1152+
# Test 1: Basic exception propagation
1153+
result = asyncio.run(test_basic_exception())
1154+
# Expected: "caught ValueError - correct!"
1155+
assert result == "caught ValueError - correct!", f"Expected exception to propagate, got: {result}"
1156+
1157+
# Test 2: Exception raised in __aexit__ replaces original exception
1158+
result = asyncio.run(test_exception_in_aexit())
1159+
# Expected: "caught RuntimeError - correct!"
1160+
# (The RuntimeError from __aexit__ should replace the ValueError)
1161+
assert result == "caught RuntimeError - correct!", f"Expected RuntimeError from __aexit__, got: {result}"
1162+
1163+
[file asyncio/__init__.pyi]
1164+
async def sleep(t: float) -> None: ...
1165+
def run(x: object) -> object: ...

0 commit comments

Comments
 (0)