From 42c28dda3b0868cfe5d3431ecf229c2bbd379b24 Mon Sep 17 00:00:00 2001 From: David White Date: Thu, 15 Oct 2026 19:25:12 +0100 Subject: [PATCH] Bound shutdown by graceful_timeout on Python 3.12+ From Python 3.12.1 Server.wait_closed waits for every active connection, not just the listening socket. Awaiting it before the graceful_timeout step meant a client that keeps its connection open, for example a TLS client that never answers the close, delays shutdown without bound and graceful_timeout is never reached. The listeners are now closed first, the connections get graceful_timeout to finish and are then cancelled, and wait_closed is awaited last, bounded by graceful_timeout, after aborting any connection still attached (Python 3.13+), such as one that has not completed its TLS handshake. Co-Authored-By: Claude Opus 5.5 --- src/hypercorn/asyncio/run.py | 15 ++++++- tests/asyncio/test_run.py | 87 ++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 tests/asyncio/test_run.py diff --git a/src/hypercorn/asyncio/run.py b/src/hypercorn/asyncio/run.py index 09fec25f..15695ca1 100644 --- a/src/hypercorn/asyncio/run.py +++ b/src/hypercorn/asyncio/run.py @@ -163,8 +163,11 @@ async def _server_callback(reader: asyncio.StreamReader, writer: asyncio.StreamW for server in servers: server.close() - await server.wait_closed() + # The connections are given graceful_timeout to finish, then + # cancelled. Server.wait_closed waits for every connection on + # Python 3.12+, so it must not be awaited before this, or a client + # that holds its connection open delays shutdown without bound. try: gathered_server_tasks = asyncio.gather(*server_tasks) await asyncio.wait_for(gathered_server_tasks, config.graceful_timeout) @@ -175,6 +178,16 @@ async def _server_callback(reader: asyncio.StreamReader, writer: asyncio.StreamW # prevent a warning that this hasn't been done. gathered_server_tasks.exception() + # A connection still attached never reached the server callback, + # for example one that has not completed its TLS handshake. + for server in servers: + if hasattr(server, "abort_clients"): # Python 3.13+ + server.abort_clients() + try: + await asyncio.wait_for(server.wait_closed(), config.graceful_timeout) + except asyncio.TimeoutError: + pass + await lifespan.wait_for_shutdown() lifespan_task.cancel() await lifespan_task diff --git a/tests/asyncio/test_run.py b/tests/asyncio/test_run.py new file mode 100644 index 00000000..68440f6b --- /dev/null +++ b/tests/asyncio/test_run.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import asyncio +import socket +import ssl +import threading +import time +from collections.abc import Awaitable, Callable + +import pytest + +from hypercorn.app_wrappers import ASGIWrapper +from hypercorn.asyncio.run import worker_serve +from hypercorn.config import Config +from ..helpers import empty_framework + + +def _hold_connection(port: int, answered: threading.Event) -> None: + # A client that sends a request, reads the response and then keeps the + # connection without reading, so it never answers the server's close. + context = ssl.create_default_context() + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + with context.wrap_socket(socket.create_connection(("127.0.0.1", port))) as sock: + sock.sendall(b"GET / HTTP/1.1\r\nHost: hypercorn\r\n\r\n") + sock.recv(1024) + answered.set() + time.sleep(30) + + +def _tls_config() -> Config: + config = Config() + config.bind = ["127.0.0.1:0"] + config.certfile = "tests/assets/cert.pem" + config.keyfile = "tests/assets/key.pem" + config.graceful_timeout = 0.5 + return config + + +async def _shutdown_time(config: Config, hold: Callable[[int], Awaitable[None]]) -> float: + sockets = config.create_sockets() + port = sockets.secure_sockets[0].getsockname()[1] + shutdown = asyncio.Event() + serve = asyncio.create_task( + worker_serve( + ASGIWrapper(empty_framework), config, sockets=sockets, shutdown_trigger=shutdown.wait + ) + ) + await asyncio.sleep(0.1) + await hold(port) + shutdown.set() + started = time.monotonic() + await asyncio.wait_for(serve, 10) + elapsed = time.monotonic() - started + await asyncio.sleep(0.1) + connections = [ + task + for task in asyncio.all_tasks() + if getattr(task.get_coro(), "__qualname__", "").endswith("_server_callback") + ] + assert connections == [] + return elapsed + + +@pytest.mark.asyncio +async def test_shutdown_is_bounded_with_a_held_connection() -> None: + answered = threading.Event() + + async def hold(port: int) -> None: + threading.Thread(target=_hold_connection, args=(port, answered), daemon=True).start() + assert await asyncio.to_thread(answered.wait, 5) + + assert await _shutdown_time(_tls_config(), hold) < 2 + + +@pytest.mark.asyncio +async def test_shutdown_is_bounded_with_an_unfinished_handshake() -> None: + held: list[socket.socket] = [] + + async def hold(port: int) -> None: + held.append(socket.create_connection(("127.0.0.1", port))) + await asyncio.sleep(0.1) + + try: + assert await _shutdown_time(_tls_config(), hold) < 2 + finally: + held[0].close()