Skip to content

Commit aa53441

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 5e9d657 commit aa53441

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
@@ -282,6 +282,13 @@ 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+
285292
OVERLOAD_CANNOT_MATCH: Final[ErrorCode] = ErrorCode(
286293
"overload-cannot-match",
287294
"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,
@@ -673,7 +676,7 @@ def try_finally_resolve_control(
673676

674677

675678
def transform_try_finally_stmt(
676-
builder: IRBuilder, try_body: GenFunc, finally_body: GenFunc
679+
builder: IRBuilder, try_body: GenFunc, finally_body: GenFunc, line: int = -1
677680
) -> None:
678681
"""Generalized try/finally handling that takes functions to gen the bodies.
679682
@@ -709,6 +712,17 @@ def transform_try_finally_stmt(
709712
builder.activate_block(out_block)
710713

711714

715+
# A simple visitor to detect await expressions
716+
class AwaitDetector(mypy.traverser.TraverserVisitor):
717+
def __init__(self) -> None:
718+
super().__init__()
719+
self.has_await = False
720+
721+
def visit_await_expr(self, o: mypy.nodes.AwaitExpr) -> None:
722+
self.has_await = True
723+
super().visit_await_expr(o)
724+
725+
712726
def transform_try_stmt(builder: IRBuilder, t: TryStmt) -> None:
713727
# Our compilation strategy for try/except/else/finally is to
714728
# treat try/except/else and try/finally as separate language
@@ -717,6 +731,35 @@ def transform_try_stmt(builder: IRBuilder, t: TryStmt) -> None:
717731
# body of a try/finally block.
718732
if t.is_star:
719733
builder.error("Exception groups and except* cannot be compiled yet", t.line)
734+
735+
# Check if we're in an async function with a finally block that contains await
736+
if t.finally_body and builder.fn_info.is_coroutine:
737+
detector = AwaitDetector()
738+
t.finally_body.accept(detector)
739+
740+
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+
)
762+
720763
if t.finally_body:
721764

722765
def transform_try_body() -> None:
@@ -727,7 +770,7 @@ def transform_try_body() -> None:
727770

728771
body = t.finally_body
729772

730-
transform_try_finally_stmt(builder, transform_try_body, lambda: builder.accept(body))
773+
transform_try_finally_stmt(builder, transform_try_body, lambda: builder.accept(body), t.line)
731774
else:
732775
transform_try_except_stmt(builder, t)
733776

@@ -818,6 +861,7 @@ def finally_body() -> None:
818861
builder,
819862
lambda: transform_try_except(builder, try_body, [(None, None, except_body)], None, line),
820863
finally_body,
864+
line
821865
)
822866

823867

mypyc/test-data/run-async.test

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -643,3 +643,220 @@ def test_async_def_contains_two_nested_functions() -> None:
643643

644644
[file asyncio/__init__.pyi]
645645
def run(x: object) -> object: ...
646+
647+
[case testAsyncTryExceptFinallyAwait]
648+
# Comprehensive test for bug where exceptions are swallowed in async functions
649+
# when the finally block contains an await statement
650+
651+
import asyncio
652+
from testutil import assertRaises
653+
654+
class TestError(Exception):
655+
pass
656+
657+
# Test 0: Simplest case - just try/finally with raise and await
658+
async def simple_try_finally_await() -> None:
659+
try: # type: ignore[mypyc-try-finally-await]
660+
raise ValueError("simple error")
661+
finally:
662+
await asyncio.sleep(0)
663+
664+
# Test 1: Raise inside try, catch in except, don't re-raise
665+
async def async_try_except_no_reraise() -> int:
666+
try: # type: ignore[mypyc-try-finally-await]
667+
raise ValueError("test error")
668+
return 1 # Never reached
669+
except ValueError:
670+
return 2 # Should return this
671+
finally:
672+
await asyncio.sleep(0)
673+
return 3 # Should not reach this
674+
675+
# Test 2: Raise inside try, catch in except, re-raise
676+
async def async_try_except_reraise() -> int:
677+
try: # type: ignore[mypyc-try-finally-await]
678+
raise ValueError("test error")
679+
return 1 # Never reached
680+
except ValueError:
681+
raise # Re-raise the exception
682+
finally:
683+
await asyncio.sleep(0)
684+
return 2 # Should not reach this
685+
686+
# Test 3: Raise inside try, catch in except, raise different error
687+
async def async_try_except_raise_different() -> int:
688+
try: # type: ignore[mypyc-try-finally-await]
689+
raise ValueError("original error")
690+
return 1 # Never reached
691+
except ValueError:
692+
raise RuntimeError("different error")
693+
finally:
694+
await asyncio.sleep(0)
695+
return 2 # Should not reach this
696+
697+
# Test 4: Another try/except block inside finally
698+
async def async_try_except_inside_finally() -> int:
699+
try: # type: ignore[mypyc-try-finally-await]
700+
raise ValueError("outer error")
701+
return 1 # Never reached
702+
finally:
703+
await asyncio.sleep(0)
704+
try:
705+
raise RuntimeError("inner error")
706+
except RuntimeError:
707+
pass # Catch inner error
708+
return 2 # What happens after finally with inner exception handled?
709+
710+
# Test 5: Another try/finally block inside finally
711+
async def async_try_finally_inside_finally() -> int:
712+
try: # type: ignore[mypyc-try-finally-await]
713+
raise ValueError("outer error")
714+
return 1 # Never reached
715+
finally:
716+
await asyncio.sleep(0)
717+
try: # type: ignore[mypyc-try-finally-await]
718+
raise RuntimeError("inner error")
719+
finally:
720+
await asyncio.sleep(0)
721+
return 2 # Should not reach this
722+
723+
# Control case: No await in finally - should work correctly
724+
async def async_exception_no_await_in_finally() -> None:
725+
"""Control case: This works correctly - exception propagates"""
726+
try:
727+
raise TestError("This exception will propagate!")
728+
finally:
729+
pass # No await here
730+
731+
# Test function with no exception to check normal flow
732+
async def async_no_exception_with_await_in_finally() -> int:
733+
try: # type: ignore[mypyc-try-finally-await]
734+
return 1 # Normal return
735+
finally:
736+
await asyncio.sleep(0)
737+
return 2 # Should not reach this
738+
739+
def test_async_try_except_finally_await() -> None:
740+
# Test 0: Simplest case - just try/finally with exception
741+
# Expected: ValueError propagates
742+
with assertRaises(ValueError):
743+
asyncio.run(simple_try_finally_await())
744+
745+
# Test 1: Exception caught, not re-raised
746+
# Expected: return 2 (from except block)
747+
result = asyncio.run(async_try_except_no_reraise())
748+
assert result == 2, f"Expected 2, got {result}"
749+
750+
# Test 2: Exception caught and re-raised
751+
# Expected: ValueError propagates
752+
with assertRaises(ValueError):
753+
asyncio.run(async_try_except_reraise())
754+
755+
# Test 3: Exception caught, different exception raised
756+
# Expected: RuntimeError propagates
757+
with assertRaises(RuntimeError):
758+
asyncio.run(async_try_except_raise_different())
759+
760+
# Test 4: Try/except inside finally
761+
# Expected: ValueError propagates (outer exception)
762+
with assertRaises(ValueError):
763+
asyncio.run(async_try_except_inside_finally())
764+
765+
# Test 5: Try/finally inside finally
766+
# Expected: RuntimeError propagates (inner error)
767+
with assertRaises(RuntimeError):
768+
asyncio.run(async_try_finally_inside_finally())
769+
770+
# Control case: No await in finally (should work correctly)
771+
with assertRaises(TestError):
772+
asyncio.run(async_exception_no_await_in_finally())
773+
774+
# Test normal flow (no exception)
775+
# Expected: return 1
776+
result = asyncio.run(async_no_exception_with_await_in_finally())
777+
assert result == 1, f"Expected 1, got {result}"
778+
779+
[file asyncio/__init__.pyi]
780+
async def sleep(t: float) -> None: ...
781+
def run(x: object) -> object: ...
782+
783+
[case testAsyncContextManagerExceptionHandling]
784+
# Test async context managers with exceptions
785+
# Async context managers use try/finally internally but seem to work
786+
# correctly
787+
788+
import asyncio
789+
from testutil import assertRaises
790+
791+
# Test 1: Basic async context manager that doesn't suppress exceptions
792+
class AsyncContextManager:
793+
async def __aenter__(self) -> 'AsyncContextManager':
794+
return self
795+
796+
async def __aexit__(self, exc_type: type[BaseException] | None,
797+
exc_val: BaseException | None,
798+
exc_tb: object) -> None:
799+
# This await in __aexit__ is like await in finally
800+
await asyncio.sleep(0)
801+
# Don't suppress the exception (return None/False)
802+
803+
async def func_with_async_context_manager() -> str:
804+
async with AsyncContextManager():
805+
raise ValueError("Exception inside async with")
806+
return "should not reach" # Never reached
807+
return "should not reach either" # Never reached
808+
809+
async def test_basic_exception() -> str:
810+
try:
811+
await func_with_async_context_manager()
812+
return "func_a returned normally - bug!"
813+
except ValueError:
814+
return "caught ValueError - correct!"
815+
except Exception as e:
816+
return f"caught different exception: {type(e).__name__}"
817+
818+
# Test 2: Async context manager that raises a different exception in __aexit__
819+
class AsyncContextManagerRaisesInExit:
820+
async def __aenter__(self) -> 'AsyncContextManagerRaisesInExit':
821+
return self
822+
823+
async def __aexit__(self, exc_type: type[BaseException] | None,
824+
exc_val: BaseException | None,
825+
exc_tb: object) -> None:
826+
# This await in __aexit__ is like await in finally
827+
await asyncio.sleep(0)
828+
# Raise a different exception - this should replace the original exception
829+
raise RuntimeError("Exception in __aexit__")
830+
831+
async def func_with_raising_context_manager() -> str:
832+
async with AsyncContextManagerRaisesInExit():
833+
raise ValueError("Original exception")
834+
return "should not reach" # Never reached
835+
return "should not reach either" # Never reached
836+
837+
async def test_exception_in_aexit() -> str:
838+
try:
839+
await func_with_raising_context_manager()
840+
return "func returned normally - unexpected!"
841+
except RuntimeError:
842+
return "caught RuntimeError - correct!"
843+
except ValueError:
844+
return "caught ValueError - original exception not replaced!"
845+
except Exception as e:
846+
return f"caught different exception: {type(e).__name__}"
847+
848+
def test_async_context_manager_exception_handling() -> None:
849+
# Test 1: Basic exception propagation
850+
result = asyncio.run(test_basic_exception())
851+
# Expected: "caught ValueError - correct!"
852+
assert result == "caught ValueError - correct!", f"Expected exception to propagate, got: {result}"
853+
854+
# Test 2: Exception raised in __aexit__ replaces original exception
855+
result = asyncio.run(test_exception_in_aexit())
856+
# Expected: "caught RuntimeError - correct!"
857+
# (The RuntimeError from __aexit__ should replace the ValueError)
858+
assert result == "caught RuntimeError - correct!", f"Expected RuntimeError from __aexit__, got: {result}"
859+
860+
[file asyncio/__init__.pyi]
861+
async def sleep(t: float) -> None: ...
862+
def run(x: object) -> object: ...

0 commit comments

Comments
 (0)