From eb0959553670216e72a12b94bd3cd36f1a3f5a19 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Mon, 31 Aug 2026 16:40:28 +0100 Subject: [PATCH 1/6] gh-127057: Re-arm proactor datagram read loop after a recoverable OSError --- Lib/asyncio/proactor_events.py | 7 ++ Lib/test/test_asyncio/test_events.py | 65 +++++++++++++++++++ ...-08-31-00-00-00.gh-issue-127057.N9yg62.rst | 6 ++ 3 files changed, 78 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2026-08-31-00-00-00.gh-issue-127057.N9yg62.rst diff --git a/Lib/asyncio/proactor_events.py b/Lib/asyncio/proactor_events.py index f18a7fe58558155..f712d2967b2de8f 100644 --- a/Lib/asyncio/proactor_events.py +++ b/Lib/asyncio/proactor_events.py @@ -573,6 +573,13 @@ def _loop_reading(self, fut=None): self.max_size) except OSError as exc: self._protocol.error_received(exc) + if not self._closing and not self._conn_lost: + # Some errors are transient and recoverable, e.g. a + # ConnectionResetError raised synchronously by WSARecvFrom() + # from a stale ICMP port-unreachable notification on a UDP + # socket. Re-arm the read loop instead of leaving it dead + # (gh-127057). + self._loop.call_soon(self._loop_reading) except exceptions.CancelledError: if not self._closing: raise diff --git a/Lib/test/test_asyncio/test_events.py b/Lib/test/test_asyncio/test_events.py index db316fae090280a..eec57b043500ca2 100644 --- a/Lib/test/test_asyncio/test_events.py +++ b/Lib/test/test_asyncio/test_events.py @@ -1583,6 +1583,71 @@ def create_socket(): transport_1.close() transport_2.close() + def test_datagram_recvfrom_connection_reset_recovers(self): + # gh-127057: on Windows, a UDP socket that previously sent a + # datagram to an address that wasn't listening can raise + # ConnectionResetError (WSAECONNRESET) on a later receive + # attempt: synchronously from WSARecvFrom() on ProactorEventLoop + # (instead of via the completion result already handled in + # _finish_recvfrom() for gh-91227), or from a plain recvfrom() + # on SelectorEventLoop. Either way the transport must keep + # working afterwards instead of the read loop dying silently. + loop = self.loop + + class Protocol(asyncio.DatagramProtocol): + def connection_made(self, transport): + self.transport = transport + self.errors = [] + self.received = [] + self.datagram_received_event = loop.create_future() + + def error_received(self, exc): + self.errors.append(exc) + + def datagram_received(self, data, addr): + self.received.append(data) + if not self.datagram_received_event.done(): + self.datagram_received_event.set_result(None) + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + sock.bind(('127.0.0.1', 0)) + addr = sock.getsockname() + + # Bind and immediately close a second socket to get an address + # that is guaranteed not to be listening. + closed = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + closed.bind(('127.0.0.1', 0)) + closed_addr = closed.getsockname() + closed.close() + + # Trigger a real ICMP port-unreachable now, before the socket is + # wrapped in a transport and before any read is armed for it -- + # on Windows this is what makes a Proactor's first WSARecvFrom() + # call raise synchronously. + sock.sendto(b'x', closed_addr) + + transport, protocol = loop.run_until_complete( + loop.create_datagram_endpoint(Protocol, sock=sock)) + + # The transport must still be able to receive afterwards -- this + # is the actual regression check, and must hold regardless of + # whether this platform surfaced an error for the bad send above. + transport.sendto(b'ping', addr) + loop.run_until_complete( + asyncio.wait_for(protocol.datagram_received_event, 10)) + self.assertEqual(protocol.received, [b'ping']) + + if sys.platform == 'win32': + # Windows reliably reports the bad send via a later recvfrom(); + # other platforms generally don't deliver ICMP errors to a + # plain recv() on an unconnected UDP socket. + self.assertEqual(len(protocol.errors), 1) + self.assertIsInstance(protocol.errors[0], ConnectionResetError) + + transport.close() + test_utils.run_briefly(loop) + def test_internal_fds(self): loop = self.create_event_loop() if not isinstance(loop, selector_events.BaseSelectorEventLoop): diff --git a/Misc/NEWS.d/next/Library/2026-08-31-00-00-00.gh-issue-127057.N9yg62.rst b/Misc/NEWS.d/next/Library/2026-08-31-00-00-00.gh-issue-127057.N9yg62.rst new file mode 100644 index 000000000000000..02d425989408c41 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-08-31-00-00-00.gh-issue-127057.N9yg62.rst @@ -0,0 +1,6 @@ +Fix :class:`asyncio.ProactorEventLoop` UDP transports so a +:exc:`ConnectionResetError` raised synchronously from ``WSARecvFrom`` +(reported when the same socket was previously used to send to an +address that isn't listening) no longer breaks the read loop. This +complements the existing handling of the equivalent asynchronous +``ERROR_PORT_UNREACHABLE`` completion result. From ca2ee219f40b76d81b4e348fd4397726c95d84c0 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Sat, 5 Sep 2026 10:59:42 +0100 Subject: [PATCH 2/6] respond to code review comments --- Lib/asyncio/proactor_events.py | 8 ++--- Lib/test/test_asyncio/test_events.py | 29 ++++++------------- ...-08-31-00-00-00.gh-issue-127057.N9yg62.rst | 9 ++---- 3 files changed, 15 insertions(+), 31 deletions(-) diff --git a/Lib/asyncio/proactor_events.py b/Lib/asyncio/proactor_events.py index f712d2967b2de8f..c660784e07c04e2 100644 --- a/Lib/asyncio/proactor_events.py +++ b/Lib/asyncio/proactor_events.py @@ -574,11 +574,9 @@ def _loop_reading(self, fut=None): except OSError as exc: self._protocol.error_received(exc) if not self._closing and not self._conn_lost: - # Some errors are transient and recoverable, e.g. a - # ConnectionResetError raised synchronously by WSARecvFrom() - # from a stale ICMP port-unreachable notification on a UDP - # socket. Re-arm the read loop instead of leaving it dead - # (gh-127057). + # The error can be transient, e.g. a ConnectionResetError + # from a stale ICMP port unreachable notification, so + # re-arm the read loop instead of leaving it dead. self._loop.call_soon(self._loop_reading) except exceptions.CancelledError: if not self._closing: diff --git a/Lib/test/test_asyncio/test_events.py b/Lib/test/test_asyncio/test_events.py index eec57b043500ca2..06f538bceea1672 100644 --- a/Lib/test/test_asyncio/test_events.py +++ b/Lib/test/test_asyncio/test_events.py @@ -1584,14 +1584,9 @@ def create_socket(): transport_2.close() def test_datagram_recvfrom_connection_reset_recovers(self): - # gh-127057: on Windows, a UDP socket that previously sent a - # datagram to an address that wasn't listening can raise - # ConnectionResetError (WSAECONNRESET) on a later receive - # attempt: synchronously from WSARecvFrom() on ProactorEventLoop - # (instead of via the completion result already handled in - # _finish_recvfrom() for gh-91227), or from a plain recvfrom() - # on SelectorEventLoop. Either way the transport must keep - # working afterwards instead of the read loop dying silently. + # gh-127057: a UDP socket that sent a datagram to an address that + # wasn't listening can raise ConnectionResetError on a later + # receive. The transport must keep working afterwards. loop = self.loop class Protocol(asyncio.DatagramProtocol): @@ -1621,27 +1616,21 @@ def datagram_received(self, data, addr): closed_addr = closed.getsockname() closed.close() - # Trigger a real ICMP port-unreachable now, before the socket is - # wrapped in a transport and before any read is armed for it -- - # on Windows this is what makes a Proactor's first WSARecvFrom() - # call raise synchronously. + # Trigger the error before the socket is wrapped in a transport, + # so that the first read raises synchronously. sock.sendto(b'x', closed_addr) transport, protocol = loop.run_until_complete( loop.create_datagram_endpoint(Protocol, sock=sock)) - # The transport must still be able to receive afterwards -- this - # is the actual regression check, and must hold regardless of - # whether this platform surfaced an error for the bad send above. transport.sendto(b'ping', addr) - loop.run_until_complete( - asyncio.wait_for(protocol.datagram_received_event, 10)) + loop.run_until_complete(asyncio.wait_for( + protocol.datagram_received_event, support.SHORT_TIMEOUT)) self.assertEqual(protocol.received, [b'ping']) if sys.platform == 'win32': - # Windows reliably reports the bad send via a later recvfrom(); - # other platforms generally don't deliver ICMP errors to a - # plain recv() on an unconnected UDP socket. + # Other platforms don't report ICMP errors on an + # unconnected UDP socket. self.assertEqual(len(protocol.errors), 1) self.assertIsInstance(protocol.errors[0], ConnectionResetError) diff --git a/Misc/NEWS.d/next/Library/2026-08-31-00-00-00.gh-issue-127057.N9yg62.rst b/Misc/NEWS.d/next/Library/2026-08-31-00-00-00.gh-issue-127057.N9yg62.rst index 02d425989408c41..f47da0453249cad 100644 --- a/Misc/NEWS.d/next/Library/2026-08-31-00-00-00.gh-issue-127057.N9yg62.rst +++ b/Misc/NEWS.d/next/Library/2026-08-31-00-00-00.gh-issue-127057.N9yg62.rst @@ -1,6 +1,3 @@ -Fix :class:`asyncio.ProactorEventLoop` UDP transports so a -:exc:`ConnectionResetError` raised synchronously from ``WSARecvFrom`` -(reported when the same socket was previously used to send to an -address that isn't listening) no longer breaks the read loop. This -complements the existing handling of the equivalent asynchronous -``ERROR_PORT_UNREACHABLE`` completion result. +Fix :class:`asyncio.ProactorEventLoop` UDP transports so that a +:exc:`ConnectionResetError` raised by ``WSARecvFrom`` no longer stops the +read loop. From 77502d2fdcf22a4e4060014eb9f432c67ae91c44 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Sat, 5 Sep 2026 11:02:57 +0100 Subject: [PATCH 3/6] Apply suggestion from @graingert --- Lib/asyncio/proactor_events.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/asyncio/proactor_events.py b/Lib/asyncio/proactor_events.py index c660784e07c04e2..fc2d41c06877ef6 100644 --- a/Lib/asyncio/proactor_events.py +++ b/Lib/asyncio/proactor_events.py @@ -573,7 +573,7 @@ def _loop_reading(self, fut=None): self.max_size) except OSError as exc: self._protocol.error_received(exc) - if not self._closing and not self._conn_lost: + if not self._conn_lost: # The error can be transient, e.g. a ConnectionResetError # from a stale ICMP port unreachable notification, so # re-arm the read loop instead of leaving it dead. From f2afd2868d7e24c70c0dedd0ef6318fbc2db7642 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Sat, 5 Sep 2026 11:03:14 +0100 Subject: [PATCH 4/6] Update Lib/asyncio/proactor_events.py Co-authored-by: Kumar Aditya --- Lib/asyncio/proactor_events.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/asyncio/proactor_events.py b/Lib/asyncio/proactor_events.py index fc2d41c06877ef6..b4cebeed7b4b9ba 100644 --- a/Lib/asyncio/proactor_events.py +++ b/Lib/asyncio/proactor_events.py @@ -576,7 +576,7 @@ def _loop_reading(self, fut=None): if not self._conn_lost: # The error can be transient, e.g. a ConnectionResetError # from a stale ICMP port unreachable notification, so - # re-arm the read loop instead of leaving it dead. + # reschedule the read loop instead of leaving it dead. self._loop.call_soon(self._loop_reading) except exceptions.CancelledError: if not self._closing: From 9afc04ba97cd54a6a500bb6548d7f2c51de4bdb3 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Sat, 5 Sep 2026 11:14:09 +0100 Subject: [PATCH 5/6] Apply suggestion from @graingert --- Lib/asyncio/proactor_events.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/asyncio/proactor_events.py b/Lib/asyncio/proactor_events.py index b4cebeed7b4b9ba..6a6c4a9f9879d0a 100644 --- a/Lib/asyncio/proactor_events.py +++ b/Lib/asyncio/proactor_events.py @@ -573,7 +573,7 @@ def _loop_reading(self, fut=None): self.max_size) except OSError as exc: self._protocol.error_received(exc) - if not self._conn_lost: + if not self._closing: # The error can be transient, e.g. a ConnectionResetError # from a stale ICMP port unreachable notification, so # reschedule the read loop instead of leaving it dead. From ad1e9be19eaaa38448ae46a95401f2f5d54fafeb Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Sat, 5 Sep 2026 11:19:36 +0100 Subject: [PATCH 6/6] only retry known recoverable errors --- Lib/asyncio/proactor_events.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Lib/asyncio/proactor_events.py b/Lib/asyncio/proactor_events.py index 6a6c4a9f9879d0a..7adb09f3fce5de7 100644 --- a/Lib/asyncio/proactor_events.py +++ b/Lib/asyncio/proactor_events.py @@ -571,13 +571,17 @@ def _loop_reading(self, fut=None): else: self._read_fut = self._loop._proactor.recvfrom(self._sock, self.max_size) - except OSError as exc: + except ConnectionResetError as exc: + # WSARecvFrom() reports a stale ICMP port unreachable + # notification as a synchronous ConnectionResetError when the + # same socket was used to send to an address that is not + # listening. This is transient, so reschedule the read loop + # instead of leaving it dead. self._protocol.error_received(exc) if not self._closing: - # The error can be transient, e.g. a ConnectionResetError - # from a stale ICMP port unreachable notification, so - # reschedule the read loop instead of leaving it dead. self._loop.call_soon(self._loop_reading) + except OSError as exc: + self._protocol.error_received(exc) except exceptions.CancelledError: if not self._closing: raise