diff --git a/docs/versionhistory.rst b/docs/versionhistory.rst index b5b8d01b9..e0205b40c 100644 --- a/docs/versionhistory.rst +++ b/docs/versionhistory.rst @@ -17,6 +17,8 @@ This library adheres to `Semantic Versioning 2.0 `_. module name. (The default name for a task spawned with ``TaskGroup.start_soon`` or ``TaskGroup.start`` typically includes the module name.) (`#1234 `_; PR by @gschaffner) +- Fixed UDP socket closing hanging on Windows if a datagram send was still in flight + (`#1237 `_; PR by @Guflly) - Fixed free-threading compatibility issues arising from the fact that on Python 3.14 free-threading builds, newly created threads inherit the current context by default, causing AnyIO to behave erroneously in relation to ``start_blocking_portal()`` and diff --git a/src/anyio/_backends/_asyncio.py b/src/anyio/_backends/_asyncio.py index 4fc1f0c64..3745f99ce 100644 --- a/src/anyio/_backends/_asyncio.py +++ b/src/anyio/_backends/_asyncio.py @@ -1403,8 +1403,11 @@ async def aclose(self) -> None: pass self._transport.close() - await sleep(0) - self._transport.abort() + try: + await sleep(0) + finally: + self._transport.abort() + await AsyncIOBackend.cancel_shielded_checkpoint() class _RawSocketMixin: @@ -1687,6 +1690,11 @@ async def aclose(self) -> None: self._closed = True if not self._transport.is_closing(): self._transport.close() + try: + await sleep(0) + finally: + self._transport.abort() + await AsyncIOBackend.cancel_shielded_checkpoint() await self._protocol.closed_event.wait() @@ -1737,6 +1745,11 @@ async def aclose(self) -> None: self._closed = True if not self._transport.is_closing(): self._transport.close() + try: + await sleep(0) + finally: + self._transport.abort() + await AsyncIOBackend.cancel_shielded_checkpoint() await self._protocol.closed_event.wait() diff --git a/tests/test_sockets.py b/tests/test_sockets.py index b456b4079..dbdb7d31f 100644 --- a/tests/test_sockets.py +++ b/tests/test_sockets.py @@ -34,6 +34,7 @@ from pytest import FixtureRequest from pytest_mock.plugin import MockerFixture +import anyio from anyio import ( BrokenResourceError, BusyResourceError, @@ -518,6 +519,14 @@ async def test_send_after_close(self, server_addr: tuple[str, int]) -> None: with pytest.raises(ClosedResourceError): await stream.send(b"foo") + @pytest.mark.parametrize("anyio_backend", asyncio_params) + async def test_aclose_forcefully(self, server_addr: tuple[str, int]) -> None: + stream = await connect_tcp(*server_addr) + sock = stream.extra(SocketAttribute.raw_socket) + await stream.send(b"x") + await anyio.aclose_forcefully(stream) + assert sock.fileno() == -1 + async def test_receive_after_peer_closed( self, family: AnyIPAddressFamily, request: FixtureRequest ) -> None: @@ -1743,6 +1752,17 @@ async def test_aclose_waits_for_fd_release( udp = await UDPSocket.from_socket(sock) await udp.aclose() + @pytest.mark.skipif(sys.platform != "win32", reason="Windows only") + @pytest.mark.parametrize("anyio_backend", asyncio_params) + async def test_aclose_during_send(self) -> None: + udp = await create_udp_socket(local_host="127.0.0.1") + sock = udp.extra(SocketAttribute.raw_socket) + await udp.sendto(b"x", "127.0.0.1", 9999) + with fail_after(1): + await anyio.aclose_forcefully(udp) + + assert sock.fileno() == -1 + async def test_extra_attributes(self, family: AnyIPAddressFamily) -> None: async with await create_udp_socket( family=family, local_host="localhost" @@ -1914,6 +1934,14 @@ async def test_aclose_waits_for_fd_release( finally: peer.close() + @pytest.mark.skipif(sys.platform != "win32", reason="Windows only") + @pytest.mark.parametrize("anyio_backend", asyncio_params) + async def test_aclose_during_send(self) -> None: + udp = await create_connected_udp_socket("127.0.0.1", 9999) + await udp.send(b"x") + with fail_after(1): + await udp.aclose() + async def test_extra_attributes(self, family: AnyIPAddressFamily) -> None: async with await create_connected_udp_socket( "localhost", 5000, family=family