From 0ec94e501a04dc6a0730d75e6eeab2782202e16b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20K=C4=99pie=C5=84?= Date: Sun, 21 Dec 2025 06:25:56 +0100 Subject: [PATCH 1/2] Make exception/signal handlers idempotent Calling asyncio.Future.set_exception() or asyncio.Future.set_result() more than once for a given Future object raises an asyncio.InvalidStateError exception. In the case of AsyncServer: - it is enough to capture the first exception raised by higher-level logic as no exceptions at all are expected to be raised in the first place, - no distinction is made between SIGINT and SIGTERM; the only purpose of the signal handler is to make the server exit cleanly. Given the above, make both AsyncServer._handle_exception() and AsyncServer._signal_done() idempotent by ignoring asyncio.InvalidStateError exceptions raised by the relevant asyncio.Future.set_*() calls. --- bin/tests/system/isctest/asyncserver.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/bin/tests/system/isctest/asyncserver.py b/bin/tests/system/isctest/asyncserver.py index 98fec6b663..6ec3b00f10 100644 --- a/bin/tests/system/isctest/asyncserver.py +++ b/bin/tests/system/isctest/asyncserver.py @@ -198,7 +198,10 @@ class AsyncServer: ) -> None: assert self._work_done exception = context.get("exception", RuntimeError(context["message"])) - self._work_done.set_exception(exception) + try: + self._work_done.set_exception(exception) + except asyncio.InvalidStateError: + pass def _setup_signals(self) -> None: loop = self._get_asyncio_loop() @@ -207,7 +210,10 @@ class AsyncServer: def _signal_done(self) -> None: assert self._work_done - self._work_done.set_result(True) + try: + self._work_done.set_result(True) + except asyncio.InvalidStateError: + pass async def _listen_udp(self) -> None: if not self._udp_handler: From 1acde358ea13b4a07ba3a3df8a625cb15646217c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20K=C4=99pie=C5=84?= Date: Sun, 21 Dec 2025 06:25:56 +0100 Subject: [PATCH 2/2] Prevent garbage-collecting ignored TCP connections Due to the way various asyncio-related objects (tasks, streams, transports, selectors) are referencing each other, pausing reads for a TCP transport (which in practice means removing the client socket from the set of descriptors monitored by a selector) can cause the client task (AsyncDnsServer._handle_tcp()) to be prematurely garbage-collected, causing asyncio code to raise a "Task was destroyed but it is pending!" exception. Who knew that solutions as elegant as the one introduced by e4078885073a6c5b59729f4313108e3e7637efdb could cause unexpected trouble? Fix by making a horrible hack even more horrible, specifically by keeping a reference to each incoming TCP connection to protect its related asyncio objects from getting garbage-collected. This prevents AsyncDnsServer from closing any of the ignored TCP connections indefinitely, which is obviously a pretty brain-dead idea for a production-grade DNS server, but AsyncDnsServer was never meant to be one and this hack reliably solves the problem at hand. Only apply this change for the IgnoreAllConnections handler as the ConnectionReset handler triggers a connection reset immediately after pausing reads for an incoming TCP connection. As pointed out in e4078885073a6c5b59729f4313108e3e7637efdb, the proper solution would require implementing a custom asyncio transport from scratch and that is still deemed to be too much work for the purpose at hand. Let's see how much longer we can limp along with the existing approach. --- bin/tests/system/isctest/asyncserver.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/bin/tests/system/isctest/asyncserver.py b/bin/tests/system/isctest/asyncserver.py index 6ec3b00f10..d2b22d7c12 100644 --- a/bin/tests/system/isctest/asyncserver.py +++ b/bin/tests/system/isctest/asyncserver.py @@ -20,6 +20,7 @@ from typing import ( Dict, List, Optional, + Set, Tuple, Union, cast, @@ -504,10 +505,26 @@ class IgnoreAllConnections(ConnectionHandler): client socket, effectively ignoring all incoming connections. """ + _connections: Set[asyncio.StreamWriter] = field(default_factory=set) + async def handle( self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, peer: Peer ) -> None: block_reading(peer, writer) + # Due to the way various asyncio-related objects (tasks, streams, + # transports, selectors) are referencing each other, pausing reads for + # a TCP transport (which in practice means removing the client socket + # from the set of descriptors monitored by a selector) can cause the + # client task (AsyncDnsServer._handle_tcp()) to be prematurely + # garbage-collected, causing asyncio code to raise a "Task was + # destroyed but it is pending!" exception. Prevent that from happening + # by keeping a reference to each incoming TCP connection to protect its + # related asyncio objects from getting garbage-collected. This + # prevents AsyncDnsServer from closing any of the ignored TCP + # connections indefinitely, which is obviously a pretty brain-dead idea + # for a production-grade DNS server, but AsyncDnsServer was never meant + # to be one and this hack reliably solves the problem at hand. + self._connections.add(writer) @dataclass