From f8e3a70140fbc29c6e2b2430c441908708a9278b Mon Sep 17 00:00:00 2001 From: Jens Jorgensen Date: Wed, 17 Jan 2024 16:26:08 -0600 Subject: [PATCH] Fix "Future exception was never retrieved" in create_connection When create_connection() is cancelled (e.g. by a wait_for() timeout) while its getaddrinfo() lookups are still in flight, asyncio.wait() leaves the lookup futures running. Once such a lookup fails, nobody retrieves its exception and the loop logs: Future exception was never retrieved future: Cancel the pending lookups when the wait is cancelled. A lookup that has already finished can't be cancelled, so mark its exception (if any) as retrieved instead. The stdlib event loop doesn't have this problem because its lookup future is awaited directly and therefore cancelled with the task. Co-Authored-By: Claude Fable 5.1 --- tests/test_dns.py | 42 ++++++++++++++++++++++++++++++++++++++++++ uvloop/loop.pyx | 14 +++++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/tests/test_dns.py b/tests/test_dns.py index 106ef580..1ff6d267 100644 --- a/tests/test_dns.py +++ b/tests/test_dns.py @@ -1,4 +1,5 @@ import asyncio +import gc import socket import unittest @@ -238,6 +239,47 @@ def test_getnameinfo_4(self): def test_getnameinfo_5(self): self._test_getnameinfo(('localhost', 8080), 0) + def test_create_connection_cancel_during_getaddrinfo(self): + # Regression test for https://github.com/MagicStack/uvloop/pull/590 + # + # When create_connection() is cancelled (e.g. by a wait_for() + # timeout) while its DNS lookup is still in flight, the lookup + # must be cancelled as well. Otherwise, once the lookup fails, + # nobody retrieves its exception and the loop logs + # "Future exception was never retrieved". + + host = 'no-such-host.invalid' # reserved TLD, see RFC 2606 + + messages = [] + self.loop.set_exception_handler( + lambda loop, ctx: messages.append(ctx)) + + async def run(): + task = self.loop.create_task( + self.loop.create_connection(asyncio.Protocol, host, 80)) + # Let create_connection() start the DNS lookup, then cancel + # it before the lookup result is delivered to the loop. + await asyncio.sleep(0) + task.cancel() + with self.assertRaises(asyncio.CancelledError): + await task + del task + + # Wait for the lookup to fail. The resolver answers requests + # roughly in order, so a second lookup of the same host is a + # good proxy for the first one being done. + try: + await self.loop.getaddrinfo(host, 80) + except socket.gaierror: + pass + else: + raise unittest.SkipTest(f'{host!r} unexpectedly resolves') + await asyncio.sleep(0.1) + gc.collect() + + self.loop.run_until_complete(run()) + self.assertEqual(messages, []) + class Test_UV_DNS(BaseTestDNS, tb.UVTestCase): diff --git a/uvloop/loop.pyx b/uvloop/loop.pyx index 802cdb69..6038b3ff 100644 --- a/uvloop/loop.pyx +++ b/uvloop/loop.pyx @@ -1972,7 +1972,19 @@ cdef class Loop: lai = &lai_static if len(fs): - await aio_wait(fs) + try: + await aio_wait(fs) + except asyncio.CancelledError: + # The caller gave up (e.g. a wait_for() timeout) while + # the DNS lookups were still in progress. Cancel them + # so that a lookup failing later doesn't get reported + # as "Future exception was never retrieved". A lookup + # that has already finished can't be cancelled, so mark + # its exception (if any) as retrieved instead. + for fut in fs: + if not fut.cancel() and not fut.cancelled(): + fut.exception() + raise if rai is NULL: ai_remote = f1.result()