From e842971da67b224a794ce7800e5bb656000c3cdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joan=20Fabr=C3=A9gat?= Date: Thu, 4 Jun 2026 16:55:29 -0700 Subject: [PATCH] Handle plain OSError when waiting for the writer to close A peer that becomes unreachable mid-connection (e.g. EHOSTUNREACH when the client host vanishes) raises a plain OSError from wait_closed(), which the except tuple in TCPServer._close() missed - only ConnectionError subclasses were caught. The exception then escaped the client_connected_cb task via run()'s finally block, or propagated into the ASGI application's send path via protocol_send(Closed). Catch OSError instead: BrokenPipeError, ConnectionAbortedError and ConnectionResetError are all OSError subclasses, so the tuple collapses to (OSError, RuntimeError, asyncio.CancelledError) - consistent with run()'s existing `except OSError`. Fixes #361 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/hypercorn/asyncio/tcp_server.py | 4 +--- tests/asyncio/test_tcp_server.py | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/hypercorn/asyncio/tcp_server.py b/src/hypercorn/asyncio/tcp_server.py index 5612214c..55d744d6 100644 --- a/src/hypercorn/asyncio/tcp_server.py +++ b/src/hypercorn/asyncio/tcp_server.py @@ -119,9 +119,7 @@ async def _close(self) -> None: self.writer.close() await self.writer.wait_closed() except ( - BrokenPipeError, - ConnectionAbortedError, - ConnectionResetError, + OSError, # Includes Connection*Errors and e.g. EHOSTUNREACH for a vanished peer RuntimeError, asyncio.CancelledError, ): diff --git a/tests/asyncio/test_tcp_server.py b/tests/asyncio/test_tcp_server.py index 1aa2898b..cb361f8b 100644 --- a/tests/asyncio/test_tcp_server.py +++ b/tests/asyncio/test_tcp_server.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import errno import pytest @@ -12,6 +13,11 @@ from ..helpers import echo_framework +class UnreachableHostWriter(MemoryWriter): + async def wait_closed(self) -> None: + raise OSError(errno.EHOSTUNREACH, "No route to host") + + @pytest.mark.asyncio async def test_completes_on_closed() -> None: event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() @@ -31,6 +37,25 @@ async def test_completes_on_closed() -> None: # hanging. +@pytest.mark.asyncio +async def test_completes_on_unreachable_host_close() -> None: + event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() + + server = TCPServer( + ASGIWrapper(echo_framework), + event_loop, + Config(), + WorkerContext(None), + {}, + MemoryReader(), # type: ignore + UnreachableHostWriter(), # type: ignore + ) + server.reader.close() # type: ignore + await server.run() + # Key is that this line is reached, rather than the above line + # raising the OSError from the writer close. + + @pytest.mark.asyncio async def test_complets_on_half_close() -> None: event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop()