From 39cdb9d873f69cb9ef3d670a8d8482d1eaaf79d6 Mon Sep 17 00:00:00 2001 From: Zo Bot Date: Wed, 1 Jul 2026 16:03:41 +0000 Subject: [PATCH] coroutine: catch asyncio.CancelledError alongside Exception so cancellations surface on the returned future The @gen.coroutine wrapper and the Runner both caught only Exception. asyncio.CancelledError inherits from BaseException in Python 3.8- and from Exception in 3.8+, so the wrapper's outer try/except Exception around ctx_run(func, ...) and the Runner's except Exception around future.result() silently dropped the cancellation. The pre-fix behaviour either let the CancelledError leak out (3.8-) or vanished into the result_future without ever surfacing on the consumer's await (3.8+). Catch (Exception, asyncio.CancelledError) explicitly at both call sites so an asyncio cancellation of a yielded future or a sync cancellation in the coroutine body is propagated to the returned future the same way any other exception is. Regression test for #3537. --- tornado/gen.py | 27 +++++++++++++++++--- tornado/test/gen_test.py | 53 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/tornado/gen.py b/tornado/gen.py index 4cd3ba273..af1527e75 100644 --- a/tornado/gen.py +++ b/tornado/gen.py @@ -205,7 +205,14 @@ def wrapper(*args: Any, **kwargs: Any) -> Future[_T]: result = ctx_run(func, *args, **kwargs) except (Return, StopIteration) as e: result = _value_from_stopiteration(e) - except Exception: + except (Exception, asyncio.CancelledError): + # asyncio.CancelledError is a BaseException in Python <3.8 and + # changed to inherit from Exception in 3.8+. We catch it + # explicitly here so that an asyncio cancellation of the wrapped + # function surfaces on the returned future (matching the + # behavior of coroutine-created tasks) rather than escaping + # the wrapper and silently breaking the IOLoop callback chain. + # See https://github.com/tornadoweb/tornado/issues/3537 future_set_exc_info(future, sys.exc_info()) try: return future @@ -225,7 +232,8 @@ def wrapper(*args: Any, **kwargs: Any) -> Future[_T]: future_set_result_unless_cancelled( future, _value_from_stopiteration(e) ) - except Exception: + except (Exception, asyncio.CancelledError): + # Same as above: see https://github.com/tornadoweb/tornado/issues/3537 future_set_exc_info(future, sys.exc_info()) else: # Provide strong references to Runner objects as long @@ -765,10 +773,14 @@ def run(self) -> None: try: try: value = future.result() - except Exception as e: + except (Exception, asyncio.CancelledError) as e: # Save the exception for later. It's important that # gen.throw() not be called inside this try/except block # because that makes sys.exc_info behave unexpectedly. + # asyncio.CancelledError is caught here so that + # cancellation of a yielded future surfaces on + # result_future rather than being silently dropped + # when the generator is resumed. See tornado #3537. exc: Exception | None = e else: exc = None @@ -793,7 +805,14 @@ def run(self) -> None: ) self.result_future = None # type: ignore return - except Exception: + except (Exception, asyncio.CancelledError): + # Catching asyncio.CancelledError alongside Exception + # mirrors the inline-first-iteration handling above: + # an asyncio cancellation that escapes the generator + # body (or is raised by the generator itself) is + # propagated to result_future instead of leaking past + # the Runner and breaking the IOLoop's bookkeeping. + # See tornado #3537. self.finished = True self.future = _null_future future_set_exc_info(self.result_future, sys.exc_info()) diff --git a/tornado/test/gen_test.py b/tornado/test/gen_test.py index 99167f718..a5e89ebe5 100644 --- a/tornado/test/gen_test.py +++ b/tornado/test/gen_test.py @@ -466,6 +466,59 @@ def f(): yield future self.finished = True + @gen_test + def test_sync_raise_cancelled_error(self): + # A coroutine that raises asyncio.CancelledError synchronously + # (before its first yield) should still surface the cancellation + # on the returned future, the same way any other Exception is + # captured. Regression test for #3537. + @gen.coroutine + def f(): + raise asyncio.CancelledError() + + future = f() + with self.assertRaises(asyncio.CancelledError): + yield future + self.finished = True + + @gen_test + def test_async_raise_cancelled_error(self): + # A coroutine that raises asyncio.CancelledError after a yield + # point should propagate the cancellation to the future through + # the Runner, matching the behaviour for plain Exception + # subclasses. Regression test for #3537. + @gen.coroutine + def f(): + yield gen.moment + raise asyncio.CancelledError() + + future = f() + with self.assertRaises(asyncio.CancelledError): + yield future + self.finished = True + + @gen_test + def test_yield_cancelled_asyncio_future(self): + # Yielding an asyncio future that was cancelled before being + # awaited should propagate CancelledError to the parent + # coroutine. Without the fix the parent coroutine never + # completes and the test times out. Regression test for #3537. + async def cancellable(): + await asyncio.sleep(10000.0) + + cancellable_future = asyncio.ensure_future(cancellable()) + cancellable_future.cancel() + + @gen.coroutine + def child(): + yield gen.moment + yield cancellable_future + + future = child() + with self.assertRaises(asyncio.CancelledError): + yield future + self.finished = True + @gen_test def test_replace_yieldpoint_exception(self): # Test exception handling: a coroutine can catch one exception