From 3979e55c44b9914a4b4942dcd0719834cfad9922 Mon Sep 17 00:00:00 2001 From: Peng Ding Date: Mon, 14 Sep 2026 19:06:01 -0500 Subject: [PATCH 1/5] feat(httpserver): add TLS support and socket tuning parameters Expose ssl_context, backlog, reuse_address, and reuse_port on run() and _serve(), forwarded to asyncio.start_server() / start_unix_server(). TLS is opt-in: pass a pre-built ssl.SSLContext to enable HTTPS. Startup log messages now include the scheme (http:// vs https://). Closes #162 --- httpserver/httpserver.py | 62 +++++++- httpserver/test_httpserver_tls.py | 250 ++++++++++++++++++++++++++++++ manifest.json | 10 +- 3 files changed, 311 insertions(+), 11 deletions(-) create mode 100644 httpserver/test_httpserver_tls.py diff --git a/httpserver/httpserver.py b/httpserver/httpserver.py index f44814a..368783f 100644 --- a/httpserver/httpserver.py +++ b/httpserver/httpserver.py @@ -45,6 +45,7 @@ def echo(request): import os import re import signal +import ssl import sys from collections.abc import AsyncIterator, Callable from email.utils import formatdate @@ -1439,6 +1440,10 @@ def run( port: int = DEFAULT_PORT, *, socket: str | None = None, + ssl_context: ssl.SSLContext | None = None, + backlog: int = 100, + reuse_address: bool | None = None, + reuse_port: bool | None = None, ) -> None: """Start the server (blocking). @@ -1450,13 +1455,44 @@ def run( on a Unix socket instead of TCP. The socket file permissions are restricted to owner-only (``0o600``) after creation. Only available on Unix-like systems. + ssl_context: An :class:`ssl.SSLContext` for TLS termination. + When provided, the server accepts HTTPS connections. + The caller is responsible for configuring the context + (loading certs, setting verify mode, etc.). + backlog: Maximum number of queued connections passed to + :func:`asyncio.start_server`. ``None`` leaves the OS + default (typically 128). + reuse_address: Sets ``SO_REUSEADDR``. ``None`` lets asyncio + decide (``True`` on non-Windows). + reuse_port: Sets ``SO_REUSEPORT`` for multi-process load + balancing. ``None`` lets asyncio decide (``False``). """ try: - asyncio.run(self._serve(host, port, socket=socket)) + asyncio.run( + self._serve( + host, + port, + socket=socket, + ssl_context=ssl_context, + backlog=backlog, + reuse_address=reuse_address, + reuse_port=reuse_port, + ) + ) except KeyboardInterrupt: pass - async def _serve(self, host: str, port: int, *, socket: str | None = None) -> None: + async def _serve( + self, + host: str, + port: int, + *, + socket: str | None = None, + ssl_context: ssl.SSLContext | None = None, + backlog: int = 100, + reuse_address: bool | None = None, + reuse_port: bool | None = None, + ) -> None: """Internal async server loop.""" self._shutdown_event = asyncio.Event() self._loop = asyncio.get_running_loop() @@ -1467,19 +1503,24 @@ async def _serve(self, host: str, port: int, *, socket: str | None = None) -> No await self._run_startup_hooks() if socket: - server = await self._start_unix_socket(socket) + server = await self._start_unix_socket(socket, ssl_context=ssl_context) else: server = await asyncio.start_server( self._handle_connection, host, port, + ssl=ssl_context, + backlog=backlog, + reuse_address=reuse_address, + reuse_port=reuse_port, ) addrs = ( server.sockets[0].getsockname() if server.sockets else (host, port) ) self.host = addrs[0] self.port = addrs[1] - logger.info("Serving on %s:%d", self.host, self.port) + scheme = "https" if ssl_context else "http" + logger.info("Serving on %s://%s:%d", scheme, self.host, self.port) self._server = server @@ -1497,7 +1538,12 @@ async def _serve(self, host: str, port: int, *, socket: str | None = None) -> No # Run shutdown hooks after server stops accepting connections await self._run_shutdown_hooks() - async def _start_unix_socket(self, socket_path: str) -> asyncio.Server: + async def _start_unix_socket( + self, + socket_path: str, + *, + ssl_context: ssl.SSLContext | None = None, + ) -> asyncio.Server: """Start listening on a Unix domain socket. Handles stale socket cleanup, permission hardening (``0o600``), @@ -1505,6 +1551,8 @@ async def _start_unix_socket(self, socket_path: str) -> asyncio.Server: Args: socket_path: Path for the Unix domain socket file. + ssl_context: Optional :class:`ssl.SSLContext` for TLS over + the Unix socket. Returns: The ``asyncio.Server`` instance. @@ -1540,6 +1588,7 @@ async def _start_unix_socket(self, socket_path: str) -> asyncio.Server: server = await asyncio.start_unix_server( self._handle_connection, path=path, + ssl=ssl_context, ) # Restrict permissions to owner-only @@ -1547,7 +1596,8 @@ async def _start_unix_socket(self, socket_path: str) -> asyncio.Server: os.chmod(path, 0o600) self._socket_path = path - logger.info("Serving on unix:%s (mode 0600)", path) + scheme = "https+unix" if ssl_context else "unix" + logger.info("Serving on %s:%s (mode 0600)", scheme, path) return server def _cleanup_socket(self) -> None: diff --git a/httpserver/test_httpserver_tls.py b/httpserver/test_httpserver_tls.py new file mode 100644 index 0000000..cf1eb43 --- /dev/null +++ b/httpserver/test_httpserver_tls.py @@ -0,0 +1,250 @@ +"""Tests for TLS support in httpserver.""" + +from __future__ import annotations + +import asyncio +import ssl +import subprocess + +import pytest + +from httpserver import App, JSONResponse + + +def _generate_self_signed_cert(cert_path: str, key_path: str) -> None: + """Generate a self-signed certificate using openssl CLI.""" + subprocess.run( + [ + "openssl", + "req", + "-x509", + "-newkey", + "rsa:2048", + "-keyout", + key_path, + "-out", + cert_path, + "-days", + "1", + "-nodes", + "-subj", + "/CN=localhost", + "-addext", + "subjectAltName=DNS:localhost,IP:127.0.0.1", + ], + check=True, + capture_output=True, + ) + + +@pytest.fixture(scope="module") +def tls_certs(tmp_path_factory): + """Generate a temporary self-signed cert/key pair.""" + d = tmp_path_factory.mktemp("tls") + cert_path = str(d / "cert.pem") + key_path = str(d / "key.pem") + _generate_self_signed_cert(cert_path, key_path) + return cert_path, key_path + + +@pytest.fixture(scope="module") +def server_ssl_context(tls_certs): + """Create a server-side SSLContext.""" + cert_path, key_path = tls_certs + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ctx.load_cert_chain(cert_path, key_path) + return ctx + + +@pytest.fixture(scope="module") +def client_ssl_context(tls_certs): + """Create a client-side SSLContext that trusts the self-signed cert.""" + cert_path, _ = tls_certs + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.load_verify_locations(cert_path) + return ctx + + +@pytest.fixture +def app(): + """Create a simple test app.""" + app = App() + + @app.route("/health") + async def health(request): + return JSONResponse({"status": "ok"}) + + @app.route("/echo", methods=["POST"]) + async def echo(request): + return JSONResponse(request.json()) + + return app + + +class TestTLSServe: + """Test TLS server lifecycle and connections.""" + + @pytest.mark.asyncio + async def test_tls_accepts_https_connection( + self, app, server_ssl_context, client_ssl_context + ): + """Server with ssl_context accepts TLS connections.""" + serve_task = asyncio.create_task( + app._serve("127.0.0.1", 0, ssl_context=server_ssl_context) + ) + await asyncio.sleep(0.3) + + reader, writer = await asyncio.open_connection( + "127.0.0.1", app.port, ssl=client_ssl_context + ) + writer.write(b"GET /health HTTP/1.1\r\nHost: localhost\r\n\r\n") + await writer.drain() + + response = await asyncio.wait_for(reader.read(4096), timeout=5) + assert b"200 OK" in response + assert b'"status": "ok"' in response + + writer.close() + await writer.wait_closed() + app.shutdown() + await serve_task + + @pytest.mark.asyncio + async def test_tls_rejects_plaintext(self, app, server_ssl_context): + """TLS server rejects plain HTTP connections.""" + serve_task = asyncio.create_task( + app._serve("127.0.0.1", 0, ssl_context=server_ssl_context) + ) + await asyncio.sleep(0.3) + + reader, writer = await asyncio.open_connection("127.0.0.1", app.port) + writer.write(b"GET /health HTTP/1.1\r\nHost: localhost\r\n\r\n") + await writer.drain() + + response = await asyncio.wait_for(reader.read(4096), timeout=2) + # Server should close the connection or return empty (TLS handshake fails) + assert response == b"" or b"200 OK" not in response + + writer.close() + await writer.wait_closed() + app.shutdown() + await serve_task + + @pytest.mark.asyncio + async def test_tls_post_request(self, app, server_ssl_context, client_ssl_context): + """POST with JSON body works over TLS.""" + serve_task = asyncio.create_task( + app._serve("127.0.0.1", 0, ssl_context=server_ssl_context) + ) + await asyncio.sleep(0.3) + + reader, writer = await asyncio.open_connection( + "127.0.0.1", app.port, ssl=client_ssl_context + ) + body = b'{"msg":"hello"}' + request = ( + b"POST /echo HTTP/1.1\r\n" + b"Host: localhost\r\n" + b"Content-Type: application/json\r\n" + b"Content-Length: " + str(len(body)).encode() + b"\r\n" + b"\r\n" + body + ) + writer.write(request) + await writer.drain() + + response = await asyncio.wait_for(reader.read(4096), timeout=5) + assert b"200 OK" in response + assert b'"msg": "hello"' in response + + writer.close() + await writer.wait_closed() + app.shutdown() + await serve_task + + @pytest.mark.asyncio + async def test_no_ssl_context_serves_plaintext(self, app): + """Without ssl_context, server accepts plain HTTP (default behavior).""" + serve_task = asyncio.create_task(app._serve("127.0.0.1", 0)) + await asyncio.sleep(0.3) + + reader, writer = await asyncio.open_connection("127.0.0.1", app.port) + writer.write(b"GET /health HTTP/1.1\r\nHost: localhost\r\n\r\n") + await writer.drain() + + response = await asyncio.wait_for(reader.read(4096), timeout=5) + assert b"200 OK" in response + + writer.close() + await writer.wait_closed() + app.shutdown() + await serve_task + + +class TestSocketTuning: + """Test backlog, reuse_address, reuse_port parameters.""" + + @pytest.mark.asyncio + async def test_custom_backlog(self, app): + """Server starts with custom backlog value.""" + serve_task = asyncio.create_task(app._serve("127.0.0.1", 0, backlog=2048)) + await asyncio.sleep(0.3) + + reader, writer = await asyncio.open_connection("127.0.0.1", app.port) + writer.write(b"GET /health HTTP/1.1\r\nHost: localhost\r\n\r\n") + await writer.drain() + + response = await asyncio.wait_for(reader.read(4096), timeout=5) + assert b"200 OK" in response + + writer.close() + await writer.wait_closed() + app.shutdown() + await serve_task + + @pytest.mark.asyncio + async def test_reuse_address(self, app): + """Server starts with reuse_address=True.""" + serve_task = asyncio.create_task(app._serve("127.0.0.1", 0, reuse_address=True)) + await asyncio.sleep(0.3) + + reader, writer = await asyncio.open_connection("127.0.0.1", app.port) + writer.write(b"GET /health HTTP/1.1\r\nHost: localhost\r\n\r\n") + await writer.drain() + + response = await asyncio.wait_for(reader.read(4096), timeout=5) + assert b"200 OK" in response + + writer.close() + await writer.wait_closed() + app.shutdown() + await serve_task + + @pytest.mark.asyncio + async def test_combined_tls_and_tuning( + self, app, server_ssl_context, client_ssl_context + ): + """TLS and socket tuning parameters work together.""" + serve_task = asyncio.create_task( + app._serve( + "127.0.0.1", + 0, + ssl_context=server_ssl_context, + backlog=512, + reuse_address=True, + ) + ) + await asyncio.sleep(0.3) + + reader, writer = await asyncio.open_connection( + "127.0.0.1", app.port, ssl=client_ssl_context + ) + writer.write(b"GET /health HTTP/1.1\r\nHost: localhost\r\n\r\n") + await writer.drain() + + response = await asyncio.wait_for(reader.read(4096), timeout=5) + assert b"200 OK" in response + + writer.close() + await writer.wait_closed() + app.shutdown() + await serve_task diff --git a/manifest.json b/manifest.json index 3848d13..4f76842 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { "version": "1", - "generated": "2026-09-15T01:26:49.606515+00:00", + "generated": "2026-09-15T05:11:46.731585+00:00", "modules": { "a2a": { "description": "A2A (Agent-to-Agent Protocol) - Zero-dependency Python implementation", @@ -179,8 +179,8 @@ "deps": [], "tier": "subsystem", "category": "network", - "last_updated": "2026-09-14T17:25:37-05:00", - "content_hash": "4f82dddf858711a6ac0019104a24ef3f73e98997072fc945ecd03ad72312749d" + "last_updated": "2026-09-14T19:04:23-05:00", + "content_hash": "4983837f8402eba1a9f7f5f5a48c7ee4eac27f893a35119c888a3d78f8c69465" }, "jsonrpc": { "description": "JSON-RPC 2.0 -- Zero-dependency Python implementation", @@ -287,8 +287,8 @@ "deps": [], "tier": "medium", "category": "devtools", - "last_updated": "2026-09-14T18:40:27-05:00", - "content_hash": "3c9a99462020857e4adc5ace31813336f5537a450f50bf295ddc5258da92cc6b" + "last_updated": "2026-09-14T20:53:33-05:00", + "content_hash": "b9bd9b27691eae5384bc5deceb1b3d008bf9f33b964bb0d28f208a8b5bdcf01a" }, "prompt": { "description": "Zero-dependency interactive CLI prompts (confirm, select, text)", From 3295c3b517ebd2aed4d91071bb0bf907241f548f Mon Sep 17 00:00:00 2001 From: Peng Ding Date: Mon, 14 Sep 2026 19:10:56 -0500 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20forward=20backlog=20to=20unix=20socket,=20fix=20typ?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Forward `backlog` to `_start_unix_socket()` so callers using unix sockets don't get silent parameter loss. - Change `backlog` type from `int = 100` to `int | None = None` so `None` actually works as the docstring describes. - Conditionally pass `backlog`/`reuse_address`/`reuse_port` only when not None, letting asyncio use its own defaults. --- httpserver/httpserver.py | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/httpserver/httpserver.py b/httpserver/httpserver.py index 368783f..ae18f5b 100644 --- a/httpserver/httpserver.py +++ b/httpserver/httpserver.py @@ -1441,7 +1441,7 @@ def run( *, socket: str | None = None, ssl_context: ssl.SSLContext | None = None, - backlog: int = 100, + backlog: int | None = None, reuse_address: bool | None = None, reuse_port: bool | None = None, ) -> None: @@ -1489,7 +1489,7 @@ async def _serve( *, socket: str | None = None, ssl_context: ssl.SSLContext | None = None, - backlog: int = 100, + backlog: int | None = None, reuse_address: bool | None = None, reuse_port: bool | None = None, ) -> None: @@ -1503,16 +1503,23 @@ async def _serve( await self._run_startup_hooks() if socket: - server = await self._start_unix_socket(socket, ssl_context=ssl_context) + server = await self._start_unix_socket( + socket, ssl_context=ssl_context, backlog=backlog + ) else: + kwargs: dict[str, Any] = {} + if backlog is not None: + kwargs["backlog"] = backlog + if reuse_address is not None: + kwargs["reuse_address"] = reuse_address + if reuse_port is not None: + kwargs["reuse_port"] = reuse_port server = await asyncio.start_server( self._handle_connection, host, port, ssl=ssl_context, - backlog=backlog, - reuse_address=reuse_address, - reuse_port=reuse_port, + **kwargs, ) addrs = ( server.sockets[0].getsockname() if server.sockets else (host, port) @@ -1543,6 +1550,7 @@ async def _start_unix_socket( socket_path: str, *, ssl_context: ssl.SSLContext | None = None, + backlog: int | None = None, ) -> asyncio.Server: """Start listening on a Unix domain socket. @@ -1553,6 +1561,8 @@ async def _start_unix_socket( socket_path: Path for the Unix domain socket file. ssl_context: Optional :class:`ssl.SSLContext` for TLS over the Unix socket. + backlog: Maximum number of queued connections. ``None`` + leaves the OS default. Returns: The ``asyncio.Server`` instance. @@ -1585,10 +1595,14 @@ async def _start_unix_socket( logger.error("Socket parent directory does not exist: %s", parent) sys.exit(1) + kwargs: dict[str, Any] = {} + if backlog is not None: + kwargs["backlog"] = backlog server = await asyncio.start_unix_server( self._handle_connection, path=path, ssl=ssl_context, + **kwargs, ) # Restrict permissions to owner-only From 3f0b255bae6930775cacc125e95fad8142c03397 Mon Sep 17 00:00:00 2001 From: Peng Ding Date: Mon, 14 Sep 2026 19:13:03 -0500 Subject: [PATCH 3/5] test: add TLS-over-Unix-socket test Cover the ssl_context forwarding path in _start_unix_socket(), as suggested in review. --- httpserver/test_httpserver_tls.py | 44 +++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/httpserver/test_httpserver_tls.py b/httpserver/test_httpserver_tls.py index cf1eb43..c676996 100644 --- a/httpserver/test_httpserver_tls.py +++ b/httpserver/test_httpserver_tls.py @@ -3,8 +3,10 @@ from __future__ import annotations import asyncio +import socket as socket_mod import ssl import subprocess +import sys import pytest @@ -248,3 +250,45 @@ async def test_combined_tls_and_tuning( await writer.wait_closed() app.shutdown() await serve_task + + +@pytest.fixture +def socket_path(tmp_path): + """Return a path for a Unix socket in a temp directory.""" + return str(tmp_path / "tls_test.sock") + + +@pytest.mark.skipif( + sys.platform == "win32", reason="Unix sockets not available on Windows" +) +class TestTLSUnixSocket: + """Test TLS over Unix domain sockets.""" + + @pytest.mark.asyncio + async def test_tls_over_unix_socket( + self, app, socket_path, server_ssl_context, client_ssl_context + ): + """TLS works over Unix domain sockets.""" + serve_task = asyncio.create_task( + app._serve("", 0, socket=socket_path, ssl_context=server_ssl_context) + ) + await asyncio.sleep(0.3) + + raw_sock = socket_mod.socket(socket_mod.AF_UNIX, socket_mod.SOCK_STREAM) + raw_sock.connect(socket_path) + raw_sock.setblocking(False) + + reader, writer = await asyncio.open_connection( + sock=raw_sock, ssl=client_ssl_context, server_hostname="localhost" + ) + writer.write(b"GET /health HTTP/1.1\r\nHost: localhost\r\n\r\n") + await writer.drain() + + response = await asyncio.wait_for(reader.read(4096), timeout=5) + assert b"200 OK" in response + assert b'"status": "ok"' in response + + writer.close() + await writer.wait_closed() + app.shutdown() + await serve_task From 9ac7f30faaf9abc57172b0ee08a35ca99f3a38bf Mon Sep 17 00:00:00 2001 From: Peng Ding Date: Mon, 14 Sep 2026 20:26:44 -0500 Subject: [PATCH 4/5] fix: skip TLS tests when openssl CLI is not available --- httpserver/test_httpserver_tls.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/httpserver/test_httpserver_tls.py b/httpserver/test_httpserver_tls.py index c676996..e7ad29f 100644 --- a/httpserver/test_httpserver_tls.py +++ b/httpserver/test_httpserver_tls.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import shutil import socket as socket_mod import ssl import subprocess @@ -12,6 +13,10 @@ from httpserver import App, JSONResponse +pytestmark = pytest.mark.skipif( + shutil.which("openssl") is None, reason="openssl CLI not found" +) + def _generate_self_signed_cert(cert_path: str, key_path: str) -> None: """Generate a self-signed certificate using openssl CLI.""" From df81edc1c2ec4f33e4ca316eecfa91b169a90f76 Mon Sep 17 00:00:00 2001 From: Peng Ding Date: Tue, 15 Sep 2026 00:11:33 -0500 Subject: [PATCH 5/5] fix: address remaining non-blocking review feedback - Replace asyncio.sleep(0.3) with readiness polling (_wait_ready helper) for deterministic startup detection instead of fixed delays. - Wrap all test bodies in try/finally to ensure server cleanup on assertion failure. - Add ASYNC240 ignore for TLS test file (os.path.exists in readiness polling, same as unix socket tests). --- httpserver/test_httpserver_tls.py | 252 ++++++++++++++++-------------- pyproject.toml | 1 + 2 files changed, 139 insertions(+), 114 deletions(-) diff --git a/httpserver/test_httpserver_tls.py b/httpserver/test_httpserver_tls.py index e7ad29f..281d26f 100644 --- a/httpserver/test_httpserver_tls.py +++ b/httpserver/test_httpserver_tls.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import os import shutil import socket as socket_mod import ssl @@ -18,6 +19,22 @@ ) +async def _wait_ready( + app: App, *, socket_path: str | None = None, timeout: float = 5 +) -> None: + """Poll until the server is ready to accept connections.""" + loop = asyncio.get_event_loop() + deadline = loop.time() + timeout + while loop.time() < deadline: + if socket_path is not None: + if os.path.exists(socket_path): + return + elif app.port is not None: + return + await asyncio.sleep(0.02) + raise TimeoutError("Server did not become ready") + + def _generate_self_signed_cert(cert_path: str, key_path: str) -> None: """Generate a self-signed certificate using openssl CLI.""" subprocess.run( @@ -99,22 +116,23 @@ async def test_tls_accepts_https_connection( serve_task = asyncio.create_task( app._serve("127.0.0.1", 0, ssl_context=server_ssl_context) ) - await asyncio.sleep(0.3) - - reader, writer = await asyncio.open_connection( - "127.0.0.1", app.port, ssl=client_ssl_context - ) - writer.write(b"GET /health HTTP/1.1\r\nHost: localhost\r\n\r\n") - await writer.drain() + await _wait_ready(app) + try: + reader, writer = await asyncio.open_connection( + "127.0.0.1", app.port, ssl=client_ssl_context + ) + writer.write(b"GET /health HTTP/1.1\r\nHost: localhost\r\n\r\n") + await writer.drain() - response = await asyncio.wait_for(reader.read(4096), timeout=5) - assert b"200 OK" in response - assert b'"status": "ok"' in response + response = await asyncio.wait_for(reader.read(4096), timeout=5) + assert b"200 OK" in response + assert b'"status": "ok"' in response - writer.close() - await writer.wait_closed() - app.shutdown() - await serve_task + writer.close() + await writer.wait_closed() + finally: + app.shutdown() + await serve_task @pytest.mark.asyncio async def test_tls_rejects_plaintext(self, app, server_ssl_context): @@ -122,20 +140,20 @@ async def test_tls_rejects_plaintext(self, app, server_ssl_context): serve_task = asyncio.create_task( app._serve("127.0.0.1", 0, ssl_context=server_ssl_context) ) - await asyncio.sleep(0.3) - - reader, writer = await asyncio.open_connection("127.0.0.1", app.port) - writer.write(b"GET /health HTTP/1.1\r\nHost: localhost\r\n\r\n") - await writer.drain() + await _wait_ready(app) + try: + reader, writer = await asyncio.open_connection("127.0.0.1", app.port) + writer.write(b"GET /health HTTP/1.1\r\nHost: localhost\r\n\r\n") + await writer.drain() - response = await asyncio.wait_for(reader.read(4096), timeout=2) - # Server should close the connection or return empty (TLS handshake fails) - assert response == b"" or b"200 OK" not in response + response = await asyncio.wait_for(reader.read(4096), timeout=2) + assert response == b"" or b"200 OK" not in response - writer.close() - await writer.wait_closed() - app.shutdown() - await serve_task + writer.close() + await writer.wait_closed() + finally: + app.shutdown() + await serve_task @pytest.mark.asyncio async def test_tls_post_request(self, app, server_ssl_context, client_ssl_context): @@ -143,48 +161,50 @@ async def test_tls_post_request(self, app, server_ssl_context, client_ssl_contex serve_task = asyncio.create_task( app._serve("127.0.0.1", 0, ssl_context=server_ssl_context) ) - await asyncio.sleep(0.3) - - reader, writer = await asyncio.open_connection( - "127.0.0.1", app.port, ssl=client_ssl_context - ) - body = b'{"msg":"hello"}' - request = ( - b"POST /echo HTTP/1.1\r\n" - b"Host: localhost\r\n" - b"Content-Type: application/json\r\n" - b"Content-Length: " + str(len(body)).encode() + b"\r\n" - b"\r\n" + body - ) - writer.write(request) - await writer.drain() + await _wait_ready(app) + try: + reader, writer = await asyncio.open_connection( + "127.0.0.1", app.port, ssl=client_ssl_context + ) + body = b'{"msg":"hello"}' + request = ( + b"POST /echo HTTP/1.1\r\n" + b"Host: localhost\r\n" + b"Content-Type: application/json\r\n" + b"Content-Length: " + str(len(body)).encode() + b"\r\n" + b"\r\n" + body + ) + writer.write(request) + await writer.drain() - response = await asyncio.wait_for(reader.read(4096), timeout=5) - assert b"200 OK" in response - assert b'"msg": "hello"' in response + response = await asyncio.wait_for(reader.read(4096), timeout=5) + assert b"200 OK" in response + assert b'"msg": "hello"' in response - writer.close() - await writer.wait_closed() - app.shutdown() - await serve_task + writer.close() + await writer.wait_closed() + finally: + app.shutdown() + await serve_task @pytest.mark.asyncio async def test_no_ssl_context_serves_plaintext(self, app): """Without ssl_context, server accepts plain HTTP (default behavior).""" serve_task = asyncio.create_task(app._serve("127.0.0.1", 0)) - await asyncio.sleep(0.3) - - reader, writer = await asyncio.open_connection("127.0.0.1", app.port) - writer.write(b"GET /health HTTP/1.1\r\nHost: localhost\r\n\r\n") - await writer.drain() + await _wait_ready(app) + try: + reader, writer = await asyncio.open_connection("127.0.0.1", app.port) + writer.write(b"GET /health HTTP/1.1\r\nHost: localhost\r\n\r\n") + await writer.drain() - response = await asyncio.wait_for(reader.read(4096), timeout=5) - assert b"200 OK" in response + response = await asyncio.wait_for(reader.read(4096), timeout=5) + assert b"200 OK" in response - writer.close() - await writer.wait_closed() - app.shutdown() - await serve_task + writer.close() + await writer.wait_closed() + finally: + app.shutdown() + await serve_task class TestSocketTuning: @@ -194,37 +214,39 @@ class TestSocketTuning: async def test_custom_backlog(self, app): """Server starts with custom backlog value.""" serve_task = asyncio.create_task(app._serve("127.0.0.1", 0, backlog=2048)) - await asyncio.sleep(0.3) + await _wait_ready(app) + try: + reader, writer = await asyncio.open_connection("127.0.0.1", app.port) + writer.write(b"GET /health HTTP/1.1\r\nHost: localhost\r\n\r\n") + await writer.drain() - reader, writer = await asyncio.open_connection("127.0.0.1", app.port) - writer.write(b"GET /health HTTP/1.1\r\nHost: localhost\r\n\r\n") - await writer.drain() + response = await asyncio.wait_for(reader.read(4096), timeout=5) + assert b"200 OK" in response - response = await asyncio.wait_for(reader.read(4096), timeout=5) - assert b"200 OK" in response - - writer.close() - await writer.wait_closed() - app.shutdown() - await serve_task + writer.close() + await writer.wait_closed() + finally: + app.shutdown() + await serve_task @pytest.mark.asyncio async def test_reuse_address(self, app): """Server starts with reuse_address=True.""" serve_task = asyncio.create_task(app._serve("127.0.0.1", 0, reuse_address=True)) - await asyncio.sleep(0.3) - - reader, writer = await asyncio.open_connection("127.0.0.1", app.port) - writer.write(b"GET /health HTTP/1.1\r\nHost: localhost\r\n\r\n") - await writer.drain() + await _wait_ready(app) + try: + reader, writer = await asyncio.open_connection("127.0.0.1", app.port) + writer.write(b"GET /health HTTP/1.1\r\nHost: localhost\r\n\r\n") + await writer.drain() - response = await asyncio.wait_for(reader.read(4096), timeout=5) - assert b"200 OK" in response + response = await asyncio.wait_for(reader.read(4096), timeout=5) + assert b"200 OK" in response - writer.close() - await writer.wait_closed() - app.shutdown() - await serve_task + writer.close() + await writer.wait_closed() + finally: + app.shutdown() + await serve_task @pytest.mark.asyncio async def test_combined_tls_and_tuning( @@ -240,21 +262,22 @@ async def test_combined_tls_and_tuning( reuse_address=True, ) ) - await asyncio.sleep(0.3) - - reader, writer = await asyncio.open_connection( - "127.0.0.1", app.port, ssl=client_ssl_context - ) - writer.write(b"GET /health HTTP/1.1\r\nHost: localhost\r\n\r\n") - await writer.drain() + await _wait_ready(app) + try: + reader, writer = await asyncio.open_connection( + "127.0.0.1", app.port, ssl=client_ssl_context + ) + writer.write(b"GET /health HTTP/1.1\r\nHost: localhost\r\n\r\n") + await writer.drain() - response = await asyncio.wait_for(reader.read(4096), timeout=5) - assert b"200 OK" in response + response = await asyncio.wait_for(reader.read(4096), timeout=5) + assert b"200 OK" in response - writer.close() - await writer.wait_closed() - app.shutdown() - await serve_task + writer.close() + await writer.wait_closed() + finally: + app.shutdown() + await serve_task @pytest.fixture @@ -277,23 +300,24 @@ async def test_tls_over_unix_socket( serve_task = asyncio.create_task( app._serve("", 0, socket=socket_path, ssl_context=server_ssl_context) ) - await asyncio.sleep(0.3) - - raw_sock = socket_mod.socket(socket_mod.AF_UNIX, socket_mod.SOCK_STREAM) - raw_sock.connect(socket_path) - raw_sock.setblocking(False) - - reader, writer = await asyncio.open_connection( - sock=raw_sock, ssl=client_ssl_context, server_hostname="localhost" - ) - writer.write(b"GET /health HTTP/1.1\r\nHost: localhost\r\n\r\n") - await writer.drain() - - response = await asyncio.wait_for(reader.read(4096), timeout=5) - assert b"200 OK" in response - assert b'"status": "ok"' in response - - writer.close() - await writer.wait_closed() - app.shutdown() - await serve_task + await _wait_ready(app, socket_path=socket_path) + try: + raw_sock = socket_mod.socket(socket_mod.AF_UNIX, socket_mod.SOCK_STREAM) + raw_sock.connect(socket_path) + raw_sock.setblocking(False) + + reader, writer = await asyncio.open_connection( + sock=raw_sock, ssl=client_ssl_context, server_hostname="localhost" + ) + writer.write(b"GET /health HTTP/1.1\r\nHost: localhost\r\n\r\n") + await writer.drain() + + response = await asyncio.wait_for(reader.read(4096), timeout=5) + assert b"200 OK" in response + assert b'"status": "ok"' in response + + writer.close() + await writer.wait_closed() + finally: + app.shutdown() + await serve_task diff --git a/pyproject.toml b/pyproject.toml index e29d9e2..7d088b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -201,6 +201,7 @@ max-complexity = 20 "diff/diff.py" = ["E501"] "vcs/vcs.py" = ["E501"] "httpserver/test_httpserver_unix_socket.py" = ["ASYNC240"] +"httpserver/test_httpserver_tls.py" = ["ASYNC240"] "xml/xml.py" = ["E501"] "a2a/a2a.py" = ["E501"] "acp/acp.py" = ["E501"]