From c0511d2599478c72cc69f197602c7f43b494e58b Mon Sep 17 00:00:00 2001 From: Joaquin Hui Gomez <132194176+joaquinhuigomez@users.noreply.github.com> Date: Sat, 28 Mar 2026 20:27:51 +0000 Subject: [PATCH 1/3] Remove asyncio.shield in _drain_helper to prevent unhandled future warnings Replace asyncio.shield(waiter) with direct await on waiter in _drain_helper. HTTP/1.1 has a single consumer per drain waiter, so shield is unnecessary and causes "Future exception was never retrieved" warnings on cancellation. Closes #12281 --- CHANGES/12281.bugfix.rst | 4 ++++ aiohttp/base_protocol.py | 2 +- tests/test_base_protocol.py | 44 +++++++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 CHANGES/12281.bugfix.rst diff --git a/CHANGES/12281.bugfix.rst b/CHANGES/12281.bugfix.rst new file mode 100644 index 00000000000..217dc224e67 --- /dev/null +++ b/CHANGES/12281.bugfix.rst @@ -0,0 +1,4 @@ +Fixed "Future exception was never retrieved" warning when a request handler +is cancelled during TCP write backpressure. ``_drain_helper`` now awaits the +drain waiter directly instead of wrapping it in :func:`asyncio.shield` +-- by :user:`joaquinhuigomez`. diff --git a/aiohttp/base_protocol.py b/aiohttp/base_protocol.py index 7f01830f4e9..d7d83425b88 100644 --- a/aiohttp/base_protocol.py +++ b/aiohttp/base_protocol.py @@ -97,4 +97,4 @@ async def _drain_helper(self) -> None: if waiter is None: waiter = self._loop.create_future() self._drain_waiter = waiter - await asyncio.shield(waiter) + await waiter diff --git a/tests/test_base_protocol.py b/tests/test_base_protocol.py index 713dba2d0c2..989957da79d 100644 --- a/tests/test_base_protocol.py +++ b/tests/test_base_protocol.py @@ -242,6 +242,50 @@ async def wait() -> None: assert pr._drain_waiter is None +async def test_cancelled_drain_no_unhandled_future_warning() -> None: + """Cancelling a task during backpressure must not leave an orphaned future. + + When the handler task is cancelled while awaiting _drain_helper and + connection_lost fires with an exception afterward, the waiter should + already be done (cancelled) so set_exception is skipped. No "Future + exception was never retrieved" warning should appear. + + Regression test for https://github.com/aio-libs/aiohttp/issues/12281 + """ + loop = asyncio.get_event_loop() + pr = BaseProtocol(loop=loop) + tr = mock.Mock() + pr.connection_made(tr) + pr.pause_writing() + + fut = loop.create_future() + + async def wait() -> None: + fut.set_result(None) + await pr._drain_helper() + + t = loop.create_task(wait()) + await fut + t.cancel() + with suppress(asyncio.CancelledError): + await t + + # After cancellation the waiter should be done (cancelled), so + # connection_lost with an exception must not call set_exception. + assert pr._drain_waiter is not None + waiter = pr._drain_waiter + assert waiter.done(), "waiter must be cancelled when task is cancelled" + + # This previously left an orphaned future with an unhandled exception + # because asyncio.shield kept the original waiter alive and uncancelled. + exc = RuntimeError("connection died") + pr.connection_lost(exc) + assert pr._drain_waiter is None + + # Verify the waiter is cancelled, not set with an exception. + assert waiter.cancelled() + + async def test_parallel_drain_race_condition() -> None: loop = asyncio.get_event_loop() pr = BaseProtocol(loop=loop) From 59ed244c1a85ffd4324169b7adef89b5ca25b66f Mon Sep 17 00:00:00 2001 From: Joaquin Hui Gomez <132194176+joaquinhuigomez@users.noreply.github.com> Date: Sun, 29 Mar 2026 13:02:17 +0100 Subject: [PATCH 2/3] Fix mypy unreachable false positive in cancellation test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mypy flags the final assertion as unreachable after the suppress(CancelledError) context manager. The code IS reachable at runtime — the suppress catches the CancelledError from the cancelled task, and execution continues normally. --- tests/test_base_protocol.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_base_protocol.py b/tests/test_base_protocol.py index 989957da79d..12d9466dbd0 100644 --- a/tests/test_base_protocol.py +++ b/tests/test_base_protocol.py @@ -283,7 +283,7 @@ async def wait() -> None: assert pr._drain_waiter is None # Verify the waiter is cancelled, not set with an exception. - assert waiter.cancelled() + assert waiter.cancelled() # type: ignore[unreachable] async def test_parallel_drain_race_condition() -> None: From 5cf4a25f149412d8573b7ce5a720c57a188f73a4 Mon Sep 17 00:00:00 2001 From: Joaquin Hui Gomez <132194176+joaquinhuigomez@users.noreply.github.com> Date: Sun, 29 Mar 2026 19:24:40 +0100 Subject: [PATCH 3/3] Fix doc-spelling: use hyphenated back-pressure --- CHANGES/12281.bugfix.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES/12281.bugfix.rst b/CHANGES/12281.bugfix.rst index 217dc224e67..8edde0ad3de 100644 --- a/CHANGES/12281.bugfix.rst +++ b/CHANGES/12281.bugfix.rst @@ -1,4 +1,4 @@ Fixed "Future exception was never retrieved" warning when a request handler -is cancelled during TCP write backpressure. ``_drain_helper`` now awaits the +is cancelled during TCP write back-pressure. ``_drain_helper`` now awaits the drain waiter directly instead of wrapping it in :func:`asyncio.shield` -- by :user:`joaquinhuigomez`.