From 058e84f5cb418fca40cabc991d40c87dfc44a39e Mon Sep 17 00:00:00 2001 From: pgjones Date: Mon, 21 Feb 2022 20:57:35 +0000 Subject: [PATCH 001/151] Update to the latest black and mypy --- src/hypercorn/asyncio/tcp_server.py | 2 +- src/hypercorn/config.py | 4 ++-- src/hypercorn/middleware/wsgi.py | 2 +- src/hypercorn/protocol/h11.py | 27 +++++++++++++++++---------- src/hypercorn/protocol/h2.py | 2 +- src/hypercorn/trio/tcp_server.py | 2 +- src/hypercorn/trio/udp_server.py | 2 +- tests/asyncio/test_sanity.py | 2 +- tests/trio/test_keep_alive.py | 4 ++-- tests/trio/test_sanity.py | 2 +- 10 files changed, 28 insertions(+), 21 deletions(-) diff --git a/src/hypercorn/asyncio/tcp_server.py b/src/hypercorn/asyncio/tcp_server.py index b143a29..9686c73 100644 --- a/src/hypercorn/asyncio/tcp_server.py +++ b/src/hypercorn/asyncio/tcp_server.py @@ -12,7 +12,7 @@ from ..typing import ASGIFramework from ..utils import parse_socket_addr -MAX_RECV = 2 ** 16 +MAX_RECV = 2**16 class EventWrapper: diff --git a/src/hypercorn/config.py b/src/hypercorn/config.py index dd2224b..84790c0 100644 --- a/src/hypercorn/config.py +++ b/src/hypercorn/config.py @@ -74,8 +74,8 @@ class Config: group: Optional[int] = None h11_max_incomplete_size = 16 * 1024 * BYTES h2_max_concurrent_streams = 100 - h2_max_header_list_size = 2 ** 16 - h2_max_inbound_frame_size = 2 ** 14 * OCTETS + h2_max_header_list_size = 2**16 + h2_max_inbound_frame_size = 2**14 * OCTETS include_server_header = True keep_alive_timeout = 5 * SECONDS keyfile: Optional[str] = None diff --git a/src/hypercorn/middleware/wsgi.py b/src/hypercorn/middleware/wsgi.py index 9ed74c9..728409f 100644 --- a/src/hypercorn/middleware/wsgi.py +++ b/src/hypercorn/middleware/wsgi.py @@ -7,7 +7,7 @@ from ..typing import HTTPScope, Scope -MAX_BODY_SIZE = 2 ** 16 +MAX_BODY_SIZE = 2**16 WSGICallable = Callable[[dict, Callable], Iterable[bytes]] diff --git a/src/hypercorn/protocol/h11.py b/src/hypercorn/protocol/h11.py index c8636bf..5baf762 100755 --- a/src/hypercorn/protocol/h11.py +++ b/src/hypercorn/protocol/h11.py @@ -1,7 +1,7 @@ from __future__ import annotations from itertools import chain -from typing import Awaitable, Callable, Optional, Tuple, Union +from typing import Awaitable, Callable, cast, Optional, Tuple, Type, Union import h11 @@ -51,6 +51,8 @@ class H11WSConnection: # events (Response, Body, EndBody). our_state = None # Prevents recycling the connection they_are_waiting_for_100_continue = False + their_state = None + trailing_data = (b"", False) def __init__(self, h11_connection: h11.Connection) -> None: self.buffer = bytearray(h11_connection.trailing_data[0]) @@ -59,7 +61,7 @@ def __init__(self, h11_connection: h11.Connection) -> None: def receive_data(self, data: bytes) -> None: self.buffer.extend(data) - def next_event(self) -> Data: + def next_event(self) -> Union[Data, Type[h11.NEED_DATA]]: if self.buffer: event = Data(stream_id=STREAM_ID, data=bytes(self.buffer)) self.buffer = bytearray() @@ -70,6 +72,9 @@ def next_event(self) -> Data: def send(self, event: H11SendableEvent) -> bytes: return self.h11_connection.send(event) + def start_next_cycle(self) -> None: + pass + class H11Protocol: def __init__( @@ -87,7 +92,7 @@ def __init__( self.can_read = context.event_class() self.client = client self.config = config - self.connection = h11.Connection( + self.connection: Union[h11.Connection, H11WSConnection] = h11.Connection( h11.SERVER, max_incomplete_event_size=self.config.h11_max_incomplete_size ) self.context = context @@ -113,14 +118,14 @@ async def stream_send(self, event: StreamEvent) -> None: if event.status_code >= 200: await self._send_h11_event( h11.Response( - headers=chain(event.headers, self.config.response_headers("h11")), + headers=list(chain(event.headers, self.config.response_headers("h11"))), status_code=event.status_code, ) ) else: await self._send_h11_event( h11.InformationalResponse( - headers=chain(event.headers, self.config.response_headers("h11")), + headers=list(chain(event.headers, self.config.response_headers("h11"))), status_code=event.status_code, ) ) @@ -198,7 +203,7 @@ async def _create_stream(self, request: h11.Request) -> None: self.stream_send, STREAM_ID, ) - self.connection = H11WSConnection(self.connection) + self.connection = H11WSConnection(cast(h11.Connection, self.connection)) else: self.stream = HTTPStream( self.app, @@ -214,7 +219,7 @@ async def _create_stream(self, request: h11.Request) -> None: await self.stream.handle( Request( stream_id=STREAM_ID, - headers=request.headers, + headers=list(request.headers), http_version=request.http_version.decode(), method=request.method.decode("ascii").upper(), raw_path=request.target, @@ -234,9 +239,11 @@ async def _send_error_response(self, status_code: int) -> None: await self._send_h11_event( h11.Response( status_code=status_code, - headers=chain( - [(b"content-length", b"0"), (b"connection", b"close")], - self.config.response_headers("h11"), + headers=list( + chain( + [(b"content-length", b"0"), (b"connection", b"close")], + self.config.response_headers("h11"), + ) ), ) ) diff --git a/src/hypercorn/protocol/h2.py b/src/hypercorn/protocol/h2.py index c743848..6204955 100755 --- a/src/hypercorn/protocol/h2.py +++ b/src/hypercorn/protocol/h2.py @@ -25,7 +25,7 @@ from ..typing import ASGIFramework, Event as IOEvent, TaskGroup, WorkerContext from ..utils import filter_pseudo_headers -BUFFER_HIGH_WATER = 2 * 2 ** 14 # Twice the default max frame size (two frames worth) +BUFFER_HIGH_WATER = 2 * 2**14 # Twice the default max frame size (two frames worth) BUFFER_LOW_WATER = BUFFER_HIGH_WATER / 2 diff --git a/src/hypercorn/trio/tcp_server.py b/src/hypercorn/trio/tcp_server.py index 069f3b7..838ebcb 100644 --- a/src/hypercorn/trio/tcp_server.py +++ b/src/hypercorn/trio/tcp_server.py @@ -13,7 +13,7 @@ from ..typing import ASGIFramework from ..utils import parse_socket_addr -MAX_RECV = 2 ** 16 +MAX_RECV = 2**16 class EventWrapper: diff --git a/src/hypercorn/trio/udp_server.py b/src/hypercorn/trio/udp_server.py index 667f12b..4e383c0 100644 --- a/src/hypercorn/trio/udp_server.py +++ b/src/hypercorn/trio/udp_server.py @@ -9,7 +9,7 @@ from ..typing import ASGIFramework from ..utils import parse_socket_addr -MAX_RECV = 2 ** 16 +MAX_RECV = 2**16 class UDPServer: diff --git a/tests/asyncio/test_sanity.py b/tests/asyncio/test_sanity.py index 8632177..8d2aa0f 100644 --- a/tests/asyncio/test_sanity.py +++ b/tests/asyncio/test_sanity.py @@ -60,7 +60,7 @@ async def test_http1_request(event_loop: asyncio.AbstractEventLoop) -> None: reason=b"", ), h11.Data(data=b"Hello & Goodbye"), - h11.EndOfMessage(headers=[]), + h11.EndOfMessage(headers=[]), # type: ignore ] server.reader.close() # type: ignore await task diff --git a/tests/trio/test_keep_alive.py b/tests/trio/test_keep_alive.py index 796c61f..989c571 100644 --- a/tests/trio/test_keep_alive.py +++ b/tests/trio/test_keep_alive.py @@ -84,7 +84,7 @@ async def test_http1_keep_alive( while True: event = client.next_event() if event == h11.NEED_DATA: - data = await client_stream.receive_some(2 ** 16) + data = await client_stream.receive_some(2**16) client.receive_data(data) elif isinstance(event, h11.EndOfMessage): break @@ -102,6 +102,6 @@ async def test_http1_keep_alive_pipelining( await client_stream.send_all( b"GET / HTTP/1.1\r\nHost: hypercorn\r\n\r\nGET / HTTP/1.1\r\nHost: hypercorn\r\n\r\n" ) - await client_stream.receive_some(2 ** 16) + await client_stream.receive_some(2**16) await trio.sleep(2 * KEEP_ALIVE_TIMEOUT) await client_stream.send_all(b"") diff --git a/tests/trio/test_sanity.py b/tests/trio/test_sanity.py index 1263333..929762a 100644 --- a/tests/trio/test_sanity.py +++ b/tests/trio/test_sanity.py @@ -67,7 +67,7 @@ async def test_http1_request(nursery: trio._core._run.Nursery) -> None: reason=b"", ), h11.Data(data=b"Hello & Goodbye"), - h11.EndOfMessage(headers=[]), + h11.EndOfMessage(headers=[]), # type: ignore ] From 9b16b1b442097eac3b583f1efb7b24279476702a Mon Sep 17 00:00:00 2001 From: pgjones Date: Mon, 21 Feb 2022 21:00:54 +0000 Subject: [PATCH 002/151] Allow control over date header addition This allows for Hypercorn to be used behind servers/proxies that add this header as well. --- src/hypercorn/config.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/hypercorn/config.py b/src/hypercorn/config.py index 84790c0..08487b1 100644 --- a/src/hypercorn/config.py +++ b/src/hypercorn/config.py @@ -76,6 +76,7 @@ class Config: h2_max_concurrent_streams = 100 h2_max_header_list_size = 2**16 h2_max_inbound_frame_size = 2**14 * OCTETS + include_date_header = True include_server_header = True keep_alive_timeout = 5 * SECONDS keyfile: Optional[str] = None @@ -267,7 +268,9 @@ def _create_sockets( return sockets def response_headers(self, protocol: str) -> List[Tuple[bytes, bytes]]: - headers = [(b"date", format_date_time(time()).encode("ascii"))] + headers = [] + if self.include_date_header: + headers.append((b"date", format_date_time(time()).encode("ascii"))) if self.include_server_header: headers.append((b"server", f"hypercorn-{protocol}".encode("ascii"))) From 8cef567301a5a3f4363d1c2cad76008e58add8aa Mon Sep 17 00:00:00 2001 From: pgjones Date: Tue, 22 Feb 2022 20:06:46 +0000 Subject: [PATCH 003/151] Utilise the pytest-asyncio strict mode This removes warnings and ensures that pytest asyncio doesn't affect the trio tests. --- pyproject.toml | 3 ++- tests/asyncio/test_keep_alive.py | 3 ++- tests/protocol/test_h11.py | 3 ++- tests/protocol/test_http_stream.py | 3 ++- tests/protocol/test_ws_stream.py | 3 ++- 5 files changed, 10 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7e353ac..4e531e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,11 +84,12 @@ warn_unused_configs = true warn_unused_ignores = true [[tool.mypy.overrides]] -module =["aioquic.*", "cryptography.*", "h11.*", "h2.*", "priority.*", "trio.*", "uvloop.*"] +module =["aioquic.*", "cryptography.*", "h11.*", "h2.*", "priority.*", "pytest_asyncio.*", "trio.*", "uvloop.*"] ignore_missing_imports = true [tool.pytest.ini_options] addopts = "--no-cov-on-fail --showlocals --strict-markers" +asyncio_mode = "strict" testpaths = ["tests"] [build-system] diff --git a/tests/asyncio/test_keep_alive.py b/tests/asyncio/test_keep_alive.py index 274802f..e1cac77 100644 --- a/tests/asyncio/test_keep_alive.py +++ b/tests/asyncio/test_keep_alive.py @@ -5,6 +5,7 @@ import h11 import pytest +import pytest_asyncio from hypercorn.asyncio.tcp_server import TCPServer from hypercorn.asyncio.worker_context import WorkerContext @@ -37,7 +38,7 @@ async def slow_framework(scope: dict, receive: Callable, send: Callable) -> None break -@pytest.fixture(name="server", scope="function") +@pytest_asyncio.fixture(name="server", scope="function") # type: ignore[misc] async def _server(event_loop: asyncio.AbstractEventLoop) -> AsyncGenerator[TCPServer, None]: config = Config() config.keep_alive_timeout = KEEP_ALIVE_TIMEOUT diff --git a/tests/protocol/test_h11.py b/tests/protocol/test_h11.py index 43b16e7..27ba0d5 100755 --- a/tests/protocol/test_h11.py +++ b/tests/protocol/test_h11.py @@ -6,6 +6,7 @@ import h11 import pytest +import pytest_asyncio from _pytest.monkeypatch import MonkeyPatch import hypercorn.protocol.h11 @@ -27,7 +28,7 @@ BASIC_HEADERS = [("Host", "hypercorn"), ("Connection", "close")] -@pytest.fixture(name="protocol") +@pytest_asyncio.fixture(name="protocol") # type: ignore[misc] async def _protocol(monkeypatch: MonkeyPatch) -> H11Protocol: MockHTTPStream = Mock() # noqa: N806 MockHTTPStream.return_value = AsyncMock(spec=HTTPStream) diff --git a/tests/protocol/test_http_stream.py b/tests/protocol/test_http_stream.py index f6550d3..a931f8b 100644 --- a/tests/protocol/test_http_stream.py +++ b/tests/protocol/test_http_stream.py @@ -4,6 +4,7 @@ from unittest.mock import call import pytest +import pytest_asyncio from hypercorn.asyncio.worker_context import WorkerContext from hypercorn.config import Config @@ -20,7 +21,7 @@ from mock import AsyncMock # type: ignore -@pytest.fixture(name="stream") +@pytest_asyncio.fixture(name="stream") # type: ignore[misc] async def _stream() -> HTTPStream: stream = HTTPStream( AsyncMock(), Config(), WorkerContext(), AsyncMock(), False, None, None, AsyncMock(), 1 diff --git a/tests/protocol/test_ws_stream.py b/tests/protocol/test_ws_stream.py index cd66622..8af8f24 100644 --- a/tests/protocol/test_ws_stream.py +++ b/tests/protocol/test_ws_stream.py @@ -5,6 +5,7 @@ from unittest.mock import call, Mock import pytest +import pytest_asyncio from wsproto.events import BytesMessage, TextMessage from hypercorn.asyncio.task_group import TaskGroup @@ -161,7 +162,7 @@ def test_handshake_accept_additional_headers() -> None: ] -@pytest.fixture(name="stream") +@pytest_asyncio.fixture(name="stream") # type: ignore[misc] async def _stream() -> WSStream: stream = WSStream( AsyncMock(), Config(), WorkerContext(), AsyncMock(), False, None, None, AsyncMock(), 1 From a2df571ee5514e505704d95591f351868ca14662 Mon Sep 17 00:00:00 2001 From: "S. Warden" Date: Tue, 16 Aug 2022 19:26:03 +0000 Subject: [PATCH 004/151] Minor typo fixes in Usage tutorial --- docs/tutorials/usage.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/tutorials/usage.rst b/docs/tutorials/usage.rst index 7e835ae..689b476 100644 --- a/docs/tutorials/usage.rst +++ b/docs/tutorials/usage.rst @@ -9,10 +9,10 @@ Hypercorn is invoked via the command line script ``hypercorn`` $ hypercorn [OPTIONS] MODULE_APP -with ``MODULE_APP`` has the pattern +where ``MODULE_APP`` has the pattern ``$(MODULE_NAME):$(VARIABLE_NAME)`` with the module name as a full (dotted) path to a python module containing a named variable that conforms to the ASGI framework specification. -See :ref:`how_to_configure` for the fill list of command line +See :ref:`how_to_configure` for the full list of command line arguments. From b87a6c98119692ad25ab3c878cc0115cb02a3ae9 Mon Sep 17 00:00:00 2001 From: pgjones Date: Thu, 25 Aug 2022 15:41:42 +0100 Subject: [PATCH 005/151] Fix mypy issues --- src/hypercorn/asyncio/run.py | 2 +- src/hypercorn/statsd.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/hypercorn/asyncio/run.py b/src/hypercorn/asyncio/run.py index cf5004e..0ac9a2e 100644 --- a/src/hypercorn/asyncio/run.py +++ b/src/hypercorn/asyncio/run.py @@ -133,7 +133,7 @@ async def _server_callback(reader: asyncio.StreamReader, writer: asyncio.StreamW _, protocol = await loop.create_datagram_endpoint( lambda: UDPServer(app, loop, config, context), sock=sock ) - server_tasks.add(loop.create_task(protocol.run())) # type: ignore + server_tasks.add(loop.create_task(protocol.run())) bind = repr_socket_addr(sock.family, sock.getsockname()) await config.log.info(f"Running on https://{bind} (QUIC) (CTRL + C to quit)") diff --git a/src/hypercorn/statsd.py b/src/hypercorn/statsd.py index 1418c1f..9cd7647 100644 --- a/src/hypercorn/statsd.py +++ b/src/hypercorn/statsd.py @@ -30,11 +30,11 @@ async def critical(self, message: str, *args: Any, **kwargs: Any) -> None: async def error(self, message: str, *args: Any, **kwargs: Any) -> None: await super().error(message, *args, **kwargs) - self.increment("hypercorn.log.error", 1) + await self.increment("hypercorn.log.error", 1) async def warning(self, message: str, *args: Any, **kwargs: Any) -> None: await super().warning(message, *args, **kwargs) - self.increment("hypercorn.log.warning", 1) + await self.increment("hypercorn.log.warning", 1) async def info(self, message: str, *args: Any, **kwargs: Any) -> None: await super().info(message, *args, **kwargs) From 721864f89d933df39feb8207eb8dfe4476e1a1cf Mon Sep 17 00:00:00 2001 From: pgjones Date: Thu, 25 Aug 2022 15:55:25 +0100 Subject: [PATCH 006/151] Fix docs build issue --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index e7c54b1..c816263 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -65,7 +65,7 @@ # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = None +language = "en" # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. From 014ab9716e76d9c0f9bb246d1aebd960499f8d72 Mon Sep 17 00:00:00 2001 From: pgjones Date: Thu, 25 Aug 2022 16:58:32 +0100 Subject: [PATCH 007/151] Bugfix ensure lifespan shutdowns occur Even when the server tasks error, for example with a "ssl.SSLError: [SSL: APPLICATION_DATA_AFTER_CLOSE_NOTIFY] application data after close notify". Thanks to @jonathanslenders for the report and suggested fix. --- src/hypercorn/asyncio/run.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/hypercorn/asyncio/run.py b/src/hypercorn/asyncio/run.py index 0ac9a2e..2b204de 100644 --- a/src/hypercorn/asyncio/run.py +++ b/src/hypercorn/asyncio/run.py @@ -172,14 +172,14 @@ async def _server_callback(reader: asyncio.StreamReader, writer: asyncio.StreamW await asyncio.wait_for(gathered_server_tasks, config.graceful_timeout) except asyncio.TimeoutError: pass + finally: + # Retrieve the Gathered Tasks Cancelled Exception, to + # prevent a warning that this hasn't been done. + gathered_server_tasks.exception() - # Retrieve the Gathered Tasks Cancelled Exception, to - # prevent a warning that this hasn't been done. - gathered_server_tasks.exception() - - await lifespan.wait_for_shutdown() - lifespan_task.cancel() - await lifespan_task + await lifespan.wait_for_shutdown() + lifespan_task.cancel() + await lifespan_task if reload_: restart() From c9ddfd789c2d2610430b82a849b9fc4b114f38f2 Mon Sep 17 00:00:00 2001 From: pgjones Date: Thu, 25 Aug 2022 20:58:28 +0100 Subject: [PATCH 008/151] Allow for logging configuration to be loaded from JSON or TOML files This allows an alternative format to the default ini format used by ``fileConfig``. This can also be used on the command line, hypercorn --log-config json:file.json ... for example. --- docs/how_to_guides/configuring.rst | 5 ++++- docs/how_to_guides/logging.rst | 13 +++++++++++++ src/hypercorn/__main__.py | 6 +++++- src/hypercorn/logging.py | 20 +++++++++++++++----- 4 files changed, 37 insertions(+), 7 deletions(-) diff --git a/docs/how_to_guides/configuring.rst b/docs/how_to_guides/configuring.rst index c47308a..b821b2e 100644 --- a/docs/how_to_guides/configuring.rst +++ b/docs/how_to_guides/configuring.rst @@ -121,7 +121,10 @@ insecure_bind ``--insecure-bind`` The TCP host/address to keep_alive_timeout ``--keep-alive`` Seconds to keep inactive connections alive before closing. keyfile ``--keyfile`` Path to the SSL key file. -logconfig ``--log-config`` A Python logging configuration file. +logconfig ``--log-config`` A Python logging configuration file. This + can be prefixed with 'json:' or 'toml:' to + load the configuration from a file in that + format. Default is the logging ini format. logconfig_dict N/A A Python logging configuration dictionary. logger_class N/A Type of class to use for logging. loglevel ``--log-level`` The (error) log level. diff --git a/docs/how_to_guides/logging.rst b/docs/how_to_guides/logging.rst index f4070ab..158e110 100644 --- a/docs/how_to_guides/logging.rst +++ b/docs/how_to_guides/logging.rst @@ -8,6 +8,19 @@ default neither will actively log. The special value of ``-`` can be used as the logging target in order to log to stdout and stderr respectively. Any other value is considered a filepath to target. +Configuring the Python logger +----------------------------- + +The Python logger can be configured using the ``logconfig`` or +``logconfig_dict`` configuration attributes. The latter, +``logconfig_dict`` will be passed to ``dictConfig`` after the loggers +have been created. + +The ``logconfig`` variable should point at a file to be used by the +``fileConfig`` function. Alternatively it can point to a JSON or TOML +formatted file which will be loaded and passed to the ``dictConfig`` +function. To use a JSON formatted file prefix the filepath with +``json:`` and for TOML use ``toml:``. Configuring access logs ----------------------- diff --git a/src/hypercorn/__main__.py b/src/hypercorn/__main__.py index 6e19ee9..0f1c976 100644 --- a/src/hypercorn/__main__.py +++ b/src/hypercorn/__main__.py @@ -121,7 +121,11 @@ def main(sys_args: Optional[List[str]] = None) -> None: action="append", ) parser.add_argument( - "--log-config", help="A Python logging configuration file.", default=sentinel + "--log-config", + help=""""A Python logging configuration file. This can be prefixed with + 'json:' or 'toml:' to load the configuration from a file in + that format. Default is the logging ini format.""", + default=sentinel, ) parser.add_argument( "--log-level", help="The (error) log level, defaults to info", default="INFO" diff --git a/src/hypercorn/logging.py b/src/hypercorn/logging.py index e583f96..3c2c657 100644 --- a/src/hypercorn/logging.py +++ b/src/hypercorn/logging.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import logging import os import sys @@ -8,6 +9,8 @@ from logging.config import dictConfig, fileConfig from typing import Any, IO, Mapping, Optional, TYPE_CHECKING, Union +import toml + if TYPE_CHECKING: from .config import Config from .typing import ResponseSummary, WWWScope @@ -58,11 +61,18 @@ def __init__(self, config: "Config") -> None: ) if config.logconfig is not None: - log_config = { - "__file__": config.logconfig, - "here": os.path.dirname(config.logconfig), - } - fileConfig(config.logconfig, defaults=log_config, disable_existing_loggers=False) + if config.logconfig.startswith("json:"): + with open(config.logconfig[5:]) as file_: + dictConfig(json.load(file_)) + elif config.logconfig.startswith("toml:"): + with open(config.logconfig[5:]) as file_: + dictConfig(toml.load(file_)) + else: + log_config = { + "__file__": config.logconfig, + "here": os.path.dirname(config.logconfig), + } + fileConfig(config.logconfig, defaults=log_config, disable_existing_loggers=False) else: if config.logconfig_dict is not None: dictConfig(config.logconfig_dict) From df569bd992b1b41ecf44b6fc96679be2f0f17a64 Mon Sep 17 00:00:00 2001 From: Noah Fontes Date: Mon, 13 Jun 2022 16:15:21 -0700 Subject: [PATCH 009/151] Close idle Keep-Alive connections on graceful exit This change makes the Keep-Alive timeout routines sensitive to the global termination switch, so that a connection in a Keep-Alive state will immediately close when termination is requested. It achieves this by making the termination flag on the context an event instead. We also fix a minor bug where the Keep-Alive timeout would never be reset after the first time it was created. More work is needed for HTTP/3, as the semantics of a connection in a GOAWAY state are different than what is currently exposed as "idle" from the UDP server's perspective. --- src/hypercorn/asyncio/run.py | 2 +- src/hypercorn/asyncio/tcp_server.py | 63 ++++++++------------ src/hypercorn/asyncio/udp_server.py | 2 +- src/hypercorn/asyncio/worker_context.py | 5 +- src/hypercorn/protocol/h11.py | 2 +- src/hypercorn/protocol/h2.py | 4 +- src/hypercorn/protocol/h3.py | 2 +- src/hypercorn/protocol/quic.py | 1 + src/hypercorn/trio/run.py | 2 +- src/hypercorn/trio/tcp_server.py | 76 ++++++++++--------------- src/hypercorn/trio/udp_server.py | 2 +- src/hypercorn/trio/worker_context.py | 5 +- src/hypercorn/typing.py | 5 +- src/hypercorn/utils.py | 6 -- tests/protocol/test_h11.py | 5 +- tests/protocol/test_h2.py | 3 +- 16 files changed, 80 insertions(+), 105 deletions(-) diff --git a/src/hypercorn/asyncio/run.py b/src/hypercorn/asyncio/run.py index 2b204de..f05d7ce 100644 --- a/src/hypercorn/asyncio/run.py +++ b/src/hypercorn/asyncio/run.py @@ -157,7 +157,7 @@ async def _server_callback(reader: asyncio.StreamReader, writer: asyncio.StreamW except (ShutdownError, KeyboardInterrupt): pass finally: - context.terminated = True + await context.terminated.set() for server in servers: server.close() diff --git a/src/hypercorn/asyncio/tcp_server.py b/src/hypercorn/asyncio/tcp_server.py index 9686c73..b01de0d 100644 --- a/src/hypercorn/asyncio/tcp_server.py +++ b/src/hypercorn/asyncio/tcp_server.py @@ -2,7 +2,7 @@ import asyncio from ssl import SSLError -from typing import Any, Callable, Generator, Optional +from typing import Any, Generator, Optional from .task_group import TaskGroup from .worker_context import WorkerContext @@ -15,20 +15,6 @@ MAX_RECV = 2**16 -class EventWrapper: - def __init__(self) -> None: - self._event = asyncio.Event() - - async def clear(self) -> None: - self._event.clear() - - async def wait(self) -> None: - await self._event.wait() - - async def set(self) -> None: - self._event.set() - - class TCPServer: def __init__( self, @@ -47,9 +33,9 @@ def __init__( self.reader = reader self.writer = writer self.send_lock = asyncio.Lock() - self.timeout_lock = asyncio.Lock() + self.idle_lock = asyncio.Lock() - self._keep_alive_timeout_handle: Optional[asyncio.Task] = None + self._idle_handle: Optional[asyncio.Task] = None def __await__(self) -> Generator[Any, None, None]: return self.run().__await__() @@ -80,7 +66,7 @@ async def run(self) -> None: alpn_protocol, ) await self.protocol.initiate() - await self._start_keep_alive_timeout() + await self._start_idle() await self._read_data() except OSError: pass @@ -100,9 +86,9 @@ async def protocol_send(self, event: Event) -> None: await self.protocol.handle(Closed()) elif isinstance(event, Updated): if event.idle: - await self._start_keep_alive_timeout() + await self._start_idle() else: - await self._stop_keep_alive_timeout() + await self._stop_idle() async def _read_data(self) -> None: while not self.reader.at_eof(): @@ -132,29 +118,30 @@ async def _close(self) -> None: except (BrokenPipeError, ConnectionResetError, RuntimeError): pass # Already closed - await self._stop_keep_alive_timeout() + await self._stop_idle() - async def _start_keep_alive_timeout(self) -> None: - async with self.timeout_lock: - if self._keep_alive_timeout_handle is None: - self._keep_alive_timeout_handle = self.loop.create_task( - _call_later(self.config.keep_alive_timeout, self._timeout) - ) - - async def _timeout(self) -> None: + async def _initiate_server_close(self) -> None: await self.protocol.handle(Closed()) self.writer.close() - async def _stop_keep_alive_timeout(self) -> None: - async with self.timeout_lock: - if self._keep_alive_timeout_handle is not None: - self._keep_alive_timeout_handle.cancel() + async def _start_idle(self) -> None: + async with self.idle_lock: + if self._idle_handle is None: + self._idle_handle = self.loop.create_task(self._run_idle()) + + async def _stop_idle(self) -> None: + async with self.idle_lock: + if self._idle_handle is not None: + self._idle_handle.cancel() try: - await self._keep_alive_timeout_handle + await self._idle_handle except asyncio.CancelledError: pass + self._idle_handle = None - -async def _call_later(timeout: float, callback: Callable) -> None: - await asyncio.sleep(timeout) - await asyncio.shield(callback()) + async def _run_idle(self) -> None: + try: + await asyncio.wait_for(self.context.terminated.wait(), self.config.keep_alive_timeout) + except asyncio.TimeoutError: + pass + await asyncio.shield(self._initiate_server_close()) diff --git a/src/hypercorn/asyncio/udp_server.py b/src/hypercorn/asyncio/udp_server.py index 02329ec..e8f6dd2 100644 --- a/src/hypercorn/asyncio/udp_server.py +++ b/src/hypercorn/asyncio/udp_server.py @@ -51,7 +51,7 @@ async def run(self) -> None: self.app, self.config, self.context, task_group, server, self.protocol_send ) - while not self.context.terminated or not self.protocol.idle: + while not self.context.terminated.is_set() or not self.protocol.idle: event = await self.protocol_queue.get() await self.protocol.handle(event) diff --git a/src/hypercorn/asyncio/worker_context.py b/src/hypercorn/asyncio/worker_context.py index fe3fd1b..fe9ad1c 100644 --- a/src/hypercorn/asyncio/worker_context.py +++ b/src/hypercorn/asyncio/worker_context.py @@ -19,12 +19,15 @@ async def wait(self) -> None: async def set(self) -> None: self._event.set() + def is_set(self) -> bool: + return self._event.is_set() + class WorkerContext: event_class: Type[Event] = EventWrapper def __init__(self) -> None: - self.terminated = False + self.terminated = self.event_class() @staticmethod async def sleep(wait: Union[float, int]) -> None: diff --git a/src/hypercorn/protocol/h11.py b/src/hypercorn/protocol/h11.py index 5baf762..fd12a1e 100755 --- a/src/hypercorn/protocol/h11.py +++ b/src/hypercorn/protocol/h11.py @@ -252,7 +252,7 @@ async def _send_error_response(self, status_code: int) -> None: async def _maybe_recycle(self) -> None: await self._close_stream() if ( - not self.context.terminated + not self.context.terminated.is_set() and self.connection.our_state is h11.DONE and self.connection.their_state is h11.DONE ): diff --git a/src/hypercorn/protocol/h2.py b/src/hypercorn/protocol/h2.py index 6204955..389c350 100755 --- a/src/hypercorn/protocol/h2.py +++ b/src/hypercorn/protocol/h2.py @@ -216,7 +216,7 @@ async def stream_send(self, event: StreamEvent) -> None: idle = len(self.streams) == 0 or all( stream.idle for stream in self.streams.values() ) - if idle and self.context.terminated: + if idle and self.context.terminated.is_set(): self.connection.close_connection() await self._flush() await self.send(Updated(idle=idle)) @@ -235,7 +235,7 @@ async def stream_send(self, event: StreamEvent) -> None: async def _handle_events(self, events: List[h2.events.Event]) -> None: for event in events: if isinstance(event, h2.events.RequestReceived): - if self.context.terminated: + if self.context.terminated.is_set(): self.connection.reset_stream(event.stream_id) self.connection.update_settings( {h2.settings.SettingCodes.MAX_CONCURRENT_STREAMS: 0} diff --git a/src/hypercorn/protocol/h3.py b/src/hypercorn/protocol/h3.py index c9e3157..a4ee438 100644 --- a/src/hypercorn/protocol/h3.py +++ b/src/hypercorn/protocol/h3.py @@ -50,7 +50,7 @@ def __init__( async def handle(self, quic_event: QuicEvent) -> None: for event in self.connection.handle_event(quic_event): if isinstance(event, HeadersReceived): - if not self.context.terminated: + if not self.context.terminated.is_set(): await self._create_stream(event) if event.stream_ended: await self.streams[event.stream_id].handle( diff --git a/src/hypercorn/protocol/quic.py b/src/hypercorn/protocol/quic.py index b6676af..f73807c 100644 --- a/src/hypercorn/protocol/quic.py +++ b/src/hypercorn/protocol/quic.py @@ -74,6 +74,7 @@ async def handle(self, event: Event) -> None: connection is None and len(event.data) >= 1200 and header.packet_type == PACKET_TYPE_INITIAL + and not self.context.terminated.is_set() ): connection = QuicConnection( configuration=self.quic_config, diff --git a/src/hypercorn/trio/run.py b/src/hypercorn/trio/run.py index 4f7a8c2..09c7333 100644 --- a/src/hypercorn/trio/run.py +++ b/src/hypercorn/trio/run.py @@ -103,7 +103,7 @@ async def worker_serve( except (ShutdownError, KeyboardInterrupt): pass finally: - context.terminated = True + await context.terminated.set() server_nursery.cancel_scope.deadline = trio.current_time() + config.graceful_timeout await lifespan.wait_for_shutdown() diff --git a/src/hypercorn/trio/tcp_server.py b/src/hypercorn/trio/tcp_server.py index 838ebcb..f162e51 100644 --- a/src/hypercorn/trio/tcp_server.py +++ b/src/hypercorn/trio/tcp_server.py @@ -1,7 +1,7 @@ from __future__ import annotations from math import inf -from typing import Any, Callable, Generator, Optional +from typing import Any, Generator, Optional import trio @@ -16,20 +16,6 @@ MAX_RECV = 2**16 -class EventWrapper: - def __init__(self) -> None: - self._event = trio.Event() - - async def clear(self) -> None: - self._event = trio.Event() - - async def wait(self) -> None: - await self._event.wait() - - async def set(self) -> None: - self._event.set() - - class TCPServer: def __init__( self, app: ASGIFramework, config: Config, context: WorkerContext, stream: trio.abc.Stream @@ -39,10 +25,10 @@ def __init__( self.context = context self.protocol: ProtocolWrapper self.send_lock = trio.Lock() - self.timeout_lock = trio.Lock() + self.idle_lock = trio.Lock() self.stream = stream - self._keep_alive_timeout_handle: Optional[trio.CancelScope] = None + self._idle_handle: Optional[trio.CancelScope] = None def __await__(self) -> Generator[Any, None, None]: return self.run().__await__() @@ -80,7 +66,7 @@ async def run(self) -> None: alpn_protocol, ) await self.protocol.initiate() - await self._start_keep_alive_timeout() + await self._start_idle() await self._read_data() except (trio.MultiError, OSError): pass @@ -101,9 +87,9 @@ async def protocol_send(self, event: Event) -> None: await self.protocol.handle(Closed()) elif isinstance(event, Updated): if event.idle: - await self._start_keep_alive_timeout() + await self._start_idle() else: - await self._stop_keep_alive_timeout() + await self._stop_idle() async def _read_data(self) -> None: while True: @@ -132,32 +118,30 @@ async def _close(self) -> None: pass await self.stream.aclose() - async def _start_keep_alive_timeout(self) -> None: - async with self.timeout_lock: - if self._keep_alive_timeout_handle is None: - self._keep_alive_timeout_handle = await self._task_group._nursery.start( - _call_later, self.config.keep_alive_timeout, self._timeout - ) - - async def _timeout(self) -> None: + async def _initiate_server_close(self) -> None: await self.protocol.handle(Closed()) await self.stream.aclose() - async def _stop_keep_alive_timeout(self) -> None: - async with self.timeout_lock: - if self._keep_alive_timeout_handle is not None: - self._keep_alive_timeout_handle.cancel() - self._keep_alive_timeout_handle = None - - -async def _call_later( - timeout: float, - callback: Callable, - task_status: trio._core._run._TaskStatus = trio.TASK_STATUS_IGNORED, -) -> None: - cancel_scope = trio.CancelScope() - task_status.started(cancel_scope) - with cancel_scope: - await trio.sleep(timeout) - cancel_scope.shield = True - await callback() + async def _start_idle(self) -> None: + async with self.idle_lock: + if self._idle_handle is None: + self._idle_handle = await self._task_group._nursery.start(self._run_idle) + + async def _stop_idle(self) -> None: + async with self.idle_lock: + if self._idle_handle is not None: + self._idle_handle.cancel() + self._idle_handle = None + + async def _run_idle( + self, + task_status: trio._core._run._TaskStatus = trio.TASK_STATUS_IGNORED, + ) -> None: + cancel_scope = trio.CancelScope() + task_status.started(cancel_scope) + with cancel_scope: + with trio.move_on_after(self.config.keep_alive_timeout): + await self.context.terminated.wait() + + cancel_scope.shield = True + await self._initiate_server_close() diff --git a/src/hypercorn/trio/udp_server.py b/src/hypercorn/trio/udp_server.py index 4e383c0..43451e9 100644 --- a/src/hypercorn/trio/udp_server.py +++ b/src/hypercorn/trio/udp_server.py @@ -37,7 +37,7 @@ async def run( self.app, self.config, self.context, task_group, server, self.protocol_send ) - while not self.context.terminated or not self.protocol.idle: + while not self.context.terminated.is_set() or not self.protocol.idle: data, address = await self.socket.recvfrom(MAX_RECV) await self.protocol.handle(RawData(data=data, address=address)) diff --git a/src/hypercorn/trio/worker_context.py b/src/hypercorn/trio/worker_context.py index c6c91e2..bcfa1a5 100644 --- a/src/hypercorn/trio/worker_context.py +++ b/src/hypercorn/trio/worker_context.py @@ -20,12 +20,15 @@ async def wait(self) -> None: async def set(self) -> None: self._event.set() + def is_set(self) -> bool: + return self._event.is_set() + class WorkerContext: event_class: Type[Event] = EventWrapper def __init__(self) -> None: - self.terminated = False + self.terminated = self.event_class() @staticmethod async def sleep(wait: Union[float, int]) -> None: diff --git a/src/hypercorn/typing.py b/src/hypercorn/typing.py index 64fa0e0..7765727 100644 --- a/src/hypercorn/typing.py +++ b/src/hypercorn/typing.py @@ -282,10 +282,13 @@ async def set(self) -> None: async def wait(self) -> None: ... + def is_set(self) -> bool: + ... + class WorkerContext(Protocol): event_class: Type[Event] - terminated: bool + terminated: Event @staticmethod async def sleep(wait: Union[float, int]) -> None: diff --git a/src/hypercorn/utils.py b/src/hypercorn/utils.py index 4fce714..08d0dce 100644 --- a/src/hypercorn/utils.py +++ b/src/hypercorn/utils.py @@ -5,7 +5,6 @@ import platform import socket import sys -from dataclasses import dataclass from enum import Enum from importlib import import_module from multiprocessing.synchronize import Event as EventType @@ -267,8 +266,3 @@ def valid_server_name(config: Config, request: "Request") -> bool: host = value.decode() break return host in config.server_names - - -@dataclass -class WorkerState: - terminated: bool = False diff --git a/tests/protocol/test_h11.py b/tests/protocol/test_h11.py index 27ba0d5..bf402a8 100755 --- a/tests/protocol/test_h11.py +++ b/tests/protocol/test_h11.py @@ -10,7 +10,7 @@ from _pytest.monkeypatch import MonkeyPatch import hypercorn.protocol.h11 -from hypercorn.asyncio.tcp_server import EventWrapper +from hypercorn.asyncio.worker_context import EventWrapper from hypercorn.config import Config from hypercorn.events import Closed, RawData, Updated from hypercorn.protocol.events import Body, Data, EndBody, EndData, Request, Response, StreamClosed @@ -34,8 +34,9 @@ async def _protocol(monkeypatch: MonkeyPatch) -> H11Protocol: MockHTTPStream.return_value = AsyncMock(spec=HTTPStream) monkeypatch.setattr(hypercorn.protocol.h11, "HTTPStream", MockHTTPStream) context = Mock() - context.terminated = False context.event_class.return_value = AsyncMock(spec=IOEvent) + context.terminated = context.event_class() + context.terminated.is_set.return_value = False return H11Protocol(AsyncMock(), Config(), context, AsyncMock(), False, None, None, AsyncMock()) diff --git a/tests/protocol/test_h2.py b/tests/protocol/test_h2.py index 91d5ea2..c44f39a 100644 --- a/tests/protocol/test_h2.py +++ b/tests/protocol/test_h2.py @@ -5,8 +5,7 @@ import pytest -from hypercorn.asyncio.tcp_server import EventWrapper -from hypercorn.asyncio.worker_context import WorkerContext +from hypercorn.asyncio.worker_context import EventWrapper, WorkerContext from hypercorn.config import Config from hypercorn.events import Closed, RawData from hypercorn.protocol.h2 import BUFFER_HIGH_WATER, BufferCompleteError, H2Protocol, StreamBuffer From ddfc11f2da9f84cf9a76e4781cd92d88fd93b4c6 Mon Sep 17 00:00:00 2001 From: synodriver Date: Tue, 23 Aug 2022 17:27:30 +0800 Subject: [PATCH 010/151] preserve response headers --- src/hypercorn/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hypercorn/utils.py b/src/hypercorn/utils.py index 08d0dce..ef0acdd 100644 --- a/src/hypercorn/utils.py +++ b/src/hypercorn/utils.py @@ -80,7 +80,7 @@ def build_and_validate_headers(headers: Iterable[Tuple[bytes, bytes]]) -> List[T for name, value in headers: if name[0] == b":"[0]: raise ValueError("Pseudo headers are not valid") - validated_headers.append((bytes(name).lower().strip(), bytes(value).strip())) + validated_headers.append((bytes(name).strip(), bytes(value).strip())) return validated_headers From fe5b44dd2fc7011a58652d87a629f2b631e59a6d Mon Sep 17 00:00:00 2001 From: pgjones Date: Fri, 26 Aug 2022 12:11:41 +0100 Subject: [PATCH 011/151] Add a test for ddfc11f2da9f84cf9a76e4781cd92d88fd93b4c6 This ensures that for HTTP/1 the returned headers match the casing as sent by the ASGI app. --- tests/protocol/test_h11.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/protocol/test_h11.py b/tests/protocol/test_h11.py index bf402a8..b989493 100755 --- a/tests/protocol/test_h11.py +++ b/tests/protocol/test_h11.py @@ -56,6 +56,25 @@ async def test_protocol_send_response(protocol: H11Protocol) -> None: ] +@pytest.mark.asyncio +async def test_protocol_preserve_headers(protocol: H11Protocol) -> None: + await protocol.stream_send( + Response(stream_id=1, status_code=201, headers=[(b"X-Special", b"Value")]) + ) + protocol.send.assert_called() # type: ignore + assert protocol.send.call_args_list == [ # type: ignore + call( + RawData( + data=( + b"HTTP/1.1 201 \r\nX-Special: Value\r\n" + b"date: Thu, 01 Jan 1970 01:23:20 GMT\r\n" + b"server: hypercorn-h11\r\nConnection: close\r\n\r\n" + ) + ) + ) + ] + + @pytest.mark.asyncio async def test_protocol_send_data(protocol: H11Protocol) -> None: await protocol.stream_send(Data(stream_id=1, data=b"hello")) From dbe00077590887ea8e1405aef24e5e4ff70df17d Mon Sep 17 00:00:00 2001 From: pgjones Date: Fri, 26 Aug 2022 13:51:21 +0100 Subject: [PATCH 012/151] Support the early hint extension This allows an ASGI application to send link header values to Hypercorn which Hypercorn will then send as an Early Hints response (103). Informational responses are ignored for HTTP/1 as it is likely clients will not correctly understand the meaning. --- src/hypercorn/protocol/events.py | 10 +++++++++ src/hypercorn/protocol/h11.py | 3 +++ src/hypercorn/protocol/h2.py | 3 ++- src/hypercorn/protocol/h3.py | 3 ++- src/hypercorn/protocol/http_stream.py | 19 +++++++++++++++++- src/hypercorn/typing.py | 6 ++++++ tests/protocol/test_http_stream.py | 29 +++++++++++++++++++++++++-- 7 files changed, 68 insertions(+), 5 deletions(-) diff --git a/src/hypercorn/protocol/events.py b/src/hypercorn/protocol/events.py index 7b39e9a..d91d203 100644 --- a/src/hypercorn/protocol/events.py +++ b/src/hypercorn/protocol/events.py @@ -43,6 +43,16 @@ class Response(Event): status_code: int +@dataclass(frozen=True) +class InformationalResponse(Event): + headers: List[Tuple[bytes, bytes]] + status_code: int + + def __post_init__(self) -> None: + if self.status_code >= 200 or self.status_code < 100: + raise ValueError(f"Status code must be 1XX not {self.status_code}") + + @dataclass(frozen=True) class StreamClosed(Event): pass diff --git a/src/hypercorn/protocol/h11.py b/src/hypercorn/protocol/h11.py index fd12a1e..7d3dadc 100755 --- a/src/hypercorn/protocol/h11.py +++ b/src/hypercorn/protocol/h11.py @@ -11,6 +11,7 @@ EndBody, EndData, Event as StreamEvent, + InformationalResponse, Request, Response, StreamClosed, @@ -129,6 +130,8 @@ async def stream_send(self, event: StreamEvent) -> None: status_code=event.status_code, ) ) + elif isinstance(event, InformationalResponse): + pass # Ignore for HTTP/1 elif isinstance(event, Body): await self._send_h11_event(h11.Data(data=event.data)) elif isinstance(event, EndBody): diff --git a/src/hypercorn/protocol/h2.py b/src/hypercorn/protocol/h2.py index 389c350..92ab043 100755 --- a/src/hypercorn/protocol/h2.py +++ b/src/hypercorn/protocol/h2.py @@ -14,6 +14,7 @@ EndBody, EndData, Event as StreamEvent, + InformationalResponse, Request, Response, StreamClosed, @@ -194,7 +195,7 @@ async def handle(self, event: Event) -> None: async def stream_send(self, event: StreamEvent) -> None: try: - if isinstance(event, Response): + if isinstance(event, (InformationalResponse, Response)): self.connection.send_headers( event.stream_id, [(b":status", b"%d" % event.status_code)] diff --git a/src/hypercorn/protocol/h3.py b/src/hypercorn/protocol/h3.py index a4ee438..77cc87d 100644 --- a/src/hypercorn/protocol/h3.py +++ b/src/hypercorn/protocol/h3.py @@ -14,6 +14,7 @@ EndBody, EndData, Event as StreamEvent, + InformationalResponse, Request, Response, StreamClosed, @@ -64,7 +65,7 @@ async def handle(self, quic_event: QuicEvent) -> None: await self.streams[event.stream_id].handle(EndBody(stream_id=event.stream_id)) async def stream_send(self, event: StreamEvent) -> None: - if isinstance(event, Response): + if isinstance(event, (InformationalResponse, Response)): self.connection.send_headers( event.stream_id, [(b":status", b"%d" % event.status_code)] diff --git a/src/hypercorn/protocol/http_stream.py b/src/hypercorn/protocol/http_stream.py index c6bdcb5..4f50f89 100644 --- a/src/hypercorn/protocol/http_stream.py +++ b/src/hypercorn/protocol/http_stream.py @@ -5,7 +5,7 @@ from typing import Awaitable, Callable, Optional, Tuple from urllib.parse import unquote -from .events import Body, EndBody, Event, Request, Response, StreamClosed +from .events import Body, EndBody, Event, InformationalResponse, Request, Response, StreamClosed from ..config import Config from ..typing import ( ASGIFramework, @@ -23,6 +23,7 @@ ) PUSH_VERSIONS = {"2", "3"} +EARLY_HINTS_VERSIONS = {"2", "3"} class ASGIHTTPState(Enum): @@ -90,6 +91,9 @@ async def handle(self, event: Event) -> None: if event.http_version in PUSH_VERSIONS: self.scope["extensions"]["http.response.push"] = {} + if event.http_version in EARLY_HINTS_VERSIONS: + self.scope["extensions"]["http.response.early_hint"] = {} + if valid_server_name(self.config, event): self.app_put = await self.task_group.spawn_app( self.app, self.config, self.scope, self.app_send @@ -142,6 +146,19 @@ async def app_send(self, message: Optional[ASGISendEvent]) -> None: raw_path=message["path"].encode(), ) ) + elif ( + message["type"] == "http.response.early_hint" + and self.scope["http_version"] in EARLY_HINTS_VERSIONS + and self.state == ASGIHTTPState.REQUEST + ): + headers = [(b"link", bytes(link).strip()) for link in message["links"]] + await self.send( + InformationalResponse( + stream_id=self.stream_id, + headers=headers, + status_code=103, + ) + ) elif message["type"] == "http.response.body" and self.state in { ASGIHTTPState.REQUEST, ASGIHTTPState.RESPONSE, diff --git a/src/hypercorn/typing.py b/src/hypercorn/typing.py index 7765727..8e4b8b2 100644 --- a/src/hypercorn/typing.py +++ b/src/hypercorn/typing.py @@ -90,6 +90,11 @@ class HTTPServerPushEvent(TypedDict): headers: Iterable[Tuple[bytes, bytes]] +class HTTPEarlyHintEvent(TypedDict): + type: Literal["http.response.early_hint"] + links: Iterable[bytes] + + class HTTPDisconnectEvent(TypedDict): type: Literal["http.disconnect"] @@ -180,6 +185,7 @@ class LifespanShutdownFailedEvent(TypedDict): HTTPResponseStartEvent, HTTPResponseBodyEvent, HTTPServerPushEvent, + HTTPEarlyHintEvent, HTTPDisconnectEvent, WebsocketAcceptEvent, WebsocketSendEvent, diff --git a/tests/protocol/test_http_stream.py b/tests/protocol/test_http_stream.py index a931f8b..23337d4 100644 --- a/tests/protocol/test_http_stream.py +++ b/tests/protocol/test_http_stream.py @@ -9,7 +9,14 @@ from hypercorn.asyncio.worker_context import WorkerContext from hypercorn.config import Config from hypercorn.logging import Logger -from hypercorn.protocol.events import Body, EndBody, Request, Response, StreamClosed +from hypercorn.protocol.events import ( + Body, + EndBody, + InformationalResponse, + Request, + Response, + StreamClosed, +) from hypercorn.protocol.http_stream import ASGIHTTPState, HTTPStream from hypercorn.typing import HTTPResponseBodyEvent, HTTPResponseStartEvent, HTTPScope from hypercorn.utils import UnexpectedMessageError @@ -76,7 +83,7 @@ async def test_handle_request_http_2(stream: HTTPStream) -> None: "headers": [], "client": None, "server": None, - "extensions": {"http.response.push": {}}, + "extensions": {"http.response.early_hint": {}, "http.response.push": {}}, } @@ -175,6 +182,24 @@ async def test_send_push(stream: HTTPStream, http_scope: HTTPScope) -> None: ] +@pytest.mark.asyncio +async def test_send_early_hint(stream: HTTPStream, http_scope: HTTPScope) -> None: + stream.scope = http_scope + stream.stream_id = 1 + await stream.app_send( + {"type": "http.response.early_hint", "links": [b'; rel="preload"; as="style"']} + ) + assert stream.send.call_args_list == [ # type: ignore + call( + InformationalResponse( + stream_id=1, + headers=[(b"link", b'; rel="preload"; as="style"')], + status_code=103, + ) + ) + ] + + @pytest.mark.asyncio async def test_send_app_error(stream: HTTPStream) -> None: await stream.handle( From 3d6bd726476fe588bd450c7103ec1fa07f056bd0 Mon Sep 17 00:00:00 2001 From: pgjones Date: Fri, 26 Aug 2022 14:59:59 +0100 Subject: [PATCH 013/151] Add a github workflow This will allow the CI to run on github rather than gitlab, allowing me to transfer. --- .github/workflows/ci.yml | 110 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7d6d2bb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,110 @@ +name: CI +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + workflow_dispatch: + +jobs: + tox: + name: ${{ matrix.name }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - {name: '3.11-dev', python: '3.11-dev', tox: py311} + - {name: '3.10', python: '3.10', tox: py310} + - {name: '3.9', python: '3.9', tox: py39} + - {name: '3.8', python: '3.8', tox: py38} + - {name: '3.7', python: '3.7', tox: py37} + - {name: 'format', python: '3.10', tox: format} + - {name: 'mypy', python: '3.10', tox: mypy} + - {name: 'pep8', python: '3.10', tox: pep8} + - {name: 'package', python: '3.10', tox: package} + + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python }} + + - name: update pip + run: | + pip install -U wheel + pip install -U setuptools + python -m pip install -U pip + - run: pip install tox + + - run: tox -e ${{ matrix.tox }} + + + h2spec: + name: ${{ matrix.name }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - {name: 'asyncio', worker: 'asyncio'} + - {name: 'trio', worker: 'trio'} + + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-python@v3 + with: + python-version: "3.10" + + - name: update pip + run: | + pip install -U wheel + pip install -U setuptools + python -m pip install -U pip + - run: pip install trio . + + - name: Run server + working-directory: compliance/h2spec + run: nohup hypercorn --keyfile key.pem --certfile cert.pem -k ${{ matrix.worker }} server:App & + + - name: Download h2spec + run: | + wget https://github.com/summerwind/h2spec/releases/download/v2.2.0/h2spec_linux_amd64.tar.gz + tar -xvf h2spec_linux_amd64.tar.gz + + - name: Run h2spec + run: ./h2spec -tk -h 127.0.0.1 -p 8000 -o 10 + + autobahn: + name: ${{ matrix.name }} + runs-on: ubuntu-latest + container: python:2.7.16-alpine3.10 + strategy: + fail-fast: false + matrix: + include: + - {name: 'asyncio', worker: 'asyncio'} + - {name: 'trio', worker: 'trio'} + + steps: + - uses: actions/checkout@v3 + + - run: apk --update add build-base libressl libressl-dev ca-certificates libffi-dev python3 python3-dev + + - name: update pip + run: | + pip install -U wheel + pip install -U setuptools + python -m pip install -U pip + - run: pip install pyopenssl==19.1.0 cryptography==2.3.1 autobahntestsuite + - run: python3 -m pip install trio . + + - name: Run server + working-directory: compliance/autobahn + run: nohup hypercorn -k ${{ matrix.worker }} server:App & + + - name: Run server + working-directory: compliance/autobahn + run: wstest -m fuzzingclient && python summarise.py From 67b637a8294efc58060aad539469619c12f46115 Mon Sep 17 00:00:00 2001 From: pgjones Date: Fri, 26 Aug 2022 15:19:51 +0100 Subject: [PATCH 014/151] Add configuration for read the docs This should, hopefully allow the docs to build and make it clearer to other users how to setup and install the docs requirements. --- .readthedocs.yaml | 16 ++++++++++++++++ pyproject.toml | 2 ++ 2 files changed, 18 insertions(+) create mode 100644 .readthedocs.yaml diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..d1f4efa --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,16 @@ +version: 2 + +build: + os: ubuntu-22.04 + tools: + python: "3.10" + +python: + install: + - method: pip + path: . + extra_requirements: + - docs + +sphinx: + configuration: docs/conf.py diff --git a/pyproject.toml b/pyproject.toml index 4e531e8..b4b8a8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ aioquic = { version = ">= 0.9.0, < 1.0", optional = true } h11 = "*" h2 = ">=3.1.0" priority = "*" +pydata_sphinx_theme = { version = "*", optional = true } toml = "*" trio = { version = ">=0.11.0", optional = true } typing_extensions = { version = ">=3.7.4", python = "<3.8" } @@ -47,6 +48,7 @@ trio = "*" hypercorn = "hypercorn.__main__:main" [tool.poetry.extras] +docs = ["pydata_sphinx_theme"] h3 = ["aioquic"] trio = ["trio"] uvloop = ["uvloop"] From f8125d1945a66854d0c2dda770020bd2284f4345 Mon Sep 17 00:00:00 2001 From: pgjones Date: Fri, 26 Aug 2022 15:41:19 +0100 Subject: [PATCH 015/151] Switch to github rather than gitlab GitHub has a better pricing model and is more popular for OSS, hence the move. --- README.rst | 32 ++++++++++++++++---------------- docs/conf.py | 10 +++++----- docs/index.rst | 12 ++++++------ pyproject.toml | 4 ++-- 4 files changed, 29 insertions(+), 29 deletions(-) diff --git a/README.rst b/README.rst index ac4cb1d..d10d83e 100644 --- a/README.rst +++ b/README.rst @@ -1,7 +1,7 @@ Hypercorn ========= -.. image:: https://assets.gitlab-static.net/pgjones/hypercorn/raw/main/artwork/logo.png +.. image:: https://github.com/pgjones/hypercorn/raw/main/artwork/logo.png :alt: Hypercorn logo |Build Status| |docs| |pypi| |http| |python| |license| @@ -24,7 +24,7 @@ choose a quic binding e.g. ``hypercorn --quic-bind localhost:4433 ...``. Hypercorn was initially part of `Quart -`_ before being separated out into a +`_ before being separated out into a standalone ASGI server. Hypercorn forked from version 0.5.0 of Quart. Quickstart @@ -59,19 +59,19 @@ Alternatively Hypercorn can be used programatically, asyncio.run(serve(app, Config())) learn more (including a Trio example of the above) in the `API usage -`_ +`_ docs. Contributing ------------ -Hypercorn is developed on `GitLab -`_. If you come across an issue, +Hypercorn is developed on `Github +`_. If you come across an issue, or have a feature request please open an `issue -`_. If you want to +`_. If you want to contribute a fix or the feature-implementation please do (typo fixes -welcome), by proposing a `merge request -`_. +welcome), by proposing a `pull request +`_. Testing ~~~~~~~ @@ -89,17 +89,17 @@ this will check the code style and run the tests. Help ---- -The Hypercorn `documentation `_ -is the best place to start, after that try searching stack overflow, -if you still can't find an answer please `open an issue -`_. +The Hypercorn `documentation `_ is +the best place to start, after that try searching stack overflow, if +you still can't find an answer please `open an issue +`_. -.. |Build Status| image:: https://gitlab.com/pgjones/hypercorn/badges/main/pipeline.svg - :target: https://gitlab.com/pgjones/hypercorn/commits/main +.. |Build Status| image:: https://github.com/pgjones/hypercorn/actions/workflows/ci.yml/badge.svg + :target: https://github.com/pgjones/hypercorn/commits/main .. |docs| image:: https://img.shields.io/badge/docs-passing-brightgreen.svg - :target: https://pgjones.gitlab.io/hypercorn/ + :target: https://hypercorn.readthedocs.io .. |pypi| image:: https://img.shields.io/pypi/v/hypercorn.svg :target: https://pypi.python.org/pypi/Hypercorn/ @@ -111,4 +111,4 @@ if you still can't find an answer please `open an issue :target: https://pypi.python.org/pypi/Hypercorn/ .. |license| image:: https://img.shields.io/badge/license-MIT-blue.svg - :target: https://gitlab.com/pgjones/hypercorn/blob/main/LICENSE + :target: https://github.com/pgjones/hypercorn/blob/main/LICENSE diff --git a/docs/conf.py b/docs/conf.py index c816263..926f7dd 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -93,14 +93,14 @@ # html_theme_options = { "external_links": [ - {"name": "Source code", "url": "https://gitlab.com/pgjones/hypercorn"}, - {"name": "Issues", "url": "https://gitlab.com/pgjones/hypercorn/issues"}, + {"name": "Source code", "url": "https://github.com/pgjones/hypercorn"}, + {"name": "Issues", "url": "https://github.com/pgjones/hypercorn/issues"}, ], "icon_links": [ { - "name": "GitLab", - "url": "https://gitlab.com/pgjones/hypercorn", - "icon": "fab fa-gitlab", + "name": "Github", + "url": "https://github.com/pgjones/hypercorn", + "icon": "fab fa-github", }, ], } diff --git a/docs/index.rst b/docs/index.rst index 1973c4c..36cfdeb 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -17,14 +17,14 @@ and HTTP/2), ASGI/2, and ASGI/3 specifications. Hypercorn can utilise asyncio, uvloop, or trio worker types. Hypercorn was initially part of `Quart -`_ before being separated out into a +`_ before being separated out into a standalone ASGI server. Hypercorn forked from version 0.5.0 of Quart. -Hypercorn is developed on `GitLab -`_. You are very welcome to -open `issues `_ or -propose `merge requests -`_. +Hypercorn is developed on `Github +`_. You are very welcome to +open `issues `_ or +propose `pull requests +`_. Contents -------- diff --git a/pyproject.toml b/pyproject.toml index b4b8a8e..95add99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,8 +20,8 @@ classifiers = [ include = ["src/hypercorn/py.typed"] license = "MIT" readme = "README.rst" -repository = "https://gitlab.com/pgjones/hypercorn/" -documentation = "https://pgjones.gitlab.io/hypercorn/" +repository = "https://github.com/pgjones/hypercorn/" +documentation = "https://hypercorn.readthedocs.io" [tool.poetry.dependencies] python = ">=3.7" From d98faef4627cccff19f290e90d1f34c7e03b3bdc Mon Sep 17 00:00:00 2001 From: pgjones Date: Fri, 26 Aug 2022 15:42:59 +0100 Subject: [PATCH 016/151] Remove unused file --- .gitlab-ci.yml | 80 -------------------------------------------------- 1 file changed, 80 deletions(-) delete mode 100644 .gitlab-ci.yml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml deleted file mode 100644 index 47f8680..0000000 --- a/.gitlab-ci.yml +++ /dev/null @@ -1,80 +0,0 @@ -py37: - image: python:3.7 - script: - - pip install tox - - tox -e py37 - -py38: - image: python:3.8 - script: - - pip install tox - - tox -e py38 - -py39: - image: python:3.9 - script: - - pip install tox - - tox -e py39 - -py310: - image: python:3.10 - script: - - pip install tox - - tox -e docs,format,mypy,py310,package,pep8 - -pages: - image: python:3.10 - script: - - pip install sphinx pydata-sphinx-theme . - - rm -rf docs/source - - sphinx-apidoc -e -f -o docs/reference/source/ src/hypercorn - - sphinx-build -b html docs/ docs/_build/html/ - - mv docs/_build/html/ public/ - artifacts: - paths: - - public - rules: - - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH - -.h2spec-script: &h2spec-script - image: python:3.10 - script: - - python3 -m pip install trio . - - cd compliance/h2spec && nohup hypercorn --keyfile key.pem --certfile cert.pem -k $WORKER_CLASS server:App & - - wget https://github.com/summerwind/h2spec/releases/download/v2.2.0/h2spec_linux_amd64.tar.gz - - tar -xvf h2spec_linux_amd64.tar.gz - - sleep 10 - - ./h2spec -tk -h 127.0.0.1 -p 8000 -o 10 - -h2spec: - <<: *h2spec-script - variables: - WORKER_CLASS: "asyncio" - -h2spec-trio: - <<: *h2spec-script - variables: - WORKER_CLASS: "trio" - -.autobahn-script: &autobahn-script - image: python:2.7.16-alpine3.10 - script: - - apk --update add build-base libressl libressl-dev ca-certificates libffi-dev python3 python3-dev - - pip install pyopenssl==19.1.0 cryptography==2.3.1 autobahntestsuite - - python3 -m pip install trio . - - cd compliance/autobahn && nohup hypercorn -k $WORKER_CLASS server:App & - - while ! netstat -l -t | grep -q 8000; do sleep 1; done - - cd compliance/autobahn && wstest -m fuzzingclient && python summarise.py - artifacts: - paths: - - compliance/autobahn/reports/servers/ - -autobahn: - <<: *autobahn-script - variables: - WORKER_CLASS: "asyncio" - -autobahn-trio: - <<: *autobahn-script - variables: - WORKER_CLASS: "trio" From 96d929775239c95f72b7b19a55931db299e4cf3d Mon Sep 17 00:00:00 2001 From: pgjones Date: Fri, 26 Aug 2022 15:46:58 +0100 Subject: [PATCH 017/151] Bump h2spec to 2.6.0 from 2.2.0 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d6d2bb..9debb7d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,7 +71,7 @@ jobs: - name: Download h2spec run: | - wget https://github.com/summerwind/h2spec/releases/download/v2.2.0/h2spec_linux_amd64.tar.gz + wget https://github.com/summerwind/h2spec/releases/download/v2.6.0/h2spec_linux_amd64.tar.gz tar -xvf h2spec_linux_amd64.tar.gz - name: Run h2spec From 9a50f5d6771bcecfc86dd00e19fdbd1ad29016a4 Mon Sep 17 00:00:00 2001 From: pgjones Date: Sat, 27 Aug 2022 09:40:37 +0100 Subject: [PATCH 018/151] Bugfix don't suppress 412 bodies RFC 7232 is clear that 204 and 304 bodies should be suppressed, but says nothing for 412. --- src/hypercorn/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hypercorn/utils.py b/src/hypercorn/utils.py index ef0acdd..6e7de45 100644 --- a/src/hypercorn/utils.py +++ b/src/hypercorn/utils.py @@ -71,7 +71,7 @@ class FrameTooLargeError(Exception): def suppress_body(method: str, status_code: int) -> bool: - return method == "HEAD" or 100 <= status_code < 200 or status_code in {204, 304, 412} + return method == "HEAD" or 100 <= status_code < 200 or status_code in {204, 304} def build_and_validate_headers(headers: Iterable[Tuple[bytes, bytes]]) -> List[Tuple[bytes, bytes]]: From da9262d75feff7ec068d5d4ef25adc84224fcb7b Mon Sep 17 00:00:00 2001 From: pgjones Date: Sat, 27 Aug 2022 10:39:44 +0100 Subject: [PATCH 019/151] Bugfix send the idle update first on HTTP/1 request receipt This ensures that the connection is marked as not idle before a protocol upgrade takes place. This therefore ensures that on upgrading a slow response is not timed out. --- src/hypercorn/protocol/h11.py | 2 +- tests/protocol/test_h11.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/hypercorn/protocol/h11.py b/src/hypercorn/protocol/h11.py index 7d3dadc..d9cb85d 100755 --- a/src/hypercorn/protocol/h11.py +++ b/src/hypercorn/protocol/h11.py @@ -161,9 +161,9 @@ async def _handle_events(self) -> None: break else: if isinstance(event, h11.Request): + await self.send(Updated(idle=False)) await self._check_protocol(event) await self._create_stream(event) - await self.send(Updated(idle=False)) elif event is h11.PAUSED: await self.can_read.clear() await self.can_read.wait() diff --git a/tests/protocol/test_h11.py b/tests/protocol/test_h11.py index b989493..2154a3c 100755 --- a/tests/protocol/test_h11.py +++ b/tests/protocol/test_h11.py @@ -296,6 +296,7 @@ async def test_protocol_handle_h2c_upgrade(protocol: H11Protocol) -> None: ) ) assert protocol.send.call_args_list == [ # type: ignore + call(Updated(idle=False)), call( RawData( b"HTTP/1.1 101 \r\n" From 625a58a3dbf03e969308ec4e4f74406286ccbac0 Mon Sep 17 00:00:00 2001 From: pgjones Date: Sat, 27 Aug 2022 10:46:38 +0100 Subject: [PATCH 020/151] Fix formatting of da9262d75feff7ec068d5d4ef25adc84224fcb7b Forgot to check. --- tests/protocol/test_h11.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/protocol/test_h11.py b/tests/protocol/test_h11.py index 2154a3c..20e0091 100755 --- a/tests/protocol/test_h11.py +++ b/tests/protocol/test_h11.py @@ -306,7 +306,7 @@ async def test_protocol_handle_h2c_upgrade(protocol: H11Protocol) -> None: b"upgrade: h2c\r\n" b"\r\n" ) - ) + ), ] assert exc_info.value.data == b"bbb" assert exc_info.value.headers == [ From c2608a0ab1133a11a2a8eeb659388c8dd13bc9b1 Mon Sep 17 00:00:00 2001 From: pgjones Date: Sun, 28 Aug 2022 17:14:53 +0100 Subject: [PATCH 021/151] Directly support serving WSGI applications This allows Hypercorn to serve a wsgi application via: hypercorn module:app With requests being run in threads via an asyncio executor or a trio run_sync call. The type of app will be determined automatically, however if it isn't an additional prefix can be used to specify wsgi or asgi as: hypercorn wsgi:module:app hypercorn asgi:module:app Note that this drops support for ASGI2 applications as it is unlikely this is useful anymore. --- docs/how_to_guides/configuring.rst | 2 + docs/how_to_guides/wsgi_apps.rst | 42 ++--- docs/tutorials/usage.rst | 5 +- src/hypercorn/app_wrappers.py | 147 ++++++++++++++++++ src/hypercorn/asyncio/__init__.py | 3 +- src/hypercorn/asyncio/lifespan.py | 12 +- src/hypercorn/asyncio/run.py | 10 +- src/hypercorn/asyncio/task_group.py | 21 ++- src/hypercorn/asyncio/tcp_server.py | 4 +- src/hypercorn/asyncio/udp_server.py | 4 +- src/hypercorn/config.py | 1 + src/hypercorn/middleware/dispatcher.py | 5 +- src/hypercorn/middleware/http_to_https.py | 3 +- src/hypercorn/middleware/wsgi.py | 138 ++-------------- src/hypercorn/protocol/__init__.py | 4 +- src/hypercorn/protocol/h11.py | 4 +- src/hypercorn/protocol/h2.py | 4 +- src/hypercorn/protocol/h3.py | 4 +- src/hypercorn/protocol/http_stream.py | 6 +- src/hypercorn/protocol/quic.py | 4 +- src/hypercorn/protocol/ws_stream.py | 6 +- src/hypercorn/trio/__init__.py | 5 +- src/hypercorn/trio/lifespan.py | 8 +- src/hypercorn/trio/run.py | 6 +- src/hypercorn/trio/task_group.py | 14 +- src/hypercorn/trio/tcp_server.py | 4 +- src/hypercorn/trio/udp_server.py | 4 +- src/hypercorn/typing.py | 30 ++-- src/hypercorn/utils.py | 80 ++++------ tests/asyncio/test_keep_alive.py | 23 ++- tests/asyncio/test_lifespan.py | 9 +- tests/asyncio/test_sanity.py | 19 ++- tests/asyncio/test_task_group.py | 9 +- tests/asyncio/test_tcp_server.py | 15 +- tests/helpers.py | 40 +++-- tests/protocol/test_http_stream.py | 4 +- tests/protocol/test_ws_stream.py | 2 +- .../test_wsgi.py => test_app_wrappers.py} | 28 ++-- tests/test_utils.py | 87 ++++------- tests/trio/test_keep_alive.py | 3 +- tests/trio/test_lifespan.py | 5 +- tests/trio/test_sanity.py | 9 +- 42 files changed, 448 insertions(+), 385 deletions(-) create mode 100644 src/hypercorn/app_wrappers.py rename tests/{middleware/test_wsgi.py => test_app_wrappers.py} (83%) diff --git a/docs/how_to_guides/configuring.rst b/docs/how_to_guides/configuring.rst index b821b2e..71ee920 100644 --- a/docs/how_to_guides/configuring.rst +++ b/docs/how_to_guides/configuring.rst @@ -163,4 +163,6 @@ worker_class ``-k``, ``--worker-class`` The type of worker to u hypercorn[uvloop]), and trio (pip install hypercorn[trio]). workers ``-w``, ``--workers`` The number of workers to spawn and use. +wsgi_max_body_size N/A The maximum size of a body that will be + accepted in WSGI mode. ========================== ============================= ========================================== diff --git a/docs/how_to_guides/wsgi_apps.rst b/docs/how_to_guides/wsgi_apps.rst index 65d2167..396d890 100644 --- a/docs/how_to_guides/wsgi_apps.rst +++ b/docs/how_to_guides/wsgi_apps.rst @@ -3,24 +3,11 @@ Serve WSGI applications ======================= -Hypercorn directly serves ASGI applications, but it can be used to -serve WSGI applications by using ``AsyncioWSGIMiddleware`` or -``TrioWSGIMiddleware`` middleware. To do so simply wrap the WSGI -app with the appropriate middleware for the hypercorn worker, - -.. code-block:: python - - from hypercorn.middleware import AsyncioWSGIMiddleware, TrioWSGIMiddleware - - asyncio_app = AsyncioWSGIMiddleware(wsgi_app) - trio_app = TrioWSGIMiddleware(wsgi_app) - -which can then be served by hypercorn, +Hypercorn directly serves WSGI applications: .. code-block:: shell - $ hypercorn module:asyncio_app - $ hypercorn --worker-class trio module:trio_app + $ hypercorn module:wsgi_app .. warning:: @@ -28,13 +15,32 @@ which can then be served by hypercorn, before being sent. This prevents the WSGI app from streaming a response. +WSGI Middleware +--------------- + +If a WSGI application is being combined with ASGI middleware it is +best to use either ``AsyncioWSGIMiddleware`` or ``TrioWSGIMiddleware`` +middleware. To do so simply wrap the WSGI app with the appropriate +middleware for the hypercorn worker, + +.. code-block:: python + + from hypercorn.middleware import AsyncioWSGIMiddleware, TrioWSGIMiddleware + + asyncio_app = AsyncioWSGIMiddleware(wsgi_app) + trio_app = TrioWSGIMiddleware(wsgi_app) + +which can then be passed to other middleware served by hypercorn, + Limiting the request body size ------------------------------ As the request body is stored in memory before being processed it is -important to limit the max size. Both the ``AsyncioWSGIMiddleware`` -and ``TrioWSGIMiddleware`` have a default max size that can be -configured, +important to limit the max size. This is configured by the +``wsgi_max_body_size`` configuration attribute. + +When using middleware the ``AsyncioWSGIMiddleware`` and +``TrioWSGIMiddleware`` have a default max size that can be configured, .. code-block:: python diff --git a/docs/tutorials/usage.rst b/docs/tutorials/usage.rst index 689b476..c6c0fc4 100644 --- a/docs/tutorials/usage.rst +++ b/docs/tutorials/usage.rst @@ -12,7 +12,10 @@ Hypercorn is invoked via the command line script ``hypercorn`` where ``MODULE_APP`` has the pattern ``$(MODULE_NAME):$(VARIABLE_NAME)`` with the module name as a full (dotted) path to a python module containing a named variable that -conforms to the ASGI framework specification. +conforms to the ASGI or WSGI framework specifications. + +The ``MODULE_APP`` can be prefixed with ``asgi:`` or ``wsgi:`` to +ensure that the loaded app is treated as either an asgi or wsgi app. See :ref:`how_to_configure` for the full list of command line arguments. diff --git a/src/hypercorn/app_wrappers.py b/src/hypercorn/app_wrappers.py new file mode 100644 index 0000000..19fdfde --- /dev/null +++ b/src/hypercorn/app_wrappers.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +from io import BytesIO +from typing import Callable, List, Optional, Tuple + +from .typing import ( + ASGIFramework, + ASGIReceiveCallable, + ASGISendCallable, + HTTPScope, + Scope, + WSGIFramework, +) + + +class InvalidPathError(Exception): + pass + + +class ASGIWrapper: + def __init__(self, app: ASGIFramework) -> None: + self.app = app + + async def __call__( + self, + scope: Scope, + receive: ASGIReceiveCallable, + send: ASGISendCallable, + sync_spawn: Callable, + ) -> None: + await self.app(scope, receive, send) + + +class WSGIWrapper: + def __init__(self, app: WSGIFramework, max_body_size: int) -> None: + self.app = app + self.max_body_size = max_body_size + + async def __call__( + self, + scope: Scope, + receive: ASGIReceiveCallable, + send: ASGISendCallable, + sync_spawn: Callable, + ) -> None: + if scope["type"] == "http": + status_code, headers, body = await self.handle_http(scope, receive, send, sync_spawn) + await send({"type": "http.response.start", "status": status_code, "headers": headers}) # type: ignore # noqa: E501 + await send({"type": "http.response.body", "body": body}) # type: ignore + elif scope["type"] == "websocket": + await send({"type": "websocket.close"}) # type: ignore + elif scope["type"] == "lifespan": + return + else: + raise Exception(f"Unknown scope type, {scope['type']}") + + async def handle_http( + self, + scope: HTTPScope, + receive: ASGIReceiveCallable, + send: ASGISendCallable, + sync_spawn: Callable, + ) -> Tuple[int, list, bytes]: + body = bytearray() + while True: + message = await receive() + body.extend(message.get("body", b"")) # type: ignore + if len(body) > self.max_body_size: + return 400, [], b"" + if not message.get("more_body"): + break + + try: + environ = _build_environ(scope, body) + except InvalidPathError: + return 404, [], b"" + else: + return await sync_spawn(self.run_app, environ) + + def run_app(self, environ: dict) -> Tuple[int, list, bytes]: + headers: List[Tuple[bytes, bytes]] + status_code: Optional[int] = None + + def start_response( + status: str, + response_headers: List[Tuple[str, str]], + exc_info: Optional[Exception] = None, + ) -> None: + nonlocal headers, status_code + + raw, _ = status.split(" ", 1) + status_code = int(raw) + headers = [ + (name.lower().encode("ascii"), value.encode("ascii")) + for name, value in response_headers + ] + + body = bytearray() + for output in self.app(environ, start_response): + body.extend(output) + return status_code, headers, body + + +def _build_environ(scope: HTTPScope, body: bytes) -> dict: + server = scope.get("server") or ("localhost", 80) + path = scope["path"] + script_name = scope.get("root_path", "") + if path.startswith(script_name): + path = path[len(script_name) :] + path = path if path != "" else "/" + else: + raise InvalidPathError() + + environ = { + "REQUEST_METHOD": scope["method"], + "SCRIPT_NAME": script_name.encode("utf8").decode("latin1"), + "PATH_INFO": path.encode("utf8").decode("latin1"), + "QUERY_STRING": scope["query_string"].decode("ascii"), + "SERVER_NAME": server[0], + "SERVER_PORT": server[1], + "SERVER_PROTOCOL": "HTTP/%s" % scope["http_version"], + "wsgi.version": (1, 0), + "wsgi.url_scheme": scope.get("scheme", "http"), + "wsgi.input": BytesIO(body), + "wsgi.errors": BytesIO(), + "wsgi.multithread": True, + "wsgi.multiprocess": True, + "wsgi.run_once": False, + } + + if "client" in scope: + environ["REMOTE_ADDR"] = scope["client"][0] + + for raw_name, raw_value in scope.get("headers", []): + name = raw_name.decode("latin1") + if name == "content-length": + corrected_name = "CONTENT_LENGTH" + elif name == "content-type": + corrected_name = "CONTENT_TYPE" + else: + corrected_name = "HTTP_%s" % name.upper().replace("-", "_") + # HTTPbis say only ASCII chars are allowed in headers, but we latin1 just in case + value = raw_value.decode("latin1") + if corrected_name in environ: + value = environ[corrected_name] + "," + value # type: ignore + environ[corrected_name] = value + return environ diff --git a/src/hypercorn/asyncio/__init__.py b/src/hypercorn/asyncio/__init__.py index 91035c7..6e2a515 100644 --- a/src/hypercorn/asyncio/__init__.py +++ b/src/hypercorn/asyncio/__init__.py @@ -4,6 +4,7 @@ from typing import Awaitable, Callable, Optional from .run import worker_serve +from ..app_wrappers import ASGIWrapper from ..config import Config from ..typing import ASGIFramework @@ -38,4 +39,4 @@ async def serve( if config.workers != 1: warnings.warn("The config `workers` has no affect when using serve", Warning) - await worker_serve(app, config, shutdown_trigger=shutdown_trigger) + await worker_serve(ASGIWrapper(app), config, shutdown_trigger=shutdown_trigger) diff --git a/src/hypercorn/asyncio/lifespan.py b/src/hypercorn/asyncio/lifespan.py index f21b762..9acfe61 100644 --- a/src/hypercorn/asyncio/lifespan.py +++ b/src/hypercorn/asyncio/lifespan.py @@ -1,10 +1,11 @@ from __future__ import annotations import asyncio +from functools import partial from ..config import Config -from ..typing import ASGIFramework, ASGIReceiveEvent, ASGISendEvent, LifespanScope -from ..utils import invoke_asgi, LifespanFailureError, LifespanTimeoutError +from ..typing import AppWrapper, ASGIReceiveEvent, ASGISendEvent, LifespanScope +from ..utils import LifespanFailureError, LifespanTimeoutError class UnexpectedMessageError(Exception): @@ -12,13 +13,14 @@ class UnexpectedMessageError(Exception): class Lifespan: - def __init__(self, app: ASGIFramework, config: Config) -> None: + def __init__(self, app: AppWrapper, config: Config, loop: asyncio.AbstractEventLoop) -> None: self.app = app self.config = config self.startup = asyncio.Event() self.shutdown = asyncio.Event() self.app_queue: asyncio.Queue = asyncio.Queue(config.max_app_queue_size) self.supported = True + self.loop = loop # This mimics the Trio nursery.start task_status and is # required to ensure the support has been checked before @@ -29,7 +31,9 @@ async def handle_lifespan(self) -> None: self._started.set() scope: LifespanScope = {"type": "lifespan", "asgi": {"spec_version": "2.0"}} try: - await invoke_asgi(self.app, scope, self.asgi_receive, self.asgi_send) + await self.app( + scope, self.asgi_receive, self.asgi_send, partial(self.loop.run_in_executor, None) + ) except LifespanFailureError: # Lifespan failures should crash the server raise diff --git a/src/hypercorn/asyncio/run.py b/src/hypercorn/asyncio/run.py index f05d7ce..e69eed4 100644 --- a/src/hypercorn/asyncio/run.py +++ b/src/hypercorn/asyncio/run.py @@ -17,7 +17,7 @@ from .udp_server import UDPServer from .worker_context import WorkerContext from ..config import Config, Sockets -from ..typing import ASGIFramework +from ..typing import AppWrapper from ..utils import ( check_multiprocess_shutdown_event, load_application, @@ -48,7 +48,7 @@ def _share_socket(sock: socket) -> socket: async def worker_serve( - app: ASGIFramework, + app: AppWrapper, config: Config, *, sockets: Optional[Sockets] = None, @@ -74,7 +74,7 @@ def _signal_handler(*_: Any) -> None: # noqa: N803 shutdown_trigger = signal_event.wait # type: ignore - lifespan = Lifespan(app, config) + lifespan = Lifespan(app, config, loop) reload_ = False lifespan_task = loop.create_task(lifespan.handle_lifespan()) @@ -188,7 +188,7 @@ async def _server_callback(reader: asyncio.StreamReader, writer: asyncio.StreamW def asyncio_worker( config: Config, sockets: Optional[Sockets] = None, shutdown_event: Optional[EventType] = None ) -> None: - app = load_application(config.application_path) + app = load_application(config.application_path, config.wsgi_max_body_size) shutdown_trigger = None if shutdown_event is not None: @@ -214,7 +214,7 @@ def uvloop_worker( else: asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) - app = load_application(config.application_path) + app = load_application(config.application_path, config.wsgi_max_body_size) shutdown_trigger = None if shutdown_event is not None: diff --git a/src/hypercorn/asyncio/task_group.py b/src/hypercorn/asyncio/task_group.py index 42867fe..5c6f16a 100644 --- a/src/hypercorn/asyncio/task_group.py +++ b/src/hypercorn/asyncio/task_group.py @@ -2,23 +2,24 @@ import asyncio import weakref +from functools import partial from types import TracebackType from typing import Any, Awaitable, Callable, Optional from ..config import Config -from ..typing import ASGIFramework, ASGIReceiveCallable, ASGIReceiveEvent, ASGISendEvent, Scope -from ..utils import invoke_asgi +from ..typing import AppWrapper, ASGIReceiveCallable, ASGIReceiveEvent, ASGISendEvent, Scope async def _handle( - app: ASGIFramework, + app: AppWrapper, config: Config, scope: Scope, receive: ASGIReceiveCallable, send: Callable[[Optional[ASGISendEvent]], Awaitable[None]], + sync_spawn: Callable, ) -> None: try: - await invoke_asgi(app, scope, receive, send) + await app(scope, receive, send, sync_spawn) except asyncio.CancelledError: raise except Exception: @@ -35,13 +36,21 @@ def __init__(self, loop: asyncio.AbstractEventLoop) -> None: async def spawn_app( self, - app: ASGIFramework, + app: AppWrapper, config: Config, scope: Scope, send: Callable[[Optional[ASGISendEvent]], Awaitable[None]], ) -> Callable[[ASGIReceiveEvent], Awaitable[None]]: app_queue: asyncio.Queue[ASGIReceiveEvent] = asyncio.Queue(config.max_app_queue_size) - self.spawn(_handle, app, config, scope, app_queue.get, send) + self.spawn( + _handle, + app, + config, + scope, + app_queue.get, + send, + partial(self._loop.run_in_executor, None), + ) return app_queue.put def spawn(self, func: Callable, *args: Any) -> None: diff --git a/src/hypercorn/asyncio/tcp_server.py b/src/hypercorn/asyncio/tcp_server.py index b01de0d..91b3c05 100644 --- a/src/hypercorn/asyncio/tcp_server.py +++ b/src/hypercorn/asyncio/tcp_server.py @@ -9,7 +9,7 @@ from ..config import Config from ..events import Closed, Event, RawData, Updated from ..protocol import ProtocolWrapper -from ..typing import ASGIFramework +from ..typing import AppWrapper from ..utils import parse_socket_addr MAX_RECV = 2**16 @@ -18,7 +18,7 @@ class TCPServer: def __init__( self, - app: ASGIFramework, + app: AppWrapper, loop: asyncio.AbstractEventLoop, config: Config, context: WorkerContext, diff --git a/src/hypercorn/asyncio/udp_server.py b/src/hypercorn/asyncio/udp_server.py index e8f6dd2..629ab9f 100644 --- a/src/hypercorn/asyncio/udp_server.py +++ b/src/hypercorn/asyncio/udp_server.py @@ -7,7 +7,7 @@ from .worker_context import WorkerContext from ..config import Config from ..events import Event, RawData -from ..typing import ASGIFramework +from ..typing import AppWrapper from ..utils import parse_socket_addr if TYPE_CHECKING: @@ -18,7 +18,7 @@ class UDPServer(asyncio.DatagramProtocol): def __init__( self, - app: ASGIFramework, + app: AppWrapper, loop: asyncio.AbstractEventLoop, config: Config, context: WorkerContext, diff --git a/src/hypercorn/config.py b/src/hypercorn/config.py index 08487b1..1d17f0b 100644 --- a/src/hypercorn/config.py +++ b/src/hypercorn/config.py @@ -102,6 +102,7 @@ class Config: websocket_ping_interval: Optional[float] = None worker_class = "asyncio" workers = 1 + wsgi_max_body_size = 16 * 1024 * 1024 * BYTES def set_cert_reqs(self, value: int) -> None: warnings.warn("Please use verify_mode instead", Warning) diff --git a/src/hypercorn/middleware/dispatcher.py b/src/hypercorn/middleware/dispatcher.py index 40832b3..009541b 100644 --- a/src/hypercorn/middleware/dispatcher.py +++ b/src/hypercorn/middleware/dispatcher.py @@ -6,7 +6,6 @@ from ..asyncio.task_group import TaskGroup from ..typing import ASGIFramework, Scope -from ..utils import invoke_asgi MAX_QUEUE_SIZE = 10 @@ -22,7 +21,7 @@ async def __call__(self, scope: Scope, receive: Callable, send: Callable) -> Non for path, app in self.mounts.items(): if scope["path"].startswith(path): scope["path"] = scope["path"][len(path) :] or "/" - return await invoke_asgi(app, scope, receive, send) + return await app(scope, receive, send) await send( { "type": "http.response.start", @@ -47,7 +46,6 @@ async def _handle_lifespan(self, scope: Scope, receive: Callable, send: Callable async with TaskGroup(asyncio.get_event_loop()) as task_group: for path, app in self.mounts.items(): task_group.spawn( - invoke_asgi, app, scope, self.app_queues[path].get, @@ -83,7 +81,6 @@ async def _handle_lifespan(self, scope: Scope, receive: Callable, send: Callable async with trio.open_nursery() as nursery: for path, app in self.mounts.items(): nursery.start_soon( - invoke_asgi, app, scope, self.app_queues[path][1].receive, diff --git a/src/hypercorn/middleware/http_to_https.py b/src/hypercorn/middleware/http_to_https.py index 200b84d..542b28f 100644 --- a/src/hypercorn/middleware/http_to_https.py +++ b/src/hypercorn/middleware/http_to_https.py @@ -4,7 +4,6 @@ from urllib.parse import urlunsplit from ..typing import ASGIFramework, HTTPScope, Scope, WebsocketScope, WWWScope -from ..utils import invoke_asgi class HTTPToHTTPSRedirectMiddleware: @@ -24,7 +23,7 @@ async def __call__(self, scope: Scope, receive: Callable, send: Callable) -> Non else: await send({"type": "websocket.close"}) else: - return await invoke_asgi(self.app, scope, receive, send) + return await self.app(scope, receive, send) async def _send_http_redirect(self, scope: HTTPScope, send: Callable) -> None: new_url = self._new_url("https", scope) diff --git a/src/hypercorn/middleware/wsgi.py b/src/hypercorn/middleware/wsgi.py index 728409f..69cdb63 100644 --- a/src/hypercorn/middleware/wsgi.py +++ b/src/hypercorn/middleware/wsgi.py @@ -2,10 +2,10 @@ import asyncio from functools import partial -from io import BytesIO -from typing import Callable, Iterable, List, Optional, Tuple +from typing import Callable, Iterable -from ..typing import HTTPScope, Scope +from ..app_wrappers import WSGIWrapper +from ..typing import ASGIReceiveCallable, ASGISendCallable, Scope, WSGIFramework MAX_BODY_SIZE = 2**16 @@ -17,134 +17,28 @@ class InvalidPathError(Exception): class _WSGIMiddleware: - def __init__(self, wsgi_app: WSGICallable, max_body_size: int = MAX_BODY_SIZE) -> None: - self.wsgi_app = wsgi_app + def __init__(self, wsgi_app: WSGIFramework, max_body_size: int = MAX_BODY_SIZE) -> None: + self.wsgi_app = WSGIWrapper(wsgi_app, max_body_size) self.max_body_size = max_body_size - async def __call__(self, scope: Scope, receive: Callable, send: Callable) -> None: - if scope["type"] == "http": - status_code, headers, body = await self._handle_http(scope, receive, send) - await send({"type": "http.response.start", "status": status_code, "headers": headers}) - await send({"type": "http.response.body", "body": body}) - elif scope["type"] == "websocket": - await send({"type": "websocket.close"}) - elif scope["type"] == "lifespan": - return - else: - raise Exception(f"Unknown scope type, {scope['type']}") - - async def _handle_http( - self, scope: HTTPScope, receive: Callable, send: Callable - ) -> Tuple[int, list, bytes]: + async def __call__( + self, scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable + ) -> None: pass class AsyncioWSGIMiddleware(_WSGIMiddleware): - async def _handle_http( - self, scope: HTTPScope, receive: Callable, send: Callable - ) -> Tuple[int, list, bytes]: + async def __call__( + self, scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable + ) -> None: loop = asyncio.get_event_loop() - instance = _WSGIInstance(self.wsgi_app, self.max_body_size) - return await instance.handle_http(scope, receive, partial(loop.run_in_executor, None)) + await self.wsgi_app(scope, receive, send, partial(loop.run_in_executor, None)) class TrioWSGIMiddleware(_WSGIMiddleware): - async def _handle_http( - self, scope: HTTPScope, receive: Callable, send: Callable - ) -> Tuple[int, list, bytes]: - import trio - - instance = _WSGIInstance(self.wsgi_app, self.max_body_size) - return await instance.handle_http(scope, receive, trio.to_thread.run_sync) - - -class _WSGIInstance: - def __init__(self, wsgi_app: WSGICallable, max_body_size: int = MAX_BODY_SIZE) -> None: - self.wsgi_app = wsgi_app - self.max_body_size = max_body_size - self.status_code = 500 - self.headers: list = [] - - async def handle_http( - self, scope: HTTPScope, receive: Callable, spawn: Callable - ) -> Tuple[int, list, bytes]: - self.scope = scope - body = bytearray() - while True: - message = await receive() - body.extend(message.get("body", b"")) - if len(body) > self.max_body_size: - return 400, [], b"" - if not message.get("more_body"): - break - return await spawn(self.run_wsgi_app, body) - - def _start_response( - self, - status: str, - response_headers: List[Tuple[str, str]], - exc_info: Optional[Exception] = None, + async def __call__( + self, scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable ) -> None: - raw, _ = status.split(" ", 1) - self.status_code = int(raw) - self.headers = [ - (name.lower().encode("ascii"), value.encode("ascii")) - for name, value in response_headers - ] - - def run_wsgi_app(self, body: bytes) -> Tuple[int, list, bytes]: - try: - environ = _build_environ(self.scope, body) - except InvalidPathError: - return 404, self.headers, b"" - else: - body = bytearray() - for output in self.wsgi_app(environ, self._start_response): - body.extend(output) - return self.status_code, self.headers, body - - -def _build_environ(scope: HTTPScope, body: bytes) -> dict: - server = scope.get("server") or ("localhost", 80) - path = scope["path"] - script_name = scope.get("root_path", "") - if path.startswith(script_name): - path = path[len(script_name) :] - path = path if path != "" else "/" - else: - raise InvalidPathError() - - environ = { - "REQUEST_METHOD": scope["method"], - "SCRIPT_NAME": script_name.encode("utf8").decode("latin1"), - "PATH_INFO": path.encode("utf8").decode("latin1"), - "QUERY_STRING": scope["query_string"].decode("ascii"), - "SERVER_NAME": server[0], - "SERVER_PORT": server[1], - "SERVER_PROTOCOL": "HTTP/%s" % scope["http_version"], - "wsgi.version": (1, 0), - "wsgi.url_scheme": scope.get("scheme", "http"), - "wsgi.input": BytesIO(body), - "wsgi.errors": BytesIO(), - "wsgi.multithread": True, - "wsgi.multiprocess": True, - "wsgi.run_once": False, - } - - if "client" in scope: - environ["REMOTE_ADDR"] = scope["client"][0] + import trio - for raw_name, raw_value in scope.get("headers", []): - name = raw_name.decode("latin1") - if name == "content-length": - corrected_name = "CONTENT_LENGTH" - elif name == "content-type": - corrected_name = "CONTENT_TYPE" - else: - corrected_name = "HTTP_%s" % name.upper().replace("-", "_") - # HTTPbis say only ASCII chars are allowed in headers, but we latin1 just in case - value = raw_value.decode("latin1") - if corrected_name in environ: - value = environ[corrected_name] + "," + value # type: ignore - environ[corrected_name] = value - return environ + await self.wsgi_app(scope, receive, send, trio.to_thread.run_sync) diff --git a/src/hypercorn/protocol/__init__.py b/src/hypercorn/protocol/__init__.py index 794ad7e..3938568 100755 --- a/src/hypercorn/protocol/__init__.py +++ b/src/hypercorn/protocol/__init__.py @@ -6,13 +6,13 @@ from .h11 import H2CProtocolRequiredError, H2ProtocolAssumedError, H11Protocol from ..config import Config from ..events import Event, RawData -from ..typing import ASGIFramework, TaskGroup, WorkerContext +from ..typing import AppWrapper, TaskGroup, WorkerContext class ProtocolWrapper: def __init__( self, - app: ASGIFramework, + app: AppWrapper, config: Config, context: WorkerContext, task_group: TaskGroup, diff --git a/src/hypercorn/protocol/h11.py b/src/hypercorn/protocol/h11.py index d9cb85d..e18d488 100755 --- a/src/hypercorn/protocol/h11.py +++ b/src/hypercorn/protocol/h11.py @@ -20,7 +20,7 @@ from .ws_stream import WSStream from ..config import Config from ..events import Closed, Event, RawData, Updated -from ..typing import ASGIFramework, H11SendableEvent, TaskGroup, WorkerContext +from ..typing import AppWrapper, H11SendableEvent, TaskGroup, WorkerContext STREAM_ID = 1 @@ -80,7 +80,7 @@ def start_next_cycle(self) -> None: class H11Protocol: def __init__( self, - app: ASGIFramework, + app: AppWrapper, config: Config, context: WorkerContext, task_group: TaskGroup, diff --git a/src/hypercorn/protocol/h2.py b/src/hypercorn/protocol/h2.py index 92ab043..6e76d49 100755 --- a/src/hypercorn/protocol/h2.py +++ b/src/hypercorn/protocol/h2.py @@ -23,7 +23,7 @@ from .ws_stream import WSStream from ..config import Config from ..events import Closed, Event, RawData, Updated -from ..typing import ASGIFramework, Event as IOEvent, TaskGroup, WorkerContext +from ..typing import AppWrapper, Event as IOEvent, TaskGroup, WorkerContext from ..utils import filter_pseudo_headers BUFFER_HIGH_WATER = 2 * 2**14 # Twice the default max frame size (two frames worth) @@ -80,7 +80,7 @@ async def pop(self, max_length: int) -> bytes: class H2Protocol: def __init__( self, - app: ASGIFramework, + app: AppWrapper, config: Config, context: WorkerContext, task_group: TaskGroup, diff --git a/src/hypercorn/protocol/h3.py b/src/hypercorn/protocol/h3.py index 77cc87d..88d9a4d 100644 --- a/src/hypercorn/protocol/h3.py +++ b/src/hypercorn/protocol/h3.py @@ -22,14 +22,14 @@ from .http_stream import HTTPStream from .ws_stream import WSStream from ..config import Config -from ..typing import ASGIFramework, TaskGroup, WorkerContext +from ..typing import AppWrapper, TaskGroup, WorkerContext from ..utils import filter_pseudo_headers class H3Protocol: def __init__( self, - app: ASGIFramework, + app: AppWrapper, config: Config, context: WorkerContext, task_group: TaskGroup, diff --git a/src/hypercorn/protocol/http_stream.py b/src/hypercorn/protocol/http_stream.py index 4f50f89..6cd9bee 100644 --- a/src/hypercorn/protocol/http_stream.py +++ b/src/hypercorn/protocol/http_stream.py @@ -8,7 +8,7 @@ from .events import Body, EndBody, Event, InformationalResponse, Request, Response, StreamClosed from ..config import Config from ..typing import ( - ASGIFramework, + AppWrapper, ASGISendEvent, HTTPResponseStartEvent, HTTPScope, @@ -38,7 +38,7 @@ class ASGIHTTPState(Enum): class HTTPStream: def __init__( self, - app: ASGIFramework, + app: AppWrapper, config: Config, context: WorkerContext, task_group: TaskGroup, @@ -76,7 +76,7 @@ async def handle(self, event: Event) -> None: self.scope = { "type": "http", "http_version": event.http_version, - "asgi": {"spec_version": "2.1"}, + "asgi": {"spec_version": "2.1", "version": "3.0"}, "method": event.method, "scheme": self.scheme, "path": unquote(path.decode("ascii")), diff --git a/src/hypercorn/protocol/quic.py b/src/hypercorn/protocol/quic.py index f73807c..3d16e54 100644 --- a/src/hypercorn/protocol/quic.py +++ b/src/hypercorn/protocol/quic.py @@ -22,13 +22,13 @@ from .h3 import H3Protocol from ..config import Config from ..events import Closed, Event, RawData -from ..typing import ASGIFramework, TaskGroup, WorkerContext +from ..typing import AppWrapper, TaskGroup, WorkerContext class QuicProtocol: def __init__( self, - app: ASGIFramework, + app: AppWrapper, config: Config, context: WorkerContext, task_group: TaskGroup, diff --git a/src/hypercorn/protocol/ws_stream.py b/src/hypercorn/protocol/ws_stream.py index 5c670c4..5b8ac74 100644 --- a/src/hypercorn/protocol/ws_stream.py +++ b/src/hypercorn/protocol/ws_stream.py @@ -23,7 +23,7 @@ from .events import Body, Data, EndBody, EndData, Event, Request, Response, StreamClosed from ..config import Config from ..typing import ( - ASGIFramework, + AppWrapper, ASGISendEvent, TaskGroup, WebsocketAcceptEvent, @@ -163,7 +163,7 @@ def to_message(self) -> dict: class WSStream: def __init__( self, - app: ASGIFramework, + app: AppWrapper, config: Config, context: WorkerContext, task_group: TaskGroup, @@ -207,7 +207,7 @@ async def handle(self, event: Event) -> None: path, _, query_string = event.raw_path.partition(b"?") self.scope = { "type": "websocket", - "asgi": {"spec_version": "2.3"}, + "asgi": {"spec_version": "2.3", "version": "3.0"}, "scheme": self.scheme, "http_version": event.http_version, "path": unquote(path.decode("ascii")), diff --git a/src/hypercorn/trio/__init__.py b/src/hypercorn/trio/__init__.py index a0aa291..76691d9 100644 --- a/src/hypercorn/trio/__init__.py +++ b/src/hypercorn/trio/__init__.py @@ -6,6 +6,7 @@ import trio from .run import worker_serve +from ..app_wrappers import ASGIWrapper from ..config import Config from ..typing import ASGIFramework @@ -41,4 +42,6 @@ async def serve( if config.workers != 1: warnings.warn("The config `workers` has no affect when using serve", Warning) - await worker_serve(app, config, shutdown_trigger=shutdown_trigger, task_status=task_status) + await worker_serve( + ASGIWrapper(app), config, shutdown_trigger=shutdown_trigger, task_status=task_status + ) diff --git a/src/hypercorn/trio/lifespan.py b/src/hypercorn/trio/lifespan.py index 94d4780..06eef81 100644 --- a/src/hypercorn/trio/lifespan.py +++ b/src/hypercorn/trio/lifespan.py @@ -3,8 +3,8 @@ import trio from ..config import Config -from ..typing import ASGIFramework, ASGIReceiveEvent, ASGISendEvent, LifespanScope -from ..utils import invoke_asgi, LifespanFailureError, LifespanTimeoutError +from ..typing import AppWrapper, ASGIReceiveEvent, ASGISendEvent, LifespanScope +from ..utils import LifespanFailureError, LifespanTimeoutError class UnexpectedMessageError(Exception): @@ -12,7 +12,7 @@ class UnexpectedMessageError(Exception): class Lifespan: - def __init__(self, app: ASGIFramework, config: Config) -> None: + def __init__(self, app: AppWrapper, config: Config) -> None: self.app = app self.config = config self.startup = trio.Event() @@ -28,7 +28,7 @@ async def handle_lifespan( task_status.started() scope: LifespanScope = {"type": "lifespan", "asgi": {"spec_version": "2.0"}} try: - await invoke_asgi(self.app, scope, self.asgi_receive, self.asgi_send) + await self.app(scope, self.asgi_receive, self.asgi_send, trio.to_thread.run_sync) except LifespanFailureError: # Lifespan failures should crash the server raise diff --git a/src/hypercorn/trio/run.py b/src/hypercorn/trio/run.py index 09c7333..886b526 100644 --- a/src/hypercorn/trio/run.py +++ b/src/hypercorn/trio/run.py @@ -12,7 +12,7 @@ from .udp_server import UDPServer from .worker_context import WorkerContext from ..config import Config, Sockets -from ..typing import ASGIFramework +from ..typing import AppWrapper from ..utils import ( check_multiprocess_shutdown_event, load_application, @@ -26,7 +26,7 @@ async def worker_serve( - app: ASGIFramework, + app: AppWrapper, config: Config, *, sockets: Optional[Sockets] = None, @@ -121,7 +121,7 @@ def trio_worker( sock.listen(config.backlog) for sock in sockets.insecure_sockets: sock.listen(config.backlog) - app = load_application(config.application_path) + app = load_application(config.application_path, config.wsgi_max_body_size) shutdown_trigger = None if shutdown_event is not None: diff --git a/src/hypercorn/trio/task_group.py b/src/hypercorn/trio/task_group.py index 35e9932..2ddf40e 100644 --- a/src/hypercorn/trio/task_group.py +++ b/src/hypercorn/trio/task_group.py @@ -6,19 +6,19 @@ import trio from ..config import Config -from ..typing import ASGIFramework, ASGIReceiveCallable, ASGIReceiveEvent, ASGISendEvent, Scope -from ..utils import invoke_asgi +from ..typing import AppWrapper, ASGIReceiveCallable, ASGIReceiveEvent, ASGISendEvent, Scope async def _handle( - app: ASGIFramework, + app: AppWrapper, config: Config, scope: Scope, receive: ASGIReceiveCallable, send: Callable[[Optional[ASGISendEvent]], Awaitable[None]], + sync_spawn: Callable, ) -> None: try: - await invoke_asgi(app, scope, receive, send) + await app(scope, receive, send, sync_spawn) except trio.Cancelled: raise except trio.MultiError as error: @@ -43,13 +43,15 @@ def __init__(self) -> None: async def spawn_app( self, - app: ASGIFramework, + app: AppWrapper, config: Config, scope: Scope, send: Callable[[Optional[ASGISendEvent]], Awaitable[None]], ) -> Callable[[ASGIReceiveEvent], Awaitable[None]]: app_send_channel, app_receive_channel = trio.open_memory_channel(config.max_app_queue_size) - self._nursery.start_soon(_handle, app, config, scope, app_receive_channel.receive, send) + self._nursery.start_soon( + _handle, app, config, scope, app_receive_channel.receive, send, trio.to_thread.run_sync + ) return app_send_channel.send def spawn(self, func: Callable, *args: Any) -> None: diff --git a/src/hypercorn/trio/tcp_server.py b/src/hypercorn/trio/tcp_server.py index f162e51..3419440 100644 --- a/src/hypercorn/trio/tcp_server.py +++ b/src/hypercorn/trio/tcp_server.py @@ -10,7 +10,7 @@ from ..config import Config from ..events import Closed, Event, RawData, Updated from ..protocol import ProtocolWrapper -from ..typing import ASGIFramework +from ..typing import AppWrapper from ..utils import parse_socket_addr MAX_RECV = 2**16 @@ -18,7 +18,7 @@ class TCPServer: def __init__( - self, app: ASGIFramework, config: Config, context: WorkerContext, stream: trio.abc.Stream + self, app: AppWrapper, config: Config, context: WorkerContext, stream: trio.abc.Stream ) -> None: self.app = app self.config = config diff --git a/src/hypercorn/trio/udp_server.py b/src/hypercorn/trio/udp_server.py index 43451e9..b8d4530 100644 --- a/src/hypercorn/trio/udp_server.py +++ b/src/hypercorn/trio/udp_server.py @@ -6,7 +6,7 @@ from .worker_context import WorkerContext from ..config import Config from ..events import Event, RawData -from ..typing import ASGIFramework +from ..typing import AppWrapper from ..utils import parse_socket_addr MAX_RECV = 2**16 @@ -15,7 +15,7 @@ class UDPServer: def __init__( self, - app: ASGIFramework, + app: AppWrapper, config: Config, context: WorkerContext, socket: trio.socket.socket, diff --git a/src/hypercorn/typing.py b/src/hypercorn/typing.py index 8e4b8b2..8d676ae 100644 --- a/src/hypercorn/typing.py +++ b/src/hypercorn/typing.py @@ -202,19 +202,7 @@ class LifespanShutdownFailedEvent(TypedDict): ASGIReceiveCallable = Callable[[], Awaitable[ASGIReceiveEvent]] ASGISendCallable = Callable[[ASGISendEvent], Awaitable[None]] - -class ASGI2Protocol(Protocol): - # Should replace with a Protocol when PEP 544 is accepted. - - def __init__(self, scope: Scope) -> None: - ... - - async def __call__(self, receive: ASGIReceiveCallable, send: ASGISendCallable) -> None: - ... - - -ASGI2Framework = Type[ASGI2Protocol] -ASGI3Framework = Callable[ +ASGIFramework = Callable[ [ Scope, ASGIReceiveCallable, @@ -222,7 +210,8 @@ async def __call__(self, receive: ASGIReceiveCallable, send: ASGISendCallable) - ], Awaitable[None], ] -ASGIFramework = Union[ASGI2Framework, ASGI3Framework] +WSGIFramework = Callable[[dict, Callable], Iterable[bytes]] +Framework = Union[ASGIFramework, WSGIFramework] class H2SyncStream(Protocol): @@ -308,7 +297,7 @@ def time() -> float: class TaskGroup(Protocol): async def spawn_app( self, - app: ASGIFramework, + app: AppWrapper, config: Config, scope: Scope, send: Callable[[Optional[ASGISendEvent]], Awaitable[None]], @@ -328,3 +317,14 @@ async def __aexit__(self, exc_type: type, exc_value: BaseException, tb: Tracebac class ResponseSummary(TypedDict): status: int headers: Iterable[Tuple[bytes, bytes]] + + +class AppWrapper(Protocol): + async def __call__( + self, + scope: Scope, + receive: ASGIReceiveCallable, + send: ASGISendCallable, + sync_spawn: Callable, + ) -> None: + ... diff --git a/src/hypercorn/utils.py b/src/hypercorn/utils.py index 6e7de45..1d020a8 100644 --- a/src/hypercorn/utils.py +++ b/src/hypercorn/utils.py @@ -9,28 +9,11 @@ from importlib import import_module from multiprocessing.synchronize import Event as EventType from pathlib import Path -from typing import ( - Any, - Awaitable, - Callable, - cast, - Dict, - Iterable, - List, - Optional, - Tuple, - TYPE_CHECKING, -) +from typing import Any, Awaitable, Callable, Dict, Iterable, List, Optional, Tuple, TYPE_CHECKING +from .app_wrappers import ASGIWrapper, WSGIWrapper from .config import Config -from .typing import ( - ASGI2Framework, - ASGI3Framework, - ASGIFramework, - ASGIReceiveCallable, - ASGISendCallable, - Scope, -) +from .typing import AppWrapper if TYPE_CHECKING: from .protocol.events import Request @@ -99,13 +82,16 @@ def filter_pseudo_headers(headers: List[Tuple[bytes, bytes]]) -> List[Tuple[byte return filtered_headers -def load_application(path: str) -> ASGIFramework: - try: - module_name, app_name = path.split(":", 1) - except ValueError: +def load_application(path: str, wsgi_max_body_size: int) -> AppWrapper: + mode = None + if ":" not in path: module_name, app_name = path, "app" - except AttributeError: - raise NoAppError() + elif path.count(":") == 2: + mode, module_name, app_name = path.split(":", 2) + if mode not in {"asgi", "wsgi"}: + raise ValueError("Invalid mode, must be 'asgi', or 'wsgi'") + else: + module_name, app_name = path.split(":", 1) module_path = Path(module_name).resolve() sys.path.insert(0, str(module_path.parent)) @@ -120,11 +106,17 @@ def load_application(path: str) -> ASGIFramework: raise NoAppError() else: raise - try: - return eval(app_name, vars(module)) + app = eval(app_name, vars(module)) except NameError: raise NoAppError() + else: + if mode is None: + mode = "asgi" if is_asgi(app) else "wsgi" + if mode == "asgi": + return ASGIWrapper(app) + else: + return WSGIWrapper(app, wsgi_max_body_size) async def observe_changes(sleep: Callable[[float], Awaitable[Any]]) -> None: @@ -232,30 +224,6 @@ def repr_socket_addr(family: int, address: tuple) -> str: return f"{address}" -async def invoke_asgi( - app: ASGIFramework, scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable -) -> None: - if _is_asgi_2(app): - scope["asgi"]["version"] = "2.0" - app = cast(ASGI2Framework, app) - asgi_instance = app(scope) - await asgi_instance(receive, send) - else: - scope["asgi"]["version"] = "3.0" - app = cast(ASGI3Framework, app) - await app(scope, receive, send) - - -def _is_asgi_2(app: ASGIFramework) -> bool: - if inspect.isclass(app): - return True - - if hasattr(app, "__call__") and inspect.iscoroutinefunction(app.__call__): # type: ignore - return False - - return not inspect.iscoroutinefunction(app) - - def valid_server_name(config: Config, request: "Request") -> bool: if len(config.server_names) == 0: return True @@ -266,3 +234,11 @@ def valid_server_name(config: Config, request: "Request") -> bool: host = value.decode() break return host in config.server_names + + +def is_asgi(app: Any) -> bool: + if inspect.iscoroutinefunction(app): + return True + elif hasattr(app, "__call__"): + return inspect.iscoroutinefunction(app.__call__) + return False diff --git a/tests/asyncio/test_keep_alive.py b/tests/asyncio/test_keep_alive.py index e1cac77..318d72d 100644 --- a/tests/asyncio/test_keep_alive.py +++ b/tests/asyncio/test_keep_alive.py @@ -1,40 +1,44 @@ from __future__ import annotations import asyncio -from typing import AsyncGenerator, Callable +from typing import AsyncGenerator import h11 import pytest import pytest_asyncio +from hypercorn.app_wrappers import ASGIWrapper from hypercorn.asyncio.tcp_server import TCPServer from hypercorn.asyncio.worker_context import WorkerContext from hypercorn.config import Config +from hypercorn.typing import ASGIReceiveCallable, ASGISendCallable, Scope from .helpers import MemoryReader, MemoryWriter KEEP_ALIVE_TIMEOUT = 0.01 REQUEST = h11.Request(method="GET", target="/", headers=[(b"host", b"hypercorn")]) -async def slow_framework(scope: dict, receive: Callable, send: Callable) -> None: +async def slow_framework( + scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable +) -> None: while True: event = await receive() if event["type"] == "http.disconnect": break elif event["type"] == "lifespan.startup": - await send({"type": "lifspan.startup.complete"}) + await send({"type": "lifspan.startup.complete"}) # type: ignore elif event["type"] == "lifespan.shutdown": - await send({"type": "lifspan.shutdown.complete"}) + await send({"type": "lifspan.shutdown.complete"}) # type: ignore elif event["type"] == "http.request" and not event.get("more_body", False): await asyncio.sleep(2 * KEEP_ALIVE_TIMEOUT) await send( - { + { # type: ignore "type": "http.response.start", "status": 200, "headers": [(b"content-length", b"0")], } ) - await send({"type": "http.response.body", "body": b"", "more_body": False}) + await send({"type": "http.response.body", "body": b"", "more_body": False}) # type: ignore # noqa: E501 break @@ -43,7 +47,12 @@ async def _server(event_loop: asyncio.AbstractEventLoop) -> AsyncGenerator[TCPSe config = Config() config.keep_alive_timeout = KEEP_ALIVE_TIMEOUT server = TCPServer( - slow_framework, event_loop, config, WorkerContext(), MemoryReader(), MemoryWriter() # type: ignore # noqa: E501 + ASGIWrapper(slow_framework), + event_loop, + config, + WorkerContext(), + MemoryReader(), # type: ignore + MemoryWriter(), # type: ignore ) task = event_loop.create_task(server.run()) yield server diff --git a/tests/asyncio/test_lifespan.py b/tests/asyncio/test_lifespan.py index 64217b4..c59a395 100644 --- a/tests/asyncio/test_lifespan.py +++ b/tests/asyncio/test_lifespan.py @@ -6,6 +6,7 @@ import pytest +from hypercorn.app_wrappers import ASGIWrapper from hypercorn.asyncio.lifespan import Lifespan from hypercorn.config import Config from hypercorn.typing import Scope @@ -22,7 +23,7 @@ async def no_lifespan_app(scope: Scope, receive: Callable, send: Callable) -> No async def test_ensure_no_race_condition(event_loop: asyncio.AbstractEventLoop) -> None: config = Config() config.startup_timeout = 0.2 - lifespan = Lifespan(no_lifespan_app, config) + lifespan = Lifespan(ASGIWrapper(no_lifespan_app), config, event_loop) task = event_loop.create_task(lifespan.handle_lifespan()) await lifespan.wait_for_startup() # Raises if there is a race condition await task @@ -32,7 +33,7 @@ async def test_ensure_no_race_condition(event_loop: asyncio.AbstractEventLoop) - async def test_startup_timeout_error(event_loop: asyncio.AbstractEventLoop) -> None: config = Config() config.startup_timeout = 0.01 - lifespan = Lifespan(SlowLifespanFramework(0.02, asyncio.sleep), config) # type: ignore + lifespan = Lifespan(ASGIWrapper(SlowLifespanFramework(0.02, asyncio.sleep)), config, event_loop) task = event_loop.create_task(lifespan.handle_lifespan()) with pytest.raises(LifespanTimeoutError) as exc_info: await lifespan.wait_for_startup() @@ -42,7 +43,7 @@ async def test_startup_timeout_error(event_loop: asyncio.AbstractEventLoop) -> N @pytest.mark.asyncio async def test_startup_failure(event_loop: asyncio.AbstractEventLoop) -> None: - lifespan = Lifespan(lifespan_failure, Config()) + lifespan = Lifespan(ASGIWrapper(lifespan_failure), Config(), event_loop) lifespan_task = event_loop.create_task(lifespan.handle_lifespan()) await lifespan.wait_for_startup() assert lifespan_task.done() @@ -57,7 +58,7 @@ async def return_app(scope: Scope, receive: Callable, send: Callable) -> None: @pytest.mark.asyncio async def test_lifespan_return(event_loop: asyncio.AbstractEventLoop) -> None: - lifespan = Lifespan(return_app, Config()) + lifespan = Lifespan(ASGIWrapper(return_app), Config(), event_loop) lifespan_task = event_loop.create_task(lifespan.handle_lifespan()) await lifespan.wait_for_startup() await lifespan.wait_for_shutdown() diff --git a/tests/asyncio/test_sanity.py b/tests/asyncio/test_sanity.py index 8d2aa0f..287cd06 100644 --- a/tests/asyncio/test_sanity.py +++ b/tests/asyncio/test_sanity.py @@ -7,6 +7,7 @@ import pytest import wsproto +from hypercorn.app_wrappers import ASGIWrapper from hypercorn.asyncio.tcp_server import TCPServer from hypercorn.asyncio.worker_context import WorkerContext from hypercorn.config import Config @@ -17,7 +18,12 @@ @pytest.mark.asyncio async def test_http1_request(event_loop: asyncio.AbstractEventLoop) -> None: server = TCPServer( - sanity_framework, event_loop, Config(), WorkerContext(), MemoryReader(), MemoryWriter() # type: ignore # noqa: E501 + ASGIWrapper(sanity_framework), + event_loop, + Config(), + WorkerContext(), + MemoryReader(), # type: ignore + MemoryWriter(), # type: ignore ) task = event_loop.create_task(server.run()) client = h11.Connection(h11.CLIENT) @@ -69,7 +75,12 @@ async def test_http1_request(event_loop: asyncio.AbstractEventLoop) -> None: @pytest.mark.asyncio async def test_http1_websocket(event_loop: asyncio.AbstractEventLoop) -> None: server = TCPServer( - sanity_framework, event_loop, Config(), WorkerContext(), MemoryReader(), MemoryWriter() # type: ignore # noqa: E501 + ASGIWrapper(sanity_framework), + event_loop, + Config(), + WorkerContext(), + MemoryReader(), # type: ignore + MemoryWriter(), # type: ignore ) task = event_loop.create_task(server.run()) client = wsproto.WSConnection(wsproto.ConnectionType.CLIENT) @@ -101,7 +112,7 @@ async def test_http1_websocket(event_loop: asyncio.AbstractEventLoop) -> None: @pytest.mark.asyncio async def test_http2_request(event_loop: asyncio.AbstractEventLoop) -> None: server = TCPServer( - sanity_framework, + ASGIWrapper(sanity_framework), event_loop, Config(), WorkerContext(), @@ -164,7 +175,7 @@ async def test_http2_request(event_loop: asyncio.AbstractEventLoop) -> None: @pytest.mark.asyncio async def test_http2_websocket(event_loop: asyncio.AbstractEventLoop) -> None: server = TCPServer( - sanity_framework, + ASGIWrapper(sanity_framework), event_loop, Config(), WorkerContext(), diff --git a/tests/asyncio/test_task_group.py b/tests/asyncio/test_task_group.py index c11d75f..48266db 100644 --- a/tests/asyncio/test_task_group.py +++ b/tests/asyncio/test_task_group.py @@ -5,6 +5,7 @@ import pytest +from hypercorn.app_wrappers import ASGIWrapper from hypercorn.asyncio.task_group import TaskGroup from hypercorn.config import Config from hypercorn.typing import HTTPScope, Scope @@ -21,7 +22,9 @@ async def _echo_app(scope: Scope, receive: Callable, send: Callable) -> None: app_queue: asyncio.Queue = asyncio.Queue() async with TaskGroup(event_loop) as task_group: - put = await task_group.spawn_app(_echo_app, Config(), http_scope, app_queue.put) + put = await task_group.spawn_app( + ASGIWrapper(_echo_app), Config(), http_scope, app_queue.put + ) await put({"type": "http.disconnect"}) # type: ignore assert (await app_queue.get()) == {"type": "http.disconnect"} await put(None) @@ -36,7 +39,7 @@ async def _error_app(scope: Scope, receive: Callable, send: Callable) -> None: app_queue: asyncio.Queue = asyncio.Queue() async with TaskGroup(event_loop) as task_group: - await task_group.spawn_app(_error_app, Config(), http_scope, app_queue.put) + await task_group.spawn_app(ASGIWrapper(_error_app), Config(), http_scope, app_queue.put) assert (await app_queue.get()) is None @@ -50,5 +53,5 @@ async def _error_app(scope: Scope, receive: Callable, send: Callable) -> None: app_queue: asyncio.Queue = asyncio.Queue() with pytest.raises(asyncio.CancelledError): async with TaskGroup(event_loop) as task_group: - await task_group.spawn_app(_error_app, Config(), http_scope, app_queue.put) + await task_group.spawn_app(ASGIWrapper(_error_app), Config(), http_scope, app_queue.put) assert (await app_queue.get()) is None diff --git a/tests/asyncio/test_tcp_server.py b/tests/asyncio/test_tcp_server.py index 959daac..f4915de 100644 --- a/tests/asyncio/test_tcp_server.py +++ b/tests/asyncio/test_tcp_server.py @@ -4,6 +4,7 @@ import pytest +from hypercorn.app_wrappers import ASGIWrapper from hypercorn.asyncio.tcp_server import TCPServer from hypercorn.asyncio.worker_context import WorkerContext from hypercorn.config import Config @@ -14,7 +15,12 @@ @pytest.mark.asyncio async def test_completes_on_closed(event_loop: asyncio.AbstractEventLoop) -> None: server = TCPServer( - echo_framework, event_loop, Config(), WorkerContext(), MemoryReader(), MemoryWriter() # type: ignore # noqa: E501 + ASGIWrapper(echo_framework), + event_loop, + Config(), + WorkerContext(), + MemoryReader(), # type: ignore + MemoryWriter(), # type: ignore ) server.reader.close() # type: ignore await server.run() @@ -25,7 +31,12 @@ async def test_completes_on_closed(event_loop: asyncio.AbstractEventLoop) -> Non @pytest.mark.asyncio async def test_complets_on_half_close(event_loop: asyncio.AbstractEventLoop) -> None: server = TCPServer( - echo_framework, event_loop, Config(), WorkerContext(), MemoryReader(), MemoryWriter() # type: ignore # noqa: E501 + ASGIWrapper(echo_framework), + event_loop, + Config(), + WorkerContext(), + MemoryReader(), # type: ignore + MemoryWriter(), # type: ignore ) task = event_loop.create_task(server.run()) await server.reader.send(b"GET / HTTP/1.1\r\nHost: hypercorn\r\n\r\n") # type: ignore diff --git a/tests/helpers.py b/tests/helpers.py index 988464e..cdac68c 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -5,7 +5,7 @@ from socket import AF_INET from typing import Callable, cast, Tuple -from hypercorn.typing import Scope, WWWScope +from hypercorn.typing import ASGIReceiveCallable, ASGISendCallable, Scope, WWWScope SANITY_BODY = b"Hello Hypercorn" @@ -26,15 +26,19 @@ async def empty_framework(scope: Scope, receive: Callable, send: Callable) -> No class SlowLifespanFramework: - def __init__(self, delay: int, sleep: Callable) -> None: + def __init__(self, delay: float, sleep: Callable) -> None: self.delay = delay self.sleep = sleep - async def __call__(self, scope: dict, receive: Callable, send: Callable) -> None: + async def __call__( + self, scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable + ) -> None: await self.sleep(self.delay) -async def echo_framework(input_scope: Scope, receive: Callable, send: Callable) -> None: +async def echo_framework( + input_scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable +) -> None: input_scope = cast(WWWScope, input_scope) scope = deepcopy(input_scope) scope["query_string"] = scope["query_string"].decode() # type: ignore @@ -54,41 +58,45 @@ async def echo_framework(input_scope: Scope, receive: Callable, send: Callable) response = dumps({"scope": scope, "request_body": body.decode()}).encode() content_length = len(response) await send( - { + { # type: ignore "type": "http.response.start", "status": 200, "headers": [(b"content-length", str(content_length).encode())], } ) - await send({"type": "http.response.body", "body": response, "more_body": False}) + await send({"type": "http.response.body", "body": response, "more_body": False}) # type: ignore # noqa: E501 break elif event["type"] == "websocket.connect": - await send({"type": "websocket.accept"}) + await send({"type": "websocket.accept"}) # type: ignore elif event["type"] == "websocket.receive": await send({"type": "websocket.send", "text": event["text"], "bytes": event["bytes"]}) -async def lifespan_failure(scope: Scope, receive: Callable, send: Callable) -> None: +async def lifespan_failure( + scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable +) -> None: while True: message = await receive() if message["type"] == "lifespan.startup": - await send({"type": "lifespan.startup.failed", "message": "Failure"}) + await send({"type": "lifespan.startup.failed", "message": "Failure"}) # type: ignore break -async def sanity_framework(scope: Scope, receive: Callable, send: Callable) -> None: +async def sanity_framework( + scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable +) -> None: body = b"" if scope["type"] == "websocket": - await send({"type": "websocket.accept"}) + await send({"type": "websocket.accept"}) # type: ignore while True: event = await receive() if event["type"] in {"http.disconnect", "websocket.disconnect"}: break elif event["type"] == "lifespan.startup": - await send({"type": "lifspan.startup.complete"}) + await send({"type": "lifspan.startup.complete"}) # type: ignore elif event["type"] == "lifespan.shutdown": - await send({"type": "lifspan.shutdown.complete"}) + await send({"type": "lifspan.shutdown.complete"}) # type: ignore elif event["type"] == "http.request" and event.get("more_body", False): body += event["body"] elif event["type"] == "http.request" and not event.get("more_body", False): @@ -97,14 +105,14 @@ async def sanity_framework(scope: Scope, receive: Callable, send: Callable) -> N response = b"Hello & Goodbye" content_length = len(response) await send( - { + { # type: ignore "type": "http.response.start", "status": 200, "headers": [(b"content-length", str(content_length).encode())], } ) - await send({"type": "http.response.body", "body": response, "more_body": False}) + await send({"type": "http.response.body", "body": response, "more_body": False}) # type: ignore # noqa: E501 break elif event["type"] == "websocket.receive": assert event["bytes"] == SANITY_BODY - await send({"type": "websocket.send", "text": "Hello & Goodbye"}) + await send({"type": "websocket.send", "text": "Hello & Goodbye"}) # type: ignore diff --git a/tests/protocol/test_http_stream.py b/tests/protocol/test_http_stream.py index 23337d4..3cb2ad7 100644 --- a/tests/protocol/test_http_stream.py +++ b/tests/protocol/test_http_stream.py @@ -49,7 +49,7 @@ async def test_handle_request_http_1(stream: HTTPStream, http_version: str) -> N assert scope == { "type": "http", "http_version": http_version, - "asgi": {"spec_version": "2.1"}, + "asgi": {"spec_version": "2.1", "version": "3.0"}, "method": "GET", "scheme": "http", "path": "/", @@ -73,7 +73,7 @@ async def test_handle_request_http_2(stream: HTTPStream) -> None: assert scope == { "type": "http", "http_version": "2", - "asgi": {"spec_version": "2.1"}, + "asgi": {"spec_version": "2.1", "version": "3.0"}, "method": "GET", "scheme": "http", "path": "/", diff --git a/tests/protocol/test_ws_stream.py b/tests/protocol/test_ws_stream.py index 8af8f24..f927cf5 100644 --- a/tests/protocol/test_ws_stream.py +++ b/tests/protocol/test_ws_stream.py @@ -188,7 +188,7 @@ async def test_handle_request(stream: WSStream) -> None: scope = stream.task_group.spawn_app.call_args[0][2] # type: ignore assert scope == { "type": "websocket", - "asgi": {"spec_version": "2.3"}, + "asgi": {"spec_version": "2.3", "version": "3.0"}, "scheme": "ws", "http_version": "2", "path": "/", diff --git a/tests/middleware/test_wsgi.py b/tests/test_app_wrappers.py similarity index 83% rename from tests/middleware/test_wsgi.py rename to tests/test_app_wrappers.py index c0b9747..f63612d 100644 --- a/tests/middleware/test_wsgi.py +++ b/tests/test_app_wrappers.py @@ -1,14 +1,14 @@ from __future__ import annotations import asyncio +from functools import partial from typing import Callable, List import pytest import trio -from hypercorn.middleware import AsyncioWSGIMiddleware, TrioWSGIMiddleware -from hypercorn.middleware.wsgi import _build_environ, InvalidPathError -from hypercorn.typing import HTTPScope +from hypercorn.app_wrappers import _build_environ, InvalidPathError, WSGIWrapper +from hypercorn.typing import ASGISendEvent, HTTPScope def echo_body(environ: dict, start_response: Callable) -> List[bytes]: @@ -24,7 +24,7 @@ def echo_body(environ: dict, start_response: Callable) -> List[bytes]: @pytest.mark.trio async def test_wsgi_trio() -> None: - middleware = TrioWSGIMiddleware(echo_body) + app = WSGIWrapper(echo_body, 2**16) scope: HTTPScope = { "http_version": "1.1", "asgi": {}, @@ -45,11 +45,11 @@ async def test_wsgi_trio() -> None: messages = [] - async def _send(message: dict) -> None: + async def _send(message: ASGISendEvent) -> None: nonlocal messages messages.append(message) - await middleware(scope, receive_channel.receive, _send) + await app(scope, receive_channel.receive, _send, trio.to_thread.run_sync) assert messages == [ { "headers": [(b"content-type", b"text/plain; charset=utf-8"), (b"content-length", b"0")], @@ -61,8 +61,8 @@ async def _send(message: dict) -> None: @pytest.mark.asyncio -async def test_wsgi_asyncio() -> None: - middleware = AsyncioWSGIMiddleware(echo_body) +async def test_wsgi_asyncio(event_loop: asyncio.AbstractEventLoop) -> None: + app = WSGIWrapper(echo_body, 2**16) scope: HTTPScope = { "http_version": "1.1", "asgi": {}, @@ -83,11 +83,11 @@ async def test_wsgi_asyncio() -> None: messages = [] - async def _send(message: dict) -> None: + async def _send(message: ASGISendEvent) -> None: nonlocal messages messages.append(message) - await middleware(scope, queue.get, _send) + await app(scope, queue.get, _send, partial(event_loop.run_in_executor, None)) assert messages == [ { "headers": [(b"content-type", b"text/plain; charset=utf-8"), (b"content-length", b"0")], @@ -99,8 +99,8 @@ async def _send(message: dict) -> None: @pytest.mark.asyncio -async def test_max_body_size() -> None: - middleware = AsyncioWSGIMiddleware(echo_body, max_body_size=4) +async def test_max_body_size(event_loop: asyncio.AbstractEventLoop) -> None: + app = WSGIWrapper(echo_body, 4) scope: HTTPScope = { "http_version": "1.1", "asgi": {}, @@ -120,11 +120,11 @@ async def test_max_body_size() -> None: await queue.put({"type": "http.request", "body": b"abcde"}) messages = [] - async def _send(message: dict) -> None: + async def _send(message: ASGISendEvent) -> None: nonlocal messages messages.append(message) - await middleware(scope, queue.get, _send) + await app(scope, queue.get, _send, partial(event_loop.run_in_executor, None)) assert messages == [ {"headers": [], "status": 400, "type": "http.response.start"}, {"body": bytearray(b""), "type": "http.response.body"}, diff --git a/tests/test_utils.py b/tests/test_utils.py index e589e95..632161a 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,58 +1,26 @@ from __future__ import annotations -from typing import Callable +from typing import Any, Callable, Iterable import pytest -import hypercorn.utils -from hypercorn.typing import ASGIFramework, HTTPScope, Scope +from hypercorn.typing import Scope +from hypercorn.utils import ( + build_and_validate_headers, + filter_pseudo_headers, + is_asgi, + suppress_body, +) @pytest.mark.parametrize( "method, status, expected", [("HEAD", 200, True), ("GET", 200, False), ("GET", 101, True)] ) def test_suppress_body(method: str, status: int, expected: bool) -> None: - assert hypercorn.utils.suppress_body(method, status) is expected - - -@pytest.mark.asyncio -async def test_invoke_asgi_3(http_scope: HTTPScope) -> None: - result: Scope = {} # type: ignore - - async def asgi3_callable(scope: Scope, receive: Callable, send: Callable) -> None: - nonlocal result - result = scope - - await hypercorn.utils.invoke_asgi(asgi3_callable, http_scope, None, None) - assert result["asgi"]["version"] == "3.0" - - -@pytest.mark.asyncio -async def test_invoke_asgi_2(http_scope: HTTPScope) -> None: - result: Scope = {} # type: ignore - - def asgi2_callable(scope: Scope) -> Callable: - nonlocal result - result = scope - - async def inner(receive: Callable, send: Callable) -> None: - pass - - return inner - - await hypercorn.utils.invoke_asgi(asgi2_callable, http_scope, None, None) # type: ignore - assert result["asgi"]["version"] == "2.0" + assert suppress_body(method, status) is expected -class ASGI2Class: - def __init__(self, scope: Scope) -> None: - pass - - async def __call__(self, receive: Callable, send: Callable) -> None: - pass - - -class ASGI3ClassInstance: +class ASGIClassInstance: def __init__(self) -> None: pass @@ -60,49 +28,54 @@ async def __call__(self, scope: Scope, receive: Callable, send: Callable) -> Non pass -def asgi2_callable(scope: Scope) -> Callable: - async def inner(receive: Callable, send: Callable) -> None: +async def asgi_callable(scope: Scope, receive: Callable, send: Callable) -> None: + pass + + +class WSGIClassInstance: + def __init__(self) -> None: pass - return inner + def __call__(self, environ: dict, start_response: Callable) -> Iterable[bytes]: + pass -async def asgi3_callable(scope: Scope, receive: Callable, send: Callable) -> None: +def wsgi_callable(environ: dict, start_response: Callable) -> Iterable[bytes]: pass @pytest.mark.parametrize( - "app, is_asgi_2", + "app, expected", [ - (ASGI2Class, True), - (ASGI3ClassInstance(), False), - (asgi2_callable, True), - (asgi3_callable, False), + (WSGIClassInstance(), False), + (ASGIClassInstance(), True), + (wsgi_callable, False), + (asgi_callable, True), ], ) -def test__is_asgi_2(app: ASGIFramework, is_asgi_2: bool) -> None: - assert hypercorn.utils._is_asgi_2(app) == is_asgi_2 +def test_is_asgi(app: Any, expected: bool) -> None: + assert is_asgi(app) == expected def test_build_and_validate_headers_validate() -> None: with pytest.raises(TypeError): - hypercorn.utils.build_and_validate_headers([("string", "string")]) # type: ignore + build_and_validate_headers([("string", "string")]) # type: ignore def test_build_and_validate_headers_pseudo() -> None: with pytest.raises(ValueError): - hypercorn.utils.build_and_validate_headers([(b":authority", b"quart")]) + build_and_validate_headers([(b":authority", b"quart")]) def test_filter_pseudo_headers() -> None: - result = hypercorn.utils.filter_pseudo_headers( + result = filter_pseudo_headers( [(b":authority", b"quart"), (b":path", b"/"), (b"user-agent", b"something")] ) assert result == [(b"host", b"quart"), (b"user-agent", b"something")] def test_filter_pseudo_headers_no_authority() -> None: - result = hypercorn.utils.filter_pseudo_headers( + result = filter_pseudo_headers( [(b"host", b"quart"), (b":path", b"/"), (b"user-agent", b"something")] ) assert result == [(b"host", b"quart"), (b"user-agent", b"something")] diff --git a/tests/trio/test_keep_alive.py b/tests/trio/test_keep_alive.py index 989c571..d30d82d 100644 --- a/tests/trio/test_keep_alive.py +++ b/tests/trio/test_keep_alive.py @@ -6,6 +6,7 @@ import pytest import trio +from hypercorn.app_wrappers import ASGIWrapper from hypercorn.config import Config from hypercorn.trio.tcp_server import TCPServer from hypercorn.trio.worker_context import WorkerContext @@ -46,7 +47,7 @@ def _client_stream( config.keep_alive_timeout = KEEP_ALIVE_TIMEOUT client_stream, server_stream = trio.testing.memory_stream_pair() server_stream.socket = MockSocket() - server = TCPServer(slow_framework, config, WorkerContext(), server_stream) + server = TCPServer(ASGIWrapper(slow_framework), config, WorkerContext(), server_stream) nursery.start_soon(server.run) yield client_stream diff --git a/tests/trio/test_lifespan.py b/tests/trio/test_lifespan.py index afa1f83..dd8ab77 100644 --- a/tests/trio/test_lifespan.py +++ b/tests/trio/test_lifespan.py @@ -3,6 +3,7 @@ import pytest import trio +from hypercorn.app_wrappers import ASGIWrapper from hypercorn.config import Config from hypercorn.trio.lifespan import Lifespan from hypercorn.utils import LifespanFailureError, LifespanTimeoutError @@ -13,7 +14,7 @@ async def test_startup_timeout_error(nursery: trio._core._run.Nursery) -> None: config = Config() config.startup_timeout = 0.01 - lifespan = Lifespan(SlowLifespanFramework(0.02, trio.sleep), config) # type: ignore + lifespan = Lifespan(ASGIWrapper(SlowLifespanFramework(0.02, trio.sleep)), config) nursery.start_soon(lifespan.handle_lifespan) with pytest.raises(LifespanTimeoutError) as exc_info: await lifespan.wait_for_startup() @@ -22,7 +23,7 @@ async def test_startup_timeout_error(nursery: trio._core._run.Nursery) -> None: @pytest.mark.trio async def test_startup_failure() -> None: - lifespan = Lifespan(lifespan_failure, Config()) + lifespan = Lifespan(ASGIWrapper(lifespan_failure), Config()) with pytest.raises(LifespanFailureError) as exc_info: async with trio.open_nursery() as lifespan_nursery: await lifespan_nursery.start(lifespan.handle_lifespan) diff --git a/tests/trio/test_sanity.py b/tests/trio/test_sanity.py index 929762a..3828e37 100644 --- a/tests/trio/test_sanity.py +++ b/tests/trio/test_sanity.py @@ -8,6 +8,7 @@ import trio import wsproto +from hypercorn.app_wrappers import ASGIWrapper from hypercorn.config import Config from hypercorn.trio.tcp_server import TCPServer from hypercorn.trio.worker_context import WorkerContext @@ -24,7 +25,7 @@ async def test_http1_request(nursery: trio._core._run.Nursery) -> None: client_stream, server_stream = trio.testing.memory_stream_pair() server_stream.socket = MockSocket() - server = TCPServer(sanity_framework, Config(), WorkerContext(), server_stream) + server = TCPServer(ASGIWrapper(sanity_framework), Config(), WorkerContext(), server_stream) nursery.start_soon(server.run) client = h11.Connection(h11.CLIENT) await client_stream.send_all( @@ -75,7 +76,7 @@ async def test_http1_request(nursery: trio._core._run.Nursery) -> None: async def test_http1_websocket(nursery: trio._core._run.Nursery) -> None: client_stream, server_stream = trio.testing.memory_stream_pair() server_stream.socket = MockSocket() - server = TCPServer(sanity_framework, Config(), WorkerContext(), server_stream) + server = TCPServer(ASGIWrapper(sanity_framework), Config(), WorkerContext(), server_stream) nursery.start_soon(server.run) client = wsproto.WSConnection(wsproto.ConnectionType.CLIENT) await client_stream.send_all(client.send(wsproto.events.Request(host="hypercorn", target="/"))) @@ -102,7 +103,7 @@ async def test_http2_request(nursery: trio._core._run.Nursery) -> None: server_stream.transport_stream = Mock(return_value=PropertyMock(return_value=MockSocket())) server_stream.do_handshake = AsyncMock() server_stream.selected_alpn_protocol = Mock(return_value="h2") - server = TCPServer(sanity_framework, Config(), WorkerContext(), server_stream) + server = TCPServer(ASGIWrapper(sanity_framework), Config(), WorkerContext(), server_stream) nursery.start_soon(server.run) client = h2.connection.H2Connection() client.initiate_connection() @@ -157,7 +158,7 @@ async def test_http2_websocket(nursery: trio._core._run.Nursery) -> None: server_stream.transport_stream = Mock(return_value=PropertyMock(return_value=MockSocket())) server_stream.do_handshake = AsyncMock() server_stream.selected_alpn_protocol = Mock(return_value="h2") - server = TCPServer(sanity_framework, Config(), WorkerContext(), server_stream) + server = TCPServer(ASGIWrapper(sanity_framework), Config(), WorkerContext(), server_stream) nursery.start_soon(server.run) h2_client = h2.connection.H2Connection() h2_client.initiate_connection() From 5b2aa954e44534bc0dd6fcebac11eec9dbaf480b Mon Sep 17 00:00:00 2001 From: pgjones Date: Sun, 28 Aug 2022 18:05:43 +0100 Subject: [PATCH 022/151] Switch compliance servers from ASGI2 to ASGI3 The former is no longer supported. --- .github/workflows/ci.yml | 4 +-- compliance/autobahn/server.py | 41 ++++++++++++--------------- compliance/h2spec/server.py | 53 ++++++++++++++++------------------- 3 files changed, 44 insertions(+), 54 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9debb7d..efa9b7f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,7 +67,7 @@ jobs: - name: Run server working-directory: compliance/h2spec - run: nohup hypercorn --keyfile key.pem --certfile cert.pem -k ${{ matrix.worker }} server:App & + run: nohup hypercorn --keyfile key.pem --certfile cert.pem -k ${{ matrix.worker }} server:app & - name: Download h2spec run: | @@ -103,7 +103,7 @@ jobs: - name: Run server working-directory: compliance/autobahn - run: nohup hypercorn -k ${{ matrix.worker }} server:App & + run: nohup hypercorn -k ${{ matrix.worker }} server:app & - name: Run server working-directory: compliance/autobahn diff --git a/compliance/autobahn/server.py b/compliance/autobahn/server.py index 5bd5d0d..adcd08d 100644 --- a/compliance/autobahn/server.py +++ b/compliance/autobahn/server.py @@ -1,23 +1,18 @@ -class App: - - def __init__(self, scope): - pass - - async def __call__(self, receive, send): - while True: - event = await receive() - if event['type'] == 'websocket.disconnect': - break - elif event['type'] == 'websocket.connect': - await send({'type': 'websocket.accept'}) - elif event['type'] == 'websocket.receive': - await send({ - 'type': 'websocket.send', - 'bytes': event['bytes'], - 'text': event['text'], - }) - elif event['type'] == 'lifespan.startup': - await send({'type': 'lifespan.startup.complete'}) - elif event['type'] == 'lifespan.shutdown': - await send({'type': 'lifespan.shutdown.complete'}) - break +async def __call__(scope, receive, send): + while True: + event = await receive() + if event['type'] == 'websocket.disconnect': + break + elif event['type'] == 'websocket.connect': + await send({'type': 'websocket.accept'}) + elif event['type'] == 'websocket.receive': + await send({ + 'type': 'websocket.send', + 'bytes': event['bytes'], + 'text': event['text'], + }) + elif event['type'] == 'lifespan.startup': + await send({'type': 'lifespan.startup.complete'}) + elif event['type'] == 'lifespan.shutdown': + await send({'type': 'lifespan.shutdown.complete'}) + break diff --git a/compliance/h2spec/server.py b/compliance/h2spec/server.py index 2f87ce3..3c3a433 100644 --- a/compliance/h2spec/server.py +++ b/compliance/h2spec/server.py @@ -1,30 +1,25 @@ -class App: +async def app(scope, receive, send): + while True: + event = await receive() + if event['type'] == 'http.disconnect': + break + elif event['type'] == 'http.request' and not event.get('more_body', False): + await send_data(send) + break + elif event['type'] == 'lifespan.startup': + await send({'type': 'lifespan.startup.complete'}) + elif event['type'] == 'lifespan.shutdown': + await send({'type': 'lifespan.shutdown.complete'}) + break - def __init__(self, scope): - pass - - async def __call__(self, receive, send): - while True: - event = await receive() - if event['type'] == 'http.disconnect': - break - elif event['type'] == 'http.request' and not event.get('more_body', False): - await self.send_data(send) - break - elif event['type'] == 'lifespan.startup': - await send({'type': 'lifespan.startup.complete'}) - elif event['type'] == 'lifespan.shutdown': - await send({'type': 'lifespan.shutdown.complete'}) - break - - async def send_data(self, send): - await send({ - 'type': 'http.response.start', - 'status': 200, - 'headers': [(b'content-length', b'5')], - }) - await send({ - 'type': 'http.response.body', - 'body': b'Hello', - 'more_body': False, - }) +async def send_data(send): + await send({ + 'type': 'http.response.start', + 'status': 200, + 'headers': [(b'content-length', b'5')], + }) + await send({ + 'type': 'http.response.body', + 'body': b'Hello', + 'more_body': False, + }) From 05f70202faa5eab9d19f0d401b2e4da3ffdbd9ba Mon Sep 17 00:00:00 2001 From: pgjones Date: Sun, 28 Aug 2022 18:10:03 +0100 Subject: [PATCH 023/151] Update docs to refer to the logger class Rather than the outdated access logger class. --- docs/how_to_guides/logging.rst | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/how_to_guides/logging.rst b/docs/how_to_guides/logging.rst index 158e110..9e16e07 100644 --- a/docs/how_to_guides/logging.rst +++ b/docs/how_to_guides/logging.rst @@ -68,10 +68,10 @@ p process ID {Variable}e environment variable =========== =========== -Customising the access logger ------------------------------ +Customising the logger +---------------------- -The acces logger class can be customised by changing the -``access_logger_class`` attribute of the ``Config`` class. This is -only possible when using the python based configuration file. The -``hypercorn.logging.AccessLogger`` class is used by default. +The logger class can be customised by changing the ``logger_class`` +attribute of the ``Config`` class. This is only possible when using +the python based configuration file. The +``hypercorn.logging.Logger`` class is used by default. From eff8fb0ebf539840ca5e1a3eb7de5b84edc6f144 Mon Sep 17 00:00:00 2001 From: pgjones Date: Sun, 28 Aug 2022 18:14:56 +0100 Subject: [PATCH 024/151] Fix 5b2aa954e44534bc0dd6fcebac11eec9dbaf480b I was too lazy with this change and didn't check. --- compliance/autobahn/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compliance/autobahn/server.py b/compliance/autobahn/server.py index adcd08d..1d22ea2 100644 --- a/compliance/autobahn/server.py +++ b/compliance/autobahn/server.py @@ -1,4 +1,4 @@ -async def __call__(scope, receive, send): +async def app(scope, receive, send): while True: event = await receive() if event['type'] == 'websocket.disconnect': From 67ff9c974f781e33965fe887af6b01810974d69e Mon Sep 17 00:00:00 2001 From: pgjones Date: Mon, 29 Aug 2022 13:30:07 +0100 Subject: [PATCH 025/151] Alter the process and reloading system Rather than using a single process with the reloader and restarting the entire program there is now a main process that checks for changes and restarts the process workers if so. This should resolve the various bugs trying to restart the entire program. A downside is that there is now always at least two processes and it is harder to use a reloader when using the API method to serve an app. Note the application must be loaded in the main process so that the observer knows what files to check for changes. --- src/hypercorn/asyncio/run.py | 12 ------ src/hypercorn/run.py | 78 ++++++++++++++++++++---------------- src/hypercorn/trio/run.py | 14 ------- src/hypercorn/utils.py | 53 ++++-------------------- 4 files changed, 51 insertions(+), 106 deletions(-) diff --git a/src/hypercorn/asyncio/run.py b/src/hypercorn/asyncio/run.py index e69eed4..b50b9c6 100644 --- a/src/hypercorn/asyncio/run.py +++ b/src/hypercorn/asyncio/run.py @@ -21,11 +21,8 @@ from ..utils import ( check_multiprocess_shutdown_event, load_application, - MustReloadError, - observe_changes, raise_shutdown, repr_socket_addr, - restart, ShutdownError, ) @@ -75,7 +72,6 @@ def _signal_handler(*_: Any) -> None: # noqa: N803 shutdown_trigger = signal_event.wait # type: ignore lifespan = Lifespan(app, config, loop) - reload_ = False lifespan_task = loop.create_task(lifespan.handle_lifespan()) await lifespan.wait_for_startup() @@ -143,17 +139,12 @@ async def _server_callback(reader: asyncio.StreamReader, writer: asyncio.StreamW tasks.append(loop.create_task(raise_shutdown(shutdown_trigger))) - if config.use_reloader: - tasks.append(loop.create_task(observe_changes(asyncio.sleep))) - try: if len(tasks): gathered_tasks = asyncio.gather(*tasks) await gathered_tasks else: loop.run_forever() - except MustReloadError: - reload_ = True except (ShutdownError, KeyboardInterrupt): pass finally: @@ -181,9 +172,6 @@ async def _server_callback(reader: asyncio.StreamReader, writer: asyncio.StreamW lifespan_task.cancel() await lifespan_task - if reload_: - restart() - def asyncio_worker( config: Config, sockets: Optional[Sockets] = None, shutdown_event: Optional[EventType] = None diff --git a/src/hypercorn/run.py b/src/hypercorn/run.py index 4dd067e..ab3ecf0 100644 --- a/src/hypercorn/run.py +++ b/src/hypercorn/run.py @@ -4,11 +4,12 @@ import signal import time from multiprocessing import Event, Process -from typing import Any +from multiprocessing.synchronize import Event as EventType +from typing import Any, List -from .config import Config +from .config import Config, Sockets from .typing import WorkerFunc -from .utils import write_pid_file +from .utils import load_application, wait_for_changes, write_pid_file def run(config: Config) -> None: @@ -31,44 +32,36 @@ def run(config: Config) -> None: else: raise ValueError(f"No worker of class {config.worker_class} exists") - if config.workers == 1: - worker_func(config) - else: - run_multiple(config, worker_func) - - -def run_multiple(config: Config, worker_func: WorkerFunc) -> None: - if config.use_reloader: - raise RuntimeError("Reloader can only be used with a single worker") - sockets = config.create_sockets() - processes = [] + active = True + while active: + # Ignore SIGINT before creating the processes, so that they + # inherit the signal handling. This means that the shutdown + # function controls the shutdown. + signal.signal(signal.SIGINT, signal.SIG_IGN) - # Ignore SIGINT before creating the processes, so that they - # inherit the signal handling. This means that the shutdown - # function controls the shutdown. - signal.signal(signal.SIGINT, signal.SIG_IGN) + shutdown_event = Event() + processes = start_processes(config, worker_func, sockets, shutdown_event) - shutdown_event = Event() + def shutdown(*args: Any) -> None: + nonlocal active, shutdown_event + shutdown_event.set() + active = False - for _ in range(config.workers): - process = Process( - target=worker_func, - kwargs={"config": config, "shutdown_event": shutdown_event, "sockets": sockets}, - ) - process.daemon = True - process.start() - processes.append(process) - if platform.system() == "Windows": - time.sleep(0.1) + for signal_name in {"SIGINT", "SIGTERM", "SIGBREAK"}: + if hasattr(signal, signal_name): + signal.signal(getattr(signal, signal_name), shutdown) - def shutdown(*args: Any) -> None: - shutdown_event.set() + if config.use_reloader: + # Reload the application so that the correct (new) paths + # are checked for changes. + load_application(config.application_path, config.wsgi_max_body_size) - for signal_name in {"SIGINT", "SIGTERM", "SIGBREAK"}: - if hasattr(signal, signal_name): - signal.signal(getattr(signal, signal_name), shutdown) + wait_for_changes(shutdown_event) + shutdown_event.set() + else: + active = False for process in processes: process.join() @@ -79,3 +72,20 @@ def shutdown(*args: Any) -> None: sock.close() for sock in sockets.insecure_sockets: sock.close() + + +def start_processes( + config: Config, worker_func: WorkerFunc, sockets: Sockets, shutdown_event: EventType +) -> List[Process]: + processes = [] + for _ in range(config.workers): + process = Process( + target=worker_func, + kwargs={"config": config, "shutdown_event": shutdown_event, "sockets": sockets}, + ) + process.daemon = True + process.start() + processes.append(process) + if platform.system() == "Windows": + time.sleep(0.1) + return processes diff --git a/src/hypercorn/trio/run.py b/src/hypercorn/trio/run.py index 886b526..5dfbf91 100644 --- a/src/hypercorn/trio/run.py +++ b/src/hypercorn/trio/run.py @@ -16,11 +16,8 @@ from ..utils import ( check_multiprocess_shutdown_event, load_application, - MustReloadError, - observe_changes, raise_shutdown, repr_socket_addr, - restart, ShutdownError, ) @@ -36,7 +33,6 @@ async def worker_serve( config.set_statsd_logger_class(StatsdLogger) lifespan = Lifespan(app, config) - reload_ = False context = WorkerContext() async with trio.open_nursery() as lifespan_nursery: @@ -80,9 +76,6 @@ async def worker_serve( task_status.started(binds) try: async with trio.open_nursery() as nursery: - if config.use_reloader: - nursery.start_soon(observe_changes, trio.sleep) - if shutdown_trigger is not None: nursery.start_soon(raise_shutdown, shutdown_trigger) @@ -96,10 +89,6 @@ async def worker_serve( ) await trio.sleep_forever() - except trio.MultiError as error: - reload_ = any(isinstance(exc, MustReloadError) for exc in error.exceptions) - except MustReloadError: - reload_ = True except (ShutdownError, KeyboardInterrupt): pass finally: @@ -109,9 +98,6 @@ async def worker_serve( await lifespan.wait_for_shutdown() lifespan_nursery.cancel_scope.cancel() - if reload_: - restart() - def trio_worker( config: Config, sockets: Optional[Sockets] = None, shutdown_event: Optional[EventType] = None diff --git a/src/hypercorn/utils.py b/src/hypercorn/utils.py index 1d020a8..3b88c2b 100644 --- a/src/hypercorn/utils.py +++ b/src/hypercorn/utils.py @@ -2,9 +2,9 @@ import inspect import os -import platform import socket import sys +import time from enum import Enum from importlib import import_module from multiprocessing.synchronize import Event as EventType @@ -23,10 +23,6 @@ class ShutdownError(Exception): pass -class MustReloadError(Exception): - pass - - class NoAppError(Exception): pass @@ -119,7 +115,7 @@ def load_application(path: str, wsgi_max_body_size: int) -> AppWrapper: return WSGIWrapper(app, wsgi_max_body_size) -async def observe_changes(sleep: Callable[[float], Awaitable[Any]]) -> None: +def wait_for_changes(shutdown_event: EventType) -> None: last_updates: Dict[Path, float] = {} for module in list(sys.modules.values()): filename = getattr(module, "__file__", None) @@ -131,60 +127,25 @@ async def observe_changes(sleep: Callable[[float], Awaitable[Any]]) -> None: except (FileNotFoundError, NotADirectoryError): pass - while True: - await sleep(1) + while not shutdown_event.is_set(): + time.sleep(1) for index, (path, last_mtime) in enumerate(last_updates.items()): if index % 10 == 0: # Yield to the event loop - await sleep(0) + time.sleep(0) try: mtime = path.stat().st_mtime except FileNotFoundError: - # File deleted - raise MustReloadError() + return else: if mtime > last_mtime: - raise MustReloadError() + return else: last_updates[path] = mtime -def restart() -> None: - # Restart this process (only safe for dev/debug) - executable = sys.executable - script_path = Path(sys.argv[0]).resolve() - args = sys.argv[1:] - main_package = sys.modules["__main__"].__package__ - - if main_package is None: - # Executed by filename - if platform.system() == "Windows": - if not script_path.exists() and script_path.with_suffix(".exe").exists(): - # quart run - executable = str(script_path.with_suffix(".exe")) - else: - # python run.py - args.append(str(script_path)) - else: - if script_path.is_file() and os.access(script_path, os.X_OK): - # hypercorn run:app --reload - executable = str(script_path) - else: - # python run.py - args.append(str(script_path)) - else: - # Executed as a module e.g. python -m run - module = script_path.stem - import_name = main_package - if module != "__main__": - import_name = f"{main_package}.{module}" - args[:0] = ["-m", import_name.lstrip(".")] - - os.execv(executable, [executable] + args) - - async def raise_shutdown(shutdown_event: Callable[..., Awaitable[None]]) -> None: await shutdown_event() raise ShutdownError() From 707c655e1e5bc45b00817af8010bdad10b3b810c Mon Sep 17 00:00:00 2001 From: pgjones Date: Mon, 29 Aug 2022 13:46:06 +0100 Subject: [PATCH 026/151] Move the load_application call outside of the loop As it doesn't reload it is pointless to load it again. --- src/hypercorn/run.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/hypercorn/run.py b/src/hypercorn/run.py index ab3ecf0..3f3629d 100644 --- a/src/hypercorn/run.py +++ b/src/hypercorn/run.py @@ -34,6 +34,10 @@ def run(config: Config) -> None: sockets = config.create_sockets() + # Load the application so that the correct paths are checked for + # changes. + load_application(config.application_path, config.wsgi_max_body_size) + active = True while active: # Ignore SIGINT before creating the processes, so that they @@ -54,10 +58,6 @@ def shutdown(*args: Any) -> None: signal.signal(getattr(signal, signal_name), shutdown) if config.use_reloader: - # Reload the application so that the correct (new) paths - # are checked for changes. - load_application(config.application_path, config.wsgi_max_body_size) - wait_for_changes(shutdown_event) shutdown_event.set() else: From 10f968687d12ba2ebe66585bf2258f84f1b2edaa Mon Sep 17 00:00:00 2001 From: pgjones Date: Mon, 29 Aug 2022 14:02:59 +0100 Subject: [PATCH 027/151] Allow WSGI apps to be served programmatically This extends the new functionality to the programatic usage. --- docs/how_to_guides/api_usage.rst | 11 +++++++-- src/hypercorn/asyncio/__init__.py | 22 ++++++++++-------- src/hypercorn/trio/__init__.py | 15 ++++++++---- src/hypercorn/utils.py | 38 +++++++++++++++++++++++-------- 4 files changed, 60 insertions(+), 26 deletions(-) diff --git a/docs/how_to_guides/api_usage.rst b/docs/how_to_guides/api_usage.rst index abdb8d5..31246ff 100644 --- a/docs/how_to_guides/api_usage.rst +++ b/docs/how_to_guides/api_usage.rst @@ -18,8 +18,8 @@ Config instance, config = Config() config.bind = ["localhost:8080"] # As an example configuration setting -Then assuming you have an ASGI framework instance called ``app``, -using asyncio, +Then assuming you have an ASGI or WSGI framework instance called +``app``, using asyncio, .. code-block:: python @@ -115,3 +115,10 @@ exception handler, loop.default_exception_handler(context) loop.set_exception_handler(_exception_handler) + +Forcing ASGI or WSGI mode +------------------------- + +The ``serve`` function takes a ``mode`` argument that can be +``"asgi"`` or ``"wsgi"`` to force the app to be considered ASGI or +WSGI as required. diff --git a/src/hypercorn/asyncio/__init__.py b/src/hypercorn/asyncio/__init__.py index 6e2a515..aff61e8 100644 --- a/src/hypercorn/asyncio/__init__.py +++ b/src/hypercorn/asyncio/__init__.py @@ -1,24 +1,25 @@ from __future__ import annotations import warnings -from typing import Awaitable, Callable, Optional +from typing import Awaitable, Callable, Literal, Optional from .run import worker_serve -from ..app_wrappers import ASGIWrapper from ..config import Config -from ..typing import ASGIFramework +from ..typing import Framework +from ..utils import wrap_app async def serve( - app: ASGIFramework, + app: Framework, config: Config, *, shutdown_trigger: Optional[Callable[..., Awaitable[None]]] = None, + mode: Optional[Literal["asgi", "wsgi"]] = None, ) -> None: - """Serve an ASGI framework app given the config. + """Serve an ASGI or WSGI framework app given the config. - This allows for a programmatic way to serve an ASGI framework, it - can be used via, + This allows for a programmatic way to serve an ASGI or WSGI + framework, it can be used via, .. code-block:: python @@ -29,14 +30,17 @@ async def serve( setup or process setup are ignored. Arguments: - app: The ASGI application to serve. + app: The ASGI or WSGI application to serve. config: A Hypercorn configuration object. shutdown_trigger: This should return to trigger a graceful shutdown. + mode: Specify if the app is WSGI or ASGI. """ if config.debug: warnings.warn("The config `debug` has no affect when using serve", Warning) if config.workers != 1: warnings.warn("The config `workers` has no affect when using serve", Warning) - await worker_serve(ASGIWrapper(app), config, shutdown_trigger=shutdown_trigger) + await worker_serve( + wrap_app(app, config.wsgi_max_body_size, mode), config, shutdown_trigger=shutdown_trigger + ) diff --git a/src/hypercorn/trio/__init__.py b/src/hypercorn/trio/__init__.py index 76691d9..44a2eb9 100644 --- a/src/hypercorn/trio/__init__.py +++ b/src/hypercorn/trio/__init__.py @@ -1,22 +1,23 @@ from __future__ import annotations import warnings -from typing import Awaitable, Callable, Optional +from typing import Awaitable, Callable, Literal, Optional import trio from .run import worker_serve -from ..app_wrappers import ASGIWrapper from ..config import Config -from ..typing import ASGIFramework +from ..typing import Framework +from ..utils import wrap_app async def serve( - app: ASGIFramework, + app: Framework, config: Config, *, shutdown_trigger: Optional[Callable[..., Awaitable[None]]] = None, task_status: trio._core._run._TaskStatus = trio.TASK_STATUS_IGNORED, + mode: Optional[Literal["asgi", "wsgi"]] = None, ) -> None: """Serve an ASGI framework app given the config. @@ -36,6 +37,7 @@ async def serve( config: A Hypercorn configuration object. shutdown_trigger: This should return to trigger a graceful shutdown. + mode: Specify if the app is WSGI or ASGI. """ if config.debug: warnings.warn("The config `debug` has no affect when using serve", Warning) @@ -43,5 +45,8 @@ async def serve( warnings.warn("The config `workers` has no affect when using serve", Warning) await worker_serve( - ASGIWrapper(app), config, shutdown_trigger=shutdown_trigger, task_status=task_status + wrap_app(app, config.wsgi_max_body_size, mode), + config, + shutdown_trigger=shutdown_trigger, + task_status=task_status, ) diff --git a/src/hypercorn/utils.py b/src/hypercorn/utils.py index 3b88c2b..3762abf 100644 --- a/src/hypercorn/utils.py +++ b/src/hypercorn/utils.py @@ -9,11 +9,23 @@ from importlib import import_module from multiprocessing.synchronize import Event as EventType from pathlib import Path -from typing import Any, Awaitable, Callable, Dict, Iterable, List, Optional, Tuple, TYPE_CHECKING +from typing import ( + Any, + Awaitable, + Callable, + cast, + Dict, + Iterable, + List, + Literal, + Optional, + Tuple, + TYPE_CHECKING, +) from .app_wrappers import ASGIWrapper, WSGIWrapper from .config import Config -from .typing import AppWrapper +from .typing import AppWrapper, ASGIFramework, Framework, WSGIFramework if TYPE_CHECKING: from .protocol.events import Request @@ -79,11 +91,11 @@ def filter_pseudo_headers(headers: List[Tuple[bytes, bytes]]) -> List[Tuple[byte def load_application(path: str, wsgi_max_body_size: int) -> AppWrapper: - mode = None + mode: Optional[Literal["asgi", "wsgi"]] = None if ":" not in path: module_name, app_name = path, "app" elif path.count(":") == 2: - mode, module_name, app_name = path.split(":", 2) + mode, module_name, app_name = path.split(":", 2) # type: ignore if mode not in {"asgi", "wsgi"}: raise ValueError("Invalid mode, must be 'asgi', or 'wsgi'") else: @@ -107,12 +119,18 @@ def load_application(path: str, wsgi_max_body_size: int) -> AppWrapper: except NameError: raise NoAppError() else: - if mode is None: - mode = "asgi" if is_asgi(app) else "wsgi" - if mode == "asgi": - return ASGIWrapper(app) - else: - return WSGIWrapper(app, wsgi_max_body_size) + return wrap_app(app, wsgi_max_body_size, mode) + + +def wrap_app( + app: Framework, wsgi_max_body_size: int, mode: Optional[Literal["asgi", "wsgi"]] +) -> AppWrapper: + if mode is None: + mode = "asgi" if is_asgi(app) else "wsgi" + if mode == "asgi": + return ASGIWrapper(cast(ASGIFramework, app)) + else: + return WSGIWrapper(cast(WSGIFramework, app), wsgi_max_body_size) def wait_for_changes(shutdown_event: EventType) -> None: From 2a0deccf2c9f886fcdcb53b262bf1b69c817df89 Mon Sep 17 00:00:00 2001 From: pgjones Date: Mon, 29 Aug 2022 14:22:55 +0100 Subject: [PATCH 028/151] Bump and release 0.14.0 --- CHANGELOG.rst | 20 ++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index aff3f5b..07bd01c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,23 @@ +0.14.0 2022-08-29 +----------------- + +* Bugfix only recycle a HTTP/1.1 connection if client is DONE. +* Bugfix uvloop may raise a RuntimeError. +* Bugfix ensure 100ms sleep between Windows workers starting. +* Bugfix ensure lifespan shutdowns occur. +* Bugfix close idle Keep-Alive connections on graceful exit. +* Bugfix don't suppress 412 bodies. +* Bugfix don't idle close upgrade requests. +* Allow control over date header addition. +* Allow for logging configuration to be loaded from JSON or TOML + files. +* Preserve response headers casing for HTTP/1. +* Support the early hint ASGI-extension. +* Alter the process and reloading system such that it should work + correctly in all configurations. +* Directly support serving WSGI applications (and drop support for + ASGI-2, now ASGI-3 only). + 0.13.2 2021-12-23 ----------------- diff --git a/pyproject.toml b/pyproject.toml index 95add99..5f99096 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "Hypercorn" -version = "0.13.2+dev" +version = "0.14.0" description = "A ASGI Server based on Hyper libraries and inspired by Gunicorn" authors = ["pgjones "] classifiers = [ From 42455cbd30b702f62df330f9b6f46a5e0d4ad1fb Mon Sep 17 00:00:00 2001 From: pgjones Date: Mon, 29 Aug 2022 14:28:49 +0100 Subject: [PATCH 029/151] Following the release of 0.14.0 bump to +dev --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5f99096..a847b8a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "Hypercorn" -version = "0.14.0" +version = "0.14.0+dev" description = "A ASGI Server based on Hyper libraries and inspired by Gunicorn" authors = ["pgjones "] classifiers = [ From fcb768c9d1ff1844f976eba9217872cbe84d097b Mon Sep 17 00:00:00 2001 From: pgjones Date: Mon, 29 Aug 2022 14:34:11 +0100 Subject: [PATCH 030/151] Fix Python3.7 compatibility I no longer test 3.7 locally. --- src/hypercorn/asyncio/__init__.py | 7 ++++++- src/hypercorn/trio/__init__.py | 7 ++++++- src/hypercorn/utils.py | 6 +++++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/hypercorn/asyncio/__init__.py b/src/hypercorn/asyncio/__init__.py index aff61e8..3ae3b66 100644 --- a/src/hypercorn/asyncio/__init__.py +++ b/src/hypercorn/asyncio/__init__.py @@ -1,13 +1,18 @@ from __future__ import annotations import warnings -from typing import Awaitable, Callable, Literal, Optional +from typing import Awaitable, Callable, Optional from .run import worker_serve from ..config import Config from ..typing import Framework from ..utils import wrap_app +try: + from typing import Literal +except ImportError: + from typing_extensions import Literal # type: ignore + async def serve( app: Framework, diff --git a/src/hypercorn/trio/__init__.py b/src/hypercorn/trio/__init__.py index 44a2eb9..5575efe 100644 --- a/src/hypercorn/trio/__init__.py +++ b/src/hypercorn/trio/__init__.py @@ -1,7 +1,7 @@ from __future__ import annotations import warnings -from typing import Awaitable, Callable, Literal, Optional +from typing import Awaitable, Callable, Optional import trio @@ -10,6 +10,11 @@ from ..typing import Framework from ..utils import wrap_app +try: + from typing import Literal +except ImportError: + from typing_extensions import Literal # type: ignore + async def serve( app: Framework, diff --git a/src/hypercorn/utils.py b/src/hypercorn/utils.py index 3762abf..75722db 100644 --- a/src/hypercorn/utils.py +++ b/src/hypercorn/utils.py @@ -17,7 +17,6 @@ Dict, Iterable, List, - Literal, Optional, Tuple, TYPE_CHECKING, @@ -27,6 +26,11 @@ from .config import Config from .typing import AppWrapper, ASGIFramework, Framework, WSGIFramework +try: + from typing import Literal +except ImportError: + from typing_extensions import Literal # type: ignore + if TYPE_CHECKING: from .protocol.events import Request From acd713dc96a21a1c753ef1a3066cb2405d54f82c Mon Sep 17 00:00:00 2001 From: pgjones Date: Mon, 29 Aug 2022 14:35:27 +0100 Subject: [PATCH 031/151] Bump and release 0.14.1 --- CHANGELOG.rst | 5 +++++ pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 07bd01c..6f8b0e9 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,8 @@ +0.14.1 2022-08-29 +----------------- + +* Fix Python3.7 compatibility. + 0.14.0 2022-08-29 ----------------- diff --git a/pyproject.toml b/pyproject.toml index a847b8a..3839f54 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "Hypercorn" -version = "0.14.0+dev" +version = "0.14.1" description = "A ASGI Server based on Hyper libraries and inspired by Gunicorn" authors = ["pgjones "] classifiers = [ From 0f7eff4500ab1f4b1256dcb57acb89cc41742af3 Mon Sep 17 00:00:00 2001 From: pgjones Date: Mon, 29 Aug 2022 14:36:22 +0100 Subject: [PATCH 032/151] Following the release of 0.14.1 bump to +dev --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3839f54..89382e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "Hypercorn" -version = "0.14.1" +version = "0.14.1+dev" description = "A ASGI Server based on Hyper libraries and inspired by Gunicorn" authors = ["pgjones "] classifiers = [ From 66cabb02775c8f1a1ab590b94402f5d3078952ea Mon Sep 17 00:00:00 2001 From: pgjones Date: Mon, 29 Aug 2022 15:35:27 +0100 Subject: [PATCH 033/151] Update the readme to note WSGI serving ability --- README.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index d10d83e..25d6870 100644 --- a/README.rst +++ b/README.rst @@ -7,13 +7,13 @@ Hypercorn |Build Status| |docs| |pypi| |http| |python| |license| Hypercorn is an `ASGI -`_ web -server based on the sans-io hyper, `h11 +`_ and +WSGI web server based on the sans-io hyper, `h11 `_, `h2 `_, and `wsproto `_ libraries and inspired by Gunicorn. Hypercorn supports HTTP/1, HTTP/2, WebSockets (over HTTP/1 -and HTTP/2), ASGI/2, and ASGI/3 specifications. Hypercorn can utilise +and HTTP/2), ASGI, and WSGI specifications. Hypercorn can utilise asyncio, uvloop, or trio worker types. Hypercorn can optionally serve the current draft of the HTTP/3 @@ -25,7 +25,7 @@ choose a quic binding e.g. ``hypercorn --quic-bind localhost:4433 Hypercorn was initially part of `Quart `_ before being separated out into a -standalone ASGI server. Hypercorn forked from version 0.5.0 of Quart. +standalone server. Hypercorn forked from version 0.5.0 of Quart. Quickstart ---------- From 4ae32dfd964638102f5f6bfa2eee44af2254a747 Mon Sep 17 00:00:00 2001 From: pgjones Date: Tue, 30 Aug 2022 08:42:34 +0100 Subject: [PATCH 034/151] Bugfix add missing ASGI version to lifespan scope This was missed when dropping ASGI 2 support. --- src/hypercorn/asyncio/lifespan.py | 5 ++++- src/hypercorn/trio/lifespan.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/hypercorn/asyncio/lifespan.py b/src/hypercorn/asyncio/lifespan.py index 9acfe61..a5fbe2e 100644 --- a/src/hypercorn/asyncio/lifespan.py +++ b/src/hypercorn/asyncio/lifespan.py @@ -29,7 +29,10 @@ def __init__(self, app: AppWrapper, config: Config, loop: asyncio.AbstractEventL async def handle_lifespan(self) -> None: self._started.set() - scope: LifespanScope = {"type": "lifespan", "asgi": {"spec_version": "2.0"}} + scope: LifespanScope = { + "type": "lifespan", + "asgi": {"spec_version": "2.0", "version": "3.0"}, + } try: await self.app( scope, self.asgi_receive, self.asgi_send, partial(self.loop.run_in_executor, None) diff --git a/src/hypercorn/trio/lifespan.py b/src/hypercorn/trio/lifespan.py index 06eef81..54f3695 100644 --- a/src/hypercorn/trio/lifespan.py +++ b/src/hypercorn/trio/lifespan.py @@ -26,7 +26,10 @@ async def handle_lifespan( self, *, task_status: trio._core._run._TaskStatus = trio.TASK_STATUS_IGNORED ) -> None: task_status.started() - scope: LifespanScope = {"type": "lifespan", "asgi": {"spec_version": "2.0"}} + scope: LifespanScope = { + "type": "lifespan", + "asgi": {"spec_version": "2.0", "version": "3.0"}, + } try: await self.app(scope, self.asgi_receive, self.asgi_send, trio.to_thread.run_sync) except LifespanFailureError: From 44cfda145a46bd4ca849e49cb111d2b6e9f2e185 Mon Sep 17 00:00:00 2001 From: pgjones Date: Tue, 30 Aug 2022 17:23:02 +0100 Subject: [PATCH 035/151] Preserve the HTTP/1 request header casing through to the ASGI app As per the ASGI spec. --- src/hypercorn/protocol/h11.py | 2 +- tests/protocol/test_h11.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/hypercorn/protocol/h11.py b/src/hypercorn/protocol/h11.py index e18d488..ba97b73 100755 --- a/src/hypercorn/protocol/h11.py +++ b/src/hypercorn/protocol/h11.py @@ -222,7 +222,7 @@ async def _create_stream(self, request: h11.Request) -> None: await self.stream.handle( Request( stream_id=STREAM_ID, - headers=list(request.headers), + headers=request.headers.raw_items(), http_version=request.http_version.decode(), method=request.method.decode("ascii").upper(), raw_path=request.target, diff --git a/tests/protocol/test_h11.py b/tests/protocol/test_h11.py index 20e0091..71ae4e5 100755 --- a/tests/protocol/test_h11.py +++ b/tests/protocol/test_h11.py @@ -165,7 +165,7 @@ async def test_protocol_handle_closed(protocol: H11Protocol) -> None: call( Request( stream_id=1, - headers=[(b"host", b"hypercorn"), (b"connection", b"close")], + headers=[(b"Host", b"hypercorn"), (b"Connection", b"close")], http_version="1.1", method="GET", raw_path=b"/", @@ -187,7 +187,7 @@ async def test_protocol_handle_request(protocol: H11Protocol) -> None: call( Request( stream_id=1, - headers=[(b"host", b"hypercorn"), (b"connection", b"close")], + headers=[(b"Host", b"hypercorn"), (b"Connection", b"close")], http_version="1.1", method="GET", raw_path=b"/?a=b", From 3f804ce45be2125334a30ad169e3bde64e5c71cb Mon Sep 17 00:00:00 2001 From: pgjones Date: Fri, 2 Sep 2022 22:12:39 +0100 Subject: [PATCH 036/151] Bugifx ensure the config loglevel is respected Previously it was overwritten by the arg default. --- src/hypercorn/__main__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/hypercorn/__main__.py b/src/hypercorn/__main__.py index 0f1c976..b3dc0e8 100644 --- a/src/hypercorn/__main__.py +++ b/src/hypercorn/__main__.py @@ -128,7 +128,7 @@ def main(sys_args: Optional[List[str]] = None) -> None: default=sentinel, ) parser.add_argument( - "--log-level", help="The (error) log level, defaults to info", default="INFO" + "--log-level", help="The (error) log level, defaults to info", default=sentinel ) parser.add_argument( "-p", "--pid", help="Location to write the PID (Program ID) to.", default=sentinel @@ -205,8 +205,9 @@ def _convert_verify_mode(value: str) -> ssl.VerifyMode: args = parser.parse_args(sys_args or sys.argv[1:]) config = _load_config(args.config) config.application_path = args.application - config.loglevel = args.log_level + if args.log_level is not sentinel: + config.loglevel = args.log_level if args.access_logformat is not sentinel: config.access_log_format = args.access_logformat if args.access_log is not sentinel: From a5bf3caac600dedf2350bdd62f2e5ec92bc6dbc0 Mon Sep 17 00:00:00 2001 From: pgjones Date: Sat, 3 Sep 2022 10:03:45 +0100 Subject: [PATCH 037/151] Bugfix ensure new processes are spawned not forked This ensures that the reloader works, as a forked process will not load the latest code. --- src/hypercorn/run.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/hypercorn/run.py b/src/hypercorn/run.py index 3f3629d..bbdec98 100644 --- a/src/hypercorn/run.py +++ b/src/hypercorn/run.py @@ -3,7 +3,9 @@ import platform import signal import time -from multiprocessing import Event, Process +from multiprocessing import get_context +from multiprocessing.context import BaseContext +from multiprocessing.process import BaseProcess from multiprocessing.synchronize import Event as EventType from typing import Any, List @@ -38,6 +40,8 @@ def run(config: Config) -> None: # changes. load_application(config.application_path, config.wsgi_max_body_size) + ctx = get_context("spawn") + active = True while active: # Ignore SIGINT before creating the processes, so that they @@ -45,8 +49,8 @@ def run(config: Config) -> None: # function controls the shutdown. signal.signal(signal.SIGINT, signal.SIG_IGN) - shutdown_event = Event() - processes = start_processes(config, worker_func, sockets, shutdown_event) + shutdown_event = ctx.Event() + processes = start_processes(config, worker_func, sockets, shutdown_event, ctx) def shutdown(*args: Any) -> None: nonlocal active, shutdown_event @@ -75,11 +79,15 @@ def shutdown(*args: Any) -> None: def start_processes( - config: Config, worker_func: WorkerFunc, sockets: Sockets, shutdown_event: EventType -) -> List[Process]: + config: Config, + worker_func: WorkerFunc, + sockets: Sockets, + shutdown_event: EventType, + ctx: BaseContext, +) -> List[BaseProcess]: processes = [] for _ in range(config.workers): - process = Process( + process = ctx.Process( target=worker_func, kwargs={"config": config, "shutdown_event": shutdown_event, "sockets": sockets}, ) From 5264da40a26b3f63f626cc35b88cd955bedb66e8 Mon Sep 17 00:00:00 2001 From: pgjones Date: Sat, 3 Sep 2022 10:29:38 +0100 Subject: [PATCH 038/151] Bugfix ignore dunder vars in config objects These often have a special meaning and usage and there are no valid dunder config attributes. --- src/hypercorn/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hypercorn/config.py b/src/hypercorn/config.py index 1d17f0b..f9a9d66 100644 --- a/src/hypercorn/config.py +++ b/src/hypercorn/config.py @@ -391,6 +391,6 @@ def from_object(cls: Type["Config"], instance: Union[object, str]) -> "Config": mapping = { key: getattr(instance, key) for key in dir(instance) - if not isinstance(getattr(instance, key), types.ModuleType) + if not isinstance(getattr(instance, key), types.ModuleType) and not key.startswith("__") } return cls.from_mapping(mapping) From f886f849e7489f7ba8edf0443f777004aa8e0ff6 Mon Sep 17 00:00:00 2001 From: pgjones Date: Sat, 3 Sep 2022 10:39:56 +0100 Subject: [PATCH 039/151] Bugfix clarify the subprotocol exception If there are no subprotocols specified by the client the exception should be thrown, rather than `TypeError: argument of type 'NoneType' is not iterable`. --- src/hypercorn/protocol/ws_stream.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hypercorn/protocol/ws_stream.py b/src/hypercorn/protocol/ws_stream.py index 5b8ac74..cebbf89 100644 --- a/src/hypercorn/protocol/ws_stream.py +++ b/src/hypercorn/protocol/ws_stream.py @@ -102,7 +102,7 @@ def accept( ) -> Tuple[int, List[Tuple[bytes, bytes]], Connection]: headers = [] if subprotocol is not None: - if subprotocol not in self.subprotocols: + if self.subprotocols is None or subprotocol not in self.subprotocols: raise Exception("Invalid Subprotocol") else: headers.append((b"sec-websocket-protocol", subprotocol.encode())) From ffb1563f606c51d94ea8fa99d09d17e635a8a2bf Mon Sep 17 00:00:00 2001 From: pgjones Date: Sat, 3 Sep 2022 10:55:36 +0100 Subject: [PATCH 040/151] Bump and release 0.14.2 --- CHANGELOG.rst | 11 +++++++++++ pyproject.toml | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 6f8b0e9..fef10e8 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,14 @@ +0.14.2 2022-09-03 +----------------- + +* Bugfix add missing ASGI version to lifespan scope. +* Bugfix preserve the HTTP/1 request header casing through to the ASGI + app. +* Bugifx ensure the config loglevel is respected. +* Bugfix ensure new processes are spawned not forked. +* Bugfix ignore dunder vars in config objects. +* Bugfix clarify the subprotocol exception. + 0.14.1 2022-08-29 ----------------- diff --git a/pyproject.toml b/pyproject.toml index 89382e9..279d80d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "Hypercorn" -version = "0.14.1+dev" +version = "0.14.2" description = "A ASGI Server based on Hyper libraries and inspired by Gunicorn" authors = ["pgjones "] classifiers = [ From 2e0b5014999d4aaad09c5897ff6af95ee605d8f2 Mon Sep 17 00:00:00 2001 From: pgjones Date: Sat, 3 Sep 2022 10:56:48 +0100 Subject: [PATCH 041/151] Following the release of 0.14.2 bump to +dev --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 279d80d..0ed8cdc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "Hypercorn" -version = "0.14.2" +version = "0.14.2+dev" description = "A ASGI Server based on Hyper libraries and inspired by Gunicorn" authors = ["pgjones "] classifiers = [ From 68e832af53b55bcd7798eac1e84c974eb5753827 Mon Sep 17 00:00:00 2001 From: pgjones Date: Sun, 4 Sep 2022 13:55:29 +0100 Subject: [PATCH 042/151] Revert "Preserve the HTTP/1 request header casing through to the ASGI app" This reverts commit 44cfda145a46bd4ca849e49cb111d2b6e9f2e185. This requires further discussion. --- src/hypercorn/protocol/h11.py | 2 +- tests/protocol/test_h11.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/hypercorn/protocol/h11.py b/src/hypercorn/protocol/h11.py index ba97b73..e18d488 100755 --- a/src/hypercorn/protocol/h11.py +++ b/src/hypercorn/protocol/h11.py @@ -222,7 +222,7 @@ async def _create_stream(self, request: h11.Request) -> None: await self.stream.handle( Request( stream_id=STREAM_ID, - headers=request.headers.raw_items(), + headers=list(request.headers), http_version=request.http_version.decode(), method=request.method.decode("ascii").upper(), raw_path=request.target, diff --git a/tests/protocol/test_h11.py b/tests/protocol/test_h11.py index 71ae4e5..20e0091 100755 --- a/tests/protocol/test_h11.py +++ b/tests/protocol/test_h11.py @@ -165,7 +165,7 @@ async def test_protocol_handle_closed(protocol: H11Protocol) -> None: call( Request( stream_id=1, - headers=[(b"Host", b"hypercorn"), (b"Connection", b"close")], + headers=[(b"host", b"hypercorn"), (b"connection", b"close")], http_version="1.1", method="GET", raw_path=b"/", @@ -187,7 +187,7 @@ async def test_protocol_handle_request(protocol: H11Protocol) -> None: call( Request( stream_id=1, - headers=[(b"Host", b"hypercorn"), (b"Connection", b"close")], + headers=[(b"host", b"hypercorn"), (b"connection", b"close")], http_version="1.1", method="GET", raw_path=b"/?a=b", From d5aa25e59c920ffb154499e25eb9bf625c3ddf45 Mon Sep 17 00:00:00 2001 From: pgjones Date: Sun, 4 Sep 2022 17:45:46 +0100 Subject: [PATCH 043/151] Bugfix stream WSGI responses This will send the body data as received from the WSGI app therefor allowing streaming. --- src/hypercorn/app_wrappers.py | 26 +++++++++++++++----------- src/hypercorn/asyncio/lifespan.py | 12 +++++++++++- src/hypercorn/asyncio/task_group.py | 9 ++++++++- src/hypercorn/middleware/wsgi.py | 11 ++++++++--- src/hypercorn/trio/lifespan.py | 8 +++++++- src/hypercorn/trio/task_group.py | 12 ++++++++++-- src/hypercorn/typing.py | 1 + tests/test_app_wrappers.py | 24 +++++++++++++++++------- 8 files changed, 77 insertions(+), 26 deletions(-) diff --git a/src/hypercorn/app_wrappers.py b/src/hypercorn/app_wrappers.py index 19fdfde..45cf2b0 100644 --- a/src/hypercorn/app_wrappers.py +++ b/src/hypercorn/app_wrappers.py @@ -1,5 +1,6 @@ from __future__ import annotations +from functools import partial from io import BytesIO from typing import Callable, List, Optional, Tuple @@ -27,6 +28,7 @@ async def __call__( receive: ASGIReceiveCallable, send: ASGISendCallable, sync_spawn: Callable, + call_soon: Callable, ) -> None: await self.app(scope, receive, send) @@ -42,11 +44,10 @@ async def __call__( receive: ASGIReceiveCallable, send: ASGISendCallable, sync_spawn: Callable, + call_soon: Callable, ) -> None: if scope["type"] == "http": - status_code, headers, body = await self.handle_http(scope, receive, send, sync_spawn) - await send({"type": "http.response.start", "status": status_code, "headers": headers}) # type: ignore # noqa: E501 - await send({"type": "http.response.body", "body": body}) # type: ignore + await self.handle_http(scope, receive, send, sync_spawn, call_soon) elif scope["type"] == "websocket": await send({"type": "websocket.close"}) # type: ignore elif scope["type"] == "lifespan": @@ -60,24 +61,28 @@ async def handle_http( receive: ASGIReceiveCallable, send: ASGISendCallable, sync_spawn: Callable, - ) -> Tuple[int, list, bytes]: + call_soon: Callable, + ) -> None: body = bytearray() while True: message = await receive() body.extend(message.get("body", b"")) # type: ignore if len(body) > self.max_body_size: - return 400, [], b"" + await send({"type": "http.response.start", "status": 400, "headers": []}) # type: ignore # noqa: E501 + await send({"type": "http.response.body", "body": b"", "more_body": False}) # type: ignore # noqa: E501 + return if not message.get("more_body"): break try: environ = _build_environ(scope, body) except InvalidPathError: - return 404, [], b"" + await send({"type": "http.response.start", "status": 404, "headers": []}) # type: ignore # noqa: E501 else: - return await sync_spawn(self.run_app, environ) + await sync_spawn(self.run_app, environ, partial(call_soon, send)) + await send({"type": "http.response.body", "body": b"", "more_body": False}) # type: ignore - def run_app(self, environ: dict) -> Tuple[int, list, bytes]: + def run_app(self, environ: dict, send: Callable) -> None: headers: List[Tuple[bytes, bytes]] status_code: Optional[int] = None @@ -94,11 +99,10 @@ def start_response( (name.lower().encode("ascii"), value.encode("ascii")) for name, value in response_headers ] + send({"type": "http.response.start", "status": status_code, "headers": headers}) - body = bytearray() for output in self.app(environ, start_response): - body.extend(output) - return status_code, headers, body + send({"type": "http.response.body", "body": output, "more_body": True}) def _build_environ(scope: HTTPScope, body: bytes) -> dict: diff --git a/src/hypercorn/asyncio/lifespan.py b/src/hypercorn/asyncio/lifespan.py index a5fbe2e..def4af9 100644 --- a/src/hypercorn/asyncio/lifespan.py +++ b/src/hypercorn/asyncio/lifespan.py @@ -2,6 +2,7 @@ import asyncio from functools import partial +from typing import Any, Callable from ..config import Config from ..typing import AppWrapper, ASGIReceiveEvent, ASGISendEvent, LifespanScope @@ -33,9 +34,18 @@ async def handle_lifespan(self) -> None: "type": "lifespan", "asgi": {"spec_version": "2.0", "version": "3.0"}, } + + def _call_soon(func: Callable, *args: Any) -> Any: + future = asyncio.run_coroutine_threadsafe(func(*args), self.loop) + return future.result() + try: await self.app( - scope, self.asgi_receive, self.asgi_send, partial(self.loop.run_in_executor, None) + scope, + self.asgi_receive, + self.asgi_send, + partial(self.loop.run_in_executor, None), + _call_soon, ) except LifespanFailureError: # Lifespan failures should crash the server diff --git a/src/hypercorn/asyncio/task_group.py b/src/hypercorn/asyncio/task_group.py index 5c6f16a..2cfb5a3 100644 --- a/src/hypercorn/asyncio/task_group.py +++ b/src/hypercorn/asyncio/task_group.py @@ -17,9 +17,10 @@ async def _handle( receive: ASGIReceiveCallable, send: Callable[[Optional[ASGISendEvent]], Awaitable[None]], sync_spawn: Callable, + call_soon: Callable, ) -> None: try: - await app(scope, receive, send, sync_spawn) + await app(scope, receive, send, sync_spawn, call_soon) except asyncio.CancelledError: raise except Exception: @@ -42,6 +43,11 @@ async def spawn_app( send: Callable[[Optional[ASGISendEvent]], Awaitable[None]], ) -> Callable[[ASGIReceiveEvent], Awaitable[None]]: app_queue: asyncio.Queue[ASGIReceiveEvent] = asyncio.Queue(config.max_app_queue_size) + + def _call_soon(func: Callable, *args: Any) -> Any: + future = asyncio.run_coroutine_threadsafe(func(*args), self._loop) + return future.result() + self.spawn( _handle, app, @@ -50,6 +56,7 @@ async def spawn_app( app_queue.get, send, partial(self._loop.run_in_executor, None), + _call_soon, ) return app_queue.put diff --git a/src/hypercorn/middleware/wsgi.py b/src/hypercorn/middleware/wsgi.py index 69cdb63..8e4f61b 100644 --- a/src/hypercorn/middleware/wsgi.py +++ b/src/hypercorn/middleware/wsgi.py @@ -2,7 +2,7 @@ import asyncio from functools import partial -from typing import Callable, Iterable +from typing import Any, Callable, Iterable from ..app_wrappers import WSGIWrapper from ..typing import ASGIReceiveCallable, ASGISendCallable, Scope, WSGIFramework @@ -32,7 +32,12 @@ async def __call__( self, scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable ) -> None: loop = asyncio.get_event_loop() - await self.wsgi_app(scope, receive, send, partial(loop.run_in_executor, None)) + + def _call_soon(func: Callable, *args: Any) -> Any: + future = asyncio.run_coroutine_threadsafe(func(*args), loop) + return future.result() + + await self.wsgi_app(scope, receive, send, partial(loop.run_in_executor, None), _call_soon) class TrioWSGIMiddleware(_WSGIMiddleware): @@ -41,4 +46,4 @@ async def __call__( ) -> None: import trio - await self.wsgi_app(scope, receive, send, trio.to_thread.run_sync) + await self.wsgi_app(scope, receive, send, trio.to_thread.run_sync, trio.from_thread.run) diff --git a/src/hypercorn/trio/lifespan.py b/src/hypercorn/trio/lifespan.py index 54f3695..acd1405 100644 --- a/src/hypercorn/trio/lifespan.py +++ b/src/hypercorn/trio/lifespan.py @@ -31,7 +31,13 @@ async def handle_lifespan( "asgi": {"spec_version": "2.0", "version": "3.0"}, } try: - await self.app(scope, self.asgi_receive, self.asgi_send, trio.to_thread.run_sync) + await self.app( + scope, + self.asgi_receive, + self.asgi_send, + trio.to_thread.run_sync, + trio.from_thread.run, + ) except LifespanFailureError: # Lifespan failures should crash the server raise diff --git a/src/hypercorn/trio/task_group.py b/src/hypercorn/trio/task_group.py index 2ddf40e..bc8447b 100644 --- a/src/hypercorn/trio/task_group.py +++ b/src/hypercorn/trio/task_group.py @@ -16,9 +16,10 @@ async def _handle( receive: ASGIReceiveCallable, send: Callable[[Optional[ASGISendEvent]], Awaitable[None]], sync_spawn: Callable, + call_soon: Callable, ) -> None: try: - await app(scope, receive, send, sync_spawn) + await app(scope, receive, send, sync_spawn, call_soon) except trio.Cancelled: raise except trio.MultiError as error: @@ -50,7 +51,14 @@ async def spawn_app( ) -> Callable[[ASGIReceiveEvent], Awaitable[None]]: app_send_channel, app_receive_channel = trio.open_memory_channel(config.max_app_queue_size) self._nursery.start_soon( - _handle, app, config, scope, app_receive_channel.receive, send, trio.to_thread.run_sync + _handle, + app, + config, + scope, + app_receive_channel.receive, + send, + trio.to_thread.run_sync, + trio.from_thread.run, ) return app_send_channel.send diff --git a/src/hypercorn/typing.py b/src/hypercorn/typing.py index 8d676ae..206415c 100644 --- a/src/hypercorn/typing.py +++ b/src/hypercorn/typing.py @@ -326,5 +326,6 @@ async def __call__( receive: ASGIReceiveCallable, send: ASGISendCallable, sync_spawn: Callable, + call_soon: Callable, ) -> None: ... diff --git a/tests/test_app_wrappers.py b/tests/test_app_wrappers.py index f63612d..bb7b589 100644 --- a/tests/test_app_wrappers.py +++ b/tests/test_app_wrappers.py @@ -2,7 +2,7 @@ import asyncio from functools import partial -from typing import Callable, List +from typing import Any, Callable, List import pytest import trio @@ -49,14 +49,15 @@ async def _send(message: ASGISendEvent) -> None: nonlocal messages messages.append(message) - await app(scope, receive_channel.receive, _send, trio.to_thread.run_sync) + await app(scope, receive_channel.receive, _send, trio.to_thread.run_sync, trio.from_thread.run) assert messages == [ { "headers": [(b"content-type", b"text/plain; charset=utf-8"), (b"content-length", b"0")], "status": 200, "type": "http.response.start", }, - {"body": bytearray(b""), "type": "http.response.body"}, + {"body": bytearray(b""), "type": "http.response.body", "more_body": True}, + {"body": bytearray(b""), "type": "http.response.body", "more_body": False}, ] @@ -87,14 +88,19 @@ async def _send(message: ASGISendEvent) -> None: nonlocal messages messages.append(message) - await app(scope, queue.get, _send, partial(event_loop.run_in_executor, None)) + def _call_soon(func: Callable, *args: Any) -> Any: + future = asyncio.run_coroutine_threadsafe(func(*args), event_loop) + return future.result() + + await app(scope, queue.get, _send, partial(event_loop.run_in_executor, None), _call_soon) assert messages == [ { "headers": [(b"content-type", b"text/plain; charset=utf-8"), (b"content-length", b"0")], "status": 200, "type": "http.response.start", }, - {"body": bytearray(b""), "type": "http.response.body"}, + {"body": bytearray(b""), "type": "http.response.body", "more_body": True}, + {"body": bytearray(b""), "type": "http.response.body", "more_body": False}, ] @@ -124,10 +130,14 @@ async def _send(message: ASGISendEvent) -> None: nonlocal messages messages.append(message) - await app(scope, queue.get, _send, partial(event_loop.run_in_executor, None)) + def _call_soon(func: Callable, *args: Any) -> Any: + future = asyncio.run_coroutine_threadsafe(func(*args), event_loop) + return future.result() + + await app(scope, queue.get, _send, partial(event_loop.run_in_executor, None), _call_soon) assert messages == [ {"headers": [], "status": 400, "type": "http.response.start"}, - {"body": bytearray(b""), "type": "http.response.body"}, + {"body": bytearray(b""), "type": "http.response.body", "more_body": False}, ] From 8c3fc86e7f48285d69b2f3ddc676b108d78d3f6e Mon Sep 17 00:00:00 2001 From: pgjones Date: Sun, 4 Sep 2022 17:48:11 +0100 Subject: [PATCH 044/151] Bump and release 0.14.3 --- CHANGELOG.rst | 7 +++++++ pyproject.toml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index fef10e8..f842df3 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,10 @@ +0.14.3 2022-09-04 +----------------- + +* Revert Preserve response headers casing for HTTP/1 as this breaks + ASGI frameworks. +* Bugfix stream WSGI responses + 0.14.2 2022-09-03 ----------------- diff --git a/pyproject.toml b/pyproject.toml index 0ed8cdc..0775cc7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "Hypercorn" -version = "0.14.2+dev" +version = "0.14.3" description = "A ASGI Server based on Hyper libraries and inspired by Gunicorn" authors = ["pgjones "] classifiers = [ From 0bd95700be6dadf59f7e3da78d6f112acd20886a Mon Sep 17 00:00:00 2001 From: pgjones Date: Sun, 4 Sep 2022 17:48:37 +0100 Subject: [PATCH 045/151] Following the release of 0.14.3 bump to +dev --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0775cc7..71ceaff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "Hypercorn" -version = "0.14.3" +version = "0.14.3+dev" description = "A ASGI Server based on Hyper libraries and inspired by Gunicorn" authors = ["pgjones "] classifiers = [ From da5492a9c4520e1a59da59bac9dc6caa1bcc7f0a Mon Sep 17 00:00:00 2001 From: Emmanuel Leblond Date: Wed, 7 Sep 2022 19:57:56 +0200 Subject: [PATCH 046/151] Document default config values in configuration.rst --- docs/how_to_guides/configuring.rst | 61 ++++++++++++++++-------------- 1 file changed, 32 insertions(+), 29 deletions(-) diff --git a/docs/how_to_guides/configuring.rst b/docs/how_to_guides/configuring.rst index 71ee920..d88669d 100644 --- a/docs/how_to_guides/configuring.rst +++ b/docs/how_to_guides/configuring.rst @@ -71,20 +71,20 @@ can be used, Configuration options ===================== -========================== ============================= ========================================== -Attribute Command line Purpose --------------------------- ----------------------------- ------------------------------------------ +========================== ============================= =============================================== ======================== +Attribute Command line Purpose Default +-------------------------- ----------------------------- ----------------------------------------------- ------------------------ access_log_format ``--access-logformat`` The log format for the access log, see :ref:`how_to_log`. accesslog ``--access-logfile`` The target logger for access logs, use ``-`` for stdout. -alpn_protocols N/A The HTTP protocols to advertise over +alpn_protocols N/A The HTTP protocols to advertise over ``h2`` and ``http/1.1`` ALPN. alt_svc_headers N/A List of header values to return as Alt-Svc headers. -application_path N/A The path location of the ASGI - application, defaults to cwd. -backlog ``--backlog`` The maximum number of pending +application_path N/A The path location of the ASGI cwd + application. +backlog ``--backlog`` The maximum number of pending 100 connections. bind ``-b``, ``--bind`` The TCP host/address to bind to. Should be either host:port, host, @@ -94,8 +94,8 @@ bind ``-b``, ``--bind`` The TCP host/address to respectively. ca_certs ``--ca-certs`` Path to the SSL CA certificate file. certfile ``--certfile`` Path to the SSL certificate file. -ciphers ``--ciphers`` Ciphers to use for the SSL setup. -debug ``--debug`` Enable debug mode, i.e. extra logging +ciphers ``--ciphers`` Ciphers to use for the SSL setup. ``ECDHE+AESGCM`` +debug ``--debug`` Enable debug mode, i.e. extra logging ``False`` and checks. dogstatsd_tags N/A DogStatsd format tag, see :ref:`using_statsd`. @@ -103,32 +103,35 @@ errorlog ``--error-logfile`` The target location for ``--log-file`` use `-` for stderr. graceful_timeout ``--graceful-timeout`` Time to wait after SIGTERM or Ctrl-C for any remaining requests (tasks) to - complete. +read_timeout ``--read-timeout`` Seconds to wait before timing out reads No timeout. + on TCP sockets. group ``-g``, ``--group`` Group to own any unix sockets. -h11_max_incomplete_size N/A The max HTTP/2 request line + headers size - in bytes. -h2_max_concurrent_streams N/A Maximum number of HTTP/2 concurrent +h11_max_incomplete_size N/A The max HTTP/1.1 request line + headers 16KiB + size in bytes. +h2_max_concurrent_streams N/A Maximum number of HTTP/2 concurrent 100 streams. -h2_max_header_list_size N/A Maximum number of HTTP/2 headers. -h2_max_inbound_frame_size N/A Maximum size of a HTTP/2 frame. -include_server_header N/A Include the ``Server: Hypercorn`` header, - default True. +h2_max_header_list_size N/A Maximum number of HTTP/2 headers. 65536 +h2_max_inbound_frame_size N/A Maximum size of a HTTP/2 frame. 16KiB +include_date_header N/A Include the ``True`` + ``Date: Tue, 15 Nov 1994 08:12:31 GMT`` + header. +include_server_header N/A Include the ``Server: Hypercorn`` header. ``True`` insecure_bind ``--insecure-bind`` The TCP host/address to bind to. SSL options will not apply to these binds. See *bind* for formatting options. Care must be taken! See HTTP -> HTTPS redirection docs. -keep_alive_timeout ``--keep-alive`` Seconds to keep inactive connections alive +keep_alive_timeout ``--keep-alive`` Seconds to keep inactive connections alive 5s before closing. keyfile ``--keyfile`` Path to the SSL key file. -logconfig ``--log-config`` A Python logging configuration file. This +logconfig ``--log-config`` A Python logging configuration file. This The logging ini format. can be prefixed with 'json:' or 'toml:' to load the configuration from a file in that - format. Default is the logging ini format. + format. logconfig_dict N/A A Python logging configuration dictionary. logger_class N/A Type of class to use for logging. -loglevel ``--log-level`` The (error) log level. -max_app_queue_size N/A The maximum number of events to queue up +loglevel ``--log-level`` The (error) log level. ``INFO`` +max_app_queue_size N/A The maximum number of events to queue up 10 sending to the ASGI application. pid_path ``-p``, ``--pid`` Location to write the PID (Program ID) to. quic_bind ``--quic-bind`` The UDP/QUIC host/address to bind to. See @@ -138,11 +141,11 @@ root_path ``--root-path`` The setting for the ASG server_names ``--server-name`` The hostnames that can be served, requests to different hosts will be responded to with 404s. -shutdown_timeout N/A Timeout when waiting for Lifespan +shutdown_timeout N/A Timeout when waiting for Lifespan 60s shutdowns to complete. -ssl_handshake_timeout N/A Timeout when waiting for SSL handshakes to +ssl_handshake_timeout N/A Timeout when waiting for SSL handshakes to 60s complete. -startup_timeout N/A Timeout when waiting for Lifespan +startup_timeout N/A Timeout when waiting for Lifespan 60s startups to complete. statsd_host ``--statsd-host`` The host:port of the statsd server. statsd_prefix ``--statsd-prefix`` Prefix for all statsd messages. @@ -154,7 +157,7 @@ verify_flags N/A SSL context verify flag verify_mode ``--verify-mode`` SSL verify mode for peer's certificate, see ssl.VerifyMode enum for possible values. -websocket_max_message_size N/A Maximum size of a WebSocket frame. +websocket_max_message_size N/A Maximum size of a WebSocket frame. 16MiB websocket_ping_interval ``--websocket-ping-interval`` If set this is the time in seconds between pings sent to the client. This can be used to keep the websocket connection alive. @@ -162,7 +165,7 @@ worker_class ``-k``, ``--worker-class`` The type of worker to u asyncio, uvloop (pip install hypercorn[uvloop]), and trio (pip install hypercorn[trio]). -workers ``-w``, ``--workers`` The number of workers to spawn and use. -wsgi_max_body_size N/A The maximum size of a body that will be +workers ``-w``, ``--workers`` The number of workers to spawn and use. 1 +wsgi_max_body_size N/A The maximum size of a body that will be 16MiB accepted in WSGI mode. -========================== ============================= ========================================== +========================== ============================= =============================================== ======================== From 4645aa0e41815e2193104331c580592c6aae8dd5 Mon Sep 17 00:00:00 2001 From: pgjones Date: Wed, 14 Sep 2022 10:34:19 +0100 Subject: [PATCH 047/151] Add explanation of PicklingErrors This should help users understand what to do if the error occurs. --- src/hypercorn/run.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/hypercorn/run.py b/src/hypercorn/run.py index bbdec98..04d5db7 100644 --- a/src/hypercorn/run.py +++ b/src/hypercorn/run.py @@ -7,6 +7,7 @@ from multiprocessing.context import BaseContext from multiprocessing.process import BaseProcess from multiprocessing.synchronize import Event as EventType +from pickle import PicklingError from typing import Any, List from .config import Config, Sockets @@ -92,7 +93,12 @@ def start_processes( kwargs={"config": config, "shutdown_event": shutdown_event, "sockets": sockets}, ) process.daemon = True - process.start() + try: + process.start() + except PicklingError as error: + raise RuntimeError( + "Cannot pickle the config, see https://docs.python.org/3/library/pickle.html#pickle-picklable" # noqa: E501 + ) from error processes.append(process) if platform.system() == "Windows": time.sleep(0.1) From 7b07da66530524476256fdc6dfa11ba063dfc6a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20G=C3=B3rny?= Date: Mon, 19 Dec 2022 15:27:41 +0100 Subject: [PATCH 048/151] Use tomllib/tomli for .toml support Replace the unmaintained and non-conformant `toml` library with the built-in `tomllib` module in Python 3.11+, with fallback to `tomli` (featuring the same ABI) in Python 3.10 and older. --- pyproject.toml | 2 +- src/hypercorn/config.py | 10 +++++++--- src/hypercorn/logging.py | 10 +++++++--- tox.ini | 1 - 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 71ceaff..1334fcf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ h11 = "*" h2 = ">=3.1.0" priority = "*" pydata_sphinx_theme = { version = "*", optional = true } -toml = "*" +tomli = { version = "*", python = "<3.11" } trio = { version = ">=0.11.0", optional = true } typing_extensions = { version = ">=3.7.4", python = "<3.8" } uvloop = { version = "*", markers = "platform_system != 'Windows'", optional = true } diff --git a/src/hypercorn/config.py b/src/hypercorn/config.py index f9a9d66..ecfa1bd 100644 --- a/src/hypercorn/config.py +++ b/src/hypercorn/config.py @@ -6,6 +6,7 @@ import os import socket import stat +import sys import types import warnings from dataclasses import dataclass @@ -22,7 +23,10 @@ from typing import Any, AnyStr, Dict, List, Mapping, Optional, Tuple, Type, Union from wsgiref.handlers import format_date_time -import toml +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib from .logging import Logger @@ -355,8 +359,8 @@ def from_toml(cls: Type["Config"], filename: FilePath) -> "Config": filename: The filename which gives the path to the file. """ file_path = os.fspath(filename) - with open(file_path) as file_: - data = toml.load(file_) + with open(file_path, "rb") as file_: + data = tomllib.load(file_) return cls.from_mapping(data) @classmethod diff --git a/src/hypercorn/logging.py b/src/hypercorn/logging.py index 3c2c657..8ca6105 100644 --- a/src/hypercorn/logging.py +++ b/src/hypercorn/logging.py @@ -9,7 +9,11 @@ from logging.config import dictConfig, fileConfig from typing import Any, IO, Mapping, Optional, TYPE_CHECKING, Union -import toml +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + if TYPE_CHECKING: from .config import Config @@ -65,8 +69,8 @@ def __init__(self, config: "Config") -> None: with open(config.logconfig[5:]) as file_: dictConfig(json.load(file_)) elif config.logconfig.startswith("toml:"): - with open(config.logconfig[5:]) as file_: - dictConfig(toml.load(file_)) + with open(config.logconfig[5:], "rb") as file_: + dictConfig(tomllib.load(file_)) else: log_config = { "__file__": config.logconfig, diff --git a/tox.ini b/tox.ini index 675992b..0f636fb 100644 --- a/tox.ini +++ b/tox.ini @@ -47,7 +47,6 @@ basepython = python3.10 deps = mypy pytest - types-toml commands = mypy src/hypercorn/ tests/ From eb068c49887ae2eff81023dc5512d58a1ef68bf2 Mon Sep 17 00:00:00 2001 From: pgjones Date: Wed, 21 Dec 2022 09:55:05 +0000 Subject: [PATCH 049/151] Fix typing issue This may have changed in typeshed? --- src/hypercorn/run.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hypercorn/run.py b/src/hypercorn/run.py index 04d5db7..bfc50f1 100644 --- a/src/hypercorn/run.py +++ b/src/hypercorn/run.py @@ -88,7 +88,7 @@ def start_processes( ) -> List[BaseProcess]: processes = [] for _ in range(config.workers): - process = ctx.Process( + process = ctx.Process( # type: ignore target=worker_func, kwargs={"config": config, "shutdown_event": shutdown_event, "sockets": sockets}, ) From 55190e2e395ec07097e7c8269e0bd017df87939d Mon Sep 17 00:00:00 2001 From: pgjones Date: Wed, 21 Dec 2022 09:55:24 +0000 Subject: [PATCH 050/151] Pin wsaccel to avoid Python 2.7 incompatibility It is probably only a matter of time now till I have to remove these tests. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index efa9b7f..0f8a4ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,7 +98,7 @@ jobs: pip install -U wheel pip install -U setuptools python -m pip install -U pip - - run: pip install pyopenssl==19.1.0 cryptography==2.3.1 autobahntestsuite + - run: pip install pyopenssl==19.1.0 cryptography==2.3.1 wsaccel==0.6.2 autobahntestsuite - run: python3 -m pip install trio . - name: Run server From 3bda9c691041a2ef0463d053a93476563f409a6f Mon Sep 17 00:00:00 2001 From: pgjones Date: Wed, 21 Dec 2022 09:58:31 +0000 Subject: [PATCH 051/151] Pin twisted to avoid Python 2.7 incompatibility See also 55190e2e395ec07097e7c8269e0bd017df87939d --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f8a4ef..9bf578b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,7 +98,7 @@ jobs: pip install -U wheel pip install -U setuptools python -m pip install -U pip - - run: pip install pyopenssl==19.1.0 cryptography==2.3.1 wsaccel==0.6.2 autobahntestsuite + - run: pip install pyopenssl==19.1.0 cryptography==2.3.1 Twisted==12.1 wsaccel==0.6.2 autobahntestsuite - run: python3 -m pip install trio . - name: Run server From 8ae17ca68204d9718389fb3649ca0ed6ba851906 Mon Sep 17 00:00:00 2001 From: Konstantin Ignatov Date: Fri, 13 Jan 2023 23:31:14 +0100 Subject: [PATCH 052/151] bug(lifespan): server hangs on startup failure message key is optional https://asgi.readthedocs.io/en/latest/specs/lifespan.html#startup-failed-send-event Still hypercorn expects, result is KeyError which doesn't terminate the server. --- src/hypercorn/asyncio/lifespan.py | 4 ++-- src/hypercorn/trio/lifespan.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/hypercorn/asyncio/lifespan.py b/src/hypercorn/asyncio/lifespan.py index def4af9..244950c 100644 --- a/src/hypercorn/asyncio/lifespan.py +++ b/src/hypercorn/asyncio/lifespan.py @@ -98,9 +98,9 @@ async def asgi_send(self, message: ASGISendEvent) -> None: self.shutdown.set() elif message["type"] == "lifespan.startup.failed": self.startup.set() - raise LifespanFailureError("startup", message["message"]) + raise LifespanFailureError("startup", message.get("message", "")) elif message["type"] == "lifespan.shutdown.failed": self.shutdown.set() - raise LifespanFailureError("shutdown", message["message"]) + raise LifespanFailureError("shutdown", message.get("message", "")) else: raise UnexpectedMessageError(message["type"]) diff --git a/src/hypercorn/trio/lifespan.py b/src/hypercorn/trio/lifespan.py index acd1405..a45fc52 100644 --- a/src/hypercorn/trio/lifespan.py +++ b/src/hypercorn/trio/lifespan.py @@ -90,8 +90,8 @@ async def asgi_send(self, message: ASGISendEvent) -> None: elif message["type"] == "lifespan.shutdown.complete": self.shutdown.set() elif message["type"] == "lifespan.startup.failed": - raise LifespanFailureError("startup", message["message"]) + raise LifespanFailureError("startup", message.get("message", "")) elif message["type"] == "lifespan.shutdown.failed": - raise LifespanFailureError("shutdown", message["message"]) + raise LifespanFailureError("shutdown", message.get("message", "")) else: raise UnexpectedMessageError(message["type"]) From 5a528ae7fb8ffd50fb3ae25e1ee4f3aad822a70e Mon Sep 17 00:00:00 2001 From: pgjones Date: Sat, 8 Jul 2023 15:20:27 +0100 Subject: [PATCH 053/151] Support and test against Python 3.11 --- .github/workflows/ci.yml | 13 +++++++------ .readthedocs.yaml | 2 +- pyproject.toml | 2 ++ tox.ini | 12 ++++++------ 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9bf578b..175c9c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,15 +14,16 @@ jobs: fail-fast: false matrix: include: - - {name: '3.11-dev', python: '3.11-dev', tox: py311} + - {name: '3.12-dev', python: '3.12-dev', tox: py312} + - {name: '3.11', python: '3.11', tox: py311} - {name: '3.10', python: '3.10', tox: py310} - {name: '3.9', python: '3.9', tox: py39} - {name: '3.8', python: '3.8', tox: py38} - {name: '3.7', python: '3.7', tox: py37} - - {name: 'format', python: '3.10', tox: format} - - {name: 'mypy', python: '3.10', tox: mypy} - - {name: 'pep8', python: '3.10', tox: pep8} - - {name: 'package', python: '3.10', tox: package} + - {name: 'format', python: '3.11', tox: format} + - {name: 'mypy', python: '3.11', tox: mypy} + - {name: 'pep8', python: '3.11', tox: pep8} + - {name: 'package', python: '3.11', tox: package} steps: - uses: actions/checkout@v3 @@ -56,7 +57,7 @@ jobs: - uses: actions/setup-python@v3 with: - python-version: "3.10" + python-version: "3.11" - name: update pip run: | diff --git a/.readthedocs.yaml b/.readthedocs.yaml index d1f4efa..7ae0e68 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -3,7 +3,7 @@ version: 2 build: os: ubuntu-22.04 tools: - python: "3.10" + python: "3.11" python: install: diff --git a/pyproject.toml b/pyproject.toml index 1334fcf..b736541 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,8 @@ classifiers = [ "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", "Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Topic :: Software Development :: Libraries :: Python Modules", ] diff --git a/tox.ini b/tox.ini index 0f636fb..bb7571b 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = docs,format,mypy,py37,py38,py39,py310,package,pep8 +envlist = docs,format,mypy,py37,py38,py39,py310,py311,package,pep8 minversion = 3.3 isolated_build = true @@ -15,7 +15,7 @@ deps = commands = pytest --cov=hypercorn {posargs} [testenv:docs] -basepython = python3.10 +basepython = python3.11 deps = pydata-sphinx-theme sphinx @@ -25,7 +25,7 @@ commands = sphinx-build -W --keep-going -b html -d {envtmpdir}/doctrees docs/ docs/_build/html/ [testenv:format] -basepython = python3.10 +basepython = python3.11 deps = black isort @@ -34,7 +34,7 @@ commands = isort --check --diff src/hypercorn tests [testenv:pep8] -basepython = python3.10 +basepython = python3.11 deps = flake8 pep8-naming @@ -43,7 +43,7 @@ deps = commands = flake8 src/hypercorn/ tests/ [testenv:mypy] -basepython = python3.10 +basepython = python3.11 deps = mypy pytest @@ -51,7 +51,7 @@ commands = mypy src/hypercorn/ tests/ [testenv:package] -basepython = python3.10 +basepython = python3.11 deps = poetry twine From ebd5b72f42e0f7cf37e9e7c44b8c0f6b5800a93f Mon Sep 17 00:00:00 2001 From: pgjones Date: Sat, 8 Jul 2023 15:39:29 +0100 Subject: [PATCH 054/151] Fix mypy issues to ensure that ci passes --- src/hypercorn/app_wrappers.py | 8 ++++---- src/hypercorn/protocol/http_stream.py | 2 +- src/hypercorn/protocol/ws_stream.py | 2 +- tests/asyncio/test_keep_alive.py | 4 ++-- tests/asyncio/test_task_group.py | 2 +- tests/helpers.py | 11 +++++------ tests/protocol/test_http_stream.py | 8 ++++---- tests/protocol/test_ws_stream.py | 8 ++++---- 8 files changed, 22 insertions(+), 23 deletions(-) diff --git a/src/hypercorn/app_wrappers.py b/src/hypercorn/app_wrappers.py index 45cf2b0..693f039 100644 --- a/src/hypercorn/app_wrappers.py +++ b/src/hypercorn/app_wrappers.py @@ -68,8 +68,8 @@ async def handle_http( message = await receive() body.extend(message.get("body", b"")) # type: ignore if len(body) > self.max_body_size: - await send({"type": "http.response.start", "status": 400, "headers": []}) # type: ignore # noqa: E501 - await send({"type": "http.response.body", "body": b"", "more_body": False}) # type: ignore # noqa: E501 + await send({"type": "http.response.start", "status": 400, "headers": []}) + await send({"type": "http.response.body", "body": b"", "more_body": False}) return if not message.get("more_body"): break @@ -77,10 +77,10 @@ async def handle_http( try: environ = _build_environ(scope, body) except InvalidPathError: - await send({"type": "http.response.start", "status": 404, "headers": []}) # type: ignore # noqa: E501 + await send({"type": "http.response.start", "status": 404, "headers": []}) else: await sync_spawn(self.run_app, environ, partial(call_soon, send)) - await send({"type": "http.response.body", "body": b"", "more_body": False}) # type: ignore + await send({"type": "http.response.body", "body": b"", "more_body": False}) def run_app(self, environ: dict, send: Callable) -> None: headers: List[Tuple[bytes, bytes]] diff --git a/src/hypercorn/protocol/http_stream.py b/src/hypercorn/protocol/http_stream.py index 6cd9bee..6c4fd18 100644 --- a/src/hypercorn/protocol/http_stream.py +++ b/src/hypercorn/protocol/http_stream.py @@ -111,7 +111,7 @@ async def handle(self, event: Event) -> None: elif isinstance(event, StreamClosed): self.closed = True if self.app_put is not None: - await self.app_put({"type": "http.disconnect"}) # type: ignore + await self.app_put({"type": "http.disconnect"}) async def app_send(self, message: Optional[ASGISendEvent]) -> None: if self.closed: diff --git a/src/hypercorn/protocol/ws_stream.py b/src/hypercorn/protocol/ws_stream.py index cebbf89..dd97511 100644 --- a/src/hypercorn/protocol/ws_stream.py +++ b/src/hypercorn/protocol/ws_stream.py @@ -231,7 +231,7 @@ async def handle(self, event: Event) -> None: self.app_put = await self.task_group.spawn_app( self.app, self.config, self.scope, self.app_send ) - await self.app_put({"type": "websocket.connect"}) # type: ignore + await self.app_put({"type": "websocket.connect"}) elif isinstance(event, (Body, Data)): self.connection.receive_data(event.data) await self._handle_events() diff --git a/tests/asyncio/test_keep_alive.py b/tests/asyncio/test_keep_alive.py index 318d72d..6b357f8 100644 --- a/tests/asyncio/test_keep_alive.py +++ b/tests/asyncio/test_keep_alive.py @@ -32,13 +32,13 @@ async def slow_framework( elif event["type"] == "http.request" and not event.get("more_body", False): await asyncio.sleep(2 * KEEP_ALIVE_TIMEOUT) await send( - { # type: ignore + { "type": "http.response.start", "status": 200, "headers": [(b"content-length", b"0")], } ) - await send({"type": "http.response.body", "body": b"", "more_body": False}) # type: ignore # noqa: E501 + await send({"type": "http.response.body", "body": b"", "more_body": False}) break diff --git a/tests/asyncio/test_task_group.py b/tests/asyncio/test_task_group.py index 48266db..e0049ae 100644 --- a/tests/asyncio/test_task_group.py +++ b/tests/asyncio/test_task_group.py @@ -25,7 +25,7 @@ async def _echo_app(scope: Scope, receive: Callable, send: Callable) -> None: put = await task_group.spawn_app( ASGIWrapper(_echo_app), Config(), http_scope, app_queue.put ) - await put({"type": "http.disconnect"}) # type: ignore + await put({"type": "http.disconnect"}) assert (await app_queue.get()) == {"type": "http.disconnect"} await put(None) diff --git a/tests/helpers.py b/tests/helpers.py index cdac68c..e9d8f83 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -11,7 +11,6 @@ class MockSocket: - family = AF_INET def getsockname(self) -> Tuple[str, int]: @@ -58,13 +57,13 @@ async def echo_framework( response = dumps({"scope": scope, "request_body": body.decode()}).encode() content_length = len(response) await send( - { # type: ignore + { "type": "http.response.start", "status": 200, "headers": [(b"content-length", str(content_length).encode())], } ) - await send({"type": "http.response.body", "body": response, "more_body": False}) # type: ignore # noqa: E501 + await send({"type": "http.response.body", "body": response, "more_body": False}) break elif event["type"] == "websocket.connect": await send({"type": "websocket.accept"}) # type: ignore @@ -78,7 +77,7 @@ async def lifespan_failure( while True: message = await receive() if message["type"] == "lifespan.startup": - await send({"type": "lifespan.startup.failed", "message": "Failure"}) # type: ignore + await send({"type": "lifespan.startup.failed", "message": "Failure"}) break @@ -105,13 +104,13 @@ async def sanity_framework( response = b"Hello & Goodbye" content_length = len(response) await send( - { # type: ignore + { "type": "http.response.start", "status": 200, "headers": [(b"content-length", str(content_length).encode())], } ) - await send({"type": "http.response.body", "body": response, "more_body": False}) # type: ignore # noqa: E501 + await send({"type": "http.response.body", "body": response, "more_body": False}) break elif event["type"] == "websocket.receive": assert event["bytes"] == SANITY_BODY diff --git a/tests/protocol/test_http_stream.py b/tests/protocol/test_http_stream.py index 3cb2ad7..a92765c 100644 --- a/tests/protocol/test_http_stream.py +++ b/tests/protocol/test_http_stream.py @@ -127,15 +127,15 @@ async def test_send_response(stream: HTTPStream) -> None: await stream.app_send( cast(HTTPResponseBodyEvent, {"type": "http.response.body", "body": b"Body"}) ) - assert stream.state == ASGIHTTPState.CLOSED - stream.send.assert_called() # type: ignore - assert stream.send.call_args_list == [ # type: ignore + assert stream.state == ASGIHTTPState.CLOSED # type: ignore + stream.send.assert_called() + assert stream.send.call_args_list == [ call(Response(stream_id=1, headers=[], status_code=200)), call(Body(stream_id=1, data=b"Body")), call(EndBody(stream_id=1)), call(StreamClosed(stream_id=1)), ] - stream.config._log.access.assert_called() # type: ignore + stream.config._log.access.assert_called() @pytest.mark.asyncio diff --git a/tests/protocol/test_ws_stream.py b/tests/protocol/test_ws_stream.py index f927cf5..59db025 100644 --- a/tests/protocol/test_ws_stream.py +++ b/tests/protocol/test_ws_stream.py @@ -298,14 +298,14 @@ async def test_send_reject(stream: WSStream) -> None: await stream.app_send( cast(WebsocketResponseBodyEvent, {"type": "websocket.http.response.body", "body": b"Body"}) ) - assert stream.state == ASGIWebsocketState.HTTPCLOSED - stream.send.assert_called() # type: ignore - assert stream.send.call_args_list == [ # type: ignore + assert stream.state == ASGIWebsocketState.HTTPCLOSED # type: ignore + stream.send.assert_called() + assert stream.send.call_args_list == [ call(Response(stream_id=1, headers=[], status_code=200)), call(Body(stream_id=1, data=b"Body")), call(EndBody(stream_id=1)), ] - stream.config._log.access.assert_called() # type: ignore + stream.config._log.access.assert_called() @pytest.mark.asyncio From 46575fef4cda74a97fc70bd7dfbd7c27514322b3 Mon Sep 17 00:00:00 2001 From: Sam McCandlish Date: Sat, 18 Mar 2023 08:37:45 -0700 Subject: [PATCH 055/151] Close websocket with 1011 on internal error (1006 is a client-only code) --- src/hypercorn/protocol/ws_stream.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hypercorn/protocol/ws_stream.py b/src/hypercorn/protocol/ws_stream.py index dd97511..9011999 100644 --- a/src/hypercorn/protocol/ws_stream.py +++ b/src/hypercorn/protocol/ws_stream.py @@ -257,7 +257,7 @@ async def app_send(self, message: Optional[ASGISendEvent]) -> None: self.scope, {"status": 500, "headers": []}, time() - self.start_time ) elif self.state == ASGIWebsocketState.CONNECTED: - await self._send_wsproto_event(CloseConnection(code=CloseReason.ABNORMAL_CLOSURE)) + await self._send_wsproto_event(CloseConnection(code=CloseReason.INTERNAL_ERROR)) await self.send(StreamClosed(stream_id=self.stream_id)) else: if message["type"] == "websocket.accept" and self.state == ASGIWebsocketState.HANDSHAKE: From 8be1e61a614efb4d4386a4de47f50e70a6d8704f Mon Sep 17 00:00:00 2001 From: pgjones Date: Sat, 8 Jul 2023 15:48:08 +0100 Subject: [PATCH 056/151] Fix tests for 46575fef4cda74a97fc70bd7dfbd7c27514322b3 --- tests/protocol/test_ws_stream.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/protocol/test_ws_stream.py b/tests/protocol/test_ws_stream.py index 59db025..5f59582 100644 --- a/tests/protocol/test_ws_stream.py +++ b/tests/protocol/test_ws_stream.py @@ -377,7 +377,7 @@ async def test_send_app_error_connected(stream: WSStream) -> None: stream.send.assert_called() # type: ignore assert stream.send.call_args_list == [ # type: ignore call(Response(stream_id=1, headers=[], status_code=200)), - call(Data(stream_id=1, data=b"\x88\x02\x03\xe8")), + call(Data(stream_id=1, data=b"\x88\x02\x03\xf3")), call(StreamClosed(stream_id=1)), ] stream.config._log.access.assert_called() # type: ignore From 00b7ed55f1ab9869a4a817697979065d1b6ce6dd Mon Sep 17 00:00:00 2001 From: Alexander Rolfes Date: Sun, 30 Apr 2023 17:27:19 +0200 Subject: [PATCH 057/151] Run autobahn-testsuite from docker container --- .github/workflows/ci.yml | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 175c9c5..5e50fe8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,7 +81,6 @@ jobs: autobahn: name: ${{ matrix.name }} runs-on: ubuntu-latest - container: python:2.7.16-alpine3.10 strategy: fail-fast: false matrix: @@ -92,20 +91,20 @@ jobs: steps: - uses: actions/checkout@v3 - - run: apk --update add build-base libressl libressl-dev ca-certificates libffi-dev python3 python3-dev + - uses: actions/setup-python@v3 + with: + python-version: "3.10" - name: update pip run: | pip install -U wheel pip install -U setuptools python -m pip install -U pip - - run: pip install pyopenssl==19.1.0 cryptography==2.3.1 Twisted==12.1 wsaccel==0.6.2 autobahntestsuite - run: python3 -m pip install trio . - - name: Run server working-directory: compliance/autobahn run: nohup hypercorn -k ${{ matrix.worker }} server:app & - - name: Run server + - name: Run Unit Tests working-directory: compliance/autobahn - run: wstest -m fuzzingclient && python summarise.py + run: docker run --rm --network=host -v "${PWD}/:/config" -v "${PWD}/reports:/reports" --name fuzzingclient crossbario/autobahn-testsuite wstest -m fuzzingclient -s /config/fuzzingclient.json && python3 summarise.py From d2c5cd00cf2408ead6bfb1971554d5f1fb8675db Mon Sep 17 00:00:00 2001 From: pgjones Date: Sat, 8 Jul 2023 16:48:07 +0100 Subject: [PATCH 058/151] Transition to trio > 0.22 utilising exception groups This is the best way, in my opinion, to support trio and exception groups whilst supporting Python versions < 3.11. This hence requires trio > 0.22. Note it is unclear why the MultiError catch in the tcp_server existed, I think it is safe to remove. With thanks to @macalinao and @tmaxwell-anthropic. --- pyproject.toml | 5 +++-- src/hypercorn/trio/run.py | 12 +++++++++--- src/hypercorn/trio/task_group.py | 12 +++++++----- src/hypercorn/trio/tcp_server.py | 2 +- 4 files changed, 20 insertions(+), 11 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b736541..4c6baed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,12 +28,13 @@ documentation = "https://hypercorn.readthedocs.io" [tool.poetry.dependencies] python = ">=3.7" aioquic = { version = ">= 0.9.0, < 1.0", optional = true } +exceptiongroup = { version = ">= 1.1.0", python = "<3.11", optional = true } h11 = "*" h2 = ">=3.1.0" priority = "*" pydata_sphinx_theme = { version = "*", optional = true } tomli = { version = "*", python = "<3.11" } -trio = { version = ">=0.11.0", optional = true } +trio = { version = ">=0.22.0", optional = true } typing_extensions = { version = ">=3.7.4", python = "<3.8" } uvloop = { version = "*", markers = "platform_system != 'Windows'", optional = true } wsproto = ">=0.14.0" @@ -52,7 +53,7 @@ hypercorn = "hypercorn.__main__:main" [tool.poetry.extras] docs = ["pydata_sphinx_theme"] h3 = ["aioquic"] -trio = ["trio"] +trio = ["exceptiongroup", "trio"] uvloop = ["uvloop"] [tool.black] diff --git a/src/hypercorn/trio/run.py b/src/hypercorn/trio/run.py index 5dfbf91..d8721bb 100644 --- a/src/hypercorn/trio/run.py +++ b/src/hypercorn/trio/run.py @@ -1,5 +1,6 @@ from __future__ import annotations +import sys from functools import partial from multiprocessing.synchronize import Event as EventType from typing import Awaitable, Callable, Optional @@ -21,6 +22,9 @@ ShutdownError, ) +if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup + async def worker_serve( app: AppWrapper, @@ -75,7 +79,7 @@ async def worker_serve( task_status.started(binds) try: - async with trio.open_nursery() as nursery: + async with trio.open_nursery(strict_exception_groups=True) as nursery: if shutdown_trigger is not None: nursery.start_soon(raise_shutdown, shutdown_trigger) @@ -89,8 +93,10 @@ async def worker_serve( ) await trio.sleep_forever() - except (ShutdownError, KeyboardInterrupt): - pass + except BaseExceptionGroup as error: + _, other_errors = error.split((ShutdownError, KeyboardInterrupt)) + if other_errors is not None: + raise other_errors finally: await context.terminated.set() server_nursery.cancel_scope.deadline = trio.current_time() + config.graceful_timeout diff --git a/src/hypercorn/trio/task_group.py b/src/hypercorn/trio/task_group.py index bc8447b..044ff85 100644 --- a/src/hypercorn/trio/task_group.py +++ b/src/hypercorn/trio/task_group.py @@ -1,5 +1,6 @@ from __future__ import annotations +import sys from types import TracebackType from typing import Any, Awaitable, Callable, Optional @@ -8,6 +9,9 @@ from ..config import Config from ..typing import AppWrapper, ASGIReceiveCallable, ASGIReceiveEvent, ASGISendEvent, Scope +if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup + async def _handle( app: AppWrapper, @@ -22,11 +26,9 @@ async def _handle( await app(scope, receive, send, sync_spawn, call_soon) except trio.Cancelled: raise - except trio.MultiError as error: - errors = trio.MultiError.filter( - lambda exc: None if isinstance(exc, trio.Cancelled) else exc, root_exc=error - ) - if errors is not None: + except BaseExceptionGroup as error: + _, other_errors = error.split(trio.Cancelled) + if other_errors is not None: await config.log.exception("Error in ASGI Framework") await send(None) else: diff --git a/src/hypercorn/trio/tcp_server.py b/src/hypercorn/trio/tcp_server.py index 3419440..5e45b91 100644 --- a/src/hypercorn/trio/tcp_server.py +++ b/src/hypercorn/trio/tcp_server.py @@ -68,7 +68,7 @@ async def run(self) -> None: await self.protocol.initiate() await self._start_idle() await self._read_data() - except (trio.MultiError, OSError): + except OSError: pass finally: await self._close() From ac0732517c86688b7260a5d66771e8bb665d9ced Mon Sep 17 00:00:00 2001 From: tomaszchalupnik Date: Wed, 28 Jun 2023 16:50:03 +0200 Subject: [PATCH 059/151] Except ConnectionAbortedError as Windows machine can populate WinError 10053 An established connection was aborted by the software in your host machine --- src/hypercorn/asyncio/tcp_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hypercorn/asyncio/tcp_server.py b/src/hypercorn/asyncio/tcp_server.py index 91b3c05..0eb40d0 100644 --- a/src/hypercorn/asyncio/tcp_server.py +++ b/src/hypercorn/asyncio/tcp_server.py @@ -115,7 +115,7 @@ async def _close(self) -> None: try: self.writer.close() await self.writer.wait_closed() - except (BrokenPipeError, ConnectionResetError, RuntimeError): + except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError, RuntimeError): pass # Already closed await self._stop_idle() From 8a3e51249332256638032e72d172975152f86c1b Mon Sep 17 00:00:00 2001 From: pgjones Date: Sat, 8 Jul 2023 17:10:58 +0100 Subject: [PATCH 060/151] Bugfix ensure that closed is sent on reading end This ensures that the protocols receive this event event when the socket closes unexpectedly in the asyncio case on OSX, see https://github.com/pgjones/hypercorn/issues/50. This requires the http_stream to allow messages to continue after closure, but only the actual messages - not None as that indicates the app has finished. Thanks to @jonathanslenders, and @tohin for related solutions. --- src/hypercorn/asyncio/tcp_server.py | 3 ++- src/hypercorn/protocol/http_stream.py | 13 +++++-------- src/hypercorn/trio/tcp_server.py | 2 +- tests/asyncio/test_tcp_server.py | 3 +-- 4 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/hypercorn/asyncio/tcp_server.py b/src/hypercorn/asyncio/tcp_server.py index 0eb40d0..bc03748 100644 --- a/src/hypercorn/asyncio/tcp_server.py +++ b/src/hypercorn/asyncio/tcp_server.py @@ -101,11 +101,12 @@ async def _read_data(self) -> None: TimeoutError, SSLError, ): - await self.protocol.handle(Closed()) break else: await self.protocol.handle(RawData(data)) + await self.protocol.handle(Closed()) + async def _close(self) -> None: try: self.writer.write_eof() diff --git a/src/hypercorn/protocol/http_stream.py b/src/hypercorn/protocol/http_stream.py index 6c4fd18..cf52c29 100644 --- a/src/hypercorn/protocol/http_stream.py +++ b/src/hypercorn/protocol/http_stream.py @@ -114,15 +114,12 @@ async def handle(self, event: Event) -> None: await self.app_put({"type": "http.disconnect"}) async def app_send(self, message: Optional[ASGISendEvent]) -> None: - if self.closed: - # Allow app to finish after close - return - if message is None: # ASGI App has finished sending messages - # Cleanup if required - if self.state == ASGIHTTPState.REQUEST: - await self._send_error_response(500) - await self.send(StreamClosed(stream_id=self.stream_id)) + if not self.closed: + # Cleanup if required + if self.state == ASGIHTTPState.REQUEST: + await self._send_error_response(500) + await self.send(StreamClosed(stream_id=self.stream_id)) else: if message["type"] == "http.response.start" and self.state == ASGIHTTPState.REQUEST: self.response = message diff --git a/src/hypercorn/trio/tcp_server.py b/src/hypercorn/trio/tcp_server.py index 5e45b91..b64ca57 100644 --- a/src/hypercorn/trio/tcp_server.py +++ b/src/hypercorn/trio/tcp_server.py @@ -97,12 +97,12 @@ async def _read_data(self) -> None: with trio.fail_after(self.config.read_timeout or inf): data = await self.stream.receive_some(MAX_RECV) except (trio.ClosedResourceError, trio.BrokenResourceError): - await self.protocol.handle(Closed()) break else: await self.protocol.handle(RawData(data)) if data == b"": break + await self.protocol.handle(Closed()) async def _close(self) -> None: try: diff --git a/tests/asyncio/test_tcp_server.py b/tests/asyncio/test_tcp_server.py index f4915de..afe00c2 100644 --- a/tests/asyncio/test_tcp_server.py +++ b/tests/asyncio/test_tcp_server.py @@ -41,10 +41,9 @@ async def test_complets_on_half_close(event_loop: asyncio.AbstractEventLoop) -> task = event_loop.create_task(server.run()) await server.reader.send(b"GET / HTTP/1.1\r\nHost: hypercorn\r\n\r\n") # type: ignore server.reader.close() # type: ignore - await asyncio.sleep(0) + await task data = await server.writer.receive() # type: ignore assert ( data == b"HTTP/1.1 200 \r\ncontent-length: 335\r\ndate: Thu, 01 Jan 1970 01:23:20 GMT\r\nserver: hypercorn-h11\r\n\r\n" # noqa: E501 ) - await task From 3179d07a9539056c272be19bb9d38fcb23225c08 Mon Sep 17 00:00:00 2001 From: Thomas Rausch Date: Mon, 10 Apr 2023 00:49:56 +0200 Subject: [PATCH 061/151] Add config option to pass raw h11 headers This allows the app to receive the raw headers - this should be used with caution as headers should be considered case insensitive. --- docs/how_to_guides/configuring.rst | 3 +++ src/hypercorn/config.py | 1 + src/hypercorn/protocol/h11.py | 8 +++++++- tests/protocol/test_h11.py | 27 +++++++++++++++++++++++++++ 4 files changed, 38 insertions(+), 1 deletion(-) diff --git a/docs/how_to_guides/configuring.rst b/docs/how_to_guides/configuring.rst index d88669d..70a18af 100644 --- a/docs/how_to_guides/configuring.rst +++ b/docs/how_to_guides/configuring.rst @@ -108,6 +108,9 @@ read_timeout ``--read-timeout`` Seconds to wait before group ``-g``, ``--group`` Group to own any unix sockets. h11_max_incomplete_size N/A The max HTTP/1.1 request line + headers 16KiB size in bytes. +h11_pass_raw_headers N/A Pass the raw headers from h11 to the ``False`` + Request object, which preserves header + casing. h2_max_concurrent_streams N/A Maximum number of HTTP/2 concurrent 100 streams. h2_max_header_list_size N/A Maximum number of HTTP/2 headers. 65536 diff --git a/src/hypercorn/config.py b/src/hypercorn/config.py index ecfa1bd..26f50f0 100644 --- a/src/hypercorn/config.py +++ b/src/hypercorn/config.py @@ -77,6 +77,7 @@ class Config: read_timeout: Optional[int] = None group: Optional[int] = None h11_max_incomplete_size = 16 * 1024 * BYTES + h11_pass_raw_headers = False h2_max_concurrent_streams = 100 h2_max_header_list_size = 2**16 h2_max_inbound_frame_size = 2**14 * OCTETS diff --git a/src/hypercorn/protocol/h11.py b/src/hypercorn/protocol/h11.py index e18d488..49f8b17 100755 --- a/src/hypercorn/protocol/h11.py +++ b/src/hypercorn/protocol/h11.py @@ -219,10 +219,16 @@ async def _create_stream(self, request: h11.Request) -> None: self.stream_send, STREAM_ID, ) + + if self.config.h11_pass_raw_headers: + headers = request.headers.raw_items() + else: + headers = list(request.headers) + await self.stream.handle( Request( stream_id=STREAM_ID, - headers=list(request.headers), + headers=headers, http_version=request.http_version.decode(), method=request.method.decode("ascii").upper(), raw_path=request.target, diff --git a/tests/protocol/test_h11.py b/tests/protocol/test_h11.py index 20e0091..86bb00a 100755 --- a/tests/protocol/test_h11.py +++ b/tests/protocol/test_h11.py @@ -197,6 +197,33 @@ async def test_protocol_handle_request(protocol: H11Protocol) -> None: ] +@pytest.mark.asyncio +async def test_protocol_handle_request_with_raw_headers(protocol: H11Protocol) -> None: + protocol.config.h11_pass_raw_headers = True + client = h11.Connection(h11.CLIENT) + headers = BASIC_HEADERS + [("FOO_BAR", "foobar")] + await protocol.handle( + RawData(data=client.send(h11.Request(method="GET", target="/?a=b", headers=headers))) + ) + protocol.stream.handle.assert_called() # type: ignore + assert protocol.stream.handle.call_args_list == [ # type: ignore + call( + Request( + stream_id=1, + headers=[ + (b"Host", b"hypercorn"), + (b"Connection", b"close"), + (b"FOO_BAR", b"foobar"), + ], + http_version="1.1", + method="GET", + raw_path=b"/?a=b", + ) + ), + call(EndBody(stream_id=1)), + ] + + @pytest.mark.asyncio async def test_protocol_handle_protocol_error(protocol: H11Protocol) -> None: await protocol.handle(RawData(data=b"broken nonsense\r\n\r\n")) From 07b03628ca0f6bc71533bc12117e20c51603a1ce Mon Sep 17 00:00:00 2001 From: richardsheridan Date: Tue, 28 Feb 2023 10:34:11 -0500 Subject: [PATCH 062/151] Handle read_timeout exception on trio --- src/hypercorn/trio/tcp_server.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/hypercorn/trio/tcp_server.py b/src/hypercorn/trio/tcp_server.py index b64ca57..dbcc7a1 100644 --- a/src/hypercorn/trio/tcp_server.py +++ b/src/hypercorn/trio/tcp_server.py @@ -96,7 +96,11 @@ async def _read_data(self) -> None: try: with trio.fail_after(self.config.read_timeout or inf): data = await self.stream.receive_some(MAX_RECV) - except (trio.ClosedResourceError, trio.BrokenResourceError): + except ( + trio.ClosedResourceError, + trio.BrokenResourceError, + trio.TooSlowError, + ): break else: await self.protocol.handle(RawData(data)) From 5d95d87657a37fa871e97e1288b363cf27df42f4 Mon Sep 17 00:00:00 2001 From: pgjones Date: Sat, 8 Jul 2023 18:11:07 +0100 Subject: [PATCH 063/151] Add a publish workflow This will allow trusted publishing to PyPI. --- .github/workflows/publish.yml | 38 +++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..b3bd05e --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,38 @@ +name: Publish +on: + push: + tags: + - '*' +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-python@v3 + with: + python-version: 3.11 + + - run: | + pip install poetry + poetry build + - uses: actions/upload-artifact@v3 + with: + path: ./dist + + pypi-publish: + needs: ['build'] + environment: 'publish' + + name: upload release to PyPI + runs-on: ubuntu-latest + permissions: + # IMPORTANT: this permission is mandatory for trusted publishing + id-token: write + steps: + - uses: actions/download-artifact@v3 + + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages_dir: artifact/ From f79df2c0be6e2d38993cd49f6799705471255dca Mon Sep 17 00:00:00 2001 From: pgjones Date: Sat, 8 Jul 2023 18:35:33 +0100 Subject: [PATCH 064/151] Bump and release 0.14.4 --- CHANGELOG.rst | 18 ++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f842df3..85cab0b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,21 @@ +0.14.4 2023-07-08 +----------------- + +* Bugfix Use tomllib/tomli for .toml support replacing the + unmaintained toml library. +* Bugfix server hanging on startup failure. +* Bugfix close websocket with 1011 on internal error (1006 is a + client-only code). +* Bugfix support trio > 0.22 utilising exception groups (note trio <= + 0.22 is not supported). +* Bugfix except ConnectionAbortedError which can be raised on Windows + machines. +* Bugfix ensure that closed is sent on reading end. +* Bugfix handle read_timeout exception on trio. +* Support and test against Python 3.11. +* Add explanation of PicklingErrors. +* Add config option to pass raw h11 headers. + 0.14.3 2022-09-04 ----------------- diff --git a/pyproject.toml b/pyproject.toml index 4c6baed..60a012b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "Hypercorn" -version = "0.14.3+dev" +version = "0.14.4" description = "A ASGI Server based on Hyper libraries and inspired by Gunicorn" authors = ["pgjones "] classifiers = [ From 86a04d1ad2f3af9946ed1669c9e92ffa75c2fa13 Mon Sep 17 00:00:00 2001 From: pgjones Date: Sat, 8 Jul 2023 18:36:47 +0100 Subject: [PATCH 065/151] Following the release of 0.14.4 bump to +dev --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 60a012b..c9f53c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "Hypercorn" -version = "0.14.4" +version = "0.14.4+dev" description = "A ASGI Server based on Hyper libraries and inspired by Gunicorn" authors = ["pgjones "] classifiers = [ From 640866f91b85cfe48b9a7bc20b15c4dd46952e3d Mon Sep 17 00:00:00 2001 From: pgjones Date: Thu, 31 Aug 2023 08:39:18 +0100 Subject: [PATCH 066/151] Improve the NoAppError It now repeats where it looked and why it failed (module or app). This will make it much easier to fix the problem. --- src/hypercorn/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hypercorn/utils.py b/src/hypercorn/utils.py index 75722db..2598007 100644 --- a/src/hypercorn/utils.py +++ b/src/hypercorn/utils.py @@ -115,13 +115,13 @@ def load_application(path: str, wsgi_max_body_size: int) -> AppWrapper: module = import_module(import_name) except ModuleNotFoundError as error: if error.name == import_name: - raise NoAppError() + raise NoAppError(f"Cannot load application from '{path}', module not found.") else: raise try: app = eval(app_name, vars(module)) except NameError: - raise NoAppError() + raise NoAppError(f"Cannot load application from '{path}', application not found.") else: return wrap_app(app, wsgi_max_body_size, mode) From c5b701ce61ebde78598cf4c1cb50b3d2dd424913 Mon Sep 17 00:00:00 2001 From: pgjones Date: Thu, 31 Aug 2023 08:39:56 +0100 Subject: [PATCH 067/151] Fix latest mypy issues --- tests/middleware/test_dispatcher.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/middleware/test_dispatcher.py b/tests/middleware/test_dispatcher.py index dbb3f43..1c3d7a2 100644 --- a/tests/middleware/test_dispatcher.py +++ b/tests/middleware/test_dispatcher.py @@ -36,9 +36,9 @@ async def send(message: dict) -> None: nonlocal sent_events sent_events.append(message) - await app({**http_scope, **{"path": "/api/x/b"}}, None, send) # type: ignore - await app({**http_scope, **{"path": "/api/b"}}, None, send) # type: ignore - await app({**http_scope, **{"path": "/"}}, None, send) # type: ignore + await app({**http_scope, **{"path": "/api/x/b"}}, None, send) + await app({**http_scope, **{"path": "/api/b"}}, None, send) + await app({**http_scope, **{"path": "/"}}, None, send) assert sent_events == [ {"type": "http.response.start", "status": 200, "headers": [(b"content-length", b"7")]}, {"type": "http.response.body", "body": b"apix-/b"}, From 7aff85bf979ab62ef396450913972d2baba9665b Mon Sep 17 00:00:00 2001 From: pgjones Date: Sat, 16 Sep 2023 15:56:32 +0100 Subject: [PATCH 068/151] Log cancelled requests as well as successful Otherwise these are effectively invisble and if they cause problems are very hard to debug. They are distinguishable as the status code will be `-`. --- src/hypercorn/asyncio/tcp_server.py | 1 - src/hypercorn/logging.py | 24 ++++++++++++++---------- src/hypercorn/protocol/http_stream.py | 1 + tests/protocol/test_http_stream.py | 6 ++++++ 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/hypercorn/asyncio/tcp_server.py b/src/hypercorn/asyncio/tcp_server.py index bc03748..025ec0a 100644 --- a/src/hypercorn/asyncio/tcp_server.py +++ b/src/hypercorn/asyncio/tcp_server.py @@ -83,7 +83,6 @@ async def protocol_send(self, event: Event) -> None: await self.protocol.handle(Closed()) elif isinstance(event, Closed): await self._close() - await self.protocol.handle(Closed()) elif isinstance(event, Updated): if event.idle: await self._start_idle() diff --git a/src/hypercorn/logging.py b/src/hypercorn/logging.py index 8ca6105..d9b8901 100644 --- a/src/hypercorn/logging.py +++ b/src/hypercorn/logging.py @@ -118,7 +118,7 @@ async def log(self, level: int, message: str, *args: Any, **kwargs: Any) -> None self.error_logger.log(level, message, *args, **kwargs) def atoms( - self, request: "WWWScope", response: "ResponseSummary", request_time: float + self, request: "WWWScope", response: Optional["ResponseSummary"], request_time: float ) -> Mapping[str, str]: """Create and return an access log atoms dictionary. @@ -133,12 +133,10 @@ def __getattr__(self, name: str) -> Any: class AccessLogAtoms(dict): def __init__( - self, request: "WWWScope", response: "ResponseSummary", request_time: float + self, request: "WWWScope", response: Optional["ResponseSummary"], request_time: float ) -> None: for name, value in request["headers"]: self[f"{{{name.decode('latin1').lower()}}}i"] = value.decode("latin1") - for name, value in response.get("headers", []): - self[f"{{{name.decode('latin1').lower()}}}o"] = value.decode("latin1") for name, value in os.environ.items(): self[f"{{{name.lower()}}}e"] = value protocol = request.get("http_version", "ws") @@ -157,11 +155,17 @@ def __init__( method = "GET" query_string = request["query_string"].decode() path_with_qs = request["path"] + ("?" + query_string if query_string else "") - status_code = response["status"] - try: - status_phrase = HTTPStatus(status_code).phrase - except ValueError: - status_phrase = f"" + + status_code = "-" + status_phrase = "-" + if response is not None: + for name, value in response.get("headers", []): # type: ignore + self[f"{{{name.decode('latin1').lower()}}}o"] = value.decode("latin1") # type: ignore # noqa: E501 + status_code = str(response["status"]) + try: + status_phrase = HTTPStatus(response["status"]).phrase + except ValueError: + status_phrase = f"" self.update( { "h": remote_addr, @@ -169,7 +173,7 @@ def __init__( "t": time.strftime("[%d/%b/%Y:%H:%M:%S %z]"), "r": f"{method} {request['path']} {protocol}", "R": f"{method} {path_with_qs} {protocol}", - "s": response["status"], + "s": status_code, "st": status_phrase, "S": request["scheme"], "m": method, diff --git a/src/hypercorn/protocol/http_stream.py b/src/hypercorn/protocol/http_stream.py index cf52c29..d244e7c 100644 --- a/src/hypercorn/protocol/http_stream.py +++ b/src/hypercorn/protocol/http_stream.py @@ -110,6 +110,7 @@ async def handle(self, event: Event) -> None: await self.app_put({"type": "http.request", "body": b"", "more_body": False}) elif isinstance(event, StreamClosed): self.closed = True + await self.config.log.access(self.scope, None, time() - self.start_time) if self.app_put is not None: await self.app_put({"type": "http.disconnect"}) diff --git a/tests/protocol/test_http_stream.py b/tests/protocol/test_http_stream.py index a92765c..24af596 100644 --- a/tests/protocol/test_http_stream.py +++ b/tests/protocol/test_http_stream.py @@ -108,6 +108,9 @@ async def test_handle_end_body(stream: HTTPStream) -> None: @pytest.mark.asyncio async def test_handle_closed(stream: HTTPStream) -> None: + await stream.handle( + Request(stream_id=1, http_version="2", headers=[], raw_path=b"/?a=b", method="GET") + ) await stream.handle(StreamClosed(stream_id=1)) stream.app_put.assert_called() # type: ignore assert stream.app_put.call_args_list == [call({"type": "http.disconnect"})] # type: ignore @@ -275,6 +278,9 @@ def test_stream_idle(stream: HTTPStream) -> None: @pytest.mark.asyncio async def test_closure(stream: HTTPStream) -> None: + await stream.handle( + Request(stream_id=1, http_version="2", headers=[], raw_path=b"/?a=b", method="GET") + ) assert not stream.closed await stream.handle(StreamClosed(stream_id=1)) assert stream.closed From d7990fb4b07a9b87390f60dcb1647789f99cdcc6 Mon Sep 17 00:00:00 2001 From: pgjones Date: Sat, 16 Sep 2023 15:57:54 +0100 Subject: [PATCH 069/151] Add some useful flow diagrams This will help understand how the events are meant to pass in sequence, especially when I forget. --- docs/conf.py | 2 +- docs/discussion/flow.rst | 49 +++++++++++++++++++++++++++++++++++++++ docs/discussion/index.rst | 1 + pyproject.toml | 3 ++- tox.ini | 1 + 5 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 docs/discussion/flow.rst diff --git a/docs/conf.py b/docs/conf.py index 926f7dd..45b063e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -32,7 +32,7 @@ # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = ['sphinx.ext.autodoc', 'sphinx.ext.napoleon'] +extensions = ['sphinx.ext.autodoc', 'sphinx.ext.napoleon', 'sphinxcontrib.mermaid'] # Add any paths that contain templates here, relative to this directory. # templates_path = ['_templates'] diff --git a/docs/discussion/flow.rst b/docs/discussion/flow.rst new file mode 100644 index 0000000..4f3b416 --- /dev/null +++ b/docs/discussion/flow.rst @@ -0,0 +1,49 @@ +Flow +==== + +These are the expected event flows/sequences. + +H11/H2 +------ + +A typical HTTP/1 or HTTP/2 request with response with the connection +specified to close on response. + +.. mermaid:: + + sequenceDiagram + TCPServer->>H11/H2: RawData + H11/H2->>HTTPStream: Request + H11/H2->>HTTPStream: Body + HTTPStream->>App: http.request[more_body=True] + H11/H2->>HTTPStream: EndBody + HTTPStream->>App: http.request[more_body=False] + App->>HTTPStream: http.response.start + App->>HTTPStream: http.response.body + HTTPStream->>H11/H2: Response + H11/H2->>TCPServer: RawData + HTTPStream->>H11/H2: Body + H11/H2->>TCPServer: RawData + HTTPStream->>H11/H2: EndBody + H11/H2->>TCPServer: RawData + H11/H2->>HTTPStream: StreamClosed + HTTPStream->>App: http.disconnect + H11/H2->>TCPServer: Closed + + +H11 early client cancel +----------------------- + +The flow as expected if the connection is closed before the server has +the opportunity to respond. + +.. mermaid:: + + sequenceDiagram + TCPServer->>H11/H2: RawData + H11/H2->>HTTPStream: Request + H11/H2->>HTTPStream: Body + HTTPStream->>App: http.request[more_body=True] + TCPServer->>H11/H2: Closed + H11/H2->>HTTPStream: StreamClosed + HTTPStream->>App: http.disconnect diff --git a/docs/discussion/index.rst b/docs/discussion/index.rst index aa1bd0e..509e1c2 100644 --- a/docs/discussion/index.rst +++ b/docs/discussion/index.rst @@ -9,5 +9,6 @@ Discussions closing.rst design_choices.rst dos_mitigations.rst + flow.rst http2.rst workers.rst diff --git a/pyproject.toml b/pyproject.toml index c9f53c6..52f11c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ h11 = "*" h2 = ">=3.1.0" priority = "*" pydata_sphinx_theme = { version = "*", optional = true } +sphinxcontrib_mermaid = { version = "*", optional = true } tomli = { version = "*", python = "<3.11" } trio = { version = ">=0.22.0", optional = true } typing_extensions = { version = ">=3.7.4", python = "<3.8" } @@ -51,7 +52,7 @@ trio = "*" hypercorn = "hypercorn.__main__:main" [tool.poetry.extras] -docs = ["pydata_sphinx_theme"] +docs = ["pydata_sphinx_theme", "sphinxcontrib_mermaid"] h3 = ["aioquic"] trio = ["exceptiongroup", "trio"] uvloop = ["uvloop"] diff --git a/tox.ini b/tox.ini index bb7571b..75de171 100644 --- a/tox.ini +++ b/tox.ini @@ -19,6 +19,7 @@ basepython = python3.11 deps = pydata-sphinx-theme sphinx + sphinxcontrib-mermaid trio commands = sphinx-apidoc -e -f -o docs/reference/source/ src/hypercorn/ src/hypercorn/protocol/quic.py src/hypercorn/protocol/h3.py From 96e3fce5c634c80b95da553b870ee62daa6af68b Mon Sep 17 00:00:00 2001 From: jwoehr Date: Sun, 3 Sep 2023 05:10:59 -0600 Subject: [PATCH 070/151] Document keyfile_password configuration factor --- docs/how_to_guides/configuring.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/how_to_guides/configuring.rst b/docs/how_to_guides/configuring.rst index 70a18af..26607ba 100644 --- a/docs/how_to_guides/configuring.rst +++ b/docs/how_to_guides/configuring.rst @@ -127,6 +127,8 @@ insecure_bind ``--insecure-bind`` The TCP host/address to keep_alive_timeout ``--keep-alive`` Seconds to keep inactive connections alive 5s before closing. keyfile ``--keyfile`` Path to the SSL key file. +keyfile_password ``--keyfile-password`` Password for the keyfile if the keyfile is + password-protected. logconfig ``--log-config`` A Python logging configuration file. This The logging ini format. can be prefixed with 'json:' or 'toml:' to load the configuration from a file in that From 42ac5db187b911db65c96a4592f863e9563988fb Mon Sep 17 00:00:00 2001 From: Jason Mitchell Date: Fri, 15 Sep 2023 11:26:14 -0700 Subject: [PATCH 071/151] Only load the application in the main process if the reloader is being used. --- src/hypercorn/run.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/hypercorn/run.py b/src/hypercorn/run.py index bfc50f1..0e03754 100644 --- a/src/hypercorn/run.py +++ b/src/hypercorn/run.py @@ -37,9 +37,10 @@ def run(config: Config) -> None: sockets = config.create_sockets() - # Load the application so that the correct paths are checked for - # changes. - load_application(config.application_path, config.wsgi_max_body_size) + if config.use_reloader: + # Load the application so that the correct paths are checked for + # changes, but only when the reloader is being used. + load_application(config.application_path, config.wsgi_max_body_size) ctx = get_context("spawn") From 4854ffd89e8661213ff20828b7568a9f004803a9 Mon Sep 17 00:00:00 2001 From: GoDjango LLC <130395881+godjangollc@users.noreply.github.com> Date: Mon, 23 Oct 2023 12:11:07 -0400 Subject: [PATCH 072/151] fix: Autoreload error because reausing old sockets Fixing issues: # 129 # 118 # 111 # 108 --- src/hypercorn/run.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/hypercorn/run.py b/src/hypercorn/run.py index 0e03754..4427aba 100644 --- a/src/hypercorn/run.py +++ b/src/hypercorn/run.py @@ -66,6 +66,7 @@ def shutdown(*args: Any) -> None: if config.use_reloader: wait_for_changes(shutdown_event) shutdown_event.set() + sockets = config.create_sockets() # Recreate the sockets to be used again in the next iteration of the loop. else: active = False From 3dc7908d59de48fc25b28cd327e2c70afac3bd93 Mon Sep 17 00:00:00 2001 From: pgjones Date: Sun, 8 Oct 2023 10:11:09 +0100 Subject: [PATCH 073/151] Support Python 3.12 drop Python 3.7 Following a new release and recent end of life. --- .github/workflows/ci.yml | 13 ++++++------- .github/workflows/publish.yml | 2 +- pyproject.toml | 5 ++--- src/hypercorn/asyncio/__init__.py | 7 +------ src/hypercorn/trio/__init__.py | 7 +------ src/hypercorn/typing.py | 21 ++++++++++++++------- src/hypercorn/utils.py | 6 +----- tox.ini | 12 ++++++------ 8 files changed, 32 insertions(+), 41 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e50fe8..558ddb4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,16 +14,15 @@ jobs: fail-fast: false matrix: include: - - {name: '3.12-dev', python: '3.12-dev', tox: py312} + - {name: '3.12', python: '3.12', tox: py312} - {name: '3.11', python: '3.11', tox: py311} - {name: '3.10', python: '3.10', tox: py310} - {name: '3.9', python: '3.9', tox: py39} - {name: '3.8', python: '3.8', tox: py38} - - {name: '3.7', python: '3.7', tox: py37} - - {name: 'format', python: '3.11', tox: format} - - {name: 'mypy', python: '3.11', tox: mypy} - - {name: 'pep8', python: '3.11', tox: pep8} - - {name: 'package', python: '3.11', tox: package} + - {name: 'format', python: '3.12', tox: format} + - {name: 'mypy', python: '3.12', tox: mypy} + - {name: 'pep8', python: '3.12', tox: pep8} + - {name: 'package', python: '3.12', tox: package} steps: - uses: actions/checkout@v3 @@ -57,7 +56,7 @@ jobs: - uses: actions/setup-python@v3 with: - python-version: "3.11" + python-version: "3.12" - name: update pip run: | diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b3bd05e..5e011a7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -11,7 +11,7 @@ jobs: - uses: actions/setup-python@v3 with: - python-version: 3.11 + python-version: 3.12 - run: | pip install poetry diff --git a/pyproject.toml b/pyproject.toml index 52f11c1..394dbc0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,11 +11,11 @@ classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Topic :: Software Development :: Libraries :: Python Modules", ] @@ -36,7 +36,6 @@ pydata_sphinx_theme = { version = "*", optional = true } sphinxcontrib_mermaid = { version = "*", optional = true } tomli = { version = "*", python = "<3.11" } trio = { version = ">=0.22.0", optional = true } -typing_extensions = { version = ">=3.7.4", python = "<3.8" } uvloop = { version = "*", markers = "platform_system != 'Windows'", optional = true } wsproto = ">=0.14.0" @@ -59,7 +58,7 @@ uvloop = ["uvloop"] [tool.black] line-length = 100 -target-version = ["py37"] +target-version = ["py38"] [tool.isort] combine_as_imports = true diff --git a/src/hypercorn/asyncio/__init__.py b/src/hypercorn/asyncio/__init__.py index 3ae3b66..aff61e8 100644 --- a/src/hypercorn/asyncio/__init__.py +++ b/src/hypercorn/asyncio/__init__.py @@ -1,18 +1,13 @@ from __future__ import annotations import warnings -from typing import Awaitable, Callable, Optional +from typing import Awaitable, Callable, Literal, Optional from .run import worker_serve from ..config import Config from ..typing import Framework from ..utils import wrap_app -try: - from typing import Literal -except ImportError: - from typing_extensions import Literal # type: ignore - async def serve( app: Framework, diff --git a/src/hypercorn/trio/__init__.py b/src/hypercorn/trio/__init__.py index 5575efe..44a2eb9 100644 --- a/src/hypercorn/trio/__init__.py +++ b/src/hypercorn/trio/__init__.py @@ -1,7 +1,7 @@ from __future__ import annotations import warnings -from typing import Awaitable, Callable, Optional +from typing import Awaitable, Callable, Literal, Optional import trio @@ -10,11 +10,6 @@ from ..typing import Framework from ..utils import wrap_app -try: - from typing import Literal -except ImportError: - from typing_extensions import Literal # type: ignore - async def serve( app: Framework, diff --git a/src/hypercorn/typing.py b/src/hypercorn/typing.py index 206415c..1299a77 100644 --- a/src/hypercorn/typing.py +++ b/src/hypercorn/typing.py @@ -2,17 +2,24 @@ from multiprocessing.synchronize import Event as EventType from types import TracebackType -from typing import Any, Awaitable, Callable, Dict, Iterable, Optional, Tuple, Type, Union +from typing import ( + Any, + Awaitable, + Callable, + Dict, + Iterable, + Literal, + Optional, + Protocol, + Tuple, + Type, + TypedDict, + Union, +) import h2.events import h11 -# Till PEP 544 is accepted -try: - from typing import Literal, Protocol, TypedDict -except ImportError: - from typing_extensions import Literal, Protocol, TypedDict # type: ignore - from .config import Config, Sockets H11SendableEvent = Union[h11.Data, h11.EndOfMessage, h11.InformationalResponse, h11.Response] diff --git a/src/hypercorn/utils.py b/src/hypercorn/utils.py index 2598007..af6e2c5 100644 --- a/src/hypercorn/utils.py +++ b/src/hypercorn/utils.py @@ -17,6 +17,7 @@ Dict, Iterable, List, + Literal, Optional, Tuple, TYPE_CHECKING, @@ -26,11 +27,6 @@ from .config import Config from .typing import AppWrapper, ASGIFramework, Framework, WSGIFramework -try: - from typing import Literal -except ImportError: - from typing_extensions import Literal # type: ignore - if TYPE_CHECKING: from .protocol.events import Request diff --git a/tox.ini b/tox.ini index 75de171..a099459 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = docs,format,mypy,py37,py38,py39,py310,py311,package,pep8 +envlist = docs,format,mypy,py38,py39,py310,py311,py312,package,pep8 minversion = 3.3 isolated_build = true @@ -15,7 +15,7 @@ deps = commands = pytest --cov=hypercorn {posargs} [testenv:docs] -basepython = python3.11 +basepython = python3.12 deps = pydata-sphinx-theme sphinx @@ -26,7 +26,7 @@ commands = sphinx-build -W --keep-going -b html -d {envtmpdir}/doctrees docs/ docs/_build/html/ [testenv:format] -basepython = python3.11 +basepython = python3.12 deps = black isort @@ -35,7 +35,7 @@ commands = isort --check --diff src/hypercorn tests [testenv:pep8] -basepython = python3.11 +basepython = python3.12 deps = flake8 pep8-naming @@ -44,7 +44,7 @@ deps = commands = flake8 src/hypercorn/ tests/ [testenv:mypy] -basepython = python3.11 +basepython = python3.12 deps = mypy pytest @@ -52,7 +52,7 @@ commands = mypy src/hypercorn/ tests/ [testenv:package] -basepython = python3.11 +basepython = python3.12 deps = poetry twine From 8133958388717c8c9c4e486115939e6e0f7eab0b Mon Sep 17 00:00:00 2001 From: pgjones Date: Sat, 28 Oct 2023 20:00:05 +0100 Subject: [PATCH 074/151] Use more modern asyncio apis This utilises the taskgroup backport to allow usage of these apis in pre Python 3.11 code. This should mean the code is more reliable, and robust. It definetly means it is clearer. Note I've switched from WeakSet to Set and the after_done_callback as the Python TaskGroup code uses Set. I hope this could fix and explain a possible memory leak reported by some users. --- pyproject.toml | 3 +- src/hypercorn/asyncio/run.py | 87 ++++++----------------------- src/hypercorn/asyncio/task_group.py | 32 +++-------- tests/asyncio/test_task_group.py | 14 ----- 4 files changed, 29 insertions(+), 107 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 394dbc0..ec7e410 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,12 +28,13 @@ documentation = "https://hypercorn.readthedocs.io" [tool.poetry.dependencies] python = ">=3.7" aioquic = { version = ">= 0.9.0, < 1.0", optional = true } -exceptiongroup = { version = ">= 1.1.0", python = "<3.11", optional = true } +exceptiongroup = ">= 1.1.0" h11 = "*" h2 = ">=3.1.0" priority = "*" pydata_sphinx_theme = { version = "*", optional = true } sphinxcontrib_mermaid = { version = "*", optional = true } +taskgroup = { version = "*", python = "<3.11", allow-prereleases = true } tomli = { version = "*", python = "<3.11" } trio = { version = ">=0.22.0", optional = true } uvloop = { version = "*", markers = "platform_system != 'Windows'", optional = true } diff --git a/src/hypercorn/asyncio/run.py b/src/hypercorn/asyncio/run.py index b50b9c6..55e8bf8 100644 --- a/src/hypercorn/asyncio/run.py +++ b/src/hypercorn/asyncio/run.py @@ -8,8 +8,7 @@ from multiprocessing.synchronize import Event as EventType from os import getpid from socket import socket -from typing import Any, Awaitable, Callable, Optional -from weakref import WeakSet +from typing import Any, Awaitable, Callable, Optional, Set from .lifespan import Lifespan from .statsd import StatsdLogger @@ -26,13 +25,10 @@ ShutdownError, ) - -async def _windows_signal_support() -> None: - # See https://bugs.python.org/issue23057, to catch signals on - # Windows it is necessary for an IO event to happen periodically. - # Fixed by Python 3.8 - while True: - await asyncio.sleep(1) +try: + from asyncio import Runner +except ImportError: + from taskgroup import Runner # type: ignore def _share_socket(sock: socket) -> socket: @@ -89,10 +85,14 @@ def _signal_handler(*_: Any) -> None: # noqa: N803 ssl_handshake_timeout = config.ssl_handshake_timeout context = WorkerContext() - server_tasks: WeakSet = WeakSet() + server_tasks: Set[asyncio.Task] = set() async def _server_callback(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: - server_tasks.add(asyncio.current_task(loop)) + nonlocal server_tasks + + task = asyncio.current_task(loop) + server_tasks.add(task) + task.add_done_callback(server_tasks.discard) await TCPServer(app, loop, config, context, reader, writer) servers = [] @@ -129,22 +129,14 @@ async def _server_callback(reader: asyncio.StreamReader, writer: asyncio.StreamW _, protocol = await loop.create_datagram_endpoint( lambda: UDPServer(app, loop, config, context), sock=sock ) - server_tasks.add(loop.create_task(protocol.run())) + task = loop.create_task(protocol.run()) + server_tasks.add(task) + task.add_done_callback(server_tasks.discard) bind = repr_socket_addr(sock.family, sock.getsockname()) await config.log.info(f"Running on https://{bind} (QUIC) (CTRL + C to quit)") - tasks = [] - if platform.system() == "Windows": - tasks.append(loop.create_task(_windows_signal_support())) - - tasks.append(loop.create_task(raise_shutdown(shutdown_trigger))) - try: - if len(tasks): - gathered_tasks = asyncio.gather(*tasks) - await gathered_tasks - else: - loop.run_forever() + await raise_shutdown(shutdown_trigger) except (ShutdownError, KeyboardInterrupt): pass finally: @@ -154,10 +146,6 @@ async def _server_callback(reader: asyncio.StreamReader, writer: asyncio.StreamW server.close() await server.wait_closed() - # Retrieve the Gathered Tasks Cancelled Exception, to - # prevent a warning that this hasn't been done. - gathered_tasks.exception() - try: gathered_server_tasks = asyncio.gather(*server_tasks) await asyncio.wait_for(gathered_server_tasks, config.graceful_timeout) @@ -221,48 +209,9 @@ def _run( debug: bool = False, shutdown_trigger: Optional[Callable[..., Awaitable[None]]] = None, ) -> None: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - loop.set_debug(debug) - loop.set_exception_handler(_exception_handler) - - try: - loop.run_until_complete(main(shutdown_trigger=shutdown_trigger)) - except KeyboardInterrupt: - pass - finally: - try: - _cancel_all_tasks(loop) - loop.run_until_complete(loop.shutdown_asyncgens()) - - try: - loop.run_until_complete(loop.shutdown_default_executor()) - except AttributeError: - pass # shutdown_default_executor is new to Python 3.9 - - finally: - asyncio.set_event_loop(None) - loop.close() - - -def _cancel_all_tasks(loop: asyncio.AbstractEventLoop) -> None: - tasks = [task for task in asyncio.all_tasks(loop) if not task.done()] - if not tasks: - return - - for task in tasks: - task.cancel() - loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True)) - - for task in tasks: - if not task.cancelled() and task.exception() is not None: - loop.call_exception_handler( - { - "message": "unhandled exception during shutdown", - "exception": task.exception(), - "task": task, - } - ) + with Runner(debug=debug) as runner: + runner.get_loop().set_exception_handler(_exception_handler) + runner.run(main(shutdown_trigger=shutdown_trigger)) def _exception_handler(loop: asyncio.AbstractEventLoop, context: dict) -> None: diff --git a/src/hypercorn/asyncio/task_group.py b/src/hypercorn/asyncio/task_group.py index 2cfb5a3..2e58903 100644 --- a/src/hypercorn/asyncio/task_group.py +++ b/src/hypercorn/asyncio/task_group.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -import weakref from functools import partial from types import TracebackType from typing import Any, Awaitable, Callable, Optional @@ -9,6 +8,11 @@ from ..config import Config from ..typing import AppWrapper, ASGIReceiveCallable, ASGIReceiveEvent, ASGISendEvent, Scope +try: + from asyncio import TaskGroup as AsyncioTaskGroup +except ImportError: + from taskgroup import TaskGroup as AsyncioTaskGroup # type: ignore + async def _handle( app: AppWrapper, @@ -32,8 +36,7 @@ async def _handle( class TaskGroup: def __init__(self, loop: asyncio.AbstractEventLoop) -> None: self._loop = loop - self._tasks: weakref.WeakSet = weakref.WeakSet() - self._exiting = False + self._task_group = AsyncioTaskGroup() async def spawn_app( self, @@ -61,28 +64,11 @@ def _call_soon(func: Callable, *args: Any) -> Any: return app_queue.put def spawn(self, func: Callable, *args: Any) -> None: - if self._exiting: - raise RuntimeError("Spawning whilst exiting") - self._tasks.add(self._loop.create_task(func(*args))) + self._task_group.create_task(func(*args)) async def __aenter__(self) -> "TaskGroup": + await self._task_group.__aenter__() return self async def __aexit__(self, exc_type: type, exc_value: BaseException, tb: TracebackType) -> None: - self._exiting = True - if exc_type is not None: - self._cancel_tasks() - - try: - task = asyncio.gather(*self._tasks) - await task - finally: - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - - def _cancel_tasks(self) -> None: - for task in self._tasks: - task.cancel() + await self._task_group.__aexit__(exc_type, exc_value, tb) diff --git a/tests/asyncio/test_task_group.py b/tests/asyncio/test_task_group.py index e0049ae..dfb509c 100644 --- a/tests/asyncio/test_task_group.py +++ b/tests/asyncio/test_task_group.py @@ -41,17 +41,3 @@ async def _error_app(scope: Scope, receive: Callable, send: Callable) -> None: async with TaskGroup(event_loop) as task_group: await task_group.spawn_app(ASGIWrapper(_error_app), Config(), http_scope, app_queue.put) assert (await app_queue.get()) is None - - -@pytest.mark.asyncio -async def test_spawn_app_cancelled( - event_loop: asyncio.AbstractEventLoop, http_scope: HTTPScope -) -> None: - async def _error_app(scope: Scope, receive: Callable, send: Callable) -> None: - raise asyncio.CancelledError() - - app_queue: asyncio.Queue = asyncio.Queue() - with pytest.raises(asyncio.CancelledError): - async with TaskGroup(event_loop) as task_group: - await task_group.spawn_app(ASGIWrapper(_error_app), Config(), http_scope, app_queue.put) - assert (await app_queue.get()) is None From 76bd00f338dcf9fc40637bd53706ccc45bb10fef Mon Sep 17 00:00:00 2001 From: pgjones Date: Sat, 28 Oct 2023 20:07:17 +0100 Subject: [PATCH 075/151] Fix fomatting in 4854ffd89e8661213ff20828b7568a9f004803a9 --- src/hypercorn/run.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/hypercorn/run.py b/src/hypercorn/run.py index 4427aba..acd6a86 100644 --- a/src/hypercorn/run.py +++ b/src/hypercorn/run.py @@ -66,7 +66,9 @@ def shutdown(*args: Any) -> None: if config.use_reloader: wait_for_changes(shutdown_event) shutdown_event.set() - sockets = config.create_sockets() # Recreate the sockets to be used again in the next iteration of the loop. + # Recreate the sockets to be used again in the next + # iteration of the loop. + sockets = config.create_sockets() else: active = False From 30e6f03a11a138280bde4dc7ec66c668242c87c6 Mon Sep 17 00:00:00 2001 From: pgjones Date: Sat, 28 Oct 2023 22:35:16 +0100 Subject: [PATCH 076/151] Bugfix scope client usage for sock binding The scope client can be None, especially for unix sock usage. This should result in REMOTE_ADDR not being set. --- src/hypercorn/app_wrappers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hypercorn/app_wrappers.py b/src/hypercorn/app_wrappers.py index 693f039..769e014 100644 --- a/src/hypercorn/app_wrappers.py +++ b/src/hypercorn/app_wrappers.py @@ -132,7 +132,7 @@ def _build_environ(scope: HTTPScope, body: bytes) -> dict: "wsgi.run_once": False, } - if "client" in scope: + if scope.get("client") is not None: environ["REMOTE_ADDR"] = scope["client"][0] for raw_name, raw_value in scope.get("headers", []): From 662ffa952331e0e0696010624e3738dec0d855fb Mon Sep 17 00:00:00 2001 From: pgjones Date: Sun, 29 Oct 2023 10:15:57 +0000 Subject: [PATCH 077/151] Disable multiprocessing if number of workers is 0 This should allow support of Digital Ocean and other hosting environments that don't support multiprocessing. Note thought there is no intention of supporting the reloader in this state (as pre 14.3). --- src/hypercorn/run.py | 86 +++++++++++++++++++++++--------------------- 1 file changed, 46 insertions(+), 40 deletions(-) diff --git a/src/hypercorn/run.py b/src/hypercorn/run.py index acd6a86..a6d2fb0 100644 --- a/src/hypercorn/run.py +++ b/src/hypercorn/run.py @@ -37,50 +37,56 @@ def run(config: Config) -> None: sockets = config.create_sockets() - if config.use_reloader: + if config.use_reloader and config.workers == 0: + raise RuntimeError("Cannot reload without workers") + + if config.use_reloader or config.workers == 0: # Load the application so that the correct paths are checked for # changes, but only when the reloader is being used. load_application(config.application_path, config.wsgi_max_body_size) - ctx = get_context("spawn") - - active = True - while active: - # Ignore SIGINT before creating the processes, so that they - # inherit the signal handling. This means that the shutdown - # function controls the shutdown. - signal.signal(signal.SIGINT, signal.SIG_IGN) - - shutdown_event = ctx.Event() - processes = start_processes(config, worker_func, sockets, shutdown_event, ctx) - - def shutdown(*args: Any) -> None: - nonlocal active, shutdown_event - shutdown_event.set() - active = False - - for signal_name in {"SIGINT", "SIGTERM", "SIGBREAK"}: - if hasattr(signal, signal_name): - signal.signal(getattr(signal, signal_name), shutdown) - - if config.use_reloader: - wait_for_changes(shutdown_event) - shutdown_event.set() - # Recreate the sockets to be used again in the next - # iteration of the loop. - sockets = config.create_sockets() - else: - active = False - - for process in processes: - process.join() - for process in processes: - process.terminate() - - for sock in sockets.secure_sockets: - sock.close() - for sock in sockets.insecure_sockets: - sock.close() + if config.workers == 0: + worker_func(config, sockets) + else: + ctx = get_context("spawn") + + active = True + while active: + # Ignore SIGINT before creating the processes, so that they + # inherit the signal handling. This means that the shutdown + # function controls the shutdown. + signal.signal(signal.SIGINT, signal.SIG_IGN) + + shutdown_event = ctx.Event() + processes = start_processes(config, worker_func, sockets, shutdown_event, ctx) + + def shutdown(*args: Any) -> None: + nonlocal active, shutdown_event + shutdown_event.set() + active = False + + for signal_name in {"SIGINT", "SIGTERM", "SIGBREAK"}: + if hasattr(signal, signal_name): + signal.signal(getattr(signal, signal_name), shutdown) + + if config.use_reloader: + wait_for_changes(shutdown_event) + shutdown_event.set() + # Recreate the sockets to be used again in the next + # iteration of the loop. + sockets = config.create_sockets() + else: + active = False + + for process in processes: + process.join() + for process in processes: + process.terminate() + + for sock in sockets.secure_sockets: + sock.close() + for sock in sockets.insecure_sockets: + sock.close() def start_processes( From 2724ad6c307239f6ee7854c7d1794a625f66f7d4 Mon Sep 17 00:00:00 2001 From: pgjones Date: Sun, 29 Oct 2023 22:48:10 +0000 Subject: [PATCH 078/151] Bump and release 0.15.0 --- CHANGELOG.rst | 16 ++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 85cab0b..e28fc5e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,19 @@ +0.14.5 2023-10-29 +----------------- + +* Improve the NoAppError to help diagnose why the app has not been + found. +* Log cancelled requests as well as successful to aid diagnositics of + failures. +* Use more modern asyncio apis. This will hopefully fix reported + memory leak issues. +* Bugfix only load the application in the main process if the reloader + is being used. +* Bugfix Autoreload error because reausing old sockets. +* Bugfix scope client usage for sock binding. +* Bugfix disable multiprocessing if number of workers is 0 to support + systems that don't support multiprocessing. + 0.14.4 2023-07-08 ----------------- diff --git a/pyproject.toml b/pyproject.toml index ec7e410..4db20bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "Hypercorn" -version = "0.14.4+dev" +version = "0.15.0" description = "A ASGI Server based on Hyper libraries and inspired by Gunicorn" authors = ["pgjones "] classifiers = [ From 042fd103c359d1f43378daaf8301317a6bb6800c Mon Sep 17 00:00:00 2001 From: pgjones Date: Sun, 29 Oct 2023 22:52:16 +0000 Subject: [PATCH 079/151] Correct the changelog I had meant to release 0.14.5, but I released 0.15.0 due to tiredness. --- CHANGELOG.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e28fc5e..e18836c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,4 +1,4 @@ -0.14.5 2023-10-29 +0.15.0 2023-10-29 ----------------- * Improve the NoAppError to help diagnose why the app has not been From 19dfb96411575a6a647cdea63fa581b48ebb9180 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tin=20Tvrtkovi=C4=87?= Date: Wed, 1 Nov 2023 11:41:32 +0000 Subject: [PATCH 080/151] Relax shutdown_trigger annotation --- src/hypercorn/asyncio/__init__.py | 2 +- src/hypercorn/asyncio/run.py | 2 +- src/hypercorn/utils.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/hypercorn/asyncio/__init__.py b/src/hypercorn/asyncio/__init__.py index aff61e8..3755da0 100644 --- a/src/hypercorn/asyncio/__init__.py +++ b/src/hypercorn/asyncio/__init__.py @@ -13,7 +13,7 @@ async def serve( app: Framework, config: Config, *, - shutdown_trigger: Optional[Callable[..., Awaitable[None]]] = None, + shutdown_trigger: Optional[Callable[..., Awaitable]] = None, mode: Optional[Literal["asgi", "wsgi"]] = None, ) -> None: """Serve an ASGI or WSGI framework app given the config. diff --git a/src/hypercorn/asyncio/run.py b/src/hypercorn/asyncio/run.py index 55e8bf8..4774538 100644 --- a/src/hypercorn/asyncio/run.py +++ b/src/hypercorn/asyncio/run.py @@ -45,7 +45,7 @@ async def worker_serve( config: Config, *, sockets: Optional[Sockets] = None, - shutdown_trigger: Optional[Callable[..., Awaitable[None]]] = None, + shutdown_trigger: Optional[Callable[..., Awaitable]] = None, ) -> None: config.set_statsd_logger_class(StatsdLogger) diff --git a/src/hypercorn/utils.py b/src/hypercorn/utils.py index af6e2c5..5629ff7 100644 --- a/src/hypercorn/utils.py +++ b/src/hypercorn/utils.py @@ -164,7 +164,7 @@ def wait_for_changes(shutdown_event: EventType) -> None: last_updates[path] = mtime -async def raise_shutdown(shutdown_event: Callable[..., Awaitable[None]]) -> None: +async def raise_shutdown(shutdown_event: Callable[..., Awaitable]) -> None: await shutdown_event() raise ShutdownError() From a230eb7304255733180c2cdb4bd0e5782161d9ae Mon Sep 17 00:00:00 2001 From: pgjones Date: Mon, 27 Nov 2023 19:37:22 +0000 Subject: [PATCH 081/151] Complete 3dc7908d59de48fc25b28cd327e2c70afac3bd93 This removes any further incorrect references to Python 3.7. --- README.rst | 2 +- docs/how_to_guides/api_usage.rst | 5 ++--- docs/tutorials/installation.rst | 5 +---- pyproject.toml | 2 +- setup.cfg | 2 +- 5 files changed, 6 insertions(+), 10 deletions(-) diff --git a/README.rst b/README.rst index 25d6870..3c676b9 100644 --- a/README.rst +++ b/README.rst @@ -37,7 +37,7 @@ Hypercorn can be installed via `pip $ pip install hypercorn -and requires Python 3.7.0 or higher. +and requires Python 3.8 or higher. With hypercorn installed ASGI frameworks (or apps) can be served via Hypercorn via the command line, diff --git a/docs/how_to_guides/api_usage.rst b/docs/how_to_guides/api_usage.rst index 31246ff..6947dcd 100644 --- a/docs/how_to_guides/api_usage.rst +++ b/docs/how_to_guides/api_usage.rst @@ -7,9 +7,8 @@ Most usage of Hypercorn is expected to be via the command line, as explained in the :ref:`usage` documentation. Alternatively it is possible to use Hypercorn programmatically via the ``serve`` function available for either the asyncio or trio :ref:`workers` (note the -asyncio ``serve`` can be used with uvloop). In Python 3.7, or better, -this can be done as follows, first you need to create a Hypercorn -Config instance, +asyncio ``serve`` can be used with uvloop). This can be done as +follows, first you need to create a Hypercorn Config instance, .. code-block:: python diff --git a/docs/tutorials/installation.rst b/docs/tutorials/installation.rst index d052ca7..7298bd7 100644 --- a/docs/tutorials/installation.rst +++ b/docs/tutorials/installation.rst @@ -3,7 +3,7 @@ Installation ============ -Hypercorn is only compatible with Python 3.7 or higher and can be +Hypercorn is only compatible with Python 3.8 or higher and can be installed using pipenv or your favorite python package manager. .. code-block:: sh @@ -21,6 +21,3 @@ using: To learn more about it visit `pipenv docs `_ - -If you do not have Python 3.7 or better an error message ``Python 3.7 -is the minimum required version`` will be displayed. diff --git a/pyproject.toml b/pyproject.toml index 4db20bd..a620dfa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ repository = "https://github.com/pgjones/hypercorn/" documentation = "https://hypercorn.readthedocs.io" [tool.poetry.dependencies] -python = ">=3.7" +python = ">=3.8" aioquic = { version = ">= 0.9.0, < 1.0", optional = true } exceptiongroup = ">= 1.1.0" h11 = "*" diff --git a/setup.cfg b/setup.cfg index 0f81513..3423d8f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [flake8] ignore = E203, E252, FI58, W503, W504 max_line_length = 100 -min_version = 3.7 +min_version = 3.8 require_code = True From 33ed00670894b29ec00f4341a4ec5100e3ade747 Mon Sep 17 00:00:00 2001 From: pgjones Date: Mon, 27 Nov 2023 19:38:33 +0000 Subject: [PATCH 082/151] Update the installation docs to use pip I don't use pipenv and think it is less clear/popular than pip. --- docs/tutorials/installation.rst | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/docs/tutorials/installation.rst b/docs/tutorials/installation.rst index 7298bd7..6a7b8db 100644 --- a/docs/tutorials/installation.rst +++ b/docs/tutorials/installation.rst @@ -4,20 +4,8 @@ Installation ============ Hypercorn is only compatible with Python 3.8 or higher and can be -installed using pipenv or your favorite python package manager. +installed using pip or your favorite python package manager. .. code-block:: sh - pipenv install hypercorn - -It is sufficient to run this single command in your working directory. Besides -installing dependency, it will also create a Pipfile if one doesn't exist yet -along with a linked virtualenv. Now you'll be able to activate your virtualenv -using: - -.. code-block:: sh - - pipenv shell - -To learn more about it visit `pipenv docs -`_ + pip install hypercorn From d94b4b09d306c472991c1f39d258c5a303e44efc Mon Sep 17 00:00:00 2001 From: pgjones Date: Tue, 26 Dec 2023 10:25:32 +0000 Subject: [PATCH 083/151] Ensure the idle task is stopped on error Otherwise the task will persist and attempt to close an already closed connection. --- src/hypercorn/asyncio/tcp_server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hypercorn/asyncio/tcp_server.py b/src/hypercorn/asyncio/tcp_server.py index 025ec0a..e90858b 100644 --- a/src/hypercorn/asyncio/tcp_server.py +++ b/src/hypercorn/asyncio/tcp_server.py @@ -117,8 +117,8 @@ async def _close(self) -> None: await self.writer.wait_closed() except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError, RuntimeError): pass # Already closed - - await self._stop_idle() + finally: + await self._stop_idle() async def _initiate_server_close(self) -> None: await self.protocol.handle(Closed()) From 0e4117da672c4d9ec09a7802a2c641539a03042c Mon Sep 17 00:00:00 2001 From: pgjones Date: Tue, 26 Dec 2023 10:26:24 +0000 Subject: [PATCH 084/151] Fix latest mypy issues --- src/hypercorn/asyncio/run.py | 2 +- src/hypercorn/utils.py | 2 +- tests/asyncio/test_sanity.py | 2 +- tests/middleware/test_dispatcher.py | 6 +++--- tests/trio/test_sanity.py | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/hypercorn/asyncio/run.py b/src/hypercorn/asyncio/run.py index 4774538..7c0982d 100644 --- a/src/hypercorn/asyncio/run.py +++ b/src/hypercorn/asyncio/run.py @@ -65,7 +65,7 @@ def _signal_handler(*_: Any) -> None: # noqa: N803 # Add signal handler may not be implemented on Windows signal.signal(getattr(signal, signal_name), _signal_handler) - shutdown_trigger = signal_event.wait # type: ignore + shutdown_trigger = signal_event.wait lifespan = Lifespan(app, config, loop) diff --git a/src/hypercorn/utils.py b/src/hypercorn/utils.py index 5629ff7..9e3520d 100644 --- a/src/hypercorn/utils.py +++ b/src/hypercorn/utils.py @@ -185,7 +185,7 @@ def write_pid_file(pid_path: str) -> None: def parse_socket_addr(family: int, address: tuple) -> Optional[Tuple[str, int]]: if family == socket.AF_INET: - return address # type: ignore + return address elif family == socket.AF_INET6: return (address[0], address[1]) else: diff --git a/tests/asyncio/test_sanity.py b/tests/asyncio/test_sanity.py index 287cd06..2d7cb0b 100644 --- a/tests/asyncio/test_sanity.py +++ b/tests/asyncio/test_sanity.py @@ -66,7 +66,7 @@ async def test_http1_request(event_loop: asyncio.AbstractEventLoop) -> None: reason=b"", ), h11.Data(data=b"Hello & Goodbye"), - h11.EndOfMessage(headers=[]), # type: ignore + h11.EndOfMessage(headers=[]), ] server.reader.close() # type: ignore await task diff --git a/tests/middleware/test_dispatcher.py b/tests/middleware/test_dispatcher.py index 1c3d7a2..dbb3f43 100644 --- a/tests/middleware/test_dispatcher.py +++ b/tests/middleware/test_dispatcher.py @@ -36,9 +36,9 @@ async def send(message: dict) -> None: nonlocal sent_events sent_events.append(message) - await app({**http_scope, **{"path": "/api/x/b"}}, None, send) - await app({**http_scope, **{"path": "/api/b"}}, None, send) - await app({**http_scope, **{"path": "/"}}, None, send) + await app({**http_scope, **{"path": "/api/x/b"}}, None, send) # type: ignore + await app({**http_scope, **{"path": "/api/b"}}, None, send) # type: ignore + await app({**http_scope, **{"path": "/"}}, None, send) # type: ignore assert sent_events == [ {"type": "http.response.start", "status": 200, "headers": [(b"content-length", b"7")]}, {"type": "http.response.body", "body": b"apix-/b"}, diff --git a/tests/trio/test_sanity.py b/tests/trio/test_sanity.py index 3828e37..6d4be8c 100644 --- a/tests/trio/test_sanity.py +++ b/tests/trio/test_sanity.py @@ -68,7 +68,7 @@ async def test_http1_request(nursery: trio._core._run.Nursery) -> None: reason=b"", ), h11.Data(data=b"Hello & Goodbye"), - h11.EndOfMessage(headers=[]), # type: ignore + h11.EndOfMessage(headers=[]), ] From 926c4303a7298ce53a772cf6cec9a3da75be35a2 Mon Sep 17 00:00:00 2001 From: pgjones Date: Tue, 26 Dec 2023 18:31:00 +0000 Subject: [PATCH 085/151] Add a max keep alive requests configuration option This will cause HTTP/1 and HTTP/2 requests to close when the limit has been reached. This matches nginx's mitigation against the rapid reset HTTP/2 attack. --- docs/discussion/dos_mitigations.rst | 11 +++++++++++ docs/how_to_guides/configuring.rst | 2 ++ src/hypercorn/config.py | 1 + src/hypercorn/protocol/h11.py | 3 +++ src/hypercorn/protocol/h2.py | 6 ++++++ tests/protocol/test_h11.py | 12 ++++++++++++ tests/protocol/test_h2.py | 23 +++++++++++++++++++++++ 7 files changed, 58 insertions(+) diff --git a/docs/discussion/dos_mitigations.rst b/docs/discussion/dos_mitigations.rst index 358ba98..88cc48b 100644 --- a/docs/discussion/dos_mitigations.rst +++ b/docs/discussion/dos_mitigations.rst @@ -169,3 +169,14 @@ data that it cannot send to the client. To mitigate this Hypercorn responds to the backpressure and pauses (blocks) the coroutine writing the response. + +Rapid reset +^^^^^^^^^^^ + +This attack works by opening and closing streams in quick succession +in the expectation that this is more costly for the server than the +client. + +To mitigate Hypercorn will only allow a maximum number of requests per +kept-alive connection before closing it. This ensures that cost of the +attack is equally born by the client. diff --git a/docs/how_to_guides/configuring.rst b/docs/how_to_guides/configuring.rst index 26607ba..d72e4ed 100644 --- a/docs/how_to_guides/configuring.rst +++ b/docs/how_to_guides/configuring.rst @@ -124,6 +124,8 @@ insecure_bind ``--insecure-bind`` The TCP host/address to See *bind* for formatting options. Care must be taken! See HTTP -> HTTPS redirection docs. +keep_alive_max_requests N/A Maximum number of requests before connection 1000 + is closed. HTTP/1 & HTTP/2 only. keep_alive_timeout ``--keep-alive`` Seconds to keep inactive connections alive 5s before closing. keyfile ``--keyfile`` Path to the SSL key file. diff --git a/src/hypercorn/config.py b/src/hypercorn/config.py index 26f50f0..fdc7a41 100644 --- a/src/hypercorn/config.py +++ b/src/hypercorn/config.py @@ -84,6 +84,7 @@ class Config: include_date_header = True include_server_header = True keep_alive_timeout = 5 * SECONDS + keep_alive_max_requests = 1000 keyfile: Optional[str] = None keyfile_password: Optional[str] = None logconfig: Optional[str] = None diff --git a/src/hypercorn/protocol/h11.py b/src/hypercorn/protocol/h11.py index 49f8b17..1cbf877 100755 --- a/src/hypercorn/protocol/h11.py +++ b/src/hypercorn/protocol/h11.py @@ -97,6 +97,7 @@ def __init__( h11.SERVER, max_incomplete_event_size=self.config.h11_max_incomplete_size ) self.context = context + self.keep_alive_requests = 0 self.send = send self.server = server self.ssl = ssl @@ -234,6 +235,7 @@ async def _create_stream(self, request: h11.Request) -> None: raw_path=request.target, ) ) + self.keep_alive_requests += 1 async def _send_h11_event(self, event: H11SendableEvent) -> None: try: @@ -264,6 +266,7 @@ async def _maybe_recycle(self) -> None: not self.context.terminated.is_set() and self.connection.our_state is h11.DONE and self.connection.their_state is h11.DONE + and self.keep_alive_requests <= self.config.keep_alive_max_requests ): try: self.connection.start_next_cycle() diff --git a/src/hypercorn/protocol/h2.py b/src/hypercorn/protocol/h2.py index 6e76d49..776902e 100755 --- a/src/hypercorn/protocol/h2.py +++ b/src/hypercorn/protocol/h2.py @@ -109,6 +109,7 @@ def __init__( }, ) + self.keep_alive_requests = 0 self.send = send self.server = server self.ssl = ssl @@ -244,6 +245,9 @@ async def _handle_events(self, events: List[h2.events.Event]) -> None: else: await self._create_stream(event) await self.send(Updated(idle=False)) + + if self.keep_alive_requests > self.config.keep_alive_max_requests: + self.connection.close_connection() elif isinstance(event, h2.events.DataReceived): await self.streams[event.stream_id].handle( Body(stream_id=event.stream_id, data=event.data) @@ -349,6 +353,7 @@ async def _create_stream(self, request: h2.events.RequestReceived) -> None: raw_path=raw_path, ) ) + self.keep_alive_requests += 1 async def _create_server_push( self, stream_id: int, path: bytes, headers: List[Tuple[bytes, bytes]] @@ -374,6 +379,7 @@ async def _create_server_push( event.headers = request_headers await self._create_stream(event) await self.streams[event.stream_id].handle(EndBody(stream_id=event.stream_id)) + self.keep_alive_max_requests += 1 async def _close_stream(self, stream_id: int) -> None: if stream_id in self.streams: diff --git a/tests/protocol/test_h11.py b/tests/protocol/test_h11.py index 86bb00a..a136fde 100755 --- a/tests/protocol/test_h11.py +++ b/tests/protocol/test_h11.py @@ -103,6 +103,18 @@ async def test_protocol_send_body(protocol: H11Protocol) -> None: ] +@pytest.mark.asyncio +async def test_protocol_keep_alive_max_requests(protocol: H11Protocol) -> None: + data = b"GET / HTTP/1.1\r\nHost: hypercorn\r\n\r\n" + protocol.config.keep_alive_max_requests = 0 + await protocol.handle(RawData(data=data)) + await protocol.stream_send(Response(stream_id=1, status_code=200, headers=[])) + await protocol.stream_send(EndBody(stream_id=1)) + await protocol.stream_send(StreamClosed(stream_id=1)) + protocol.send.assert_called() # type: ignore + assert protocol.send.call_args_list[3] == call(Closed()) # type: ignore + + @pytest.mark.asyncio @pytest.mark.parametrize("keep_alive, expected", [(True, Updated(idle=True)), (False, Closed())]) async def test_protocol_send_stream_closed( diff --git a/tests/protocol/test_h2.py b/tests/protocol/test_h2.py index c44f39a..77bcf51 100644 --- a/tests/protocol/test_h2.py +++ b/tests/protocol/test_h2.py @@ -4,6 +4,8 @@ from unittest.mock import call, Mock import pytest +from h2.connection import H2Connection +from h2.events import ConnectionTerminated from hypercorn.asyncio.worker_context import EventWrapper, WorkerContext from hypercorn.config import Config @@ -78,3 +80,24 @@ async def test_protocol_handle_protocol_error() -> None: await protocol.handle(RawData(data=b"broken nonsense\r\n\r\n")) protocol.send.assert_awaited() # type: ignore assert protocol.send.call_args_list == [call(Closed())] # type: ignore + + +@pytest.mark.asyncio +async def test_protocol_keep_alive_max_requests() -> None: + protocol = H2Protocol( + Mock(), Config(), WorkerContext(), AsyncMock(), False, None, None, AsyncMock() + ) + protocol.config.keep_alive_max_requests = 0 + client = H2Connection() + client.initiate_connection() + headers = [ + (":method", "GET"), + (":path", "/reqinfo"), + (":authority", "hypercorn"), + (":scheme", "https"), + ] + client.send_headers(1, headers, end_stream=True) + await protocol.handle(RawData(data=client.data_to_send())) + protocol.send.assert_awaited() # type: ignore + events = client.receive_data(protocol.send.call_args_list[1].args[0].data) # type: ignore + assert isinstance(events[-1], ConnectionTerminated) From 5a77873ecf0693bdd4fba0baab225864c8d2ae87 Mon Sep 17 00:00:00 2001 From: pgjones Date: Wed, 27 Dec 2023 10:51:26 +0000 Subject: [PATCH 086/151] Correct 926c4303a7298ce53a772cf6cec9a3da75be35a2 --- src/hypercorn/protocol/h2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hypercorn/protocol/h2.py b/src/hypercorn/protocol/h2.py index 776902e..2604878 100755 --- a/src/hypercorn/protocol/h2.py +++ b/src/hypercorn/protocol/h2.py @@ -379,7 +379,7 @@ async def _create_server_push( event.headers = request_headers await self._create_stream(event) await self.streams[event.stream_id].handle(EndBody(stream_id=event.stream_id)) - self.keep_alive_max_requests += 1 + self.keep_alive_requests += 1 async def _close_stream(self, stream_id: int) -> None: if stream_id in self.streams: From ebb09a6c606c2a9c4e6e3a2d4c7a27262cdf6573 Mon Sep 17 00:00:00 2001 From: pgjones Date: Wed, 27 Dec 2023 10:36:15 +0000 Subject: [PATCH 087/151] Revert "fix: Autoreload error because reausing old sockets" This reverts commit 4854ffd89e8661213ff20828b7568a9f004803a9. It doesn't fix the issue and creates additional reported issues. --- src/hypercorn/run.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/hypercorn/run.py b/src/hypercorn/run.py index a6d2fb0..563822f 100644 --- a/src/hypercorn/run.py +++ b/src/hypercorn/run.py @@ -72,9 +72,6 @@ def shutdown(*args: Any) -> None: if config.use_reloader: wait_for_changes(shutdown_event) shutdown_event.set() - # Recreate the sockets to be used again in the next - # iteration of the loop. - sockets = config.create_sockets() else: active = False From 2b0aad3b1fde7362d785eb489f36e152d1deec16 Mon Sep 17 00:00:00 2001 From: Thomas Baker Date: Thu, 16 Nov 2023 16:26:25 -0500 Subject: [PATCH 088/151] Send the hinted error from h11 on RemoteProtocolErrors This properly punches through 431 status codes --- src/hypercorn/protocol/h11.py | 4 ++-- tests/protocol/test_h11.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/hypercorn/protocol/h11.py b/src/hypercorn/protocol/h11.py index 1cbf877..ec04593 100755 --- a/src/hypercorn/protocol/h11.py +++ b/src/hypercorn/protocol/h11.py @@ -155,9 +155,9 @@ async def _handle_events(self) -> None: try: event = self.connection.next_event() - except h11.RemoteProtocolError: + except h11.RemoteProtocolError as error: if self.connection.our_state in {h11.IDLE, h11.SEND_RESPONSE}: - await self._send_error_response(400) + await self._send_error_response(error.error_status_hint) await self.send(Closed()) break else: diff --git a/tests/protocol/test_h11.py b/tests/protocol/test_h11.py index a136fde..27c80b5 100755 --- a/tests/protocol/test_h11.py +++ b/tests/protocol/test_h11.py @@ -314,7 +314,7 @@ async def test_protocol_handle_max_incomplete(monkeypatch: MonkeyPatch) -> None: assert protocol.send.call_args_list == [ # type: ignore call( RawData( - data=b"HTTP/1.1 400 \r\ncontent-length: 0\r\nconnection: close\r\n" + data=b"HTTP/1.1 431 \r\ncontent-length: 0\r\nconnection: close\r\n" b"date: Thu, 01 Jan 1970 01:23:20 GMT\r\nserver: hypercorn-h11\r\n\r\n" ) ), From 1f874fc2076541feeacff78f472fdddb01ccc0a7 Mon Sep 17 00:00:00 2001 From: stopdropandrew Date: Mon, 11 Dec 2023 11:43:39 -0800 Subject: [PATCH 089/151] Handle `asyncio.CancelledError` when socket is closed without flushing --- src/hypercorn/asyncio/tcp_server.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/hypercorn/asyncio/tcp_server.py b/src/hypercorn/asyncio/tcp_server.py index e90858b..ed9d710 100644 --- a/src/hypercorn/asyncio/tcp_server.py +++ b/src/hypercorn/asyncio/tcp_server.py @@ -115,7 +115,13 @@ async def _close(self) -> None: try: self.writer.close() await self.writer.wait_closed() - except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError, RuntimeError): + except ( + BrokenPipeError, + ConnectionAbortedError, + ConnectionResetError, + RuntimeError, + asyncio.CancelledError, + ): pass # Already closed finally: await self._stop_idle() From 80fa1940089ad00ab5cc7dea2337c6d4aeec8b33 Mon Sep 17 00:00:00 2001 From: seidnerj Date: Mon, 25 Dec 2023 14:36:54 +0200 Subject: [PATCH 090/151] update .gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 8ef153f..c436bdd 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ docs/reference/source/ dist/ .coverage poetry.lock +.idea/ +.DS_Store From cb443a4a4e0f4ff200cf94b83e52161413ea4501 Mon Sep 17 00:00:00 2001 From: seidnerj Date: Tue, 26 Dec 2023 00:30:27 +0200 Subject: [PATCH 091/151] if any of our subprocesses exits with a non-zero exit code, we should also exit with a non-zero exit code. --- src/hypercorn/__main__.py | 6 +++--- src/hypercorn/run.py | 10 +++++++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/hypercorn/__main__.py b/src/hypercorn/__main__.py index b3dc0e8..769a5e0 100644 --- a/src/hypercorn/__main__.py +++ b/src/hypercorn/__main__.py @@ -23,7 +23,7 @@ def _load_config(config_path: Optional[str]) -> Config: return Config.from_toml(config_path) -def main(sys_args: Optional[List[str]] = None) -> None: +def main(sys_args: Optional[List[str]] = None) -> int: parser = argparse.ArgumentParser() parser.add_argument( "application", help="The application to dispatch to as path.to.module:instance.path" @@ -284,8 +284,8 @@ def _convert_verify_mode(value: str) -> ssl.VerifyMode: if len(args.server_names) > 0: config.server_names = args.server_names - run(config) + return run(config) if __name__ == "__main__": - main() + sys.exit(main()) diff --git a/src/hypercorn/run.py b/src/hypercorn/run.py index 563822f..05ab239 100644 --- a/src/hypercorn/run.py +++ b/src/hypercorn/run.py @@ -15,7 +15,9 @@ from .utils import load_application, wait_for_changes, write_pid_file -def run(config: Config) -> None: +def run(config: Config) -> int: + exit_code = 0 + if config.pid_path is not None: write_pid_file(config.pid_path) @@ -77,14 +79,20 @@ def shutdown(*args: Any) -> None: for process in processes: process.join() + if process.exitcode != 0: + exit_code = process.exitcode + for process in processes: process.terminate() for sock in sockets.secure_sockets: sock.close() + for sock in sockets.insecure_sockets: sock.close() + return exit_code + def start_processes( config: Config, From 2d2c62bac7b83a8c6766fe3a517f63ff842e5c38 Mon Sep 17 00:00:00 2001 From: pgjones Date: Wed, 27 Dec 2023 17:13:34 +0000 Subject: [PATCH 092/151] Improve WSGI compliance The response body is closed if it has a close method as per PEP 3333. In addition the response headers are only sent when the first response body byte is available to send. Finally, an error is raised if start_response has not been called by the app. --- src/hypercorn/app_wrappers.py | 23 ++++++++-- tests/test_app_wrappers.py | 79 ++++++++++++++++++++++------------- 2 files changed, 69 insertions(+), 33 deletions(-) diff --git a/src/hypercorn/app_wrappers.py b/src/hypercorn/app_wrappers.py index 769e014..cfc41cf 100644 --- a/src/hypercorn/app_wrappers.py +++ b/src/hypercorn/app_wrappers.py @@ -84,6 +84,8 @@ async def handle_http( def run_app(self, environ: dict, send: Callable) -> None: headers: List[Tuple[bytes, bytes]] + headers_sent = False + response_started = False status_code: Optional[int] = None def start_response( @@ -91,7 +93,7 @@ def start_response( response_headers: List[Tuple[str, str]], exc_info: Optional[Exception] = None, ) -> None: - nonlocal headers, status_code + nonlocal headers, response_started, status_code raw, _ = status.split(" ", 1) status_code = int(raw) @@ -99,10 +101,23 @@ def start_response( (name.lower().encode("ascii"), value.encode("ascii")) for name, value in response_headers ] - send({"type": "http.response.start", "status": status_code, "headers": headers}) + response_started = True - for output in self.app(environ, start_response): - send({"type": "http.response.body", "body": output, "more_body": True}) + response_body = self.app(environ, start_response) + + if not response_started: + raise RuntimeError("WSGI app did not call start_response") + + try: + for output in response_body: + if not headers_sent: + send({"type": "http.response.start", "status": status_code, "headers": headers}) + headers_sent = True + + send({"type": "http.response.body", "body": output, "more_body": True}) + finally: + if hasattr(response_body, "close"): + response_body.close() def _build_environ(scope: HTTPScope, body: bytes) -> dict: diff --git a/tests/test_app_wrappers.py b/tests/test_app_wrappers.py index bb7b589..c68ba0c 100644 --- a/tests/test_app_wrappers.py +++ b/tests/test_app_wrappers.py @@ -61,8 +61,28 @@ async def _send(message: ASGISendEvent) -> None: ] +async def _run_app(app: WSGIWrapper, scope: HTTPScope, body: bytes = b"") -> List[ASGISendEvent]: + queue: asyncio.Queue = asyncio.Queue() + await queue.put({"type": "http.request", "body": body}) + + messages = [] + + async def _send(message: ASGISendEvent) -> None: + nonlocal messages + messages.append(message) + + event_loop = asyncio.get_running_loop() + + def _call_soon(func: Callable, *args: Any) -> Any: + future = asyncio.run_coroutine_threadsafe(func(*args), event_loop) + return future.result() + + await app(scope, queue.get, _send, partial(event_loop.run_in_executor, None), _call_soon) + return messages + + @pytest.mark.asyncio -async def test_wsgi_asyncio(event_loop: asyncio.AbstractEventLoop) -> None: +async def test_wsgi_asyncio() -> None: app = WSGIWrapper(echo_body, 2**16) scope: HTTPScope = { "http_version": "1.1", @@ -79,20 +99,7 @@ async def test_wsgi_asyncio(event_loop: asyncio.AbstractEventLoop) -> None: "server": None, "extensions": {}, } - queue: asyncio.Queue = asyncio.Queue() - await queue.put({"type": "http.request"}) - - messages = [] - - async def _send(message: ASGISendEvent) -> None: - nonlocal messages - messages.append(message) - - def _call_soon(func: Callable, *args: Any) -> Any: - future = asyncio.run_coroutine_threadsafe(func(*args), event_loop) - return future.result() - - await app(scope, queue.get, _send, partial(event_loop.run_in_executor, None), _call_soon) + messages = await _run_app(app, scope) assert messages == [ { "headers": [(b"content-type", b"text/plain; charset=utf-8"), (b"content-length", b"0")], @@ -105,7 +112,7 @@ def _call_soon(func: Callable, *args: Any) -> Any: @pytest.mark.asyncio -async def test_max_body_size(event_loop: asyncio.AbstractEventLoop) -> None: +async def test_max_body_size() -> None: app = WSGIWrapper(echo_body, 4) scope: HTTPScope = { "http_version": "1.1", @@ -122,25 +129,39 @@ async def test_max_body_size(event_loop: asyncio.AbstractEventLoop) -> None: "server": None, "extensions": {}, } - queue: asyncio.Queue = asyncio.Queue() - await queue.put({"type": "http.request", "body": b"abcde"}) - messages = [] - - async def _send(message: ASGISendEvent) -> None: - nonlocal messages - messages.append(message) - - def _call_soon(func: Callable, *args: Any) -> Any: - future = asyncio.run_coroutine_threadsafe(func(*args), event_loop) - return future.result() - - await app(scope, queue.get, _send, partial(event_loop.run_in_executor, None), _call_soon) + messages = await _run_app(app, scope, b"abcde") assert messages == [ {"headers": [], "status": 400, "type": "http.response.start"}, {"body": bytearray(b""), "type": "http.response.body", "more_body": False}, ] +def no_start_response(environ: dict, start_response: Callable) -> List[bytes]: + return [b"result"] + + +@pytest.mark.asyncio +async def test_no_start_response() -> None: + app = WSGIWrapper(no_start_response, 2**16) + scope: HTTPScope = { + "http_version": "1.1", + "asgi": {}, + "method": "GET", + "headers": [], + "path": "/", + "root_path": "/", + "query_string": b"a=b", + "raw_path": b"/", + "scheme": "http", + "type": "http", + "client": ("localhost", 80), + "server": None, + "extensions": {}, + } + with pytest.raises(RuntimeError): + await _run_app(app, scope) + + def test_build_environ_encoding() -> None: scope: HTTPScope = { "http_version": "1.0", From 4fc0372483210257d28d9e0b5f7746df145449c6 Mon Sep 17 00:00:00 2001 From: pgjones Date: Thu, 28 Dec 2023 11:48:17 +0000 Subject: [PATCH 093/151] Add ProxyFix middleware This allows for Hypercorn to be used behind a proxy with the headers being "fixed" such that the proxy is not present as far as the app is concerned. This makes it easier to write applications that run behind proxies. Note I've defaulted to legacy mode as AWS's load balancers don't support the modern Forwarded header and I assume that makes up a large percentage of real world usage. --- docs/how_to_guides/index.rst | 1 + docs/how_to_guides/proxy_fix.rst | 33 ++++++++++++ src/hypercorn/middleware/__init__.py | 2 + src/hypercorn/middleware/proxy_fix.py | 78 +++++++++++++++++++++++++++ tests/middleware/test_proxy_fix.py | 64 ++++++++++++++++++++++ 5 files changed, 178 insertions(+) create mode 100644 docs/how_to_guides/proxy_fix.rst create mode 100644 src/hypercorn/middleware/proxy_fix.py create mode 100644 tests/middleware/test_proxy_fix.py diff --git a/docs/how_to_guides/index.rst b/docs/how_to_guides/index.rst index bccdd54..9f4bf2d 100644 --- a/docs/how_to_guides/index.rst +++ b/docs/how_to_guides/index.rst @@ -11,6 +11,7 @@ How to guides dispatch_apps.rst http_https_redirect.rst logging.rst + proxy_fix.rst server_names.rst statsd.rst wsgi_apps.rst diff --git a/docs/how_to_guides/proxy_fix.rst b/docs/how_to_guides/proxy_fix.rst new file mode 100644 index 0000000..dd8d080 --- /dev/null +++ b/docs/how_to_guides/proxy_fix.rst @@ -0,0 +1,33 @@ +Fixing proxy headers +==================== + +If you are serving Hypercorn behind a proxy e.g. a load balancer the +client-address, scheme, and host-header will match that of the +connection between the proxy and Hypercorn rather than the user-agent +(client). However, most proxies provide headers with the original +user-agent (client) values which can be used to "fix" the headers to +these values. + +Modern proxies should provide this information via a ``Forwarded`` +header from `RFC 7239 +`_. However, this is +rare in practice with legacy proxies using a combination of +``X-Forwarded-For``, ``X-Forwarded-Proto`` and +``X-Forwarded-Host``. It is important that you chose the correct mode +(legacy, or modern) based on the proxy you use. + +To use the proxy fix middleware behind a single legacy proxy simply +wrap your app and serve the wrapped app, + +.. code-block:: python + + from hypercorn.middleware import ProxyFixMiddleware + + fixed_app = ProxyFixMiddleware(app, mode="legacy", trusted_hops=1) + +.. warning:: + + The mode and number of trusted hops must match your setup or the + user-agent (client) may be trusted and hence able to set + alternative for, proto, and host values. This can, depending on + your usage in the app, lead to security vulnerabilities. diff --git a/src/hypercorn/middleware/__init__.py b/src/hypercorn/middleware/__init__.py index 83ea29c..e7f017c 100644 --- a/src/hypercorn/middleware/__init__.py +++ b/src/hypercorn/middleware/__init__.py @@ -2,11 +2,13 @@ from .dispatcher import DispatcherMiddleware from .http_to_https import HTTPToHTTPSRedirectMiddleware +from .proxy_fix import ProxyFixMiddleware from .wsgi import AsyncioWSGIMiddleware, TrioWSGIMiddleware __all__ = ( "AsyncioWSGIMiddleware", "DispatcherMiddleware", "HTTPToHTTPSRedirectMiddleware", + "ProxyFixMiddleware", "TrioWSGIMiddleware", ) diff --git a/src/hypercorn/middleware/proxy_fix.py b/src/hypercorn/middleware/proxy_fix.py new file mode 100644 index 0000000..509941c --- /dev/null +++ b/src/hypercorn/middleware/proxy_fix.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from copy import deepcopy +from typing import Callable, Iterable, Literal, Optional, Tuple + +from ..typing import ASGIFramework, Scope + + +class ProxyFixMiddleware: + def __init__( + self, + app: ASGIFramework, + mode: Literal["legacy", "modern"] = "legacy", + trusted_hops: int = 1, + ) -> None: + self.app = app + self.mode = mode + self.trusted_hops = trusted_hops + + async def __call__(self, scope: Scope, receive: Callable, send: Callable) -> None: + if scope["type"] in {"http", "websocket"}: + scope = deepcopy(scope) + headers = scope["headers"] # type: ignore + client: Optional[str] = None + scheme: Optional[str] = None + host: Optional[str] = None + + if ( + self.mode == "modern" + and (value := _get_trusted_value(b"forwarded", headers, self.trusted_hops)) + is not None + ): + for part in value.split(";"): + if part.startswith("for="): + client = part[4:].strip() + elif part.startswith("host="): + host = part[5:].strip() + elif part.startswith("proto="): + scheme = part[6:].strip() + + else: + client = _get_trusted_value(b"x-forwarded-for", headers, self.trusted_hops) + scheme = _get_trusted_value(b"x-forwarded-proto", headers, self.trusted_hops) + host = _get_trusted_value(b"x-forwarded-host", headers, self.trusted_hops) + + if client is not None: + scope["client"] = (client, 0) # type: ignore + + if scheme is not None: + scope["scheme"] = scheme # type: ignore + + if host is not None: + headers = [ + (name, header_value) + for name, header_value in headers + if name.lower() != b"host" + ] + headers.append((b"host", host)) + scope["headers"] = headers # type: ignore + + await self.app(scope, receive, send) + + +def _get_trusted_value( + name: bytes, headers: Iterable[Tuple[bytes, bytes]], trusted_hops: int +) -> Optional[str]: + if trusted_hops == 0: + return None + + values = [] + for header_name, header_value in headers: + if header_name.lower() == name: + values.extend([value.decode("latin1").strip() for value in header_value.split(b",")]) + + if len(values) >= trusted_hops: + return values[-trusted_hops] + + return None diff --git a/tests/middleware/test_proxy_fix.py b/tests/middleware/test_proxy_fix.py new file mode 100644 index 0000000..2a43b58 --- /dev/null +++ b/tests/middleware/test_proxy_fix.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest + +from hypercorn.middleware import ProxyFixMiddleware +from hypercorn.typing import HTTPScope + + +@pytest.mark.asyncio +async def test_proxy_fix_legacy() -> None: + mock = AsyncMock() + app = ProxyFixMiddleware(mock) + scope: HTTPScope = { + "type": "http", + "asgi": {}, + "http_version": "2", + "method": "GET", + "scheme": "http", + "path": "/", + "raw_path": b"/", + "query_string": b"", + "root_path": "", + "headers": [ + (b"x-forwarded-for", b"127.0.0.1"), + (b"x-forwarded-for", b"127.0.0.2"), + (b"x-forwarded-proto", b"http,https"), + ], + "client": ("127.0.0.3", 80), + "server": None, + "extensions": {}, + } + await app(scope, None, None) + mock.assert_called() + assert mock.call_args[0][0]["client"] == ("127.0.0.2", 0) + assert mock.call_args[0][0]["scheme"] == "https" + + +@pytest.mark.asyncio +async def test_proxy_fix_modern() -> None: + mock = AsyncMock() + app = ProxyFixMiddleware(mock, mode="modern") + scope: HTTPScope = { + "type": "http", + "asgi": {}, + "http_version": "2", + "method": "GET", + "scheme": "http", + "path": "/", + "raw_path": b"/", + "query_string": b"", + "root_path": "", + "headers": [ + (b"forwarded", b"for=127.0.0.1;proto=http,for=127.0.0.2;proto=https"), + ], + "client": ("127.0.0.3", 80), + "server": None, + "extensions": {}, + } + await app(scope, None, None) + mock.assert_called() + assert mock.call_args[0][0]["client"] == ("127.0.0.2", 0) + assert mock.call_args[0][0]["scheme"] == "https" From 125bb002903c0e2d60ab6cb36d00dba3cfad6d03 Mon Sep 17 00:00:00 2001 From: pgjones Date: Thu, 28 Dec 2023 12:00:54 +0000 Subject: [PATCH 094/151] Switch wsgi.errors to stdout This matches other examples and the WSGI specification. --- src/hypercorn/app_wrappers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/hypercorn/app_wrappers.py b/src/hypercorn/app_wrappers.py index cfc41cf..633abb1 100644 --- a/src/hypercorn/app_wrappers.py +++ b/src/hypercorn/app_wrappers.py @@ -1,5 +1,6 @@ from __future__ import annotations +import sys from functools import partial from io import BytesIO from typing import Callable, List, Optional, Tuple @@ -141,7 +142,7 @@ def _build_environ(scope: HTTPScope, body: bytes) -> dict: "wsgi.version": (1, 0), "wsgi.url_scheme": scope.get("scheme", "http"), "wsgi.input": BytesIO(body), - "wsgi.errors": BytesIO(), + "wsgi.errors": sys.stdout, "wsgi.multithread": True, "wsgi.multiprocess": True, "wsgi.run_once": False, From c0468e555c6e6617dd92377c2c2efff862268de7 Mon Sep 17 00:00:00 2001 From: Florian Apolloner Date: Fri, 29 Dec 2023 11:21:36 +0100 Subject: [PATCH 095/151] Remove old warning --- docs/how_to_guides/wsgi_apps.rst | 6 ------ 1 file changed, 6 deletions(-) diff --git a/docs/how_to_guides/wsgi_apps.rst b/docs/how_to_guides/wsgi_apps.rst index 396d890..df6b72b 100644 --- a/docs/how_to_guides/wsgi_apps.rst +++ b/docs/how_to_guides/wsgi_apps.rst @@ -9,12 +9,6 @@ Hypercorn directly serves WSGI applications: $ hypercorn module:wsgi_app -.. warning:: - - The full response from the WSGI app will be stored in memory - before being sent. This prevents the WSGI app from streaming a - response. - WSGI Middleware --------------- From 7c39c68b61012a3c30979176080861c8b00fb229 Mon Sep 17 00:00:00 2001 From: pgjones Date: Mon, 1 Jan 2024 02:47:10 +0000 Subject: [PATCH 096/151] Support restarting workers after max requests This is useful as a "solution" to memory leaks in apps as it ensures that after the max requests have been handled the worker will restart hence freeing any memory leak. The options match those used by Gunicorn. This also ensures that the workers self-heal such that if a worker crashes it will be restored. --- src/hypercorn/__main__.py | 17 ++++++ src/hypercorn/asyncio/run.py | 23 ++++++- src/hypercorn/asyncio/worker_context.py | 15 ++++- src/hypercorn/config.py | 2 + src/hypercorn/protocol/h11.py | 1 + src/hypercorn/protocol/h2.py | 1 + src/hypercorn/protocol/h3.py | 1 + src/hypercorn/run.py | 81 ++++++++++++++++--------- src/hypercorn/trio/run.py | 7 ++- src/hypercorn/trio/worker_context.py | 15 ++++- src/hypercorn/typing.py | 4 ++ src/hypercorn/utils.py | 30 ++++----- tests/asyncio/test_keep_alive.py | 2 +- tests/asyncio/test_sanity.py | 8 +-- tests/asyncio/test_tcp_server.py | 4 +- tests/protocol/test_h11.py | 2 + tests/protocol/test_h2.py | 4 +- tests/protocol/test_http_stream.py | 2 +- tests/protocol/test_ws_stream.py | 2 +- tests/trio/test_keep_alive.py | 2 +- tests/trio/test_sanity.py | 8 +-- 21 files changed, 163 insertions(+), 68 deletions(-) diff --git a/src/hypercorn/__main__.py b/src/hypercorn/__main__.py index 769a5e0..aed33b1 100644 --- a/src/hypercorn/__main__.py +++ b/src/hypercorn/__main__.py @@ -89,6 +89,19 @@ def main(sys_args: Optional[List[str]] = None) -> int: default=sentinel, type=int, ) + parser.add_argument( + "--max-requests", + help="""Maximum number of requests a worker will process before restarting""", + default=sentinel, + type=int, + ) + parser.add_argument( + "--max-requests-jitter", + help="This jitter causes the max-requests per worker to be " + "randomized by randint(0, max_requests_jitter)", + default=sentinel, + type=int, + ) parser.add_argument( "-g", "--group", help="Group to own any unix sockets.", default=sentinel, type=int ) @@ -252,6 +265,10 @@ def _convert_verify_mode(value: str) -> ssl.VerifyMode: config.keyfile_password = args.keyfile_password if args.log_config is not sentinel: config.logconfig = args.log_config + if args.max_requests is not sentinel: + config.max_requests = args.max_requests + if args.max_requests_jitter is not sentinel: + config.max_requests_jitter = args.max_requests if args.pid is not sentinel: config.pid_path = args.pid if args.root_path is not sentinel: diff --git a/src/hypercorn/asyncio/run.py b/src/hypercorn/asyncio/run.py index 7c0982d..c633c5b 100644 --- a/src/hypercorn/asyncio/run.py +++ b/src/hypercorn/asyncio/run.py @@ -4,9 +4,11 @@ import platform import signal import ssl +import sys from functools import partial from multiprocessing.synchronize import Event as EventType from os import getpid +from random import randint from socket import socket from typing import Any, Awaitable, Callable, Optional, Set @@ -30,6 +32,14 @@ except ImportError: from taskgroup import Runner # type: ignore +try: + from asyncio import TaskGroup +except ImportError: + from taskgroup import TaskGroup # type: ignore + +if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup + def _share_socket(sock: socket) -> socket: # Windows requires the socket be explicitly shared across @@ -84,7 +94,10 @@ def _signal_handler(*_: Any) -> None: # noqa: N803 ssl_context = config.create_ssl_context() ssl_handshake_timeout = config.ssl_handshake_timeout - context = WorkerContext() + max_requests = None + if config.max_requests is not None: + max_requests = config.max_requests + randint(0, config.max_requests_jitter) + context = WorkerContext(max_requests) server_tasks: Set[asyncio.Task] = set() async def _server_callback(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: @@ -136,7 +149,13 @@ async def _server_callback(reader: asyncio.StreamReader, writer: asyncio.StreamW await config.log.info(f"Running on https://{bind} (QUIC) (CTRL + C to quit)") try: - await raise_shutdown(shutdown_trigger) + async with TaskGroup() as task_group: + task_group.create_task(raise_shutdown(shutdown_trigger)) + task_group.create_task(raise_shutdown(context.terminate.wait)) + except BaseExceptionGroup as error: + _, other_errors = error.split((ShutdownError, KeyboardInterrupt)) + if other_errors is not None: + raise other_errors except (ShutdownError, KeyboardInterrupt): pass finally: diff --git a/src/hypercorn/asyncio/worker_context.py b/src/hypercorn/asyncio/worker_context.py index fe9ad1c..d16f76b 100644 --- a/src/hypercorn/asyncio/worker_context.py +++ b/src/hypercorn/asyncio/worker_context.py @@ -1,7 +1,7 @@ from __future__ import annotations import asyncio -from typing import Type, Union +from typing import Optional, Type, Union from ..typing import Event @@ -26,9 +26,20 @@ def is_set(self) -> bool: class WorkerContext: event_class: Type[Event] = EventWrapper - def __init__(self) -> None: + def __init__(self, max_requests: Optional[int]) -> None: + self.max_requests = max_requests + self.requests = 0 + self.terminate = self.event_class() self.terminated = self.event_class() + async def mark_request(self) -> None: + if self.max_requests is None: + return + + self.requests += 1 + if self.requests > self.max_requests: + await self.terminate.set() + @staticmethod async def sleep(wait: Union[float, int]) -> None: return await asyncio.sleep(wait) diff --git a/src/hypercorn/config.py b/src/hypercorn/config.py index fdc7a41..f00c7d5 100644 --- a/src/hypercorn/config.py +++ b/src/hypercorn/config.py @@ -92,6 +92,8 @@ class Config: logger_class = Logger loglevel: str = "INFO" max_app_queue_size: int = 10 + max_requests: Optional[int] = None + max_requests_jitter: int = 0 pid_path: Optional[str] = None server_names: List[str] = [] shutdown_timeout = 60 * SECONDS diff --git a/src/hypercorn/protocol/h11.py b/src/hypercorn/protocol/h11.py index ec04593..a33ad4a 100755 --- a/src/hypercorn/protocol/h11.py +++ b/src/hypercorn/protocol/h11.py @@ -236,6 +236,7 @@ async def _create_stream(self, request: h11.Request) -> None: ) ) self.keep_alive_requests += 1 + await self.context.mark_request() async def _send_h11_event(self, event: H11SendableEvent) -> None: try: diff --git a/src/hypercorn/protocol/h2.py b/src/hypercorn/protocol/h2.py index 2604878..9c92ab3 100755 --- a/src/hypercorn/protocol/h2.py +++ b/src/hypercorn/protocol/h2.py @@ -354,6 +354,7 @@ async def _create_stream(self, request: h2.events.RequestReceived) -> None: ) ) self.keep_alive_requests += 1 + await self.context.mark_request() async def _create_server_push( self, stream_id: int, path: bytes, headers: List[Tuple[bytes, bytes]] diff --git a/src/hypercorn/protocol/h3.py b/src/hypercorn/protocol/h3.py index 88d9a4d..151c066 100644 --- a/src/hypercorn/protocol/h3.py +++ b/src/hypercorn/protocol/h3.py @@ -125,6 +125,7 @@ async def _create_stream(self, request: HeadersReceived) -> None: raw_path=raw_path, ) ) + await self.context.mark_request() async def _create_server_push( self, stream_id: int, path: bytes, headers: List[Tuple[bytes, bytes]] diff --git a/src/hypercorn/run.py b/src/hypercorn/run.py index 05ab239..cfe801a 100644 --- a/src/hypercorn/run.py +++ b/src/hypercorn/run.py @@ -4,6 +4,7 @@ import signal import time from multiprocessing import get_context +from multiprocessing.connection import wait from multiprocessing.context import BaseContext from multiprocessing.process import BaseProcess from multiprocessing.synchronize import Event as EventType @@ -12,12 +13,10 @@ from .config import Config, Sockets from .typing import WorkerFunc -from .utils import load_application, wait_for_changes, write_pid_file +from .utils import check_for_updates, files_to_watch, load_application, write_pid_file def run(config: Config) -> int: - exit_code = 0 - if config.pid_path is not None: write_pid_file(config.pid_path) @@ -42,67 +41,82 @@ def run(config: Config) -> int: if config.use_reloader and config.workers == 0: raise RuntimeError("Cannot reload without workers") - if config.use_reloader or config.workers == 0: - # Load the application so that the correct paths are checked for - # changes, but only when the reloader is being used. - load_application(config.application_path, config.wsgi_max_body_size) - + exitcode = 0 if config.workers == 0: worker_func(config, sockets) else: + if config.use_reloader: + # Load the application so that the correct paths are checked for + # changes, but only when the reloader is being used. + load_application(config.application_path, config.wsgi_max_body_size) + ctx = get_context("spawn") active = True + shutdown_event = ctx.Event() + + def shutdown(*args: Any) -> None: + nonlocal active, shutdown_event + shutdown_event.set() + active = False + + processes: List[BaseProcess] = [] while active: # Ignore SIGINT before creating the processes, so that they # inherit the signal handling. This means that the shutdown # function controls the shutdown. signal.signal(signal.SIGINT, signal.SIG_IGN) - shutdown_event = ctx.Event() - processes = start_processes(config, worker_func, sockets, shutdown_event, ctx) - - def shutdown(*args: Any) -> None: - nonlocal active, shutdown_event - shutdown_event.set() - active = False + _populate(processes, config, worker_func, sockets, shutdown_event, ctx) for signal_name in {"SIGINT", "SIGTERM", "SIGBREAK"}: if hasattr(signal, signal_name): signal.signal(getattr(signal, signal_name), shutdown) if config.use_reloader: - wait_for_changes(shutdown_event) - shutdown_event.set() + files = files_to_watch() + while True: + finished = wait((process.sentinel for process in processes), timeout=1) + updated = check_for_updates(files) + if updated: + shutdown_event.set() + for process in processes: + process.join() + shutdown_event.clear() + break + if len(finished) > 0: + break else: - active = False + wait(process.sentinel for process in processes) - for process in processes: - process.join() - if process.exitcode != 0: - exit_code = process.exitcode + exitcode = _join_exited(processes) + if exitcode != 0: + shutdown_event.set() + active = False for process in processes: process.terminate() + exitcode = _join_exited(processes) if exitcode != 0 else exitcode + for sock in sockets.secure_sockets: sock.close() for sock in sockets.insecure_sockets: sock.close() - return exit_code + return exitcode -def start_processes( +def _populate( + processes: List[BaseProcess], config: Config, worker_func: WorkerFunc, sockets: Sockets, shutdown_event: EventType, ctx: BaseContext, -) -> List[BaseProcess]: - processes = [] - for _ in range(config.workers): +) -> None: + for _ in range(config.workers - len(processes)): process = ctx.Process( # type: ignore target=worker_func, kwargs={"config": config, "shutdown_event": shutdown_event, "sockets": sockets}, @@ -117,4 +131,15 @@ def start_processes( processes.append(process) if platform.system() == "Windows": time.sleep(0.1) - return processes + + +def _join_exited(processes: List[BaseProcess]) -> int: + exitcode = 0 + for index in reversed(range(len(processes))): + worker = processes[index] + if worker.exitcode is not None: + worker.join() + exitcode = worker.exitcode if exitcode == 0 else exitcode + del processes[index] + + return exitcode diff --git a/src/hypercorn/trio/run.py b/src/hypercorn/trio/run.py index d8721bb..2cfe5db 100644 --- a/src/hypercorn/trio/run.py +++ b/src/hypercorn/trio/run.py @@ -3,6 +3,7 @@ import sys from functools import partial from multiprocessing.synchronize import Event as EventType +from random import randint from typing import Awaitable, Callable, Optional import trio @@ -37,7 +38,10 @@ async def worker_serve( config.set_statsd_logger_class(StatsdLogger) lifespan = Lifespan(app, config) - context = WorkerContext() + max_requests = None + if config.max_requests is not None: + max_requests = config.max_requests + randint(0, config.max_requests_jitter) + context = WorkerContext(max_requests) async with trio.open_nursery() as lifespan_nursery: await lifespan_nursery.start(lifespan.handle_lifespan) @@ -82,6 +86,7 @@ async def worker_serve( async with trio.open_nursery(strict_exception_groups=True) as nursery: if shutdown_trigger is not None: nursery.start_soon(raise_shutdown, shutdown_trigger) + nursery.start_soon(raise_shutdown, context.terminate.wait) nursery.start_soon( partial( diff --git a/src/hypercorn/trio/worker_context.py b/src/hypercorn/trio/worker_context.py index bcfa1a5..c09c4fb 100644 --- a/src/hypercorn/trio/worker_context.py +++ b/src/hypercorn/trio/worker_context.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Type, Union +from typing import Optional, Type, Union import trio @@ -27,9 +27,20 @@ def is_set(self) -> bool: class WorkerContext: event_class: Type[Event] = EventWrapper - def __init__(self) -> None: + def __init__(self, max_requests: Optional[int]) -> None: + self.max_requests = max_requests + self.requests = 0 + self.terminate = self.event_class() self.terminated = self.event_class() + async def mark_request(self) -> None: + if self.max_requests is None: + return + + self.requests += 1 + if self.requests > self.max_requests: + await self.terminate.set() + @staticmethod async def sleep(wait: Union[float, int]) -> None: return await trio.sleep(wait) diff --git a/src/hypercorn/typing.py b/src/hypercorn/typing.py index 1299a77..2ebb711 100644 --- a/src/hypercorn/typing.py +++ b/src/hypercorn/typing.py @@ -290,8 +290,12 @@ def is_set(self) -> bool: class WorkerContext(Protocol): event_class: Type[Event] + terminate: Event terminated: Event + async def mark_request(self) -> None: + ... + @staticmethod async def sleep(wait: Union[float, int]) -> None: ... diff --git a/src/hypercorn/utils.py b/src/hypercorn/utils.py index 9e3520d..39249c5 100644 --- a/src/hypercorn/utils.py +++ b/src/hypercorn/utils.py @@ -4,7 +4,6 @@ import os import socket import sys -import time from enum import Enum from importlib import import_module from multiprocessing.synchronize import Event as EventType @@ -133,7 +132,7 @@ def wrap_app( return WSGIWrapper(cast(WSGIFramework, app), wsgi_max_body_size) -def wait_for_changes(shutdown_event: EventType) -> None: +def files_to_watch() -> Dict[Path, float]: last_updates: Dict[Path, float] = {} for module in list(sys.modules.values()): filename = getattr(module, "__file__", None) @@ -144,24 +143,21 @@ def wait_for_changes(shutdown_event: EventType) -> None: last_updates[Path(filename)] = path.stat().st_mtime except (FileNotFoundError, NotADirectoryError): pass + return last_updates - while not shutdown_event.is_set(): - time.sleep(1) - for index, (path, last_mtime) in enumerate(last_updates.items()): - if index % 10 == 0: - # Yield to the event loop - time.sleep(0) - - try: - mtime = path.stat().st_mtime - except FileNotFoundError: - return +def check_for_updates(files: Dict[Path, float]) -> bool: + for path, last_mtime in files.items(): + try: + mtime = path.stat().st_mtime + except FileNotFoundError: + return True + else: + if mtime > last_mtime: + return True else: - if mtime > last_mtime: - return - else: - last_updates[path] = mtime + files[path] = mtime + return False async def raise_shutdown(shutdown_event: Callable[..., Awaitable]) -> None: diff --git a/tests/asyncio/test_keep_alive.py b/tests/asyncio/test_keep_alive.py index 6b357f8..a46f4cf 100644 --- a/tests/asyncio/test_keep_alive.py +++ b/tests/asyncio/test_keep_alive.py @@ -50,7 +50,7 @@ async def _server(event_loop: asyncio.AbstractEventLoop) -> AsyncGenerator[TCPSe ASGIWrapper(slow_framework), event_loop, config, - WorkerContext(), + WorkerContext(None), MemoryReader(), # type: ignore MemoryWriter(), # type: ignore ) diff --git a/tests/asyncio/test_sanity.py b/tests/asyncio/test_sanity.py index 2d7cb0b..cde2929 100644 --- a/tests/asyncio/test_sanity.py +++ b/tests/asyncio/test_sanity.py @@ -21,7 +21,7 @@ async def test_http1_request(event_loop: asyncio.AbstractEventLoop) -> None: ASGIWrapper(sanity_framework), event_loop, Config(), - WorkerContext(), + WorkerContext(None), MemoryReader(), # type: ignore MemoryWriter(), # type: ignore ) @@ -78,7 +78,7 @@ async def test_http1_websocket(event_loop: asyncio.AbstractEventLoop) -> None: ASGIWrapper(sanity_framework), event_loop, Config(), - WorkerContext(), + WorkerContext(None), MemoryReader(), # type: ignore MemoryWriter(), # type: ignore ) @@ -115,7 +115,7 @@ async def test_http2_request(event_loop: asyncio.AbstractEventLoop) -> None: ASGIWrapper(sanity_framework), event_loop, Config(), - WorkerContext(), + WorkerContext(None), MemoryReader(), # type: ignore MemoryWriter(http2=True), # type: ignore ) @@ -178,7 +178,7 @@ async def test_http2_websocket(event_loop: asyncio.AbstractEventLoop) -> None: ASGIWrapper(sanity_framework), event_loop, Config(), - WorkerContext(), + WorkerContext(None), MemoryReader(), # type: ignore MemoryWriter(http2=True), # type: ignore ) diff --git a/tests/asyncio/test_tcp_server.py b/tests/asyncio/test_tcp_server.py index afe00c2..1dfd421 100644 --- a/tests/asyncio/test_tcp_server.py +++ b/tests/asyncio/test_tcp_server.py @@ -18,7 +18,7 @@ async def test_completes_on_closed(event_loop: asyncio.AbstractEventLoop) -> Non ASGIWrapper(echo_framework), event_loop, Config(), - WorkerContext(), + WorkerContext(None), MemoryReader(), # type: ignore MemoryWriter(), # type: ignore ) @@ -34,7 +34,7 @@ async def test_complets_on_half_close(event_loop: asyncio.AbstractEventLoop) -> ASGIWrapper(echo_framework), event_loop, Config(), - WorkerContext(), + WorkerContext(None), MemoryReader(), # type: ignore MemoryWriter(), # type: ignore ) diff --git a/tests/protocol/test_h11.py b/tests/protocol/test_h11.py index 27c80b5..09e85b7 100755 --- a/tests/protocol/test_h11.py +++ b/tests/protocol/test_h11.py @@ -35,6 +35,8 @@ async def _protocol(monkeypatch: MonkeyPatch) -> H11Protocol: monkeypatch.setattr(hypercorn.protocol.h11, "HTTPStream", MockHTTPStream) context = Mock() context.event_class.return_value = AsyncMock(spec=IOEvent) + context.mark_request = AsyncMock() + context.terminate = context.event_class() context.terminated = context.event_class() context.terminated.is_set.return_value = False return H11Protocol(AsyncMock(), Config(), context, AsyncMock(), False, None, None, AsyncMock()) diff --git a/tests/protocol/test_h2.py b/tests/protocol/test_h2.py index 77bcf51..cec6c26 100644 --- a/tests/protocol/test_h2.py +++ b/tests/protocol/test_h2.py @@ -75,7 +75,7 @@ async def test_stream_buffer_complete(event_loop: asyncio.AbstractEventLoop) -> @pytest.mark.asyncio async def test_protocol_handle_protocol_error() -> None: protocol = H2Protocol( - Mock(), Config(), WorkerContext(), AsyncMock(), False, None, None, AsyncMock() + Mock(), Config(), WorkerContext(None), AsyncMock(), False, None, None, AsyncMock() ) await protocol.handle(RawData(data=b"broken nonsense\r\n\r\n")) protocol.send.assert_awaited() # type: ignore @@ -85,7 +85,7 @@ async def test_protocol_handle_protocol_error() -> None: @pytest.mark.asyncio async def test_protocol_keep_alive_max_requests() -> None: protocol = H2Protocol( - Mock(), Config(), WorkerContext(), AsyncMock(), False, None, None, AsyncMock() + Mock(), Config(), WorkerContext(None), AsyncMock(), False, None, None, AsyncMock() ) protocol.config.keep_alive_max_requests = 0 client = H2Connection() diff --git a/tests/protocol/test_http_stream.py b/tests/protocol/test_http_stream.py index 24af596..6f656de 100644 --- a/tests/protocol/test_http_stream.py +++ b/tests/protocol/test_http_stream.py @@ -31,7 +31,7 @@ @pytest_asyncio.fixture(name="stream") # type: ignore[misc] async def _stream() -> HTTPStream: stream = HTTPStream( - AsyncMock(), Config(), WorkerContext(), AsyncMock(), False, None, None, AsyncMock(), 1 + AsyncMock(), Config(), WorkerContext(None), AsyncMock(), False, None, None, AsyncMock(), 1 ) stream.app_put = AsyncMock() stream.config._log = AsyncMock(spec=Logger) diff --git a/tests/protocol/test_ws_stream.py b/tests/protocol/test_ws_stream.py index 5f59582..0540313 100644 --- a/tests/protocol/test_ws_stream.py +++ b/tests/protocol/test_ws_stream.py @@ -165,7 +165,7 @@ def test_handshake_accept_additional_headers() -> None: @pytest_asyncio.fixture(name="stream") # type: ignore[misc] async def _stream() -> WSStream: stream = WSStream( - AsyncMock(), Config(), WorkerContext(), AsyncMock(), False, None, None, AsyncMock(), 1 + AsyncMock(), Config(), WorkerContext(None), AsyncMock(), False, None, None, AsyncMock(), 1 ) stream.task_group.spawn_app.return_value = AsyncMock() # type: ignore stream.app_put = AsyncMock() diff --git a/tests/trio/test_keep_alive.py b/tests/trio/test_keep_alive.py index d30d82d..6bed437 100644 --- a/tests/trio/test_keep_alive.py +++ b/tests/trio/test_keep_alive.py @@ -47,7 +47,7 @@ def _client_stream( config.keep_alive_timeout = KEEP_ALIVE_TIMEOUT client_stream, server_stream = trio.testing.memory_stream_pair() server_stream.socket = MockSocket() - server = TCPServer(ASGIWrapper(slow_framework), config, WorkerContext(), server_stream) + server = TCPServer(ASGIWrapper(slow_framework), config, WorkerContext(None), server_stream) nursery.start_soon(server.run) yield client_stream diff --git a/tests/trio/test_sanity.py b/tests/trio/test_sanity.py index 6d4be8c..b5bf75b 100644 --- a/tests/trio/test_sanity.py +++ b/tests/trio/test_sanity.py @@ -25,7 +25,7 @@ async def test_http1_request(nursery: trio._core._run.Nursery) -> None: client_stream, server_stream = trio.testing.memory_stream_pair() server_stream.socket = MockSocket() - server = TCPServer(ASGIWrapper(sanity_framework), Config(), WorkerContext(), server_stream) + server = TCPServer(ASGIWrapper(sanity_framework), Config(), WorkerContext(None), server_stream) nursery.start_soon(server.run) client = h11.Connection(h11.CLIENT) await client_stream.send_all( @@ -76,7 +76,7 @@ async def test_http1_request(nursery: trio._core._run.Nursery) -> None: async def test_http1_websocket(nursery: trio._core._run.Nursery) -> None: client_stream, server_stream = trio.testing.memory_stream_pair() server_stream.socket = MockSocket() - server = TCPServer(ASGIWrapper(sanity_framework), Config(), WorkerContext(), server_stream) + server = TCPServer(ASGIWrapper(sanity_framework), Config(), WorkerContext(None), server_stream) nursery.start_soon(server.run) client = wsproto.WSConnection(wsproto.ConnectionType.CLIENT) await client_stream.send_all(client.send(wsproto.events.Request(host="hypercorn", target="/"))) @@ -103,7 +103,7 @@ async def test_http2_request(nursery: trio._core._run.Nursery) -> None: server_stream.transport_stream = Mock(return_value=PropertyMock(return_value=MockSocket())) server_stream.do_handshake = AsyncMock() server_stream.selected_alpn_protocol = Mock(return_value="h2") - server = TCPServer(ASGIWrapper(sanity_framework), Config(), WorkerContext(), server_stream) + server = TCPServer(ASGIWrapper(sanity_framework), Config(), WorkerContext(None), server_stream) nursery.start_soon(server.run) client = h2.connection.H2Connection() client.initiate_connection() @@ -158,7 +158,7 @@ async def test_http2_websocket(nursery: trio._core._run.Nursery) -> None: server_stream.transport_stream = Mock(return_value=PropertyMock(return_value=MockSocket())) server_stream.do_handshake = AsyncMock() server_stream.selected_alpn_protocol = Mock(return_value="h2") - server = TCPServer(ASGIWrapper(sanity_framework), Config(), WorkerContext(), server_stream) + server = TCPServer(ASGIWrapper(sanity_framework), Config(), WorkerContext(None), server_stream) nursery.start_soon(server.run) h2_client = h2.connection.H2Connection() h2_client.initiate_connection() From 0bb4fb9de5e00dbaece82a6c02617d1d9c0c8e56 Mon Sep 17 00:00:00 2001 From: pgjones Date: Mon, 1 Jan 2024 13:41:10 +0000 Subject: [PATCH 097/151] Don't error on LocalProtoclErrors for ws streams There is a race condition being hit in the autobahn compliance tests whereby the client closes the stream and the server responds with an acknowledgement. Whilst the server responds the app sends a message, which now errors as the WSConnection state is closed. As the state is managed by the WSStream, rather than the WSConnection it makes sense to ignore these errors. --- src/hypercorn/protocol/ws_stream.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/hypercorn/protocol/ws_stream.py b/src/hypercorn/protocol/ws_stream.py index 9011999..5709952 100644 --- a/src/hypercorn/protocol/ws_stream.py +++ b/src/hypercorn/protocol/ws_stream.py @@ -18,7 +18,7 @@ from wsproto.extensions import Extension, PerMessageDeflate from wsproto.frame_protocol import CloseReason from wsproto.handshake import server_extensions_handshake, WEBSOCKET_VERSION -from wsproto.utilities import generate_accept_token, split_comma_header +from wsproto.utilities import generate_accept_token, LocalProtocolError, split_comma_header from .events import Body, Data, EndBody, EndData, Event, Request, Response, StreamClosed from ..config import Config @@ -333,8 +333,12 @@ async def _send_error_response(self, status_code: int) -> None: ) async def _send_wsproto_event(self, event: WSProtoEvent) -> None: - data = self.connection.send(event) - await self.send(Data(stream_id=self.stream_id, data=data)) + try: + data = self.connection.send(event) + except LocalProtocolError: + pass + else: + await self.send(Data(stream_id=self.stream_id, data=data)) async def _accept(self, message: WebsocketAcceptEvent) -> None: self.state = ASGIWebsocketState.CONNECTED From f8e4e5de3aec7f8eb986535163c3d5b4f424465c Mon Sep 17 00:00:00 2001 From: pgjones Date: Mon, 1 Jan 2024 13:49:48 +0000 Subject: [PATCH 098/151] Bump and release 0.16.0 --- CHANGELOG.rst | 21 +++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e18836c..a68991e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,24 @@ +0.16.0 2023-01-01 +----------------- + +* Add a max keep alive requests configuration option, this mitigates + the HTTP/2 rapid reset attack. +* Return subprocess exit code if non-zero. +* Add ProxyFix middleware to make it easier to run Hypercorn behind a + proxy. +* Support restarting workers after max requests to make it easier to + manage memory leaks in apps. +* Bugfix ensure the idle task is stopped on error. +* Bugfix revert autoreload error because reausing old sockets. +* Bugfix send the hinted error from h11 on RemoteProtocolErrors. +* Bugfix handle asyncio.CancelledError when socket is closed without + flushing. +* Bugfix improve WSGI compliance by closing iterators, only sending + headers on first response byte, erroring if ``start_response`` is + not called, and switching wsgi.errors to stdout. +* Don't error on LocalProtoclErrors for ws streams to better cope with + race conditions. + 0.15.0 2023-10-29 ----------------- diff --git a/pyproject.toml b/pyproject.toml index a620dfa..37d199d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "Hypercorn" -version = "0.15.0" +version = "0.16.0" description = "A ASGI Server based on Hyper libraries and inspired by Gunicorn" authors = ["pgjones "] classifiers = [ From b8197d5f5dc1617063b75046fdb772ff9991877e Mon Sep 17 00:00:00 2001 From: pgjones Date: Mon, 1 Jan 2024 14:48:02 +0000 Subject: [PATCH 099/151] Fix the year It is now 2024! --- CHANGELOG.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a68991e..3ca9b35 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,4 +1,4 @@ -0.16.0 2023-01-01 +0.16.0 2024-01-01 ----------------- * Add a max keep alive requests configuration option, this mitigates From bc39603a06458a6fa7fc7bd7aafee52b08614e88 Mon Sep 17 00:00:00 2001 From: pgjones Date: Mon, 1 Jan 2024 15:16:39 +0000 Subject: [PATCH 100/151] Add max_requests and max_requests_jitter to configuration docs This was missed in 7c39c68b61012a3c30979176080861c8b00fb229 --- docs/how_to_guides/configuring.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/how_to_guides/configuring.rst b/docs/how_to_guides/configuring.rst index d72e4ed..ab3a07c 100644 --- a/docs/how_to_guides/configuring.rst +++ b/docs/how_to_guides/configuring.rst @@ -140,6 +140,11 @@ logger_class N/A Type of class to use fo loglevel ``--log-level`` The (error) log level. ``INFO`` max_app_queue_size N/A The maximum number of events to queue up 10 sending to the ASGI application. +max_requests ``--max-requests`` Maximum number of requests a worker will + process before restarting. +max_requests_jitter ``--max-requests-jitter`` This jitter causes the max-requests per worker 0 + to be randomized by + ``randint(0, max_requests_jitter)`` pid_path ``-p``, ``--pid`` Location to write the PID (Program ID) to. quic_bind ``--quic-bind`` The UDP/QUIC host/address to bind to. See *bind* for formatting options. From 3fbd5f245e5dfeaba6ad852d9135d6a32b228d05 Mon Sep 17 00:00:00 2001 From: Florian Apolloner Date: Mon, 1 Jan 2024 21:58:45 +0100 Subject: [PATCH 101/151] Properly set host header to ascii string in ProxyFixMiddleware. --- src/hypercorn/middleware/proxy_fix.py | 13 +++++++------ tests/middleware/test_proxy_fix.py | 17 ++++++++++++----- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/hypercorn/middleware/proxy_fix.py b/src/hypercorn/middleware/proxy_fix.py index 509941c..bd3dc4c 100644 --- a/src/hypercorn/middleware/proxy_fix.py +++ b/src/hypercorn/middleware/proxy_fix.py @@ -18,9 +18,10 @@ def __init__( self.trusted_hops = trusted_hops async def __call__(self, scope: Scope, receive: Callable, send: Callable) -> None: - if scope["type"] in {"http", "websocket"}: + # Keep the `or` instead of `in {'http' …}` to allow type narrowing + if scope["type"] == "http" or scope["type"] == "websocket": scope = deepcopy(scope) - headers = scope["headers"] # type: ignore + headers = scope["headers"] client: Optional[str] = None scheme: Optional[str] = None host: Optional[str] = None @@ -44,10 +45,10 @@ async def __call__(self, scope: Scope, receive: Callable, send: Callable) -> Non host = _get_trusted_value(b"x-forwarded-host", headers, self.trusted_hops) if client is not None: - scope["client"] = (client, 0) # type: ignore + scope["client"] = (client, 0) if scheme is not None: - scope["scheme"] = scheme # type: ignore + scope["scheme"] = scheme if host is not None: headers = [ @@ -55,8 +56,8 @@ async def __call__(self, scope: Scope, receive: Callable, send: Callable) -> Non for name, header_value in headers if name.lower() != b"host" ] - headers.append((b"host", host)) - scope["headers"] = headers # type: ignore + headers.append((b"host", host.encode())) + scope["headers"] = headers await self.app(scope, receive, send) diff --git a/tests/middleware/test_proxy_fix.py b/tests/middleware/test_proxy_fix.py index 2a43b58..dd9ad4f 100644 --- a/tests/middleware/test_proxy_fix.py +++ b/tests/middleware/test_proxy_fix.py @@ -26,6 +26,7 @@ async def test_proxy_fix_legacy() -> None: (b"x-forwarded-for", b"127.0.0.1"), (b"x-forwarded-for", b"127.0.0.2"), (b"x-forwarded-proto", b"http,https"), + (b"x-forwarded-host", b"example.com"), ], "client": ("127.0.0.3", 80), "server": None, @@ -33,8 +34,11 @@ async def test_proxy_fix_legacy() -> None: } await app(scope, None, None) mock.assert_called() - assert mock.call_args[0][0]["client"] == ("127.0.0.2", 0) - assert mock.call_args[0][0]["scheme"] == "https" + scope = mock.call_args[0][0] + assert scope["client"] == ("127.0.0.2", 0) + assert scope["scheme"] == "https" + host_headers = [h for h in scope["headers"] if h[0].lower() == b"host"] + assert host_headers == [(b"host", b"example.com")] @pytest.mark.asyncio @@ -52,7 +56,7 @@ async def test_proxy_fix_modern() -> None: "query_string": b"", "root_path": "", "headers": [ - (b"forwarded", b"for=127.0.0.1;proto=http,for=127.0.0.2;proto=https"), + (b"forwarded", b"for=127.0.0.1;proto=http,for=127.0.0.2;proto=https;host=example.com"), ], "client": ("127.0.0.3", 80), "server": None, @@ -60,5 +64,8 @@ async def test_proxy_fix_modern() -> None: } await app(scope, None, None) mock.assert_called() - assert mock.call_args[0][0]["client"] == ("127.0.0.2", 0) - assert mock.call_args[0][0]["scheme"] == "https" + scope = mock.call_args[0][0] + assert scope["client"] == ("127.0.0.2", 0) + assert scope["scheme"] == "https" + host_headers = [h for h in scope["headers"] if h[0].lower() == b"host"] + assert host_headers == [(b"host", b"example.com")] From 0342e391dd81aee0c31b892d0ea828d812c378c5 Mon Sep 17 00:00:00 2001 From: seidnerj Date: Wed, 20 Mar 2024 12:47:29 +0200 Subject: [PATCH 102/151] fix typing.py formatting issues as per tox --- src/hypercorn/typing.py | 71 +++++++++++++++-------------------------- 1 file changed, 25 insertions(+), 46 deletions(-) diff --git a/src/hypercorn/typing.py b/src/hypercorn/typing.py index 2ebb711..f30f1bc 100644 --- a/src/hypercorn/typing.py +++ b/src/hypercorn/typing.py @@ -224,17 +224,13 @@ class LifespanShutdownFailedEvent(TypedDict): class H2SyncStream(Protocol): scope: dict - def data_received(self, data: bytes) -> None: - ... + def data_received(self, data: bytes) -> None: ... - def ended(self) -> None: - ... + def ended(self) -> None: ... - def reset(self) -> None: - ... + def reset(self) -> None: ... - def close(self) -> None: - ... + def close(self) -> None: ... async def handle_request( self, @@ -242,24 +238,19 @@ async def handle_request( scheme: str, client: Tuple[str, int], server: Tuple[str, int], - ) -> None: - ... + ) -> None: ... class H2AsyncStream(Protocol): scope: dict - async def data_received(self, data: bytes) -> None: - ... + async def data_received(self, data: bytes) -> None: ... - async def ended(self) -> None: - ... + async def ended(self) -> None: ... - async def reset(self) -> None: - ... + async def reset(self) -> None: ... - async def close(self) -> None: - ... + async def close(self) -> None: ... async def handle_request( self, @@ -267,25 +258,19 @@ async def handle_request( scheme: str, client: Tuple[str, int], server: Tuple[str, int], - ) -> None: - ... + ) -> None: ... class Event(Protocol): - def __init__(self) -> None: - ... + def __init__(self) -> None: ... - async def clear(self) -> None: - ... + async def clear(self) -> None: ... - async def set(self) -> None: - ... + async def set(self) -> None: ... - async def wait(self) -> None: - ... + async def wait(self) -> None: ... - def is_set(self) -> bool: - ... + def is_set(self) -> bool: ... class WorkerContext(Protocol): @@ -293,16 +278,13 @@ class WorkerContext(Protocol): terminate: Event terminated: Event - async def mark_request(self) -> None: - ... + async def mark_request(self) -> None: ... @staticmethod - async def sleep(wait: Union[float, int]) -> None: - ... + async def sleep(wait: Union[float, int]) -> None: ... @staticmethod - def time() -> float: - ... + def time() -> float: ... class TaskGroup(Protocol): @@ -312,17 +294,15 @@ async def spawn_app( config: Config, scope: Scope, send: Callable[[Optional[ASGISendEvent]], Awaitable[None]], - ) -> Callable[[ASGIReceiveEvent], Awaitable[None]]: - ... + ) -> Callable[[ASGIReceiveEvent], Awaitable[None]]: ... - def spawn(self, func: Callable, *args: Any) -> None: - ... + def spawn(self, func: Callable, *args: Any) -> None: ... - async def __aenter__(self) -> TaskGroup: - ... + async def __aenter__(self) -> TaskGroup: ... - async def __aexit__(self, exc_type: type, exc_value: BaseException, tb: TracebackType) -> None: - ... + async def __aexit__( + self, exc_type: type, exc_value: BaseException, tb: TracebackType + ) -> None: ... class ResponseSummary(TypedDict): @@ -338,5 +318,4 @@ async def __call__( send: ASGISendCallable, sync_spawn: Callable, call_soon: Callable, - ) -> None: - ... + ) -> None: ... From ac7b10ecbd0214ad1491fa511cb5b0f0c832a4ee Mon Sep 17 00:00:00 2001 From: seidnerj Date: Wed, 20 Mar 2024 13:21:32 +0200 Subject: [PATCH 103/151] reverted typing.py formatting fixes as it conflicts with pep8, instead replaced "..." with "pass", that way both " tox -e format" and "tox -e pep8" --- src/hypercorn/typing.py | 71 ++++++++++++++++++++++++-------------- tests/assets/config_ssl.py | 4 +-- 2 files changed, 48 insertions(+), 27 deletions(-) diff --git a/src/hypercorn/typing.py b/src/hypercorn/typing.py index f30f1bc..ccbb50d 100644 --- a/src/hypercorn/typing.py +++ b/src/hypercorn/typing.py @@ -224,13 +224,17 @@ class LifespanShutdownFailedEvent(TypedDict): class H2SyncStream(Protocol): scope: dict - def data_received(self, data: bytes) -> None: ... + def data_received(self, data: bytes) -> None: + pass - def ended(self) -> None: ... + def ended(self) -> None: + pass - def reset(self) -> None: ... + def reset(self) -> None: + pass - def close(self) -> None: ... + def close(self) -> None: + pass async def handle_request( self, @@ -238,19 +242,24 @@ async def handle_request( scheme: str, client: Tuple[str, int], server: Tuple[str, int], - ) -> None: ... + ) -> None: + pass class H2AsyncStream(Protocol): scope: dict - async def data_received(self, data: bytes) -> None: ... + async def data_received(self, data: bytes) -> None: + pass - async def ended(self) -> None: ... + async def ended(self) -> None: + pass - async def reset(self) -> None: ... + async def reset(self) -> None: + pass - async def close(self) -> None: ... + async def close(self) -> None: + pass async def handle_request( self, @@ -258,19 +267,25 @@ async def handle_request( scheme: str, client: Tuple[str, int], server: Tuple[str, int], - ) -> None: ... + ) -> None: + pass class Event(Protocol): - def __init__(self) -> None: ... + def __init__(self) -> None: + pass - async def clear(self) -> None: ... + async def clear(self) -> None: + pass - async def set(self) -> None: ... + async def set(self) -> None: + pass - async def wait(self) -> None: ... + async def wait(self) -> None: + pass - def is_set(self) -> bool: ... + def is_set(self) -> bool: + pass class WorkerContext(Protocol): @@ -278,13 +293,16 @@ class WorkerContext(Protocol): terminate: Event terminated: Event - async def mark_request(self) -> None: ... + async def mark_request(self) -> None: + pass @staticmethod - async def sleep(wait: Union[float, int]) -> None: ... + async def sleep(wait: Union[float, int]) -> None: + pass @staticmethod - def time() -> float: ... + def time() -> float: + pass class TaskGroup(Protocol): @@ -294,15 +312,17 @@ async def spawn_app( config: Config, scope: Scope, send: Callable[[Optional[ASGISendEvent]], Awaitable[None]], - ) -> Callable[[ASGIReceiveEvent], Awaitable[None]]: ... + ) -> Callable[[ASGIReceiveEvent], Awaitable[None]]: + pass - def spawn(self, func: Callable, *args: Any) -> None: ... + def spawn(self, func: Callable, *args: Any) -> None: + pass - async def __aenter__(self) -> TaskGroup: ... + async def __aenter__(self) -> TaskGroup: + pass - async def __aexit__( - self, exc_type: type, exc_value: BaseException, tb: TracebackType - ) -> None: ... + async def __aexit__(self, exc_type: type, exc_value: BaseException, tb: TracebackType) -> None: + pass class ResponseSummary(TypedDict): @@ -318,4 +338,5 @@ async def __call__( send: ASGISendCallable, sync_spawn: Callable, call_soon: Callable, - ) -> None: ... + ) -> None: + pass diff --git a/tests/assets/config_ssl.py b/tests/assets/config_ssl.py index e9cb0a0..68ea8a3 100644 --- a/tests/assets/config_ssl.py +++ b/tests/assets/config_ssl.py @@ -2,7 +2,7 @@ access_log_format = "bob" bind = ["127.0.0.1:5555"] -certfile = "tests/assets/cert.pem" +certfile = "assets/cert.pem" ciphers = "ECDHE+AESGCM" h11_max_incomplete_size = 4 -keyfile = "tests/assets/key.pem" +keyfile = "assets/key.pem" From 06c7ef38edb06732d3d806fc184959184573eb3d Mon Sep 17 00:00:00 2001 From: seidnerj Date: Wed, 20 Mar 2024 13:28:45 +0200 Subject: [PATCH 104/151] Update ci.yml due to Node.js 16 actions being deprecated As per: https://github.blog/changelog/2023-09-22-github-actions-transitioning-from-node-16-to-node-20/ --- .github/workflows/ci.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 558ddb4..3eb2612 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,9 +25,9 @@ jobs: - {name: 'package', python: '3.12', tox: package} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - - uses: actions/setup-python@v3 + - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} @@ -52,9 +52,9 @@ jobs: - {name: 'trio', worker: 'trio'} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - - uses: actions/setup-python@v3 + - uses: actions/setup-python@v5 with: python-version: "3.12" @@ -88,9 +88,9 @@ jobs: - {name: 'trio', worker: 'trio'} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - - uses: actions/setup-python@v3 + - uses: actions/setup-python@v5 with: python-version: "3.10" From 1cd0030d5b3ed11ea60ddeca87a75b9b1d2daf18 Mon Sep 17 00:00:00 2001 From: seidnerj Date: Wed, 20 Mar 2024 13:39:55 +0200 Subject: [PATCH 105/151] fix tests as per pytest's deprecation warning: PytestDeprecationWarning: is asynchronous and explicitly requests the "event_loop" fixture. Asynchronous fixtures and test functions should use "asyncio.get_running_loop()" instead. --- tests/asyncio/test_keep_alive.py | 3 ++- tests/asyncio/test_lifespan.py | 12 ++++++++---- tests/asyncio/test_sanity.py | 12 ++++++++---- tests/asyncio/test_task_group.py | 6 ++++-- tests/asyncio/test_tcp_server.py | 6 ++++-- tests/protocol/test_h11.py | 3 ++- tests/protocol/test_h2.py | 10 ++++++---- tests/protocol/test_ws_stream.py | 4 +++- 8 files changed, 37 insertions(+), 19 deletions(-) diff --git a/tests/asyncio/test_keep_alive.py b/tests/asyncio/test_keep_alive.py index a46f4cf..8e99162 100644 --- a/tests/asyncio/test_keep_alive.py +++ b/tests/asyncio/test_keep_alive.py @@ -43,7 +43,8 @@ async def slow_framework( @pytest_asyncio.fixture(name="server", scope="function") # type: ignore[misc] -async def _server(event_loop: asyncio.AbstractEventLoop) -> AsyncGenerator[TCPServer, None]: +async def _server() -> AsyncGenerator[TCPServer, None]: + event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() config = Config() config.keep_alive_timeout = KEEP_ALIVE_TIMEOUT server = TCPServer( diff --git a/tests/asyncio/test_lifespan.py b/tests/asyncio/test_lifespan.py index c59a395..6081d5c 100644 --- a/tests/asyncio/test_lifespan.py +++ b/tests/asyncio/test_lifespan.py @@ -20,7 +20,8 @@ async def no_lifespan_app(scope: Scope, receive: Callable, send: Callable) -> No @pytest.mark.asyncio -async def test_ensure_no_race_condition(event_loop: asyncio.AbstractEventLoop) -> None: +async def test_ensure_no_race_condition() -> None: + event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() config = Config() config.startup_timeout = 0.2 lifespan = Lifespan(ASGIWrapper(no_lifespan_app), config, event_loop) @@ -30,7 +31,8 @@ async def test_ensure_no_race_condition(event_loop: asyncio.AbstractEventLoop) - @pytest.mark.asyncio -async def test_startup_timeout_error(event_loop: asyncio.AbstractEventLoop) -> None: +async def test_startup_timeout_error() -> None: + event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() config = Config() config.startup_timeout = 0.01 lifespan = Lifespan(ASGIWrapper(SlowLifespanFramework(0.02, asyncio.sleep)), config, event_loop) @@ -42,7 +44,8 @@ async def test_startup_timeout_error(event_loop: asyncio.AbstractEventLoop) -> N @pytest.mark.asyncio -async def test_startup_failure(event_loop: asyncio.AbstractEventLoop) -> None: +async def test_startup_failure() -> None: + event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() lifespan = Lifespan(ASGIWrapper(lifespan_failure), Config(), event_loop) lifespan_task = event_loop.create_task(lifespan.handle_lifespan()) await lifespan.wait_for_startup() @@ -57,7 +60,8 @@ async def return_app(scope: Scope, receive: Callable, send: Callable) -> None: @pytest.mark.asyncio -async def test_lifespan_return(event_loop: asyncio.AbstractEventLoop) -> None: +async def test_lifespan_return() -> None: + event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() lifespan = Lifespan(ASGIWrapper(return_app), Config(), event_loop) lifespan_task = event_loop.create_task(lifespan.handle_lifespan()) await lifespan.wait_for_startup() diff --git a/tests/asyncio/test_sanity.py b/tests/asyncio/test_sanity.py index cde2929..a7748ed 100644 --- a/tests/asyncio/test_sanity.py +++ b/tests/asyncio/test_sanity.py @@ -16,7 +16,8 @@ @pytest.mark.asyncio -async def test_http1_request(event_loop: asyncio.AbstractEventLoop) -> None: +async def test_http1_request() -> None: + event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() server = TCPServer( ASGIWrapper(sanity_framework), event_loop, @@ -73,7 +74,8 @@ async def test_http1_request(event_loop: asyncio.AbstractEventLoop) -> None: @pytest.mark.asyncio -async def test_http1_websocket(event_loop: asyncio.AbstractEventLoop) -> None: +async def test_http1_websocket() -> None: + event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() server = TCPServer( ASGIWrapper(sanity_framework), event_loop, @@ -110,7 +112,8 @@ async def test_http1_websocket(event_loop: asyncio.AbstractEventLoop) -> None: @pytest.mark.asyncio -async def test_http2_request(event_loop: asyncio.AbstractEventLoop) -> None: +async def test_http2_request() -> None: + event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() server = TCPServer( ASGIWrapper(sanity_framework), event_loop, @@ -173,7 +176,8 @@ async def test_http2_request(event_loop: asyncio.AbstractEventLoop) -> None: @pytest.mark.asyncio -async def test_http2_websocket(event_loop: asyncio.AbstractEventLoop) -> None: +async def test_http2_websocket() -> None: + event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() server = TCPServer( ASGIWrapper(sanity_framework), event_loop, diff --git a/tests/asyncio/test_task_group.py b/tests/asyncio/test_task_group.py index dfb509c..7f86cfc 100644 --- a/tests/asyncio/test_task_group.py +++ b/tests/asyncio/test_task_group.py @@ -12,7 +12,8 @@ @pytest.mark.asyncio -async def test_spawn_app(event_loop: asyncio.AbstractEventLoop, http_scope: HTTPScope) -> None: +async def test_spawn_app(http_scope: HTTPScope) -> None: + event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() async def _echo_app(scope: Scope, receive: Callable, send: Callable) -> None: while True: message = await receive() @@ -32,8 +33,9 @@ async def _echo_app(scope: Scope, receive: Callable, send: Callable) -> None: @pytest.mark.asyncio async def test_spawn_app_error( - event_loop: asyncio.AbstractEventLoop, http_scope: HTTPScope + http_scope: HTTPScope ) -> None: + event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() async def _error_app(scope: Scope, receive: Callable, send: Callable) -> None: raise Exception() diff --git a/tests/asyncio/test_tcp_server.py b/tests/asyncio/test_tcp_server.py index 1dfd421..00cbdcc 100644 --- a/tests/asyncio/test_tcp_server.py +++ b/tests/asyncio/test_tcp_server.py @@ -13,7 +13,8 @@ @pytest.mark.asyncio -async def test_completes_on_closed(event_loop: asyncio.AbstractEventLoop) -> None: +async def test_completes_on_closed() -> None: + event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() server = TCPServer( ASGIWrapper(echo_framework), event_loop, @@ -29,7 +30,8 @@ async def test_completes_on_closed(event_loop: asyncio.AbstractEventLoop) -> Non @pytest.mark.asyncio -async def test_complets_on_half_close(event_loop: asyncio.AbstractEventLoop) -> None: +async def test_complets_on_half_close() -> None: + event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() server = TCPServer( ASGIWrapper(echo_framework), event_loop, diff --git a/tests/protocol/test_h11.py b/tests/protocol/test_h11.py index 09e85b7..b6160d0 100755 --- a/tests/protocol/test_h11.py +++ b/tests/protocol/test_h11.py @@ -137,8 +137,9 @@ async def test_protocol_send_stream_closed( @pytest.mark.asyncio async def test_protocol_instant_recycle( - protocol: H11Protocol, event_loop: asyncio.AbstractEventLoop + protocol: H11Protocol ) -> None: + event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() # This test task acts as the asgi app, spawned tasks act as the # server. data = b"GET / HTTP/1.1\r\nHost: hypercorn\r\n\r\n" diff --git a/tests/protocol/test_h2.py b/tests/protocol/test_h2.py index cec6c26..8e4bdbc 100644 --- a/tests/protocol/test_h2.py +++ b/tests/protocol/test_h2.py @@ -20,7 +20,8 @@ @pytest.mark.asyncio -async def test_stream_buffer_push_and_pop(event_loop: asyncio.AbstractEventLoop) -> None: +async def test_stream_buffer_push_and_pop() -> None: + event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() stream_buffer = StreamBuffer(EventWrapper) async def _push_over_limit() -> bool: @@ -36,7 +37,8 @@ async def _push_over_limit() -> bool: @pytest.mark.asyncio -async def test_stream_buffer_drain(event_loop: asyncio.AbstractEventLoop) -> None: +async def test_stream_buffer_drain() -> None: + event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() stream_buffer = StreamBuffer(EventWrapper) await stream_buffer.push(b"a" * 10) @@ -51,7 +53,7 @@ async def _drain() -> bool: @pytest.mark.asyncio -async def test_stream_buffer_closed(event_loop: asyncio.AbstractEventLoop) -> None: +async def test_stream_buffer_closed() -> None: stream_buffer = StreamBuffer(EventWrapper) await stream_buffer.close() await stream_buffer._is_empty.wait() @@ -62,7 +64,7 @@ async def test_stream_buffer_closed(event_loop: asyncio.AbstractEventLoop) -> No @pytest.mark.asyncio -async def test_stream_buffer_complete(event_loop: asyncio.AbstractEventLoop) -> None: +async def test_stream_buffer_complete() -> None: stream_buffer = StreamBuffer(EventWrapper) await stream_buffer.push(b"a" * 10) assert not stream_buffer.complete diff --git a/tests/protocol/test_ws_stream.py b/tests/protocol/test_ws_stream.py index 0540313..a12ed82 100644 --- a/tests/protocol/test_ws_stream.py +++ b/tests/protocol/test_ws_stream.py @@ -407,7 +407,9 @@ async def test_send_connection(stream: WSStream) -> None: @pytest.mark.asyncio -async def test_pings(stream: WSStream, event_loop: asyncio.AbstractEventLoop) -> None: +async def test_pings(stream: WSStream) -> None: + event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() + stream.config.websocket_ping_interval = 0.1 await stream.handle( Request( From 9400eb97275c05bab30aa55da8a8665eb5c1eb9d Mon Sep 17 00:00:00 2001 From: seidnerj Date: Wed, 20 Mar 2024 13:54:58 +0200 Subject: [PATCH 106/151] fix test_startup_failure test to support ExceptionGroup exceptions --- tests/trio/test_lifespan.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/tests/trio/test_lifespan.py b/tests/trio/test_lifespan.py index dd8ab77..062f935 100644 --- a/tests/trio/test_lifespan.py +++ b/tests/trio/test_lifespan.py @@ -1,4 +1,8 @@ from __future__ import annotations +import sys + +if sys.version_info < (3, 11): + from exceptiongroup import ExceptionGroup import pytest import trio @@ -24,9 +28,17 @@ async def test_startup_timeout_error(nursery: trio._core._run.Nursery) -> None: @pytest.mark.trio async def test_startup_failure() -> None: lifespan = Lifespan(ASGIWrapper(lifespan_failure), Config()) + with pytest.raises(LifespanFailureError) as exc_info: - async with trio.open_nursery() as lifespan_nursery: - await lifespan_nursery.start(lifespan.handle_lifespan) - await lifespan.wait_for_startup() + try: + async with trio.open_nursery() as lifespan_nursery: + await lifespan_nursery.start(lifespan.handle_lifespan) + await lifespan.wait_for_startup() + except ExceptionGroup as exception: + target_exception = exception + if len(exception.exceptions) == 1: + target_exception = exception.exceptions[0] + + raise target_exception.with_traceback(target_exception.__traceback__) assert str(exc_info.value) == "Lifespan failure in startup. 'Failure'" From c37a9abc10c6282e6006c31c8c4312df34d6d822 Mon Sep 17 00:00:00 2001 From: seidnerj Date: Wed, 20 Mar 2024 14:27:08 +0200 Subject: [PATCH 107/151] fixed all tests --- tests/assets/config_ssl.py | 4 ++-- tests/asyncio/test_keep_alive.py | 1 + tests/asyncio/test_lifespan.py | 4 ++++ tests/asyncio/test_sanity.py | 4 ++++ tests/asyncio/test_task_group.py | 6 +++--- tests/asyncio/test_tcp_server.py | 2 ++ tests/protocol/test_h11.py | 5 ++--- tests/protocol/test_h2.py | 2 ++ tests/test_config.py | 23 +++++++++++++++++------ tests/trio/test_lifespan.py | 1 + 10 files changed, 38 insertions(+), 14 deletions(-) diff --git a/tests/assets/config_ssl.py b/tests/assets/config_ssl.py index 68ea8a3..e9cb0a0 100644 --- a/tests/assets/config_ssl.py +++ b/tests/assets/config_ssl.py @@ -2,7 +2,7 @@ access_log_format = "bob" bind = ["127.0.0.1:5555"] -certfile = "assets/cert.pem" +certfile = "tests/assets/cert.pem" ciphers = "ECDHE+AESGCM" h11_max_incomplete_size = 4 -keyfile = "assets/key.pem" +keyfile = "tests/assets/key.pem" diff --git a/tests/asyncio/test_keep_alive.py b/tests/asyncio/test_keep_alive.py index 8e99162..5b0e162 100644 --- a/tests/asyncio/test_keep_alive.py +++ b/tests/asyncio/test_keep_alive.py @@ -45,6 +45,7 @@ async def slow_framework( @pytest_asyncio.fixture(name="server", scope="function") # type: ignore[misc] async def _server() -> AsyncGenerator[TCPServer, None]: event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() + config = Config() config.keep_alive_timeout = KEEP_ALIVE_TIMEOUT server = TCPServer( diff --git a/tests/asyncio/test_lifespan.py b/tests/asyncio/test_lifespan.py index 6081d5c..2e1a50c 100644 --- a/tests/asyncio/test_lifespan.py +++ b/tests/asyncio/test_lifespan.py @@ -22,6 +22,7 @@ async def no_lifespan_app(scope: Scope, receive: Callable, send: Callable) -> No @pytest.mark.asyncio async def test_ensure_no_race_condition() -> None: event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() + config = Config() config.startup_timeout = 0.2 lifespan = Lifespan(ASGIWrapper(no_lifespan_app), config, event_loop) @@ -33,6 +34,7 @@ async def test_ensure_no_race_condition() -> None: @pytest.mark.asyncio async def test_startup_timeout_error() -> None: event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() + config = Config() config.startup_timeout = 0.01 lifespan = Lifespan(ASGIWrapper(SlowLifespanFramework(0.02, asyncio.sleep)), config, event_loop) @@ -46,6 +48,7 @@ async def test_startup_timeout_error() -> None: @pytest.mark.asyncio async def test_startup_failure() -> None: event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() + lifespan = Lifespan(ASGIWrapper(lifespan_failure), Config(), event_loop) lifespan_task = event_loop.create_task(lifespan.handle_lifespan()) await lifespan.wait_for_startup() @@ -62,6 +65,7 @@ async def return_app(scope: Scope, receive: Callable, send: Callable) -> None: @pytest.mark.asyncio async def test_lifespan_return() -> None: event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() + lifespan = Lifespan(ASGIWrapper(return_app), Config(), event_loop) lifespan_task = event_loop.create_task(lifespan.handle_lifespan()) await lifespan.wait_for_startup() diff --git a/tests/asyncio/test_sanity.py b/tests/asyncio/test_sanity.py index a7748ed..90858d9 100644 --- a/tests/asyncio/test_sanity.py +++ b/tests/asyncio/test_sanity.py @@ -18,6 +18,7 @@ @pytest.mark.asyncio async def test_http1_request() -> None: event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() + server = TCPServer( ASGIWrapper(sanity_framework), event_loop, @@ -76,6 +77,7 @@ async def test_http1_request() -> None: @pytest.mark.asyncio async def test_http1_websocket() -> None: event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() + server = TCPServer( ASGIWrapper(sanity_framework), event_loop, @@ -114,6 +116,7 @@ async def test_http1_websocket() -> None: @pytest.mark.asyncio async def test_http2_request() -> None: event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() + server = TCPServer( ASGIWrapper(sanity_framework), event_loop, @@ -178,6 +181,7 @@ async def test_http2_request() -> None: @pytest.mark.asyncio async def test_http2_websocket() -> None: event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() + server = TCPServer( ASGIWrapper(sanity_framework), event_loop, diff --git a/tests/asyncio/test_task_group.py b/tests/asyncio/test_task_group.py index 7f86cfc..0e64efd 100644 --- a/tests/asyncio/test_task_group.py +++ b/tests/asyncio/test_task_group.py @@ -14,6 +14,7 @@ @pytest.mark.asyncio async def test_spawn_app(http_scope: HTTPScope) -> None: event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() + async def _echo_app(scope: Scope, receive: Callable, send: Callable) -> None: while True: message = await receive() @@ -32,10 +33,9 @@ async def _echo_app(scope: Scope, receive: Callable, send: Callable) -> None: @pytest.mark.asyncio -async def test_spawn_app_error( - http_scope: HTTPScope -) -> None: +async def test_spawn_app_error(http_scope: HTTPScope) -> None: event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() + async def _error_app(scope: Scope, receive: Callable, send: Callable) -> None: raise Exception() diff --git a/tests/asyncio/test_tcp_server.py b/tests/asyncio/test_tcp_server.py index 00cbdcc..ac5caad 100644 --- a/tests/asyncio/test_tcp_server.py +++ b/tests/asyncio/test_tcp_server.py @@ -15,6 +15,7 @@ @pytest.mark.asyncio async def test_completes_on_closed() -> None: event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() + server = TCPServer( ASGIWrapper(echo_framework), event_loop, @@ -32,6 +33,7 @@ async def test_completes_on_closed() -> None: @pytest.mark.asyncio async def test_complets_on_half_close() -> None: event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() + server = TCPServer( ASGIWrapper(echo_framework), event_loop, diff --git a/tests/protocol/test_h11.py b/tests/protocol/test_h11.py index b6160d0..1d2c679 100755 --- a/tests/protocol/test_h11.py +++ b/tests/protocol/test_h11.py @@ -136,10 +136,9 @@ async def test_protocol_send_stream_closed( @pytest.mark.asyncio -async def test_protocol_instant_recycle( - protocol: H11Protocol -) -> None: +async def test_protocol_instant_recycle(protocol: H11Protocol) -> None: event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() + # This test task acts as the asgi app, spawned tasks act as the # server. data = b"GET / HTTP/1.1\r\nHost: hypercorn\r\n\r\n" diff --git a/tests/protocol/test_h2.py b/tests/protocol/test_h2.py index 8e4bdbc..b2e308d 100644 --- a/tests/protocol/test_h2.py +++ b/tests/protocol/test_h2.py @@ -22,6 +22,7 @@ @pytest.mark.asyncio async def test_stream_buffer_push_and_pop() -> None: event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() + stream_buffer = StreamBuffer(EventWrapper) async def _push_over_limit() -> bool: @@ -39,6 +40,7 @@ async def _push_over_limit() -> bool: @pytest.mark.asyncio async def test_stream_buffer_drain() -> None: event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() + stream_buffer = StreamBuffer(EventWrapper) await stream_buffer.push(b"a" * 10) diff --git a/tests/test_config.py b/tests/test_config.py index fe758b5..fa511cf 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -53,12 +53,23 @@ def test_create_ssl_context() -> None: path = os.path.join(os.path.dirname(__file__), "assets/config_ssl.py") config = Config.from_pyfile(path) context = config.create_ssl_context() - assert context.options & ( - ssl.OP_NO_SSLv2 - | ssl.OP_NO_SSLv3 - | ssl.OP_NO_TLSv1 - | ssl.OP_NO_TLSv1_1 - | ssl.OP_NO_COMPRESSION + + # NOTE: In earlier versions of python context.options is equal to + # hence the ANDing context.options with the specified ssl options results in + # "", which as a Boolean value, is False. + # + # To overcome this, instead of checking that the result in True, we will check that it is + # equal to "context.options". + assert ( + context.options + & ( + ssl.OP_NO_SSLv2 + | ssl.OP_NO_SSLv3 + | ssl.OP_NO_TLSv1 + | ssl.OP_NO_TLSv1_1 + | ssl.OP_NO_COMPRESSION + ) + == context.options ) diff --git a/tests/trio/test_lifespan.py b/tests/trio/test_lifespan.py index 062f935..4de1493 100644 --- a/tests/trio/test_lifespan.py +++ b/tests/trio/test_lifespan.py @@ -1,4 +1,5 @@ from __future__ import annotations + import sys if sys.version_info < (3, 11): From 155a1f66714f01582ab3d6c0a9bcb01f1b64d684 Mon Sep 17 00:00:00 2001 From: twoyang0917 Date: Thu, 14 Mar 2024 16:53:20 +0800 Subject: [PATCH 108/151] encode headers using latin-1 --- src/hypercorn/app_wrappers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hypercorn/app_wrappers.py b/src/hypercorn/app_wrappers.py index 633abb1..834586d 100644 --- a/src/hypercorn/app_wrappers.py +++ b/src/hypercorn/app_wrappers.py @@ -99,7 +99,7 @@ def start_response( raw, _ = status.split(" ", 1) status_code = int(raw) headers = [ - (name.lower().encode("ascii"), value.encode("ascii")) + (name.lower().encode("latin-1"), value.encode("latin-1")) for name, value in response_headers ] response_started = True From 31639ec2f4d03aa920b95c84686163901224c6cf Mon Sep 17 00:00:00 2001 From: pgjones Date: Thu, 11 Apr 2024 20:59:39 +0100 Subject: [PATCH 109/151] Bugfix don't double-access log if the response was sent The access log after StreamClosed is meant for requests that are not responded to - usually due to an error that would otherwise go unnoticed without this log. --- src/hypercorn/protocol/http_stream.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/hypercorn/protocol/http_stream.py b/src/hypercorn/protocol/http_stream.py index d244e7c..a0f0940 100644 --- a/src/hypercorn/protocol/http_stream.py +++ b/src/hypercorn/protocol/http_stream.py @@ -110,7 +110,8 @@ async def handle(self, event: Event) -> None: await self.app_put({"type": "http.request", "body": b"", "more_body": False}) elif isinstance(event, StreamClosed): self.closed = True - await self.config.log.access(self.scope, None, time() - self.start_time) + if self.state != ASGIHTTPState.CLOSED: + await self.config.log.access(self.scope, None, time() - self.start_time) if self.app_put is not None: await self.app_put({"type": "http.disconnect"}) From a099217fdfd80035f43fc3bd467818acdef7b5af Mon Sep 17 00:00:00 2001 From: Alex Silverstein Date: Mon, 6 May 2024 18:12:08 -0700 Subject: [PATCH 110/151] Set TCP_NODELAY on sockets --- src/hypercorn/config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/hypercorn/config.py b/src/hypercorn/config.py index f00c7d5..8867694 100644 --- a/src/hypercorn/config.py +++ b/src/hypercorn/config.py @@ -246,6 +246,7 @@ def _create_sockets( except (ValueError, IndexError): host, port = bind, 8000 sock = socket.socket(socket.AF_INET6 if ":" in host else socket.AF_INET, type_) + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) if self.workers > 1: try: sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) From aee69f5fcc5dda48e33e6566e9068d4fe03c1e30 Mon Sep 17 00:00:00 2001 From: Alex Silverstein Date: Mon, 6 May 2024 17:13:56 -0700 Subject: [PATCH 111/151] Demonstrate and fix a statsd logging bug --- src/hypercorn/statsd.py | 7 ++++--- tests/protocol/test_http_stream.py | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/hypercorn/statsd.py b/src/hypercorn/statsd.py index 9cd7647..58e2cde 100644 --- a/src/hypercorn/statsd.py +++ b/src/hypercorn/statsd.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, TYPE_CHECKING +from typing import Any, Optional, TYPE_CHECKING from .logging import Logger @@ -67,12 +67,13 @@ async def log(self, level: int, message: str, *args: Any, **kwargs: Any) -> None await super().warning("Failed to log to statsd", exc_info=True) async def access( - self, request: "WWWScope", response: "ResponseSummary", request_time: float + self, request: "WWWScope", response: Optional["ResponseSummary"], request_time: float ) -> None: await super().access(request, response, request_time) await self.histogram("hypercorn.request.duration", request_time * 1_000) await self.increment("hypercorn.requests", 1) - await self.increment(f"hypercorn.request.status.{response['status']}", 1) + if response is not None: + await self.increment(f"hypercorn.request.status.{response['status']}", 1) async def gauge(self, name: str, value: int) -> None: await self._send(f"{self.prefix}{name}:{value}|g") diff --git a/tests/protocol/test_http_stream.py b/tests/protocol/test_http_stream.py index 6f656de..f3961a9 100644 --- a/tests/protocol/test_http_stream.py +++ b/tests/protocol/test_http_stream.py @@ -6,6 +6,7 @@ import pytest import pytest_asyncio +from hypercorn.asyncio.statsd import StatsdLogger from hypercorn.asyncio.worker_context import WorkerContext from hypercorn.config import Config from hypercorn.logging import Logger @@ -298,3 +299,21 @@ async def test_closed_app_send_noop(stream: HTTPStream) -> None: cast(HTTPResponseStartEvent, {"type": "http.response.start", "status": 200, "headers": []}) ) stream.send.assert_not_called() # type: ignore + + +@pytest.mark.asyncio +async def test_abnormal_close_logging() -> None: + config = Config() + config.accesslog = "-" + config.statsd_host = "localhost:9125" + # This exercises an issue where `HTTPStream` at one point called the statsd logger + # with `response=None` when the statsd logger failed to handle it. + config.set_statsd_logger_class(StatsdLogger) + stream = HTTPStream( + AsyncMock(), config, WorkerContext(None), AsyncMock(), False, None, None, AsyncMock(), 1 + ) + + await stream.handle( + Request(stream_id=1, http_version="2", headers=[], raw_path=b"/?a=b", method="GET") + ) + await stream.handle(StreamClosed(stream_id=1)) From 110403452c10487e489c644f3441f1a83aa41bc0 Mon Sep 17 00:00:00 2001 From: Alex Silverstein Date: Wed, 8 May 2024 09:37:57 -0700 Subject: [PATCH 112/151] Bad disconnect demo --- tests/e2e/exercise_e2e.py | 45 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 tests/e2e/exercise_e2e.py diff --git a/tests/e2e/exercise_e2e.py b/tests/e2e/exercise_e2e.py new file mode 100644 index 0000000..dd1f479 --- /dev/null +++ b/tests/e2e/exercise_e2e.py @@ -0,0 +1,45 @@ +import hypercorn.trio +from hypercorn.config import Config +from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.requests import Request +from starlette.responses import Response +from starlette.routing import Route +import trio +import httpx + + +async def test_post(request: Request): + print("HEY") + return Response("boo", 200) + +async def run_test(): + app = Starlette( + routes=[ + Route( "/test", test_post, methods=["POST"]) + ] + ) + + config = Config() + config.bind = f"0.0.0.0:1234" + config.accesslog = "-" # Log to stdout/err + config.errorlog = "-" + config.keep_alive_max_requests = 2 + + async with trio.open_nursery() as nursery: + nursery.start_soon(hypercorn.trio.serve, app, config) + + await trio.sleep(0.1) + + client = httpx.AsyncClient() + for _ in range(10): + msg = {"key": "key1", "value": "value1"} + try: + result = await client.post("http://0.0.0.0:1234/test", json=msg) + except (httpx.ReadError, httpx.RemoteProtocolError): + raise + result.raise_for_status() + print(result) + +if __name__ == "__main__": + trio.run(run_test) \ No newline at end of file From 3c2b27f73ce5de4e21b6163addbd523936374d8d Mon Sep 17 00:00:00 2001 From: Alex Silverstein Date: Thu, 9 May 2024 08:51:00 -0700 Subject: [PATCH 113/151] Maybe --- tests/e2e/exercise_e2e.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/tests/e2e/exercise_e2e.py b/tests/e2e/exercise_e2e.py index dd1f479..5c59e2a 100644 --- a/tests/e2e/exercise_e2e.py +++ b/tests/e2e/exercise_e2e.py @@ -1,25 +1,25 @@ import hypercorn.trio from hypercorn.config import Config -from starlette.applications import Starlette -from starlette.middleware import Middleware -from starlette.requests import Request -from starlette.responses import Response -from starlette.routing import Route import trio import httpx -async def test_post(request: Request): - print("HEY") - return Response("boo", 200) +async def app(scope, receive, send): + assert scope['type'] == 'http' -async def run_test(): - app = Starlette( - routes=[ - Route( "/test", test_post, methods=["POST"]) - ] - ) + await send({ + 'type': 'http.response.start', + 'status': 200, + 'headers': [ + [b'content-type', b'text/plain'], + ], + }) + await send({ + 'type': 'http.response.body', + 'body': b'Hello, world!', + }) +async def run_test(): config = Config() config.bind = f"0.0.0.0:1234" config.accesslog = "-" # Log to stdout/err From f1551776d157929d64aea92bca130482c01b226b Mon Sep 17 00:00:00 2001 From: Alex Silverstein Date: Thu, 9 May 2024 08:55:23 -0700 Subject: [PATCH 114/151] shutdown --- tests/e2e/exercise_e2e.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/e2e/exercise_e2e.py b/tests/e2e/exercise_e2e.py index 5c59e2a..36dfb57 100644 --- a/tests/e2e/exercise_e2e.py +++ b/tests/e2e/exercise_e2e.py @@ -27,7 +27,10 @@ async def run_test(): config.keep_alive_max_requests = 2 async with trio.open_nursery() as nursery: - nursery.start_soon(hypercorn.trio.serve, app, config) + shutdown = trio.Event() + async def serve(): + await hypercorn.trio.serve(app, config, shutdown_trigger=shutdown.wait) + nursery.start_soon(serve) await trio.sleep(0.1) @@ -41,5 +44,7 @@ async def run_test(): result.raise_for_status() print(result) + shutdown.set() + if __name__ == "__main__": trio.run(run_test) \ No newline at end of file From db586db2e17e9e8bced5ca85b2918b6949be8235 Mon Sep 17 00:00:00 2001 From: Alex Silverstein Date: Thu, 9 May 2024 10:58:14 -0700 Subject: [PATCH 115/151] Add a test and fix --- pyproject.toml | 1 + src/hypercorn/protocol/h11.py | 6 ++++-- tests/e2e/__init__.py | 0 tests/e2e/{exercise_e2e.py => test_httpx.py} | 21 +++++++++----------- tox.ini | 1 + 5 files changed, 15 insertions(+), 14 deletions(-) create mode 100644 tests/e2e/__init__.py rename tests/e2e/{exercise_e2e.py => test_httpx.py} (69%) diff --git a/pyproject.toml b/pyproject.toml index 37d199d..2709633 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ uvloop = { version = "*", markers = "platform_system != 'Windows'", optional = t wsproto = ">=0.14.0" [tool.poetry.dev-dependencies] +httpx = "*" hypothesis = "*" mock = "*" pytest = "*" diff --git a/src/hypercorn/protocol/h11.py b/src/hypercorn/protocol/h11.py index a33ad4a..b35425f 100755 --- a/src/hypercorn/protocol/h11.py +++ b/src/hypercorn/protocol/h11.py @@ -118,9 +118,12 @@ async def handle(self, event: Event) -> None: async def stream_send(self, event: StreamEvent) -> None: if isinstance(event, Response): if event.status_code >= 200: + headers = list(chain(event.headers, self.config.response_headers("h11"))) + if self.keep_alive_requests >= self.config.keep_alive_max_requests: + headers.append((b"connection", b"close")) await self._send_h11_event( h11.Response( - headers=list(chain(event.headers, self.config.response_headers("h11"))), + headers=headers, status_code=event.status_code, ) ) @@ -267,7 +270,6 @@ async def _maybe_recycle(self) -> None: not self.context.terminated.is_set() and self.connection.our_state is h11.DONE and self.connection.their_state is h11.DONE - and self.keep_alive_requests <= self.config.keep_alive_max_requests ): try: self.connection.start_next_cycle() diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/exercise_e2e.py b/tests/e2e/test_httpx.py similarity index 69% rename from tests/e2e/exercise_e2e.py rename to tests/e2e/test_httpx.py index 36dfb57..316790a 100644 --- a/tests/e2e/exercise_e2e.py +++ b/tests/e2e/test_httpx.py @@ -2,6 +2,7 @@ from hypercorn.config import Config import trio import httpx +import pytest async def app(scope, receive, send): @@ -19,7 +20,8 @@ async def app(scope, receive, send): 'body': b'Hello, world!', }) -async def run_test(): +@pytest.mark.trio +async def test_keep_alive_max_requests_regression(): config = Config() config.bind = f"0.0.0.0:1234" config.accesslog = "-" # Log to stdout/err @@ -32,19 +34,14 @@ async def serve(): await hypercorn.trio.serve(app, config, shutdown_trigger=shutdown.wait) nursery.start_soon(serve) - await trio.sleep(0.1) + await trio.testing.wait_all_tasks_blocked() client = httpx.AsyncClient() + + # Make sure that we properly clean up connections when `keep_alive_max_requests` + # is hit such that the client stays good over multiple hangups. for _ in range(10): - msg = {"key": "key1", "value": "value1"} - try: - result = await client.post("http://0.0.0.0:1234/test", json=msg) - except (httpx.ReadError, httpx.RemoteProtocolError): - raise + result = await client.post("http://0.0.0.0:1234/test", json={"key": "value"}) result.raise_for_status() - print(result) - - shutdown.set() -if __name__ == "__main__": - trio.run(run_test) \ No newline at end of file + shutdown.set() \ No newline at end of file diff --git a/tox.ini b/tox.ini index a099459..3d8fc90 100644 --- a/tox.ini +++ b/tox.ini @@ -6,6 +6,7 @@ isolated_build = true [testenv] deps = py37: mock + httpx hypothesis pytest pytest-asyncio From ab1552457722a78f5f0a98e2c554fd16b81678e9 Mon Sep 17 00:00:00 2001 From: Alex Silverstein Date: Thu, 9 May 2024 11:02:15 -0700 Subject: [PATCH 116/151] format --- tests/e2e/test_httpx.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/e2e/test_httpx.py b/tests/e2e/test_httpx.py index 316790a..a26b7a3 100644 --- a/tests/e2e/test_httpx.py +++ b/tests/e2e/test_httpx.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import hypercorn.trio from hypercorn.config import Config import trio @@ -20,16 +22,18 @@ async def app(scope, receive, send): 'body': b'Hello, world!', }) + @pytest.mark.trio async def test_keep_alive_max_requests_regression(): config = Config() - config.bind = f"0.0.0.0:1234" + config.bind = "0.0.0.0:1234" config.accesslog = "-" # Log to stdout/err config.errorlog = "-" config.keep_alive_max_requests = 2 async with trio.open_nursery() as nursery: shutdown = trio.Event() + async def serve(): await hypercorn.trio.serve(app, config, shutdown_trigger=shutdown.wait) nursery.start_soon(serve) @@ -44,4 +48,4 @@ async def serve(): result = await client.post("http://0.0.0.0:1234/test", json={"key": "value"}) result.raise_for_status() - shutdown.set() \ No newline at end of file + shutdown.set() From b316c2464388010c1fa5874e6d8678a9e7c30933 Mon Sep 17 00:00:00 2001 From: Alex Silverstein Date: Thu, 9 May 2024 11:09:46 -0700 Subject: [PATCH 117/151] more format --- tests/e2e/test_httpx.py | 44 +++++++++++++++++++++++------------------ 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/tests/e2e/test_httpx.py b/tests/e2e/test_httpx.py index a26b7a3..9ce2cc7 100644 --- a/tests/e2e/test_httpx.py +++ b/tests/e2e/test_httpx.py @@ -1,32 +1,37 @@ from __future__ import annotations +import httpx # type: ignore +import pytest +import trio + import hypercorn.trio from hypercorn.config import Config -import trio -import httpx -import pytest -async def app(scope, receive, send): - assert scope['type'] == 'http' +async def app(scope, receive, send) -> None: # type: ignore + assert scope["type"] == "http" - await send({ - 'type': 'http.response.start', - 'status': 200, - 'headers': [ - [b'content-type', b'text/plain'], - ], - }) - await send({ - 'type': 'http.response.body', - 'body': b'Hello, world!', - }) + await send( + { + "type": "http.response.start", + "status": 200, + "headers": [ + [b"content-type", b"text/plain"], + ], + } + ) + await send( + { + "type": "http.response.body", + "body": b"Hello, world!", + } + ) @pytest.mark.trio -async def test_keep_alive_max_requests_regression(): +async def test_keep_alive_max_requests_regression() -> None: config = Config() - config.bind = "0.0.0.0:1234" + config.bind = ["0.0.0.0:1234"] config.accesslog = "-" # Log to stdout/err config.errorlog = "-" config.keep_alive_max_requests = 2 @@ -34,8 +39,9 @@ async def test_keep_alive_max_requests_regression(): async with trio.open_nursery() as nursery: shutdown = trio.Event() - async def serve(): + async def serve() -> None: await hypercorn.trio.serve(app, config, shutdown_trigger=shutdown.wait) + nursery.start_soon(serve) await trio.testing.wait_all_tasks_blocked() From 2bb1c35eac03be19354ddf4fdc73ba4fdba4fa12 Mon Sep 17 00:00:00 2001 From: Alex Silverstein Date: Thu, 9 May 2024 11:35:39 -0700 Subject: [PATCH 118/151] Don't completely crash server on uncaught exception --- src/hypercorn/trio/tcp_server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hypercorn/trio/tcp_server.py b/src/hypercorn/trio/tcp_server.py index dbcc7a1..911f91c 100644 --- a/src/hypercorn/trio/tcp_server.py +++ b/src/hypercorn/trio/tcp_server.py @@ -68,8 +68,8 @@ async def run(self) -> None: await self.protocol.initiate() await self._start_idle() await self._read_data() - except OSError: - pass + except (OSError, BaseExceptionGroup): + await self.config.log.exception("Internal hypercorn error") finally: await self._close() From e3403388caa17ebcc1084933504bc04ac140e717 Mon Sep 17 00:00:00 2001 From: Alex Silverstein Date: Thu, 9 May 2024 16:21:24 -0700 Subject: [PATCH 119/151] Fix import --- src/hypercorn/trio/tcp_server.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/hypercorn/trio/tcp_server.py b/src/hypercorn/trio/tcp_server.py index 911f91c..1d648b6 100644 --- a/src/hypercorn/trio/tcp_server.py +++ b/src/hypercorn/trio/tcp_server.py @@ -13,6 +13,9 @@ from ..typing import AppWrapper from ..utils import parse_socket_addr +if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup + MAX_RECV = 2**16 From 277a1bdca0031d42bb949a183402954bf33e81a8 Mon Sep 17 00:00:00 2001 From: Alex Silverstein Date: Thu, 9 May 2024 16:35:54 -0700 Subject: [PATCH 120/151] use exceptiongroup.catch --- src/hypercorn/trio/tcp_server.py | 47 ++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/src/hypercorn/trio/tcp_server.py b/src/hypercorn/trio/tcp_server.py index 1d648b6..e6f2d47 100644 --- a/src/hypercorn/trio/tcp_server.py +++ b/src/hypercorn/trio/tcp_server.py @@ -1,8 +1,10 @@ from __future__ import annotations +import sys from math import inf from typing import Any, Generator, Optional +import exceptiongroup import trio from .task_group import TaskGroup @@ -51,28 +53,31 @@ async def run(self) -> None: socket = self.stream.socket ssl = False + def log_handler(e: Exception): + if self.config.log.error_logger is not None: + self.config.log.error_logger.exception("Internal hypercorn error") + try: - client = parse_socket_addr(socket.family, socket.getpeername()) - server = parse_socket_addr(socket.family, socket.getsockname()) - - async with TaskGroup() as task_group: - self._task_group = task_group - self.protocol = ProtocolWrapper( - self.app, - self.config, - self.context, - task_group, - ssl, - client, - server, - self.protocol_send, - alpn_protocol, - ) - await self.protocol.initiate() - await self._start_idle() - await self._read_data() - except (OSError, BaseExceptionGroup): - await self.config.log.exception("Internal hypercorn error") + with exceptiongroup.catch({Exception: log_handler}): + client = parse_socket_addr(socket.family, socket.getpeername()) + server = parse_socket_addr(socket.family, socket.getsockname()) + + async with TaskGroup() as task_group: + self._task_group = task_group + self.protocol = ProtocolWrapper( + self.app, + self.config, + self.context, + task_group, + ssl, + client, + server, + self.protocol_send, + alpn_protocol, + ) + await self.protocol.initiate() + await self._start_idle() + await self._read_data() finally: await self._close() From f285548dd6707af65c6f67cc20d517ec1d71a301 Mon Sep 17 00:00:00 2001 From: Alex Silverstein Date: Thu, 9 May 2024 16:54:01 -0700 Subject: [PATCH 121/151] Possibly fix --- src/hypercorn/trio/tcp_server.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/hypercorn/trio/tcp_server.py b/src/hypercorn/trio/tcp_server.py index e6f2d47..bfbdc74 100644 --- a/src/hypercorn/trio/tcp_server.py +++ b/src/hypercorn/trio/tcp_server.py @@ -15,9 +15,6 @@ from ..typing import AppWrapper from ..utils import parse_socket_addr -if sys.version_info < (3, 11): - from exceptiongroup import BaseExceptionGroup - MAX_RECV = 2**16 From b9b278f1a607709ef048350468886fea6aafc6e2 Mon Sep 17 00:00:00 2001 From: Alex Silverstein Date: Thu, 9 May 2024 17:19:58 -0700 Subject: [PATCH 122/151] fix dependencies --- pyproject.toml | 2 +- src/hypercorn/trio/tcp_server.py | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2709633..4b02695 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,7 +55,7 @@ hypercorn = "hypercorn.__main__:main" [tool.poetry.extras] docs = ["pydata_sphinx_theme", "sphinxcontrib_mermaid"] h3 = ["aioquic"] -trio = ["exceptiongroup", "trio"] +trio = ["trio"] uvloop = ["uvloop"] [tool.black] diff --git a/src/hypercorn/trio/tcp_server.py b/src/hypercorn/trio/tcp_server.py index bfbdc74..2dd64c3 100644 --- a/src/hypercorn/trio/tcp_server.py +++ b/src/hypercorn/trio/tcp_server.py @@ -1,6 +1,5 @@ from __future__ import annotations -import sys from math import inf from typing import Any, Generator, Optional @@ -50,12 +49,12 @@ async def run(self) -> None: socket = self.stream.socket ssl = False - def log_handler(e: Exception): + def log_handler(e: Exception) -> None: if self.config.log.error_logger is not None: self.config.log.error_logger.exception("Internal hypercorn error") try: - with exceptiongroup.catch({Exception: log_handler}): + with exceptiongroup.catch({Exception: log_handler}): # type: ignore client = parse_socket_addr(socket.family, socket.getpeername()) server = parse_socket_addr(socket.family, socket.getsockname()) From 975d7833406a5791d0a3dccdf73e81da6bbed0d5 Mon Sep 17 00:00:00 2001 From: Alex Silverstein Date: Thu, 9 May 2024 17:27:05 -0700 Subject: [PATCH 123/151] explicit exception group --- src/hypercorn/trio/tcp_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hypercorn/trio/tcp_server.py b/src/hypercorn/trio/tcp_server.py index 2dd64c3..81e55e0 100644 --- a/src/hypercorn/trio/tcp_server.py +++ b/src/hypercorn/trio/tcp_server.py @@ -51,7 +51,7 @@ async def run(self) -> None: def log_handler(e: Exception) -> None: if self.config.log.error_logger is not None: - self.config.log.error_logger.exception("Internal hypercorn error") + self.config.log.error_logger.exception("Internal hypercorn error", exc_info=e) try: with exceptiongroup.catch({Exception: log_handler}): # type: ignore From bb34f360cbefbbecec72605e306cf164af349296 Mon Sep 17 00:00:00 2001 From: Alex Silverstein Date: Thu, 9 May 2024 17:35:56 -0700 Subject: [PATCH 124/151] don't log oserror --- src/hypercorn/trio/tcp_server.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/hypercorn/trio/tcp_server.py b/src/hypercorn/trio/tcp_server.py index 81e55e0..3527e2e 100644 --- a/src/hypercorn/trio/tcp_server.py +++ b/src/hypercorn/trio/tcp_server.py @@ -54,7 +54,9 @@ def log_handler(e: Exception) -> None: self.config.log.error_logger.exception("Internal hypercorn error", exc_info=e) try: - with exceptiongroup.catch({Exception: log_handler}): # type: ignore + with exceptiongroup.catch( + {OSError: lambda e: None, Exception: log_handler} # type: ignore + ): client = parse_socket_addr(socket.family, socket.getpeername()) server = parse_socket_addr(socket.family, socket.getsockname()) From 24aacf432ef44ddf8ba32e2427b507e560f63ce2 Mon Sep 17 00:00:00 2001 From: merlinz01 <158784988+merlinz01@users.noreply.github.com> Date: Fri, 1 Mar 2024 14:02:00 -0500 Subject: [PATCH 125/151] Bugfix handle already-closed on StreamEnded This occurs if the response is sent before the full request body has been received. --- src/hypercorn/protocol/h2.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/hypercorn/protocol/h2.py b/src/hypercorn/protocol/h2.py index 9c92ab3..ef40c3d 100755 --- a/src/hypercorn/protocol/h2.py +++ b/src/hypercorn/protocol/h2.py @@ -256,7 +256,12 @@ async def _handle_events(self, events: List[h2.events.Event]) -> None: event.flow_controlled_length, event.stream_id ) elif isinstance(event, h2.events.StreamEnded): - await self.streams[event.stream_id].handle(EndBody(stream_id=event.stream_id)) + try: + await self.streams[event.stream_id].handle(EndBody(stream_id=event.stream_id)) + except KeyError: + # Response sent before full request received, + # nothing to do already closed. + pass elif isinstance(event, h2.events.StreamReset): await self._close_stream(event.stream_id) await self._window_updated(event.stream_id) From 6a9fd70d79fb5ad54709ab224ebbe9956bb9454b Mon Sep 17 00:00:00 2001 From: "Benjamin A. Beasley" Date: Wed, 31 Jan 2024 17:05:46 -0500 Subject: [PATCH 126/151] Remove executable permissions from non-script sources These files are not script-like (no `if __name__ == "__main__":` or interesting side effects) and have no shebang lines (`#!`), so there is no reason for the executable bit to be set in their filesystem permissions. --- src/hypercorn/protocol/__init__.py | 0 src/hypercorn/protocol/h11.py | 0 src/hypercorn/protocol/h2.py | 0 3 files changed, 0 insertions(+), 0 deletions(-) mode change 100755 => 100644 src/hypercorn/protocol/__init__.py mode change 100755 => 100644 src/hypercorn/protocol/h11.py mode change 100755 => 100644 src/hypercorn/protocol/h2.py diff --git a/src/hypercorn/protocol/__init__.py b/src/hypercorn/protocol/__init__.py old mode 100755 new mode 100644 diff --git a/src/hypercorn/protocol/h11.py b/src/hypercorn/protocol/h11.py old mode 100755 new mode 100644 diff --git a/src/hypercorn/protocol/h2.py b/src/hypercorn/protocol/h2.py old mode 100755 new mode 100644 From 751f6d232e70749dbe26ef4f46386e71884814d8 Mon Sep 17 00:00:00 2001 From: pgjones Date: Sat, 25 May 2024 21:41:17 +0100 Subject: [PATCH 127/151] Revert #228 I wish to consider this solution, and also ensure any solution applies in the same manner for asyncio and UDP servers. --- src/hypercorn/trio/tcp_server.py | 48 ++++++++++++++------------------ 1 file changed, 21 insertions(+), 27 deletions(-) diff --git a/src/hypercorn/trio/tcp_server.py b/src/hypercorn/trio/tcp_server.py index 3527e2e..dbcc7a1 100644 --- a/src/hypercorn/trio/tcp_server.py +++ b/src/hypercorn/trio/tcp_server.py @@ -3,7 +3,6 @@ from math import inf from typing import Any, Generator, Optional -import exceptiongroup import trio from .task_group import TaskGroup @@ -49,33 +48,28 @@ async def run(self) -> None: socket = self.stream.socket ssl = False - def log_handler(e: Exception) -> None: - if self.config.log.error_logger is not None: - self.config.log.error_logger.exception("Internal hypercorn error", exc_info=e) - try: - with exceptiongroup.catch( - {OSError: lambda e: None, Exception: log_handler} # type: ignore - ): - client = parse_socket_addr(socket.family, socket.getpeername()) - server = parse_socket_addr(socket.family, socket.getsockname()) - - async with TaskGroup() as task_group: - self._task_group = task_group - self.protocol = ProtocolWrapper( - self.app, - self.config, - self.context, - task_group, - ssl, - client, - server, - self.protocol_send, - alpn_protocol, - ) - await self.protocol.initiate() - await self._start_idle() - await self._read_data() + client = parse_socket_addr(socket.family, socket.getpeername()) + server = parse_socket_addr(socket.family, socket.getsockname()) + + async with TaskGroup() as task_group: + self._task_group = task_group + self.protocol = ProtocolWrapper( + self.app, + self.config, + self.context, + task_group, + ssl, + client, + server, + self.protocol_send, + alpn_protocol, + ) + await self.protocol.initiate() + await self._start_idle() + await self._read_data() + except OSError: + pass finally: await self._close() From 0a23657b12d945c620e15afc90fc70c1ba9269c0 Mon Sep 17 00:00:00 2001 From: "Benjamin A. Beasley" Date: Wed, 31 Jan 2024 16:37:17 -0500 Subject: [PATCH 128/151] Drop exceptiongroup dependency in Python 3.11 and later All of the imports are already conditionalized on the Python interpreter version; there is no need to install this backport package on current interpreters. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4b02695..f5e1aca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ documentation = "https://hypercorn.readthedocs.io" [tool.poetry.dependencies] python = ">=3.8" aioquic = { version = ">= 0.9.0, < 1.0", optional = true } -exceptiongroup = ">= 1.1.0" +exceptiongroup = { version = ">= 1.1.0", python = "<3.11" } h11 = "*" h2 = ">=3.1.0" priority = "*" From d264794d09fd8de4172623eec459a2efe31257ad Mon Sep 17 00:00:00 2001 From: pgjones Date: Sun, 26 May 2024 10:07:27 +0100 Subject: [PATCH 129/151] Improve the proxy fix docs This should make it clearer what the trusted_hops argument should be. This is based on the Werkzeug docs. --- docs/how_to_guides/proxy_fix.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/how_to_guides/proxy_fix.rst b/docs/how_to_guides/proxy_fix.rst index dd8d080..ca7e6f7 100644 --- a/docs/how_to_guides/proxy_fix.rst +++ b/docs/how_to_guides/proxy_fix.rst @@ -31,3 +31,8 @@ wrap your app and serve the wrapped app, user-agent (client) may be trusted and hence able to set alternative for, proto, and host values. This can, depending on your usage in the app, lead to security vulnerabilities. + +The ``trusted_hops`` argument should be set to the number of proxies +that are chained in front of Hypercorn. You should set this to how +many proxies are setting the headers so the middleware knows what to +trust. From 81bbb32642f4daa2dbeb3b4912da53bd146fd9ae Mon Sep 17 00:00:00 2001 From: pgjones Date: Sun, 26 May 2024 11:05:09 +0100 Subject: [PATCH 130/151] Send a 400 if data is received before the websocket is accepted This is necessary as the data is not known to be either websocket or http data as the server must accept or reject the connection and hence it is a bad request. In practice this is rare as the upgrade request is a GET request which rarely has body data and most websocket clients wait for acceptance. --- src/hypercorn/protocol/ws_stream.py | 5 +++++ tests/protocol/test_ws_stream.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/hypercorn/protocol/ws_stream.py b/src/hypercorn/protocol/ws_stream.py index 5709952..80d2167 100644 --- a/src/hypercorn/protocol/ws_stream.py +++ b/src/hypercorn/protocol/ws_stream.py @@ -56,6 +56,7 @@ class FrameTooLargeError(Exception): class Handshake: def __init__(self, headers: List[Tuple[bytes, bytes]], http_version: str) -> None: + self.accepted = False self.http_version = http_version self.connection_tokens: Optional[List[str]] = None self.extensions: Optional[List[str]] = None @@ -129,6 +130,7 @@ def accept( headers.append((name, value)) + self.accepted = True return status_code, headers, Connection(ConnectionType.SERVER, extensions) @@ -232,6 +234,9 @@ async def handle(self, event: Event) -> None: self.app, self.config, self.scope, self.app_send ) await self.app_put({"type": "websocket.connect"}) + elif isinstance(event, (Body, Data)) and not self.handshake.accepted: + await self._send_error_response(400) + self.closed = True elif isinstance(event, (Body, Data)): self.connection.receive_data(event.data) await self._handle_events() diff --git a/tests/protocol/test_ws_stream.py b/tests/protocol/test_ws_stream.py index a12ed82..adbe78f 100644 --- a/tests/protocol/test_ws_stream.py +++ b/tests/protocol/test_ws_stream.py @@ -203,6 +203,35 @@ async def test_handle_request(stream: WSStream) -> None: } +@pytest.mark.asyncio +async def test_handle_data_before_acceptance(stream: WSStream) -> None: + await stream.handle( + Request( + stream_id=1, + http_version="2", + headers=[(b"sec-websocket-version", b"13")], + raw_path=b"/?a=b", + method="GET", + ) + ) + await stream.handle( + Data( + stream_id=1, + data=b"X", + ) + ) + assert stream.send.call_args_list == [ # type: ignore + call( + Response( + stream_id=1, + headers=[(b"content-length", b"0"), (b"connection", b"close")], + status_code=400, + ) + ), + call(EndBody(stream_id=1)), + ] + + @pytest.mark.asyncio async def test_handle_connection(stream: WSStream) -> None: await stream.handle( From ab98383f521a8b7d4f173ffa1f28b443e85561d8 Mon Sep 17 00:00:00 2001 From: pgjones Date: Mon, 27 May 2024 10:14:54 +0100 Subject: [PATCH 131/151] Ensure only a single QUIC timer task per connection This prevents Hypercorn calling aioquic's interface too many times, due to many tasks running concurrently, triggering exponential backoff and errors. Many thanks to @rthalley from whom's work this is based. --- src/hypercorn/asyncio/tcp_server.py | 34 ++++--------- src/hypercorn/asyncio/worker_context.py | 33 +++++++++++- src/hypercorn/protocol/quic.py | 68 ++++++++++++++++--------- src/hypercorn/trio/tcp_server.py | 45 +++++----------- src/hypercorn/trio/worker_context.py | 37 +++++++++++++- src/hypercorn/typing.py | 12 +++++ 6 files changed, 144 insertions(+), 85 deletions(-) diff --git a/src/hypercorn/asyncio/tcp_server.py b/src/hypercorn/asyncio/tcp_server.py index ed9d710..91f0a11 100644 --- a/src/hypercorn/asyncio/tcp_server.py +++ b/src/hypercorn/asyncio/tcp_server.py @@ -2,10 +2,10 @@ import asyncio from ssl import SSLError -from typing import Any, Generator, Optional +from typing import Any, Generator from .task_group import TaskGroup -from .worker_context import WorkerContext +from .worker_context import AsyncioSingleTask, WorkerContext from ..config import Config from ..events import Closed, Event, RawData, Updated from ..protocol import ProtocolWrapper @@ -33,9 +33,7 @@ def __init__( self.reader = reader self.writer = writer self.send_lock = asyncio.Lock() - self.idle_lock = asyncio.Lock() - - self._idle_handle: Optional[asyncio.Task] = None + self.idle_task = AsyncioSingleTask() def __await__(self) -> Generator[Any, None, None]: return self.run().__await__() @@ -54,6 +52,7 @@ async def run(self) -> None: alpn_protocol = "http/1.1" async with TaskGroup(self.loop) as task_group: + self._task_group = task_group self.protocol = ProtocolWrapper( self.app, self.config, @@ -66,7 +65,7 @@ async def run(self) -> None: alpn_protocol, ) await self.protocol.initiate() - await self._start_idle() + await self.idle_task.restart(task_group, self._idle_timeout) await self._read_data() except OSError: pass @@ -85,9 +84,9 @@ async def protocol_send(self, event: Event) -> None: await self._close() elif isinstance(event, Updated): if event.idle: - await self._start_idle() + await self.idle_task.restart(self._task_group, self._idle_timeout) else: - await self._stop_idle() + await self.idle_task.stop() async def _read_data(self) -> None: while not self.reader.at_eof(): @@ -124,28 +123,13 @@ async def _close(self) -> None: ): pass # Already closed finally: - await self._stop_idle() + await self.idle_task.stop() async def _initiate_server_close(self) -> None: await self.protocol.handle(Closed()) self.writer.close() - async def _start_idle(self) -> None: - async with self.idle_lock: - if self._idle_handle is None: - self._idle_handle = self.loop.create_task(self._run_idle()) - - async def _stop_idle(self) -> None: - async with self.idle_lock: - if self._idle_handle is not None: - self._idle_handle.cancel() - try: - await self._idle_handle - except asyncio.CancelledError: - pass - self._idle_handle = None - - async def _run_idle(self) -> None: + async def _idle_timeout(self) -> None: try: await asyncio.wait_for(self.context.terminated.wait(), self.config.keep_alive_timeout) except asyncio.TimeoutError: diff --git a/src/hypercorn/asyncio/worker_context.py b/src/hypercorn/asyncio/worker_context.py index d16f76b..31e9877 100644 --- a/src/hypercorn/asyncio/worker_context.py +++ b/src/hypercorn/asyncio/worker_context.py @@ -1,9 +1,37 @@ from __future__ import annotations import asyncio -from typing import Optional, Type, Union +from typing import Callable, Optional, Type, Union -from ..typing import Event +from ..typing import Event, SingleTask, TaskGroup + + +class AsyncioSingleTask: + def __init__(self) -> None: + self._handle: Optional[asyncio.Task] = None + self._lock = asyncio.Lock() + + async def restart(self, task_group: TaskGroup, action: Callable) -> None: + async with self._lock: + if self._handle is not None: + self._handle.cancel() + try: + await self._handle + except asyncio.CancelledError: + pass + + self._handle = task_group._task_group.create_task(action()) # type: ignore + + async def stop(self) -> None: + async with self._lock: + if self._handle is not None: + self._handle.cancel() + try: + await self._handle + except asyncio.CancelledError: + pass + + self._handle = None class EventWrapper: @@ -25,6 +53,7 @@ def is_set(self) -> bool: class WorkerContext: event_class: Type[Event] = EventWrapper + single_task_class: Type[SingleTask] = AsyncioSingleTask def __init__(self, max_requests: Optional[int]) -> None: self.max_requests = max_requests diff --git a/src/hypercorn/protocol/quic.py b/src/hypercorn/protocol/quic.py index 3d16e54..98bd8b4 100644 --- a/src/hypercorn/protocol/quic.py +++ b/src/hypercorn/protocol/quic.py @@ -1,7 +1,8 @@ from __future__ import annotations +from dataclasses import dataclass from functools import partial -from typing import Awaitable, Callable, Dict, Optional, Tuple +from typing import Awaitable, Callable, Dict, Optional, Set, Tuple from aioquic.buffer import Buffer from aioquic.h3.connection import H3_ALPN @@ -22,7 +23,15 @@ from .h3 import H3Protocol from ..config import Config from ..events import Closed, Event, RawData -from ..typing import AppWrapper, TaskGroup, WorkerContext +from ..typing import AppWrapper, SingleTask, TaskGroup, WorkerContext + + +@dataclass +class _Connection: + cids: Set[bytes] + quic: QuicConnection + task: SingleTask + h3: Optional[H3Protocol] = None class QuicProtocol: @@ -38,8 +47,7 @@ def __init__( self.app = app self.config = config self.context = context - self.connections: Dict[bytes, QuicConnection] = {} - self.http_connections: Dict[QuicConnection, H3Protocol] = {} + self.connections: Dict[bytes, _Connection] = {} self.send = send self.server = server self.task_group = task_group @@ -49,7 +57,7 @@ def __init__( @property def idle(self) -> bool: - return len(self.connections) == 0 and len(self.http_connections) == 0 + return len(self.connections) == 0 async def handle(self, event: Event) -> None: if isinstance(event, RawData): @@ -76,32 +84,46 @@ async def handle(self, event: Event) -> None: and header.packet_type == PACKET_TYPE_INITIAL and not self.context.terminated.is_set() ): - connection = QuicConnection( + quic_connection = QuicConnection( configuration=self.quic_config, original_destination_connection_id=header.destination_cid, ) + connection = _Connection( + cids={header.destination_cid, quic_connection.host_cid}, + quic=quic_connection, + task=self.context.single_task_class(), + ) self.connections[header.destination_cid] = connection - self.connections[connection.host_cid] = connection + self.connections[quic_connection.host_cid] = connection if connection is not None: - connection.receive_datagram(event.data, event.address, now=self.context.time()) + connection.quic.receive_datagram(event.data, event.address, now=self.context.time()) await self._handle_events(connection, event.address) elif isinstance(event, Closed): pass - async def send_all(self, connection: QuicConnection) -> None: - for data, address in connection.datagrams_to_send(now=self.context.time()): + async def send_all(self, connection: _Connection) -> None: + for data, address in connection.quic.datagrams_to_send(now=self.context.time()): await self.send(RawData(data=data, address=address)) + timer = connection.quic.get_timer() + if timer is not None: + await connection.task.restart( + self.task_group, partial(self._handle_timer, timer, connection) + ) + async def _handle_events( - self, connection: QuicConnection, client: Optional[Tuple[str, int]] = None + self, connection: _Connection, client: Optional[Tuple[str, int]] = None ) -> None: - event = connection.next_event() + event = connection.quic.next_event() while event is not None: if isinstance(event, ConnectionTerminated): - pass + await connection.task.stop() + for cid in connection.cids: + del self.connections[cid] + connection.cids = set() elif isinstance(event, ProtocolNegotiated): - self.http_connections[connection] = H3Protocol( + connection.h3 = H3Protocol( self.app, self.config, self.context, @@ -112,24 +134,22 @@ async def _handle_events( partial(self.send_all, connection), ) elif isinstance(event, ConnectionIdIssued): + connection.cids.add(event.connection_id) self.connections[event.connection_id] = connection elif isinstance(event, ConnectionIdRetired): + connection.cids.remove(event.connection_id) del self.connections[event.connection_id] - if connection in self.http_connections: - await self.http_connections[connection].handle(event) + if connection.h3 is not None: + await connection.h3.handle(event) - event = connection.next_event() + event = connection.quic.next_event() await self.send_all(connection) - timer = connection.get_timer() - if timer is not None: - self.task_group.spawn(self._handle_timer, timer, connection) - - async def _handle_timer(self, timer: float, connection: QuicConnection) -> None: + async def _handle_timer(self, timer: float, connection: _Connection) -> None: wait = max(0, timer - self.context.time()) await self.context.sleep(wait) - if connection._close_at is not None: - connection.handle_timer(now=self.context.time()) + if connection.quic._close_at is not None: + connection.quic.handle_timer(now=self.context.time()) await self._handle_events(connection, None) diff --git a/src/hypercorn/trio/tcp_server.py b/src/hypercorn/trio/tcp_server.py index dbcc7a1..5c6c68e 100644 --- a/src/hypercorn/trio/tcp_server.py +++ b/src/hypercorn/trio/tcp_server.py @@ -1,12 +1,12 @@ from __future__ import annotations from math import inf -from typing import Any, Generator, Optional +from typing import Any, Generator import trio from .task_group import TaskGroup -from .worker_context import WorkerContext +from .worker_context import TrioSingleTask, WorkerContext from ..config import Config from ..events import Closed, Event, RawData, Updated from ..protocol import ProtocolWrapper @@ -25,11 +25,9 @@ def __init__( self.context = context self.protocol: ProtocolWrapper self.send_lock = trio.Lock() - self.idle_lock = trio.Lock() + self.idle_task = TrioSingleTask() self.stream = stream - self._idle_handle: Optional[trio.CancelScope] = None - def __await__(self) -> Generator[Any, None, None]: return self.run().__await__() @@ -66,7 +64,7 @@ async def run(self) -> None: alpn_protocol, ) await self.protocol.initiate() - await self._start_idle() + await self.idle_task.restart(self._task_group, self._idle_timeout) await self._read_data() except OSError: pass @@ -87,9 +85,9 @@ async def protocol_send(self, event: Event) -> None: await self.protocol.handle(Closed()) elif isinstance(event, Updated): if event.idle: - await self._start_idle() + await self.idle_task.restart(self._task_group, self._idle_timeout) else: - await self._stop_idle() + await self.idle_task.stop() async def _read_data(self) -> None: while True: @@ -122,30 +120,13 @@ async def _close(self) -> None: pass await self.stream.aclose() + async def _idle_timeout(self) -> None: + with trio.move_on_after(self.config.keep_alive_timeout): + await self.context.terminated.wait() + + with trio.CancelScope(shield=True): + await self._initiate_server_close() + async def _initiate_server_close(self) -> None: await self.protocol.handle(Closed()) await self.stream.aclose() - - async def _start_idle(self) -> None: - async with self.idle_lock: - if self._idle_handle is None: - self._idle_handle = await self._task_group._nursery.start(self._run_idle) - - async def _stop_idle(self) -> None: - async with self.idle_lock: - if self._idle_handle is not None: - self._idle_handle.cancel() - self._idle_handle = None - - async def _run_idle( - self, - task_status: trio._core._run._TaskStatus = trio.TASK_STATUS_IGNORED, - ) -> None: - cancel_scope = trio.CancelScope() - task_status.started(cancel_scope) - with cancel_scope: - with trio.move_on_after(self.config.keep_alive_timeout): - await self.context.terminated.wait() - - cancel_scope.shield = True - await self._initiate_server_close() diff --git a/src/hypercorn/trio/worker_context.py b/src/hypercorn/trio/worker_context.py index c09c4fb..dddcf42 100644 --- a/src/hypercorn/trio/worker_context.py +++ b/src/hypercorn/trio/worker_context.py @@ -1,10 +1,42 @@ from __future__ import annotations -from typing import Optional, Type, Union +from functools import wraps +from typing import Awaitable, Callable, Optional, Type, Union import trio -from ..typing import Event +from ..typing import Event, SingleTask, TaskGroup + + +def _cancel_wrapper(func: Callable[[], Awaitable[None]]) -> Callable[[], Awaitable[None]]: + @wraps(func) + async def wrapper( + task_status: trio._core._run._TaskStatus = trio.TASK_STATUS_IGNORED, + ) -> None: + cancel_scope = trio.CancelScope() + task_status.started(cancel_scope) + with cancel_scope: + await func() + + return wrapper + + +class TrioSingleTask: + def __init__(self) -> None: + self._handle: Optional[trio.CancelScope] = None + self._lock = trio.Lock() + + async def restart(self, task_group: TaskGroup, action: Callable) -> None: + async with self._lock: + if self._handle is not None: + self._handle.cancel() + self._handle = await task_group._nursery.start(_cancel_wrapper(action)) # type: ignore + + async def stop(self) -> None: + async with self._lock: + if self._handle is not None: + self._handle.cancel() + self._handle = None class EventWrapper: @@ -26,6 +58,7 @@ def is_set(self) -> bool: class WorkerContext: event_class: Type[Event] = EventWrapper + single_task_class: Type[SingleTask] = TrioSingleTask def __init__(self, max_requests: Optional[int]) -> None: self.max_requests = max_requests diff --git a/src/hypercorn/typing.py b/src/hypercorn/typing.py index ccbb50d..e733d7a 100644 --- a/src/hypercorn/typing.py +++ b/src/hypercorn/typing.py @@ -290,6 +290,7 @@ def is_set(self) -> bool: class WorkerContext(Protocol): event_class: Type[Event] + single_task_class: Type[SingleTask] terminate: Event terminated: Event @@ -340,3 +341,14 @@ async def __call__( call_soon: Callable, ) -> None: pass + + +class SingleTask(Protocol): + def __init__(self) -> None: + pass + + async def restart(self, task_group: TaskGroup, action: Callable) -> None: + pass + + async def stop(self) -> None: + pass From d8de5f28adc99b1bc9b47a6c557fe25972ab966f Mon Sep 17 00:00:00 2001 From: pgjones Date: Mon, 27 May 2024 14:38:37 +0100 Subject: [PATCH 132/151] Support sending trailing headers on h2/h3 This adds support for the ASGI http-trailers extension which allows for trailing headers to be sent after a response. Many thanks to @jeffsawatzky from whom parts of this implementation are based. --- src/hypercorn/protocol/events.py | 5 ++ src/hypercorn/protocol/h2.py | 4 ++ src/hypercorn/protocol/h3.py | 4 ++ src/hypercorn/protocol/http_stream.py | 41 +++++++++++++-- src/hypercorn/typing.py | 13 +++++ tests/protocol/test_http_stream.py | 72 ++++++++++++++++++++++++++- 6 files changed, 134 insertions(+), 5 deletions(-) diff --git a/src/hypercorn/protocol/events.py b/src/hypercorn/protocol/events.py index d91d203..7c51db7 100644 --- a/src/hypercorn/protocol/events.py +++ b/src/hypercorn/protocol/events.py @@ -27,6 +27,11 @@ class EndBody(Event): pass +@dataclass(frozen=True) +class Trailers(Event): + headers: List[Tuple[bytes, bytes]] + + @dataclass(frozen=True) class Data(Event): data: bytes diff --git a/src/hypercorn/protocol/h2.py b/src/hypercorn/protocol/h2.py index ef40c3d..c21b2f9 100644 --- a/src/hypercorn/protocol/h2.py +++ b/src/hypercorn/protocol/h2.py @@ -18,6 +18,7 @@ Request, Response, StreamClosed, + Trailers, ) from .http_stream import HTTPStream from .ws_stream import WSStream @@ -213,6 +214,9 @@ async def stream_send(self, event: StreamEvent) -> None: self.priority.unblock(event.stream_id) await self.has_data.set() await self.stream_buffers[event.stream_id].drain() + elif isinstance(event, Trailers): + self.connection.send_headers(event.stream_id, event.headers) + await self._flush() elif isinstance(event, StreamClosed): await self._close_stream(event.stream_id) idle = len(self.streams) == 0 or all( diff --git a/src/hypercorn/protocol/h3.py b/src/hypercorn/protocol/h3.py index 151c066..3dd9e6d 100644 --- a/src/hypercorn/protocol/h3.py +++ b/src/hypercorn/protocol/h3.py @@ -18,6 +18,7 @@ Request, Response, StreamClosed, + Trailers, ) from .http_stream import HTTPStream from .ws_stream import WSStream @@ -79,6 +80,9 @@ async def stream_send(self, event: StreamEvent) -> None: elif isinstance(event, (EndBody, EndData)): self.connection.send_data(event.stream_id, b"", True) await self.send() + elif isinstance(event, Trailers): + self.connection.send_headers(event.stream_id, event.headers) + await self.send() elif isinstance(event, StreamClosed): pass # ?? elif isinstance(event, Request): diff --git a/src/hypercorn/protocol/http_stream.py b/src/hypercorn/protocol/http_stream.py index a0f0940..2a9ebb0 100644 --- a/src/hypercorn/protocol/http_stream.py +++ b/src/hypercorn/protocol/http_stream.py @@ -5,7 +5,16 @@ from typing import Awaitable, Callable, Optional, Tuple from urllib.parse import unquote -from .events import Body, EndBody, Event, InformationalResponse, Request, Response, StreamClosed +from .events import ( + Body, + EndBody, + Event, + InformationalResponse, + Request, + Response, + StreamClosed, + Trailers, +) from ..config import Config from ..typing import ( AppWrapper, @@ -22,6 +31,7 @@ valid_server_name, ) +TRAILERS_VERSIONS = {"2", "3"} PUSH_VERSIONS = {"2", "3"} EARLY_HINTS_VERSIONS = {"2", "3"} @@ -32,6 +42,7 @@ class ASGIHTTPState(Enum): # state tracking is required. REQUEST = auto() RESPONSE = auto() + TRAILERS = auto() CLOSED = auto() @@ -88,6 +99,10 @@ async def handle(self, event: Event) -> None: "server": self.server, "extensions": {}, } + + if event.http_version in TRAILERS_VERSIONS: + self.scope["extensions"]["http.response.trailers"] = {} + if event.http_version in PUSH_VERSIONS: self.scope["extensions"]["http.response.push"] = {} @@ -182,13 +197,33 @@ async def app_send(self, message: Optional[ASGISendEvent]) -> None: ) if not message.get("more_body", False): - if self.state != ASGIHTTPState.CLOSED: + await self.send(EndBody(stream_id=self.stream_id)) + + if self.response.get("trailers", False): + self.state = ASGIHTTPState.TRAILERS + else: self.state = ASGIHTTPState.CLOSED await self.config.log.access( self.scope, self.response, time() - self.start_time ) - await self.send(EndBody(stream_id=self.stream_id)) await self.send(StreamClosed(stream_id=self.stream_id)) + elif ( + message["type"] == "http.response.trailers" + and self.scope["http_version"] in TRAILERS_VERSIONS + and self.state == ASGIHTTPState.TRAILERS + ): + for name, value in self.scope["headers"]: + if name == b"te" and value == b"trailers": + headers = build_and_validate_headers(message["headers"]) + await self.send(Trailers(stream_id=self.stream_id, headers=headers)) + break + + if not message.get("more_trailers", False): + self.state = ASGIHTTPState.CLOSED + await self.config.log.access( + self.scope, self.response, time() - self.start_time + ) + await self.send(StreamClosed(stream_id=self.stream_id)) else: raise UnexpectedMessageError(self.state, message["type"]) diff --git a/src/hypercorn/typing.py b/src/hypercorn/typing.py index e733d7a..4d84756 100644 --- a/src/hypercorn/typing.py +++ b/src/hypercorn/typing.py @@ -22,6 +22,11 @@ from .config import Config, Sockets +try: + from typing import NotRequired +except ImportError: + from typing_extensions import NotRequired + H11SendableEvent = Union[h11.Data, h11.EndOfMessage, h11.InformationalResponse, h11.Response] WorkerFunc = Callable[[Config, Optional[Sockets], Optional[EventType]], None] @@ -83,6 +88,7 @@ class HTTPResponseStartEvent(TypedDict): type: Literal["http.response.start"] status: int headers: Iterable[Tuple[bytes, bytes]] + trailers: NotRequired[bool] class HTTPResponseBodyEvent(TypedDict): @@ -91,6 +97,12 @@ class HTTPResponseBodyEvent(TypedDict): more_body: bool +class HTTPResponseTrailersEvent(TypedDict): + type: Literal["http.response.trailers"] + headers: Iterable[Tuple[bytes, bytes]] + more_trailers: NotRequired[bool] + + class HTTPServerPushEvent(TypedDict): type: Literal["http.response.push"] path: str @@ -191,6 +203,7 @@ class LifespanShutdownFailedEvent(TypedDict): ASGISendEvent = Union[ HTTPResponseStartEvent, HTTPResponseBodyEvent, + HTTPResponseTrailersEvent, HTTPServerPushEvent, HTTPEarlyHintEvent, HTTPDisconnectEvent, diff --git a/tests/protocol/test_http_stream.py b/tests/protocol/test_http_stream.py index f3961a9..d7202b6 100644 --- a/tests/protocol/test_http_stream.py +++ b/tests/protocol/test_http_stream.py @@ -17,6 +17,7 @@ Request, Response, StreamClosed, + Trailers, ) from hypercorn.protocol.http_stream import ASGIHTTPState, HTTPStream from hypercorn.typing import HTTPResponseBodyEvent, HTTPResponseStartEvent, HTTPScope @@ -84,7 +85,11 @@ async def test_handle_request_http_2(stream: HTTPStream) -> None: "headers": [], "client": None, "server": None, - "extensions": {"http.response.early_hint": {}, "http.response.push": {}}, + "extensions": { + "http.response.trailers": {}, + "http.response.early_hint": {}, + "http.response.push": {}, + }, } @@ -204,6 +209,65 @@ async def test_send_early_hint(stream: HTTPStream, http_scope: HTTPScope) -> Non ] +@pytest.mark.asyncio +async def test_send_trailers(stream: HTTPStream) -> None: + await stream.handle( + Request( + stream_id=1, + http_version="2", + headers=[(b"te", b"trailers")], + raw_path=b"/?a=b", + method="GET", + ) + ) + await stream.app_send( + cast( + HTTPResponseStartEvent, + {"type": "http.response.start", "status": 200, "trailers": True}, + ) + ) + await stream.app_send( + cast(HTTPResponseBodyEvent, {"type": "http.response.body", "body": b"Body"}) + ) + await stream.app_send({"type": "http.response.trailers", "headers": [(b"X", b"V")]}) + assert stream.send.call_args_list == [ # type: ignore + call(Response(stream_id=1, headers=[], status_code=200)), + call(Body(stream_id=1, data=b"Body")), + call(EndBody(stream_id=1)), + call(Trailers(stream_id=1, headers=[(b"X", b"V")])), + call(StreamClosed(stream_id=1)), + ] + + +@pytest.mark.asyncio +async def test_send_trailers_ignored(stream: HTTPStream) -> None: + await stream.handle( + Request( + stream_id=1, + http_version="2", + headers=[], # no TE: trailers header + raw_path=b"/?a=b", + method="GET", + ) + ) + await stream.app_send( + cast( + HTTPResponseStartEvent, + {"type": "http.response.start", "status": 200, "trailers": True}, + ) + ) + await stream.app_send( + cast(HTTPResponseBodyEvent, {"type": "http.response.body", "body": b"Body"}) + ) + await stream.app_send({"type": "http.response.trailers", "headers": [(b"X", b"V")]}) + assert stream.send.call_args_list == [ # type: ignore + call(Response(stream_id=1, headers=[], status_code=200)), + call(Body(stream_id=1, data=b"Body")), + call(EndBody(stream_id=1)), + call(StreamClosed(stream_id=1)), + ] + + @pytest.mark.asyncio async def test_send_app_error(stream: HTTPStream) -> None: await stream.handle( @@ -229,16 +293,20 @@ async def test_send_app_error(stream: HTTPStream) -> None: "state, message_type", [ (ASGIHTTPState.REQUEST, "not_a_real_type"), + (ASGIHTTPState.REQUEST, "http.response.trailers"), (ASGIHTTPState.RESPONSE, "http.response.start"), + (ASGIHTTPState.TRAILERS, "http.response.start"), (ASGIHTTPState.CLOSED, "http.response.start"), (ASGIHTTPState.CLOSED, "http.response.body"), + (ASGIHTTPState.CLOSED, "http.response.trailers"), ], ) @pytest.mark.asyncio async def test_send_invalid_message_given_state( - stream: HTTPStream, state: ASGIHTTPState, message_type: str + stream: HTTPStream, state: ASGIHTTPState, http_scope: HTTPScope, message_type: str ) -> None: stream.state = state + stream.scope = http_scope with pytest.raises(UnexpectedMessageError): await stream.app_send({"type": message_type}) # type: ignore From 116bd8cdf7b547ddfd5d2e52a317d00aeb53bbd0 Mon Sep 17 00:00:00 2001 From: pgjones Date: Mon, 27 May 2024 16:15:01 +0100 Subject: [PATCH 133/151] Add a dependency on typing extensions This is required to use `NotRequired` when type hinting TypedDicts as required in d8de5f28adc99b1bc9b47a6c557fe25972ab966f. --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index f5e1aca..678aa6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ sphinxcontrib_mermaid = { version = "*", optional = true } taskgroup = { version = "*", python = "<3.11", allow-prereleases = true } tomli = { version = "*", python = "<3.11" } trio = { version = ">=0.22.0", optional = true } +typing_extensions = { version = "*", python = "<3.11" } uvloop = { version = "*", markers = "platform_system != 'Windows'", optional = true } wsproto = ">=0.14.0" From ba3d813684e8b8076d295f9a23778a39cdb9b470 Mon Sep 17 00:00:00 2001 From: pgjones Date: Mon, 27 May 2024 16:19:48 +0100 Subject: [PATCH 134/151] Don't check the internal close_at with QUIC Better to handle the timer every time. With thanks to @rthalley. --- src/hypercorn/protocol/quic.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/hypercorn/protocol/quic.py b/src/hypercorn/protocol/quic.py index 98bd8b4..a5522bd 100644 --- a/src/hypercorn/protocol/quic.py +++ b/src/hypercorn/protocol/quic.py @@ -150,6 +150,5 @@ async def _handle_events( async def _handle_timer(self, timer: float, connection: _Connection) -> None: wait = max(0, timer - self.context.time()) await self.context.sleep(wait) - if connection.quic._close_at is not None: - connection.quic.handle_timer(now=self.context.time()) - await self._handle_events(connection, None) + connection.quic.handle_timer(now=self.context.time()) + await self._handle_events(connection, None) From d1c1a23ae2a24fc2cd059a51b844013725993532 Mon Sep 17 00:00:00 2001 From: Adrian Garcia Badaracco <1755071+adriangb@users.noreply.github.com> Date: Sat, 11 Mar 2023 12:48:29 -0600 Subject: [PATCH 135/151] Add support for lifespan state This allows ASGI apps to store state during startup that is then passed in every scope. --- src/hypercorn/asyncio/lifespan.py | 12 +++- src/hypercorn/asyncio/run.py | 9 +-- src/hypercorn/asyncio/tcp_server.py | 5 +- src/hypercorn/asyncio/udp_server.py | 12 +++- src/hypercorn/protocol/__init__.py | 8 ++- src/hypercorn/protocol/events.py | 3 + src/hypercorn/protocol/h11.py | 5 +- src/hypercorn/protocol/h2.py | 5 +- src/hypercorn/protocol/h3.py | 5 +- src/hypercorn/protocol/http_stream.py | 2 + src/hypercorn/protocol/quic.py | 5 +- src/hypercorn/protocol/ws_stream.py | 1 + src/hypercorn/trio/lifespan.py | 6 +- src/hypercorn/trio/run.py | 19 +++++-- src/hypercorn/trio/tcp_server.py | 11 +++- src/hypercorn/trio/udp_server.py | 12 +++- src/hypercorn/typing.py | 8 +++ tests/asyncio/test_keep_alive.py | 1 + tests/asyncio/test_lifespan.py | 10 ++-- tests/asyncio/test_sanity.py | 4 ++ tests/asyncio/test_tcp_server.py | 4 +- tests/conftest.py | 3 +- tests/helpers.py | 1 + tests/middleware/test_dispatcher.py | 4 +- tests/middleware/test_http_to_https.py | 7 ++- tests/middleware/test_proxy_fix.py | 4 +- tests/protocol/test_h11.py | 27 ++++++++- tests/protocol/test_h2.py | 21 ++++++- tests/protocol/test_http_stream.py | 76 +++++++++++++++++++++++--- tests/protocol/test_ws_stream.py | 13 +++++ tests/test_app_wrappers.py | 8 ++- tests/trio/test_keep_alive.py | 2 +- tests/trio/test_lifespan.py | 5 +- tests/trio/test_sanity.py | 16 ++++-- 34 files changed, 278 insertions(+), 56 deletions(-) diff --git a/src/hypercorn/asyncio/lifespan.py b/src/hypercorn/asyncio/lifespan.py index 244950c..eaef906 100644 --- a/src/hypercorn/asyncio/lifespan.py +++ b/src/hypercorn/asyncio/lifespan.py @@ -5,7 +5,7 @@ from typing import Any, Callable from ..config import Config -from ..typing import AppWrapper, ASGIReceiveEvent, ASGISendEvent, LifespanScope +from ..typing import AppWrapper, ASGIReceiveEvent, ASGISendEvent, LifespanScope, LifespanState from ..utils import LifespanFailureError, LifespanTimeoutError @@ -14,7 +14,13 @@ class UnexpectedMessageError(Exception): class Lifespan: - def __init__(self, app: AppWrapper, config: Config, loop: asyncio.AbstractEventLoop) -> None: + def __init__( + self, + app: AppWrapper, + config: Config, + loop: asyncio.AbstractEventLoop, + lifespan_state: LifespanState, + ) -> None: self.app = app self.config = config self.startup = asyncio.Event() @@ -22,6 +28,7 @@ def __init__(self, app: AppWrapper, config: Config, loop: asyncio.AbstractEventL self.app_queue: asyncio.Queue = asyncio.Queue(config.max_app_queue_size) self.supported = True self.loop = loop + self.state = lifespan_state # This mimics the Trio nursery.start task_status and is # required to ensure the support has been checked before @@ -33,6 +40,7 @@ async def handle_lifespan(self) -> None: scope: LifespanScope = { "type": "lifespan", "asgi": {"spec_version": "2.0", "version": "3.0"}, + "state": self.state, } def _call_soon(func: Callable, *args: Any) -> Any: diff --git a/src/hypercorn/asyncio/run.py b/src/hypercorn/asyncio/run.py index c633c5b..a4c8027 100644 --- a/src/hypercorn/asyncio/run.py +++ b/src/hypercorn/asyncio/run.py @@ -18,7 +18,7 @@ from .udp_server import UDPServer from .worker_context import WorkerContext from ..config import Config, Sockets -from ..typing import AppWrapper +from ..typing import AppWrapper, LifespanState from ..utils import ( check_multiprocess_shutdown_event, load_application, @@ -77,7 +77,8 @@ def _signal_handler(*_: Any) -> None: # noqa: N803 shutdown_trigger = signal_event.wait - lifespan = Lifespan(app, config, loop) + lifespan_state: LifespanState = {} + lifespan = Lifespan(app, config, loop, lifespan_state) lifespan_task = loop.create_task(lifespan.handle_lifespan()) await lifespan.wait_for_startup() @@ -106,7 +107,7 @@ async def _server_callback(reader: asyncio.StreamReader, writer: asyncio.StreamW task = asyncio.current_task(loop) server_tasks.add(task) task.add_done_callback(server_tasks.discard) - await TCPServer(app, loop, config, context, reader, writer) + await TCPServer(app, loop, config, context, lifespan_state, reader, writer) servers = [] for sock in sockets.secure_sockets: @@ -140,7 +141,7 @@ async def _server_callback(reader: asyncio.StreamReader, writer: asyncio.StreamW sock = _share_socket(sock) _, protocol = await loop.create_datagram_endpoint( - lambda: UDPServer(app, loop, config, context), sock=sock + lambda: UDPServer(app, loop, config, context, lifespan_state), sock=sock ) task = loop.create_task(protocol.run()) server_tasks.add(task) diff --git a/src/hypercorn/asyncio/tcp_server.py b/src/hypercorn/asyncio/tcp_server.py index 91f0a11..bf9d9fe 100644 --- a/src/hypercorn/asyncio/tcp_server.py +++ b/src/hypercorn/asyncio/tcp_server.py @@ -9,7 +9,7 @@ from ..config import Config from ..events import Closed, Event, RawData, Updated from ..protocol import ProtocolWrapper -from ..typing import AppWrapper +from ..typing import AppWrapper, ConnectionState, LifespanState from ..utils import parse_socket_addr MAX_RECV = 2**16 @@ -22,6 +22,7 @@ def __init__( loop: asyncio.AbstractEventLoop, config: Config, context: WorkerContext, + state: LifespanState, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, ) -> None: @@ -33,6 +34,7 @@ def __init__( self.reader = reader self.writer = writer self.send_lock = asyncio.Lock() + self.state = state self.idle_task = AsyncioSingleTask() def __await__(self) -> Generator[Any, None, None]: @@ -58,6 +60,7 @@ async def run(self) -> None: self.config, self.context, task_group, + ConnectionState(self.state.copy()), ssl, client, server, diff --git a/src/hypercorn/asyncio/udp_server.py b/src/hypercorn/asyncio/udp_server.py index 629ab9f..32857cc 100644 --- a/src/hypercorn/asyncio/udp_server.py +++ b/src/hypercorn/asyncio/udp_server.py @@ -7,7 +7,7 @@ from .worker_context import WorkerContext from ..config import Config from ..events import Event, RawData -from ..typing import AppWrapper +from ..typing import AppWrapper, ConnectionState, LifespanState from ..utils import parse_socket_addr if TYPE_CHECKING: @@ -22,6 +22,7 @@ def __init__( loop: asyncio.AbstractEventLoop, config: Config, context: WorkerContext, + state: LifespanState, ) -> None: self.app = app self.config = config @@ -30,6 +31,7 @@ def __init__( self.protocol: "QuicProtocol" self.protocol_queue: asyncio.Queue = asyncio.Queue(10) self.transport: Optional[asyncio.DatagramTransport] = None + self.state = state def connection_made(self, transport: asyncio.DatagramTransport) -> None: # type: ignore self.transport = transport @@ -48,7 +50,13 @@ async def run(self) -> None: server = parse_socket_addr(socket.family, socket.getsockname()) async with TaskGroup(self.loop) as task_group: self.protocol = QuicProtocol( - self.app, self.config, self.context, task_group, server, self.protocol_send + self.app, + self.config, + self.context, + task_group, + ConnectionState(self.state.copy()), + server, + self.protocol_send, ) while not self.context.terminated.is_set() or not self.protocol.idle: diff --git a/src/hypercorn/protocol/__init__.py b/src/hypercorn/protocol/__init__.py index 3938568..4e8feae 100644 --- a/src/hypercorn/protocol/__init__.py +++ b/src/hypercorn/protocol/__init__.py @@ -6,7 +6,7 @@ from .h11 import H2CProtocolRequiredError, H2ProtocolAssumedError, H11Protocol from ..config import Config from ..events import Event, RawData -from ..typing import AppWrapper, TaskGroup, WorkerContext +from ..typing import AppWrapper, ConnectionState, TaskGroup, WorkerContext class ProtocolWrapper: @@ -16,6 +16,7 @@ def __init__( config: Config, context: WorkerContext, task_group: TaskGroup, + state: ConnectionState, ssl: bool, client: Optional[Tuple[str, int]], server: Optional[Tuple[str, int]], @@ -30,6 +31,7 @@ def __init__( self.client = client self.server = server self.send = send + self.state = state self.protocol: Union[H11Protocol, H2Protocol] if alpn_protocol == "h2": self.protocol = H2Protocol( @@ -37,6 +39,7 @@ def __init__( self.config, self.context, self.task_group, + self.state, self.ssl, self.client, self.server, @@ -48,6 +51,7 @@ def __init__( self.config, self.context, self.task_group, + self.state, self.ssl, self.client, self.server, @@ -66,6 +70,7 @@ async def handle(self, event: Event) -> None: self.config, self.context, self.task_group, + self.state, self.ssl, self.client, self.server, @@ -80,6 +85,7 @@ async def handle(self, event: Event) -> None: self.config, self.context, self.task_group, + self.state, self.ssl, self.client, self.server, diff --git a/src/hypercorn/protocol/events.py b/src/hypercorn/protocol/events.py index 7c51db7..0ded34a 100644 --- a/src/hypercorn/protocol/events.py +++ b/src/hypercorn/protocol/events.py @@ -3,6 +3,8 @@ from dataclasses import dataclass from typing import List, Tuple +from hypercorn.typing import ConnectionState + @dataclass(frozen=True) class Event: @@ -15,6 +17,7 @@ class Request(Event): http_version: str method: str raw_path: bytes + state: ConnectionState @dataclass(frozen=True) diff --git a/src/hypercorn/protocol/h11.py b/src/hypercorn/protocol/h11.py index b35425f..c3c6e0f 100644 --- a/src/hypercorn/protocol/h11.py +++ b/src/hypercorn/protocol/h11.py @@ -20,7 +20,7 @@ from .ws_stream import WSStream from ..config import Config from ..events import Closed, Event, RawData, Updated -from ..typing import AppWrapper, H11SendableEvent, TaskGroup, WorkerContext +from ..typing import AppWrapper, ConnectionState, H11SendableEvent, TaskGroup, WorkerContext STREAM_ID = 1 @@ -84,6 +84,7 @@ def __init__( config: Config, context: WorkerContext, task_group: TaskGroup, + connection_state: ConnectionState, ssl: bool, client: Optional[Tuple[str, int]], server: Optional[Tuple[str, int]], @@ -103,6 +104,7 @@ def __init__( self.ssl = ssl self.stream: Optional[Union[HTTPStream, WSStream]] = None self.task_group = task_group + self.connection_state = connection_state async def initiate(self) -> None: pass @@ -236,6 +238,7 @@ async def _create_stream(self, request: h11.Request) -> None: http_version=request.http_version.decode(), method=request.method.decode("ascii").upper(), raw_path=request.target, + state=self.connection_state, ) ) self.keep_alive_requests += 1 diff --git a/src/hypercorn/protocol/h2.py b/src/hypercorn/protocol/h2.py index c21b2f9..b19a2bc 100644 --- a/src/hypercorn/protocol/h2.py +++ b/src/hypercorn/protocol/h2.py @@ -24,7 +24,7 @@ from .ws_stream import WSStream from ..config import Config from ..events import Closed, Event, RawData, Updated -from ..typing import AppWrapper, Event as IOEvent, TaskGroup, WorkerContext +from ..typing import AppWrapper, ConnectionState, Event as IOEvent, TaskGroup, WorkerContext from ..utils import filter_pseudo_headers BUFFER_HIGH_WATER = 2 * 2**14 # Twice the default max frame size (two frames worth) @@ -85,6 +85,7 @@ def __init__( config: Config, context: WorkerContext, task_group: TaskGroup, + connection_state: ConnectionState, ssl: bool, client: Optional[Tuple[str, int]], server: Optional[Tuple[str, int]], @@ -96,6 +97,7 @@ def __init__( self.config = config self.context = context self.task_group = task_group + self.connection_state = connection_state self.connection = h2.connection.H2Connection( config=h2.config.H2Configuration(client_side=False, header_encoding=None) @@ -360,6 +362,7 @@ async def _create_stream(self, request: h2.events.RequestReceived) -> None: http_version="2", method=method, raw_path=raw_path, + state=self.connection_state, ) ) self.keep_alive_requests += 1 diff --git a/src/hypercorn/protocol/h3.py b/src/hypercorn/protocol/h3.py index 3dd9e6d..ae2eb8f 100644 --- a/src/hypercorn/protocol/h3.py +++ b/src/hypercorn/protocol/h3.py @@ -23,7 +23,7 @@ from .http_stream import HTTPStream from .ws_stream import WSStream from ..config import Config -from ..typing import AppWrapper, TaskGroup, WorkerContext +from ..typing import AppWrapper, ConnectionState, TaskGroup, WorkerContext from ..utils import filter_pseudo_headers @@ -34,6 +34,7 @@ def __init__( config: Config, context: WorkerContext, task_group: TaskGroup, + state: ConnectionState, client: Optional[Tuple[str, int]], server: Optional[Tuple[str, int]], quic: QuicConnection, @@ -48,6 +49,7 @@ def __init__( self.server = server self.streams: Dict[int, Union[HTTPStream, WSStream]] = {} self.task_group = task_group + self.state = state async def handle(self, quic_event: QuicEvent) -> None: for event in self.connection.handle_event(quic_event): @@ -127,6 +129,7 @@ async def _create_stream(self, request: HeadersReceived) -> None: http_version="3", method=method, raw_path=raw_path, + state=self.state, ) ) await self.context.mark_request() diff --git a/src/hypercorn/protocol/http_stream.py b/src/hypercorn/protocol/http_stream.py index 2a9ebb0..1a68cdc 100644 --- a/src/hypercorn/protocol/http_stream.py +++ b/src/hypercorn/protocol/http_stream.py @@ -97,6 +97,7 @@ async def handle(self, event: Event) -> None: "headers": event.headers, "client": self.client, "server": self.server, + "state": event.state, "extensions": {}, } @@ -158,6 +159,7 @@ async def app_send(self, message: Optional[ASGISendEvent]) -> None: http_version=self.scope["http_version"], method="GET", raw_path=message["path"].encode(), + state=self.scope["state"], ) ) elif ( diff --git a/src/hypercorn/protocol/quic.py b/src/hypercorn/protocol/quic.py index a5522bd..2a15c43 100644 --- a/src/hypercorn/protocol/quic.py +++ b/src/hypercorn/protocol/quic.py @@ -23,7 +23,7 @@ from .h3 import H3Protocol from ..config import Config from ..events import Closed, Event, RawData -from ..typing import AppWrapper, SingleTask, TaskGroup, WorkerContext +from ..typing import AppWrapper, ConnectionState, SingleTask, TaskGroup, WorkerContext @dataclass @@ -41,6 +41,7 @@ def __init__( config: Config, context: WorkerContext, task_group: TaskGroup, + state: ConnectionState, server: Optional[Tuple[str, int]], send: Callable[[Event], Awaitable[None]], ) -> None: @@ -51,6 +52,7 @@ def __init__( self.send = send self.server = server self.task_group = task_group + self.state = state self.quic_config = QuicConfiguration(alpn_protocols=H3_ALPN, is_client=False) self.quic_config.load_cert_chain(certfile=config.certfile, keyfile=config.keyfile) @@ -128,6 +130,7 @@ async def _handle_events( self.config, self.context, self.task_group, + self.state, client, self.server, connection, diff --git a/src/hypercorn/protocol/ws_stream.py b/src/hypercorn/protocol/ws_stream.py index 80d2167..7b39815 100644 --- a/src/hypercorn/protocol/ws_stream.py +++ b/src/hypercorn/protocol/ws_stream.py @@ -219,6 +219,7 @@ async def handle(self, event: Event) -> None: "headers": event.headers, "client": self.client, "server": self.server, + "state": event.state, "subprotocols": self.handshake.subprotocols or [], "extensions": {"websocket.http.response": {}}, } diff --git a/src/hypercorn/trio/lifespan.py b/src/hypercorn/trio/lifespan.py index a45fc52..21f4dd2 100644 --- a/src/hypercorn/trio/lifespan.py +++ b/src/hypercorn/trio/lifespan.py @@ -3,7 +3,7 @@ import trio from ..config import Config -from ..typing import AppWrapper, ASGIReceiveEvent, ASGISendEvent, LifespanScope +from ..typing import AppWrapper, ASGIReceiveEvent, ASGISendEvent, LifespanScope, LifespanState from ..utils import LifespanFailureError, LifespanTimeoutError @@ -12,7 +12,7 @@ class UnexpectedMessageError(Exception): class Lifespan: - def __init__(self, app: AppWrapper, config: Config) -> None: + def __init__(self, app: AppWrapper, config: Config, state: LifespanState) -> None: self.app = app self.config = config self.startup = trio.Event() @@ -20,6 +20,7 @@ def __init__(self, app: AppWrapper, config: Config) -> None: self.app_send_channel, self.app_receive_channel = trio.open_memory_channel( config.max_app_queue_size ) + self.state = state self.supported = True async def handle_lifespan( @@ -29,6 +30,7 @@ async def handle_lifespan( scope: LifespanScope = { "type": "lifespan", "asgi": {"spec_version": "2.0", "version": "3.0"}, + "state": self.state, } try: await self.app( diff --git a/src/hypercorn/trio/run.py b/src/hypercorn/trio/run.py index 2cfe5db..0d25368 100644 --- a/src/hypercorn/trio/run.py +++ b/src/hypercorn/trio/run.py @@ -14,7 +14,7 @@ from .udp_server import UDPServer from .worker_context import WorkerContext from ..config import Config, Sockets -from ..typing import AppWrapper +from ..typing import AppWrapper, ConnectionState, LifespanState from ..utils import ( check_multiprocess_shutdown_event, load_application, @@ -37,7 +37,8 @@ async def worker_serve( ) -> None: config.set_statsd_logger_class(StatsdLogger) - lifespan = Lifespan(app, config) + lifespan_state: LifespanState = {} + lifespan = Lifespan(app, config, lifespan_state) max_requests = None if config.max_requests is not None: max_requests = config.max_requests + randint(0, config.max_requests_jitter) @@ -77,7 +78,11 @@ async def worker_serve( await config.log.info(f"Running on http://{bind} (CTRL + C to quit)") for sock in sockets.quic_sockets: - await server_nursery.start(UDPServer(app, config, context, sock).run) + await server_nursery.start( + UDPServer( + app, config, context, ConnectionState(lifespan_state.copy()), sock + ).run + ) bind = repr_socket_addr(sock.family, sock.getsockname()) await config.log.info(f"Running on https://{bind} (QUIC) (CTRL + C to quit)") @@ -91,7 +96,13 @@ async def worker_serve( nursery.start_soon( partial( trio.serve_listeners, - partial(TCPServer, app, config, context), + partial( + TCPServer, + app, + config, + context, + ConnectionState(lifespan_state.copy()), + ), listeners, handler_nursery=server_nursery, ), diff --git a/src/hypercorn/trio/tcp_server.py b/src/hypercorn/trio/tcp_server.py index 5c6c68e..7eb7711 100644 --- a/src/hypercorn/trio/tcp_server.py +++ b/src/hypercorn/trio/tcp_server.py @@ -10,7 +10,7 @@ from ..config import Config from ..events import Closed, Event, RawData, Updated from ..protocol import ProtocolWrapper -from ..typing import AppWrapper +from ..typing import AppWrapper, ConnectionState, LifespanState from ..utils import parse_socket_addr MAX_RECV = 2**16 @@ -18,7 +18,12 @@ class TCPServer: def __init__( - self, app: AppWrapper, config: Config, context: WorkerContext, stream: trio.abc.Stream + self, + app: AppWrapper, + config: Config, + context: WorkerContext, + state: LifespanState, + stream: trio.abc.Stream, ) -> None: self.app = app self.config = config @@ -27,6 +32,7 @@ def __init__( self.send_lock = trio.Lock() self.idle_task = TrioSingleTask() self.stream = stream + self.state = state def __await__(self) -> Generator[Any, None, None]: return self.run().__await__() @@ -57,6 +63,7 @@ async def run(self) -> None: self.config, self.context, task_group, + ConnectionState(self.state.copy()), ssl, client, server, diff --git a/src/hypercorn/trio/udp_server.py b/src/hypercorn/trio/udp_server.py index b8d4530..d66b037 100644 --- a/src/hypercorn/trio/udp_server.py +++ b/src/hypercorn/trio/udp_server.py @@ -6,7 +6,7 @@ from .worker_context import WorkerContext from ..config import Config from ..events import Event, RawData -from ..typing import AppWrapper +from ..typing import AppWrapper, ConnectionState, LifespanState from ..utils import parse_socket_addr MAX_RECV = 2**16 @@ -18,12 +18,14 @@ def __init__( app: AppWrapper, config: Config, context: WorkerContext, + state: LifespanState, socket: trio.socket.socket, ) -> None: self.app = app self.config = config self.context = context self.socket = trio.socket.from_stdlib_socket(socket) + self.state = state async def run( self, task_status: trio._core._run._TaskStatus = trio.TASK_STATUS_IGNORED @@ -34,7 +36,13 @@ async def run( server = parse_socket_addr(self.socket.family, self.socket.getsockname()) async with TaskGroup() as task_group: self.protocol = QuicProtocol( - self.app, self.config, self.context, task_group, server, self.protocol_send + self.app, + self.config, + self.context, + task_group, + ConnectionState(self.state.copy()), + server, + self.protocol_send, ) while not self.context.terminated.is_set() or not self.protocol.idle: diff --git a/src/hypercorn/typing.py b/src/hypercorn/typing.py index 4d84756..cba7d5d 100644 --- a/src/hypercorn/typing.py +++ b/src/hypercorn/typing.py @@ -9,6 +9,7 @@ Dict, Iterable, Literal, + NewType, Optional, Protocol, Tuple, @@ -31,6 +32,10 @@ WorkerFunc = Callable[[Config, Optional[Sockets], Optional[EventType]], None] +LifespanState = Dict[str, Any] + +ConnectionState = NewType("ConnectionState", Dict[str, Any]) + class ASGIVersions(TypedDict, total=False): spec_version: str @@ -50,6 +55,7 @@ class HTTPScope(TypedDict): headers: Iterable[Tuple[bytes, bytes]] client: Optional[Tuple[str, int]] server: Optional[Tuple[str, Optional[int]]] + state: ConnectionState extensions: Dict[str, dict] @@ -66,12 +72,14 @@ class WebsocketScope(TypedDict): client: Optional[Tuple[str, int]] server: Optional[Tuple[str, Optional[int]]] subprotocols: Iterable[str] + state: ConnectionState extensions: Dict[str, dict] class LifespanScope(TypedDict): type: Literal["lifespan"] asgi: ASGIVersions + state: LifespanState WWWScope = Union[HTTPScope, WebsocketScope] diff --git a/tests/asyncio/test_keep_alive.py b/tests/asyncio/test_keep_alive.py index 5b0e162..9ed4cf6 100644 --- a/tests/asyncio/test_keep_alive.py +++ b/tests/asyncio/test_keep_alive.py @@ -53,6 +53,7 @@ async def _server() -> AsyncGenerator[TCPServer, None]: event_loop, config, WorkerContext(None), + {}, MemoryReader(), # type: ignore MemoryWriter(), # type: ignore ) diff --git a/tests/asyncio/test_lifespan.py b/tests/asyncio/test_lifespan.py index 2e1a50c..bf2cfc6 100644 --- a/tests/asyncio/test_lifespan.py +++ b/tests/asyncio/test_lifespan.py @@ -25,7 +25,7 @@ async def test_ensure_no_race_condition() -> None: config = Config() config.startup_timeout = 0.2 - lifespan = Lifespan(ASGIWrapper(no_lifespan_app), config, event_loop) + lifespan = Lifespan(ASGIWrapper(no_lifespan_app), config, event_loop, {}) task = event_loop.create_task(lifespan.handle_lifespan()) await lifespan.wait_for_startup() # Raises if there is a race condition await task @@ -37,7 +37,9 @@ async def test_startup_timeout_error() -> None: config = Config() config.startup_timeout = 0.01 - lifespan = Lifespan(ASGIWrapper(SlowLifespanFramework(0.02, asyncio.sleep)), config, event_loop) + lifespan = Lifespan( + ASGIWrapper(SlowLifespanFramework(0.02, asyncio.sleep)), config, event_loop, {} + ) task = event_loop.create_task(lifespan.handle_lifespan()) with pytest.raises(LifespanTimeoutError) as exc_info: await lifespan.wait_for_startup() @@ -49,7 +51,7 @@ async def test_startup_timeout_error() -> None: async def test_startup_failure() -> None: event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() - lifespan = Lifespan(ASGIWrapper(lifespan_failure), Config(), event_loop) + lifespan = Lifespan(ASGIWrapper(lifespan_failure), Config(), event_loop, {}) lifespan_task = event_loop.create_task(lifespan.handle_lifespan()) await lifespan.wait_for_startup() assert lifespan_task.done() @@ -66,7 +68,7 @@ async def return_app(scope: Scope, receive: Callable, send: Callable) -> None: async def test_lifespan_return() -> None: event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() - lifespan = Lifespan(ASGIWrapper(return_app), Config(), event_loop) + lifespan = Lifespan(ASGIWrapper(return_app), Config(), event_loop, {}) lifespan_task = event_loop.create_task(lifespan.handle_lifespan()) await lifespan.wait_for_startup() await lifespan.wait_for_shutdown() diff --git a/tests/asyncio/test_sanity.py b/tests/asyncio/test_sanity.py index 90858d9..c4c87f9 100644 --- a/tests/asyncio/test_sanity.py +++ b/tests/asyncio/test_sanity.py @@ -24,6 +24,7 @@ async def test_http1_request() -> None: event_loop, Config(), WorkerContext(None), + {}, MemoryReader(), # type: ignore MemoryWriter(), # type: ignore ) @@ -83,6 +84,7 @@ async def test_http1_websocket() -> None: event_loop, Config(), WorkerContext(None), + {}, MemoryReader(), # type: ignore MemoryWriter(), # type: ignore ) @@ -122,6 +124,7 @@ async def test_http2_request() -> None: event_loop, Config(), WorkerContext(None), + {}, MemoryReader(), # type: ignore MemoryWriter(http2=True), # type: ignore ) @@ -187,6 +190,7 @@ async def test_http2_websocket() -> None: event_loop, Config(), WorkerContext(None), + {}, MemoryReader(), # type: ignore MemoryWriter(http2=True), # type: ignore ) diff --git a/tests/asyncio/test_tcp_server.py b/tests/asyncio/test_tcp_server.py index ac5caad..1aa2898 100644 --- a/tests/asyncio/test_tcp_server.py +++ b/tests/asyncio/test_tcp_server.py @@ -21,6 +21,7 @@ async def test_completes_on_closed() -> None: event_loop, Config(), WorkerContext(None), + {}, MemoryReader(), # type: ignore MemoryWriter(), # type: ignore ) @@ -39,6 +40,7 @@ async def test_complets_on_half_close() -> None: event_loop, Config(), WorkerContext(None), + {}, MemoryReader(), # type: ignore MemoryWriter(), # type: ignore ) @@ -49,5 +51,5 @@ async def test_complets_on_half_close() -> None: data = await server.writer.receive() # type: ignore assert ( data - == b"HTTP/1.1 200 \r\ncontent-length: 335\r\ndate: Thu, 01 Jan 1970 01:23:20 GMT\r\nserver: hypercorn-h11\r\n\r\n" # noqa: E501 + == b"HTTP/1.1 200 \r\ncontent-length: 348\r\ndate: Thu, 01 Jan 1970 01:23:20 GMT\r\nserver: hypercorn-h11\r\n\r\n" # noqa: E501 ) diff --git a/tests/conftest.py b/tests/conftest.py index f25c3f1..be84f59 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,7 +4,7 @@ from _pytest.monkeypatch import MonkeyPatch import hypercorn.config -from hypercorn.typing import HTTPScope +from hypercorn.typing import ConnectionState, HTTPScope @pytest.fixture(autouse=True) @@ -32,4 +32,5 @@ def _http_scope() -> HTTPScope: "client": ("127.0.0.1", 80), "server": None, "extensions": {}, + "state": ConnectionState({}), } diff --git a/tests/helpers.py b/tests/helpers.py index e9d8f83..b72b179 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -93,6 +93,7 @@ async def sanity_framework( if event["type"] in {"http.disconnect", "websocket.disconnect"}: break elif event["type"] == "lifespan.startup": + assert "state" in scope await send({"type": "lifspan.startup.complete"}) # type: ignore elif event["type"] == "lifespan.shutdown": await send({"type": "lifspan.shutdown.complete"}) # type: ignore diff --git a/tests/middleware/test_dispatcher.py b/tests/middleware/test_dispatcher.py index dbb3f43..2d0b9e3 100644 --- a/tests/middleware/test_dispatcher.py +++ b/tests/middleware/test_dispatcher.py @@ -72,7 +72,7 @@ async def send(message: dict) -> None: async def receive() -> dict: return {"type": "lifespan.shutdown"} - await app({"type": "lifespan", "asgi": {"version": "3.0"}}, receive, send) + await app({"type": "lifespan", "asgi": {"version": "3.0"}, "state": {}}, receive, send) assert sent_events == [{"type": "lifespan.startup.complete"}] @@ -89,5 +89,5 @@ async def send(message: dict) -> None: async def receive() -> dict: return {"type": "lifespan.shutdown"} - await app({"type": "lifespan", "asgi": {"version": "3.0"}}, receive, send) + await app({"type": "lifespan", "asgi": {"version": "3.0"}, "state": {}}, receive, send) assert sent_events == [{"type": "lifespan.startup.complete"}] diff --git a/tests/middleware/test_http_to_https.py b/tests/middleware/test_http_to_https.py index a4880c0..01583e2 100644 --- a/tests/middleware/test_http_to_https.py +++ b/tests/middleware/test_http_to_https.py @@ -3,7 +3,7 @@ import pytest from hypercorn.middleware import HTTPToHTTPSRedirectMiddleware -from hypercorn.typing import HTTPScope, WebsocketScope +from hypercorn.typing import ConnectionState, HTTPScope, WebsocketScope from ..helpers import empty_framework @@ -31,6 +31,7 @@ async def send(message: dict) -> None: "client": ("127.0.0.1", 80), "server": None, "extensions": {}, + "state": ConnectionState({}), } await app(scope, None, send) @@ -69,6 +70,7 @@ async def send(message: dict) -> None: "server": None, "subprotocols": [], "extensions": {"websocket.http.response": {}}, + "state": ConnectionState({}), } await app(scope, None, send) @@ -105,6 +107,7 @@ async def send(message: dict) -> None: "server": None, "subprotocols": [], "extensions": {"websocket.http.response": {}}, + "state": ConnectionState({}), } await app(scope, None, send) @@ -141,6 +144,7 @@ async def send(message: dict) -> None: "server": None, "subprotocols": [], "extensions": {}, + "state": ConnectionState({}), } await app(scope, None, send) @@ -165,6 +169,7 @@ def test_http_to_https_redirect_new_url_header() -> None: "client": None, "server": None, "extensions": {}, + "state": ConnectionState({}), }, ) assert new_url == "https://localhost/" diff --git a/tests/middleware/test_proxy_fix.py b/tests/middleware/test_proxy_fix.py index dd9ad4f..5a9cf41 100644 --- a/tests/middleware/test_proxy_fix.py +++ b/tests/middleware/test_proxy_fix.py @@ -5,7 +5,7 @@ import pytest from hypercorn.middleware import ProxyFixMiddleware -from hypercorn.typing import HTTPScope +from hypercorn.typing import ConnectionState, HTTPScope @pytest.mark.asyncio @@ -31,6 +31,7 @@ async def test_proxy_fix_legacy() -> None: "client": ("127.0.0.3", 80), "server": None, "extensions": {}, + "state": ConnectionState({}), } await app(scope, None, None) mock.assert_called() @@ -61,6 +62,7 @@ async def test_proxy_fix_modern() -> None: "client": ("127.0.0.3", 80), "server": None, "extensions": {}, + "state": ConnectionState({}), } await app(scope, None, None) mock.assert_called() diff --git a/tests/protocol/test_h11.py b/tests/protocol/test_h11.py index 1d2c679..aa3b0bd 100755 --- a/tests/protocol/test_h11.py +++ b/tests/protocol/test_h11.py @@ -16,7 +16,7 @@ from hypercorn.protocol.events import Body, Data, EndBody, EndData, Request, Response, StreamClosed from hypercorn.protocol.h11 import H2CProtocolRequiredError, H2ProtocolAssumedError, H11Protocol from hypercorn.protocol.http_stream import HTTPStream -from hypercorn.typing import Event as IOEvent +from hypercorn.typing import ConnectionState, Event as IOEvent try: from unittest.mock import AsyncMock @@ -39,7 +39,17 @@ async def _protocol(monkeypatch: MonkeyPatch) -> H11Protocol: context.terminate = context.event_class() context.terminated = context.event_class() context.terminated.is_set.return_value = False - return H11Protocol(AsyncMock(), Config(), context, AsyncMock(), False, None, None, AsyncMock()) + return H11Protocol( + AsyncMock(), + Config(), + context, + AsyncMock(), + ConnectionState({}), + False, + None, + None, + AsyncMock(), + ) @pytest.mark.asyncio @@ -183,6 +193,7 @@ async def test_protocol_handle_closed(protocol: H11Protocol) -> None: http_version="1.1", method="GET", raw_path=b"/", + state=ConnectionState({}), ) ), call(EndBody(stream_id=1)), @@ -205,6 +216,7 @@ async def test_protocol_handle_request(protocol: H11Protocol) -> None: http_version="1.1", method="GET", raw_path=b"/?a=b", + state=ConnectionState({}), ) ), call(EndBody(stream_id=1)), @@ -232,6 +244,7 @@ async def test_protocol_handle_request_with_raw_headers(protocol: H11Protocol) - http_version="1.1", method="GET", raw_path=b"/?a=b", + state=ConnectionState({}), ) ), call(EndBody(stream_id=1)), @@ -309,7 +322,15 @@ async def test_protocol_handle_max_incomplete(monkeypatch: MonkeyPatch) -> None: context = Mock() context.event_class.return_value = AsyncMock(spec=IOEvent) protocol = H11Protocol( - AsyncMock(), config, context, AsyncMock(), False, None, None, AsyncMock() + AsyncMock(), + config, + context, + AsyncMock(), + ConnectionState({}), + False, + None, + None, + AsyncMock(), ) await protocol.handle(RawData(data=b"GET / HTTP/1.1\r\nHost: hypercorn\r\n")) protocol.send.assert_called() # type: ignore diff --git a/tests/protocol/test_h2.py b/tests/protocol/test_h2.py index b2e308d..a13c494 100644 --- a/tests/protocol/test_h2.py +++ b/tests/protocol/test_h2.py @@ -11,6 +11,7 @@ from hypercorn.config import Config from hypercorn.events import Closed, RawData from hypercorn.protocol.h2 import BUFFER_HIGH_WATER, BufferCompleteError, H2Protocol, StreamBuffer +from hypercorn.typing import ConnectionState try: from unittest.mock import AsyncMock @@ -79,7 +80,15 @@ async def test_stream_buffer_complete() -> None: @pytest.mark.asyncio async def test_protocol_handle_protocol_error() -> None: protocol = H2Protocol( - Mock(), Config(), WorkerContext(None), AsyncMock(), False, None, None, AsyncMock() + Mock(), + Config(), + WorkerContext(None), + AsyncMock(), + ConnectionState({}), + False, + None, + None, + AsyncMock(), ) await protocol.handle(RawData(data=b"broken nonsense\r\n\r\n")) protocol.send.assert_awaited() # type: ignore @@ -89,7 +98,15 @@ async def test_protocol_handle_protocol_error() -> None: @pytest.mark.asyncio async def test_protocol_keep_alive_max_requests() -> None: protocol = H2Protocol( - Mock(), Config(), WorkerContext(None), AsyncMock(), False, None, None, AsyncMock() + Mock(), + Config(), + WorkerContext(None), + AsyncMock(), + ConnectionState({}), + False, + None, + None, + AsyncMock(), ) protocol.config.keep_alive_max_requests = 0 client = H2Connection() diff --git a/tests/protocol/test_http_stream.py b/tests/protocol/test_http_stream.py index d7202b6..5518c8b 100644 --- a/tests/protocol/test_http_stream.py +++ b/tests/protocol/test_http_stream.py @@ -20,7 +20,12 @@ Trailers, ) from hypercorn.protocol.http_stream import ASGIHTTPState, HTTPStream -from hypercorn.typing import HTTPResponseBodyEvent, HTTPResponseStartEvent, HTTPScope +from hypercorn.typing import ( + ConnectionState, + HTTPResponseBodyEvent, + HTTPResponseStartEvent, + HTTPScope, +) from hypercorn.utils import UnexpectedMessageError try: @@ -44,7 +49,14 @@ async def _stream() -> HTTPStream: @pytest.mark.asyncio async def test_handle_request_http_1(stream: HTTPStream, http_version: str) -> None: await stream.handle( - Request(stream_id=1, http_version=http_version, headers=[], raw_path=b"/?a=b", method="GET") + Request( + stream_id=1, + http_version=http_version, + headers=[], + raw_path=b"/?a=b", + method="GET", + state=ConnectionState({}), + ) ) stream.task_group.spawn_app.assert_called() # type: ignore scope = stream.task_group.spawn_app.call_args[0][2] # type: ignore @@ -62,13 +74,21 @@ async def test_handle_request_http_1(stream: HTTPStream, http_version: str) -> N "client": None, "server": None, "extensions": {}, + "state": ConnectionState({}), } @pytest.mark.asyncio async def test_handle_request_http_2(stream: HTTPStream) -> None: await stream.handle( - Request(stream_id=1, http_version="2", headers=[], raw_path=b"/?a=b", method="GET") + Request( + stream_id=1, + http_version="2", + headers=[], + raw_path=b"/?a=b", + method="GET", + state=ConnectionState({}), + ) ) stream.task_group.spawn_app.assert_called() # type: ignore scope = stream.task_group.spawn_app.call_args[0][2] # type: ignore @@ -90,6 +110,7 @@ async def test_handle_request_http_2(stream: HTTPStream) -> None: "http.response.early_hint": {}, "http.response.push": {}, }, + "state": ConnectionState({}), } @@ -115,7 +136,14 @@ async def test_handle_end_body(stream: HTTPStream) -> None: @pytest.mark.asyncio async def test_handle_closed(stream: HTTPStream) -> None: await stream.handle( - Request(stream_id=1, http_version="2", headers=[], raw_path=b"/?a=b", method="GET") + Request( + stream_id=1, + http_version="2", + headers=[], + raw_path=b"/?a=b", + method="GET", + state=ConnectionState({}), + ) ) await stream.handle(StreamClosed(stream_id=1)) stream.app_put.assert_called() # type: ignore @@ -125,7 +153,14 @@ async def test_handle_closed(stream: HTTPStream) -> None: @pytest.mark.asyncio async def test_send_response(stream: HTTPStream) -> None: await stream.handle( - Request(stream_id=1, http_version="2", headers=[], raw_path=b"/?a=b", method="GET") + Request( + stream_id=1, + http_version="2", + headers=[], + raw_path=b"/?a=b", + method="GET", + state=ConnectionState({}), + ) ) await stream.app_send( cast(HTTPResponseStartEvent, {"type": "http.response.start", "status": 200, "headers": []}) @@ -157,6 +192,7 @@ async def test_invalid_server_name(stream: HTTPStream) -> None: headers=[(b"host", b"example.com")], raw_path=b"/", method="GET", + state=ConnectionState({}), ) ) assert stream.send.call_args_list == [ # type: ignore @@ -186,6 +222,7 @@ async def test_send_push(stream: HTTPStream, http_scope: HTTPScope) -> None: http_version="2", method="GET", raw_path=b"/push", + state=ConnectionState({}), ) ) ] @@ -218,6 +255,7 @@ async def test_send_trailers(stream: HTTPStream) -> None: headers=[(b"te", b"trailers")], raw_path=b"/?a=b", method="GET", + state=ConnectionState({}), ) ) await stream.app_send( @@ -248,6 +286,7 @@ async def test_send_trailers_ignored(stream: HTTPStream) -> None: headers=[], # no TE: trailers header raw_path=b"/?a=b", method="GET", + state=ConnectionState({}), ) ) await stream.app_send( @@ -271,7 +310,14 @@ async def test_send_trailers_ignored(stream: HTTPStream) -> None: @pytest.mark.asyncio async def test_send_app_error(stream: HTTPStream) -> None: await stream.handle( - Request(stream_id=1, http_version="2", headers=[], raw_path=b"/?a=b", method="GET") + Request( + stream_id=1, + http_version="2", + headers=[], + raw_path=b"/?a=b", + method="GET", + state=ConnectionState({}), + ) ) await stream.app_send(None) stream.send.assert_called() # type: ignore @@ -348,7 +394,14 @@ def test_stream_idle(stream: HTTPStream) -> None: @pytest.mark.asyncio async def test_closure(stream: HTTPStream) -> None: await stream.handle( - Request(stream_id=1, http_version="2", headers=[], raw_path=b"/?a=b", method="GET") + Request( + stream_id=1, + http_version="2", + headers=[], + raw_path=b"/?a=b", + method="GET", + state=ConnectionState({}), + ) ) assert not stream.closed await stream.handle(StreamClosed(stream_id=1)) @@ -382,6 +435,13 @@ async def test_abnormal_close_logging() -> None: ) await stream.handle( - Request(stream_id=1, http_version="2", headers=[], raw_path=b"/?a=b", method="GET") + Request( + stream_id=1, + http_version="2", + headers=[], + raw_path=b"/?a=b", + method="GET", + state=ConnectionState({}), + ) ) await stream.handle(StreamClosed(stream_id=1)) diff --git a/tests/protocol/test_ws_stream.py b/tests/protocol/test_ws_stream.py index adbe78f..7b5ee98 100644 --- a/tests/protocol/test_ws_stream.py +++ b/tests/protocol/test_ws_stream.py @@ -21,6 +21,7 @@ WSStream, ) from hypercorn.typing import ( + ConnectionState, WebsocketAcceptEvent, WebsocketCloseEvent, WebsocketResponseBodyEvent, @@ -182,6 +183,7 @@ async def test_handle_request(stream: WSStream) -> None: headers=[(b"sec-websocket-version", b"13")], raw_path=b"/?a=b", method="GET", + state=ConnectionState({}), ) ) stream.task_group.spawn_app.assert_called() # type: ignore @@ -200,6 +202,7 @@ async def test_handle_request(stream: WSStream) -> None: "server": None, "subprotocols": [], "extensions": {"websocket.http.response": {}}, + "state": ConnectionState({}), } @@ -212,6 +215,7 @@ async def test_handle_data_before_acceptance(stream: WSStream) -> None: headers=[(b"sec-websocket-version", b"13")], raw_path=b"/?a=b", method="GET", + state=ConnectionState({}), ) ) await stream.handle( @@ -241,6 +245,7 @@ async def test_handle_connection(stream: WSStream) -> None: headers=[(b"sec-websocket-version", b"13")], raw_path=b"/?a=b", method="GET", + state=ConnectionState({}), ) ) await stream.app_send(cast(WebsocketAcceptEvent, {"type": "websocket.accept"})) @@ -270,6 +275,7 @@ async def test_send_accept(stream: WSStream) -> None: headers=[(b"sec-websocket-version", b"13")], raw_path=b"/", method="GET", + state=ConnectionState({}), ) ) await stream.app_send(cast(WebsocketAcceptEvent, {"type": "websocket.accept"})) @@ -289,6 +295,7 @@ async def test_send_accept_with_additional_headers(stream: WSStream) -> None: headers=[(b"sec-websocket-version", b"13")], raw_path=b"/", method="GET", + state=ConnectionState({}), ) ) await stream.app_send( @@ -313,6 +320,7 @@ async def test_send_reject(stream: WSStream) -> None: headers=[(b"sec-websocket-version", b"13")], raw_path=b"/", method="GET", + state=ConnectionState({}), ) ) await stream.app_send( @@ -347,6 +355,7 @@ async def test_invalid_server_name(stream: WSStream) -> None: headers=[(b"host", b"example.com"), (b"sec-websocket-version", b"13")], raw_path=b"/", method="GET", + state=ConnectionState({}), ) ) assert stream.send.call_args_list == [ # type: ignore @@ -372,6 +381,7 @@ async def test_send_app_error_handshake(stream: WSStream) -> None: headers=[(b"sec-websocket-version", b"13")], raw_path=b"/", method="GET", + state=ConnectionState({}), ) ) await stream.app_send(None) @@ -399,6 +409,7 @@ async def test_send_app_error_connected(stream: WSStream) -> None: headers=[(b"sec-websocket-version", b"13")], raw_path=b"/", method="GET", + state=ConnectionState({}), ) ) await stream.app_send(cast(WebsocketAcceptEvent, {"type": "websocket.accept"})) @@ -421,6 +432,7 @@ async def test_send_connection(stream: WSStream) -> None: headers=[(b"sec-websocket-version", b"13")], raw_path=b"/", method="GET", + state=ConnectionState({}), ) ) await stream.app_send(cast(WebsocketAcceptEvent, {"type": "websocket.accept"})) @@ -447,6 +459,7 @@ async def test_pings(stream: WSStream) -> None: headers=[(b"sec-websocket-version", b"13")], raw_path=b"/?a=b", method="GET", + state=ConnectionState({}), ) ) async with TaskGroup(event_loop) as task_group: diff --git a/tests/test_app_wrappers.py b/tests/test_app_wrappers.py index c68ba0c..4ddce09 100644 --- a/tests/test_app_wrappers.py +++ b/tests/test_app_wrappers.py @@ -8,7 +8,7 @@ import trio from hypercorn.app_wrappers import _build_environ, InvalidPathError, WSGIWrapper -from hypercorn.typing import ASGISendEvent, HTTPScope +from hypercorn.typing import ASGISendEvent, ConnectionState, HTTPScope def echo_body(environ: dict, start_response: Callable) -> List[bytes]: @@ -39,6 +39,7 @@ async def test_wsgi_trio() -> None: "client": ("localhost", 80), "server": None, "extensions": {}, + "state": ConnectionState({}), } send_channel, receive_channel = trio.open_memory_channel(1) await send_channel.send({"type": "http.request"}) @@ -98,6 +99,7 @@ async def test_wsgi_asyncio() -> None: "client": ("localhost", 80), "server": None, "extensions": {}, + "state": ConnectionState({}), } messages = await _run_app(app, scope) assert messages == [ @@ -128,6 +130,7 @@ async def test_max_body_size() -> None: "client": ("localhost", 80), "server": None, "extensions": {}, + "state": ConnectionState({}), } messages = await _run_app(app, scope, b"abcde") assert messages == [ @@ -157,6 +160,7 @@ async def test_no_start_response() -> None: "client": ("localhost", 80), "server": None, "extensions": {}, + "state": ConnectionState({}), } with pytest.raises(RuntimeError): await _run_app(app, scope) @@ -177,6 +181,7 @@ def test_build_environ_encoding() -> None: "client": ("localhost", 80), "server": None, "extensions": {}, + "state": ConnectionState({}), } environ = _build_environ(scope, b"") assert environ["SCRIPT_NAME"] == "/中".encode("utf8").decode("latin-1") @@ -198,6 +203,7 @@ def test_build_environ_root_path() -> None: "client": ("localhost", 80), "server": None, "extensions": {}, + "state": ConnectionState({}), } with pytest.raises(InvalidPathError): _build_environ(scope, b"") diff --git a/tests/trio/test_keep_alive.py b/tests/trio/test_keep_alive.py index 6bed437..afbed0f 100644 --- a/tests/trio/test_keep_alive.py +++ b/tests/trio/test_keep_alive.py @@ -47,7 +47,7 @@ def _client_stream( config.keep_alive_timeout = KEEP_ALIVE_TIMEOUT client_stream, server_stream = trio.testing.memory_stream_pair() server_stream.socket = MockSocket() - server = TCPServer(ASGIWrapper(slow_framework), config, WorkerContext(None), server_stream) + server = TCPServer(ASGIWrapper(slow_framework), config, WorkerContext(None), {}, server_stream) nursery.start_soon(server.run) yield client_stream diff --git a/tests/trio/test_lifespan.py b/tests/trio/test_lifespan.py index 4de1493..bdccf45 100644 --- a/tests/trio/test_lifespan.py +++ b/tests/trio/test_lifespan.py @@ -19,7 +19,7 @@ async def test_startup_timeout_error(nursery: trio._core._run.Nursery) -> None: config = Config() config.startup_timeout = 0.01 - lifespan = Lifespan(ASGIWrapper(SlowLifespanFramework(0.02, trio.sleep)), config) + lifespan = Lifespan(ASGIWrapper(SlowLifespanFramework(0.02, trio.sleep)), config, {}) nursery.start_soon(lifespan.handle_lifespan) with pytest.raises(LifespanTimeoutError) as exc_info: await lifespan.wait_for_startup() @@ -28,8 +28,7 @@ async def test_startup_timeout_error(nursery: trio._core._run.Nursery) -> None: @pytest.mark.trio async def test_startup_failure() -> None: - lifespan = Lifespan(ASGIWrapper(lifespan_failure), Config()) - + lifespan = Lifespan(ASGIWrapper(lifespan_failure), Config(), {}) with pytest.raises(LifespanFailureError) as exc_info: try: async with trio.open_nursery() as lifespan_nursery: diff --git a/tests/trio/test_sanity.py b/tests/trio/test_sanity.py index b5bf75b..6410428 100644 --- a/tests/trio/test_sanity.py +++ b/tests/trio/test_sanity.py @@ -25,7 +25,9 @@ async def test_http1_request(nursery: trio._core._run.Nursery) -> None: client_stream, server_stream = trio.testing.memory_stream_pair() server_stream.socket = MockSocket() - server = TCPServer(ASGIWrapper(sanity_framework), Config(), WorkerContext(None), server_stream) + server = TCPServer( + ASGIWrapper(sanity_framework), Config(), WorkerContext(None), {}, server_stream + ) nursery.start_soon(server.run) client = h11.Connection(h11.CLIENT) await client_stream.send_all( @@ -76,7 +78,9 @@ async def test_http1_request(nursery: trio._core._run.Nursery) -> None: async def test_http1_websocket(nursery: trio._core._run.Nursery) -> None: client_stream, server_stream = trio.testing.memory_stream_pair() server_stream.socket = MockSocket() - server = TCPServer(ASGIWrapper(sanity_framework), Config(), WorkerContext(None), server_stream) + server = TCPServer( + ASGIWrapper(sanity_framework), Config(), WorkerContext(None), {}, server_stream + ) nursery.start_soon(server.run) client = wsproto.WSConnection(wsproto.ConnectionType.CLIENT) await client_stream.send_all(client.send(wsproto.events.Request(host="hypercorn", target="/"))) @@ -103,7 +107,9 @@ async def test_http2_request(nursery: trio._core._run.Nursery) -> None: server_stream.transport_stream = Mock(return_value=PropertyMock(return_value=MockSocket())) server_stream.do_handshake = AsyncMock() server_stream.selected_alpn_protocol = Mock(return_value="h2") - server = TCPServer(ASGIWrapper(sanity_framework), Config(), WorkerContext(None), server_stream) + server = TCPServer( + ASGIWrapper(sanity_framework), Config(), WorkerContext(None), {}, server_stream + ) nursery.start_soon(server.run) client = h2.connection.H2Connection() client.initiate_connection() @@ -158,7 +164,9 @@ async def test_http2_websocket(nursery: trio._core._run.Nursery) -> None: server_stream.transport_stream = Mock(return_value=PropertyMock(return_value=MockSocket())) server_stream.do_handshake = AsyncMock() server_stream.selected_alpn_protocol = Mock(return_value="h2") - server = TCPServer(ASGIWrapper(sanity_framework), Config(), WorkerContext(None), server_stream) + server = TCPServer( + ASGIWrapper(sanity_framework), Config(), WorkerContext(None), {}, server_stream + ) nursery.start_soon(server.run) h2_client = h2.connection.H2Connection() h2_client.initiate_connection() From a40aa2c87f3eecc71a564139781914e205a51a32 Mon Sep 17 00:00:00 2001 From: pgjones Date: Mon, 27 May 2024 17:52:22 +0100 Subject: [PATCH 136/151] Allow sending of the response before body data arrives This was an ASGI specification condition that has now been relaxed. --- src/hypercorn/protocol/http_stream.py | 25 ++++++++++--------------- tests/protocol/test_http_stream.py | 13 +------------ 2 files changed, 11 insertions(+), 27 deletions(-) diff --git a/src/hypercorn/protocol/http_stream.py b/src/hypercorn/protocol/http_stream.py index 1a68cdc..7183854 100644 --- a/src/hypercorn/protocol/http_stream.py +++ b/src/hypercorn/protocol/http_stream.py @@ -141,6 +141,15 @@ async def app_send(self, message: Optional[ASGISendEvent]) -> None: else: if message["type"] == "http.response.start" and self.state == ASGIHTTPState.REQUEST: self.response = message + headers = build_and_validate_headers(self.response.get("headers", [])) + await self.send( + Response( + stream_id=self.stream_id, + headers=headers, + status_code=int(self.response["status"]), + ) + ) + self.state = ASGIHTTPState.RESPONSE elif ( message["type"] == "http.response.push" and self.scope["http_version"] in PUSH_VERSIONS @@ -175,21 +184,7 @@ async def app_send(self, message: Optional[ASGISendEvent]) -> None: status_code=103, ) ) - elif message["type"] == "http.response.body" and self.state in { - ASGIHTTPState.REQUEST, - ASGIHTTPState.RESPONSE, - }: - if self.state == ASGIHTTPState.REQUEST: - headers = build_and_validate_headers(self.response.get("headers", [])) - await self.send( - Response( - stream_id=self.stream_id, - headers=headers, - status_code=int(self.response["status"]), - ) - ) - self.state = ASGIHTTPState.RESPONSE - + elif message["type"] == "http.response.body" and self.state == ASGIHTTPState.RESPONSE: if ( not suppress_body(self.scope["method"], int(self.response["status"])) and message.get("body", b"") != b"" diff --git a/tests/protocol/test_http_stream.py b/tests/protocol/test_http_stream.py index 5518c8b..3deb405 100644 --- a/tests/protocol/test_http_stream.py +++ b/tests/protocol/test_http_stream.py @@ -165,9 +165,7 @@ async def test_send_response(stream: HTTPStream) -> None: await stream.app_send( cast(HTTPResponseStartEvent, {"type": "http.response.start", "status": 200, "headers": []}) ) - assert stream.state == ASGIHTTPState.REQUEST - # Must wait for response before sending anything - stream.send.assert_not_called() # type: ignore + assert stream.state == ASGIHTTPState.RESPONSE await stream.app_send( cast(HTTPResponseBodyEvent, {"type": "http.response.body", "body": b"Body"}) ) @@ -413,15 +411,6 @@ async def test_closure(stream: HTTPStream) -> None: assert stream.app_put.call_args_list == [call({"type": "http.disconnect"})] -@pytest.mark.asyncio -async def test_closed_app_send_noop(stream: HTTPStream) -> None: - stream.closed = True - await stream.app_send( - cast(HTTPResponseStartEvent, {"type": "http.response.start", "status": 200, "headers": []}) - ) - stream.send.assert_not_called() # type: ignore - - @pytest.mark.asyncio async def test_abnormal_close_logging() -> None: config = Config() From e47757d5471f705d7c3d9018e436be205ce3f05c Mon Sep 17 00:00:00 2001 From: pgjones Date: Mon, 27 May 2024 17:56:57 +0100 Subject: [PATCH 137/151] Ensure responses are sent with empty bodies for WSGI The previous code was meant to ensure the response was not sent until the first byte was recevied. However, as the response_body has been set this should be enough and ensures that empty response bodies work. --- src/hypercorn/app_wrappers.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/hypercorn/app_wrappers.py b/src/hypercorn/app_wrappers.py index 834586d..56c1bfa 100644 --- a/src/hypercorn/app_wrappers.py +++ b/src/hypercorn/app_wrappers.py @@ -85,7 +85,6 @@ async def handle_http( def run_app(self, environ: dict, send: Callable) -> None: headers: List[Tuple[bytes, bytes]] - headers_sent = False response_started = False status_code: Optional[int] = None @@ -109,12 +108,9 @@ def start_response( if not response_started: raise RuntimeError("WSGI app did not call start_response") + send({"type": "http.response.start", "status": status_code, "headers": headers}) try: for output in response_body: - if not headers_sent: - send({"type": "http.response.start", "status": status_code, "headers": headers}) - headers_sent = True - send({"type": "http.response.body", "body": output, "more_body": True}) finally: if hasattr(response_body, "close"): From d16b50398aaedc509f83fe2b5c6c83a0ffbfc991 Mon Sep 17 00:00:00 2001 From: pgjones Date: Mon, 27 May 2024 20:49:48 +0100 Subject: [PATCH 138/151] Improve the trailing headers support The EndBody event should only be sent after the headers as this results in the stream being closed. It is acceptable to send no response and only trailing headers, in which case a default 200 status code response is sent with the headers. --- src/hypercorn/protocol/http_stream.py | 44 ++++++++++++++++++++------- tests/protocol/test_http_stream.py | 3 +- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/src/hypercorn/protocol/http_stream.py b/src/hypercorn/protocol/http_stream.py index 7183854..7ffac1d 100644 --- a/src/hypercorn/protocol/http_stream.py +++ b/src/hypercorn/protocol/http_stream.py @@ -194,16 +194,36 @@ async def app_send(self, message: Optional[ASGISendEvent]) -> None: ) if not message.get("more_body", False): - await self.send(EndBody(stream_id=self.stream_id)) - if self.response.get("trailers", False): self.state = ASGIHTTPState.TRAILERS else: - self.state = ASGIHTTPState.CLOSED - await self.config.log.access( - self.scope, self.response, time() - self.start_time + await self._send_closed() + elif ( + message["type"] == "http.response.trailers" + and self.scope["http_version"] in TRAILERS_VERSIONS + and self.state == ASGIHTTPState.REQUEST + ): + for name, value in self.scope["headers"]: + if name == b"te" and value == b"trailers": + headers = build_and_validate_headers(message["headers"]) + self.response = { + "type": "http.response.start", + "status": 200, + "headers": headers, + } + await self.send( + Response( + stream_id=self.stream_id, + headers=headers, + status_code=200, + ) ) - await self.send(StreamClosed(stream_id=self.stream_id)) + self.state = ASGIHTTPState.TRAILERS + break + + if not message.get("more_trailers", False): + await self._send_closed() + elif ( message["type"] == "http.response.trailers" and self.scope["http_version"] in TRAILERS_VERSIONS @@ -216,14 +236,16 @@ async def app_send(self, message: Optional[ASGISendEvent]) -> None: break if not message.get("more_trailers", False): - self.state = ASGIHTTPState.CLOSED - await self.config.log.access( - self.scope, self.response, time() - self.start_time - ) - await self.send(StreamClosed(stream_id=self.stream_id)) + await self._send_closed() else: raise UnexpectedMessageError(self.state, message["type"]) + async def _send_closed(self) -> None: + await self.send(EndBody(stream_id=self.stream_id)) + self.state = ASGIHTTPState.CLOSED + await self.config.log.access(self.scope, self.response, time() - self.start_time) + await self.send(StreamClosed(stream_id=self.stream_id)) + async def _send_error_response(self, status_code: int) -> None: await self.send( Response( diff --git a/tests/protocol/test_http_stream.py b/tests/protocol/test_http_stream.py index 3deb405..b25cb2f 100644 --- a/tests/protocol/test_http_stream.py +++ b/tests/protocol/test_http_stream.py @@ -269,8 +269,8 @@ async def test_send_trailers(stream: HTTPStream) -> None: assert stream.send.call_args_list == [ # type: ignore call(Response(stream_id=1, headers=[], status_code=200)), call(Body(stream_id=1, data=b"Body")), - call(EndBody(stream_id=1)), call(Trailers(stream_id=1, headers=[(b"X", b"V")])), + call(EndBody(stream_id=1)), call(StreamClosed(stream_id=1)), ] @@ -337,7 +337,6 @@ async def test_send_app_error(stream: HTTPStream) -> None: "state, message_type", [ (ASGIHTTPState.REQUEST, "not_a_real_type"), - (ASGIHTTPState.REQUEST, "http.response.trailers"), (ASGIHTTPState.RESPONSE, "http.response.start"), (ASGIHTTPState.TRAILERS, "http.response.start"), (ASGIHTTPState.CLOSED, "http.response.start"), From a2e7bcece2c9dc1ad3dca5bbc4de3a7fce2ea5ce Mon Sep 17 00:00:00 2001 From: pgjones Date: Mon, 27 May 2024 20:56:05 +0100 Subject: [PATCH 139/151] Bump and release 0.17.0 --- CHANGELOG.rst | 18 ++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 3ca9b35..c9afd7a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,21 @@ +0.17.0 2024-05-27 +----------------- + +* Set TCP_NODELAY on sockets. +* Support sending trailing headers on h2/h3. +* Add support for lifespan state. +* Allow sending of the response before body data arrives. +* Bugfix properly set host header to ascii string in + ProxyFixMiddleware. +* Bugfix encode headers using latin-1. +* Bugfix don't double-access log if the response was sent. +* Bugfix a statsd logging bug. +* Bugfix handle already-closed on StreamEnded. +* Bugfix send a 400 response if data is received before the websocket + is accepted. +* Bugfix ensure only a single QUIC timer task per connection. +* Bugfix ensure responses are sent with empty bodies for WSGI. + 0.16.0 2024-01-01 ----------------- diff --git a/pyproject.toml b/pyproject.toml index 678aa6d..694b042 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "Hypercorn" -version = "0.16.0" +version = "0.17.0" description = "A ASGI Server based on Hyper libraries and inspired by Gunicorn" authors = ["pgjones "] classifiers = [ From 74d5b958d4bf85c4c52fa535f4b9d2cfb2e0738e Mon Sep 17 00:00:00 2001 From: pgjones Date: Mon, 27 May 2024 21:10:12 +0100 Subject: [PATCH 140/151] Revert "Set TCP_NODELAY on sockets" This reverts commit a099217fdfd80035f43fc3bd467818acdef7b5af. As get OSError: [Errno 92] Protocol not available in Docker containers - needs further investigation. --- src/hypercorn/config.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/hypercorn/config.py b/src/hypercorn/config.py index 8867694..f00c7d5 100644 --- a/src/hypercorn/config.py +++ b/src/hypercorn/config.py @@ -246,7 +246,6 @@ def _create_sockets( except (ValueError, IndexError): host, port = bind, 8000 sock = socket.socket(socket.AF_INET6 if ":" in host else socket.AF_INET, type_) - sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) if self.workers > 1: try: sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) From 494268ae438540f8db098881d03c2481d182ea34 Mon Sep 17 00:00:00 2001 From: pgjones Date: Mon, 27 May 2024 21:11:14 +0100 Subject: [PATCH 141/151] Bump and release 0.17.1 --- CHANGELOG.rst | 5 +++++ pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index c9afd7a..9aa702a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,8 @@ +0.17.1 2024-05-27 +----------------- + +* Bugfix revert set TCP_NODELAY on sockets. + 0.17.0 2024-05-27 ----------------- diff --git a/pyproject.toml b/pyproject.toml index 694b042..44d1f96 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "Hypercorn" -version = "0.17.0" +version = "0.17.1" description = "A ASGI Server based on Hyper libraries and inspired by Gunicorn" authors = ["pgjones "] classifiers = [ From c03a75f15d5316e2a1bcc5f46550da5b24c314e6 Mon Sep 17 00:00:00 2001 From: pgjones Date: Mon, 27 May 2024 21:20:19 +0100 Subject: [PATCH 142/151] Bugfix pass the correct quic connection to the H3 Protocol This was a mistake in the recent QUIC work. Odd that mypy didn't pick this up. --- src/hypercorn/protocol/quic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hypercorn/protocol/quic.py b/src/hypercorn/protocol/quic.py index 2a15c43..40625a6 100644 --- a/src/hypercorn/protocol/quic.py +++ b/src/hypercorn/protocol/quic.py @@ -133,7 +133,7 @@ async def _handle_events( self.state, client, self.server, - connection, + connection.quic, partial(self.send_all, connection), ) elif isinstance(event, ConnectionIdIssued): From 7136c61029532d9f9563e993f045f6c16630c53b Mon Sep 17 00:00:00 2001 From: pgjones Date: Mon, 27 May 2024 21:21:26 +0100 Subject: [PATCH 143/151] Bump and release 0.17.2 --- CHANGELOG.rst | 5 +++++ pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9aa702a..44d4498 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,8 @@ +0.17.2 2024-05-27 +----------------- + +* Bugfix pass the correct quic connection to the H3 Protocol. + 0.17.1 2024-05-27 ----------------- diff --git a/pyproject.toml b/pyproject.toml index 44d1f96..a2ac79a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "Hypercorn" -version = "0.17.1" +version = "0.17.2" description = "A ASGI Server based on Hyper libraries and inspired by Gunicorn" authors = ["pgjones "] classifiers = [ From bc6e1c05c0aff8ed2f60f25bb5e3681368fcdc1b Mon Sep 17 00:00:00 2001 From: pgjones Date: Tue, 28 May 2024 19:40:59 +0100 Subject: [PATCH 144/151] Restore set TCP_NODELAY on TCP sockets This reverts commit 74d5b958d4bf85c4c52fa535f4b9d2cfb2e0738e. The issue wasn't Docker, but rather trying to set TCP properties on a UDP socket, hence the additional condition in this version. --- src/hypercorn/config.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/hypercorn/config.py b/src/hypercorn/config.py index f00c7d5..71d5b2e 100644 --- a/src/hypercorn/config.py +++ b/src/hypercorn/config.py @@ -246,6 +246,10 @@ def _create_sockets( except (ValueError, IndexError): host, port = bind, 8000 sock = socket.socket(socket.AF_INET6 if ":" in host else socket.AF_INET, type_) + + if type_ == socket.SOCK_STREAM: + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + if self.workers > 1: try: sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) From edd0aac585043a00d2c8291b83482928750f555c Mon Sep 17 00:00:00 2001 From: pgjones Date: Tue, 28 May 2024 20:00:35 +0100 Subject: [PATCH 145/151] Support uvloop >= 0.18 and the loop_factory argument This brings Hypercorn in line with what is required for Python 3.13 onwards. --- pyproject.toml | 2 +- src/hypercorn/asyncio/run.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a2ac79a..81c797a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,7 @@ taskgroup = { version = "*", python = "<3.11", allow-prereleases = true } tomli = { version = "*", python = "<3.11" } trio = { version = ">=0.22.0", optional = true } typing_extensions = { version = "*", python = "<3.11" } -uvloop = { version = "*", markers = "platform_system != 'Windows'", optional = true } +uvloop = { version = ">=0.18", markers = "platform_system != 'Windows'", optional = true } wsproto = ">=0.14.0" [tool.poetry.dev-dependencies] diff --git a/src/hypercorn/asyncio/run.py b/src/hypercorn/asyncio/run.py index a4c8027..93bd7fc 100644 --- a/src/hypercorn/asyncio/run.py +++ b/src/hypercorn/asyncio/run.py @@ -207,8 +207,6 @@ def uvloop_worker( import uvloop except ImportError as error: raise Exception("uvloop is not installed") from error - else: - asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) app = load_application(config.application_path, config.wsgi_max_body_size) @@ -220,6 +218,7 @@ def uvloop_worker( partial(worker_serve, app, config, sockets=sockets), debug=config.debug, shutdown_trigger=shutdown_trigger, + loop_factory=uvloop.new_event_loop, ) @@ -228,8 +227,9 @@ def _run( *, debug: bool = False, shutdown_trigger: Optional[Callable[..., Awaitable[None]]] = None, + loop_factory: Callable[[], asyncio.AbstractEventLoop] | None = None, ) -> None: - with Runner(debug=debug) as runner: + with Runner(debug=debug, loop_factory=loop_factory) as runner: runner.get_loop().set_exception_handler(_exception_handler) runner.run(main(shutdown_trigger=shutdown_trigger)) From bfb087756da53c25fd9390b4ea0acf914408ecbf Mon Sep 17 00:00:00 2001 From: pgjones Date: Tue, 28 May 2024 21:19:55 +0100 Subject: [PATCH 146/151] Bugfix ensure ExceptionGroup lifespan failures crash the server A lifespan.startup.failure should crash the server, however if these became wrapped in an Exception group in the ASGI app the server wouldn't crash, now fixed. --- src/hypercorn/asyncio/lifespan.py | 12 ++++++++++- src/hypercorn/trio/lifespan.py | 13 +++++++++++- tests/asyncio/test_lifespan.py | 25 +++++++++++++++++----- tests/helpers.py | 10 --------- tests/trio/test_lifespan.py | 35 ++++++++++++++++++------------- 5 files changed, 63 insertions(+), 32 deletions(-) diff --git a/src/hypercorn/asyncio/lifespan.py b/src/hypercorn/asyncio/lifespan.py index eaef906..bd22c8f 100644 --- a/src/hypercorn/asyncio/lifespan.py +++ b/src/hypercorn/asyncio/lifespan.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import sys from functools import partial from typing import Any, Callable @@ -8,6 +9,9 @@ from ..typing import AppWrapper, ASGIReceiveEvent, ASGISendEvent, LifespanScope, LifespanState from ..utils import LifespanFailureError, LifespanTimeoutError +if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup + class UnexpectedMessageError(Exception): pass @@ -58,7 +62,13 @@ def _call_soon(func: Callable, *args: Any) -> Any: except LifespanFailureError: # Lifespan failures should crash the server raise - except Exception: + except (BaseExceptionGroup, Exception) as error: + if isinstance(error, BaseExceptionGroup): + failure_error = error.subgroup(LifespanFailureError) + if failure_error is not None: + # Lifespan failures should crash the server + raise failure_error + self.supported = False if not self.startup.is_set(): await self.config.log.warning( diff --git a/src/hypercorn/trio/lifespan.py b/src/hypercorn/trio/lifespan.py index 21f4dd2..cd80984 100644 --- a/src/hypercorn/trio/lifespan.py +++ b/src/hypercorn/trio/lifespan.py @@ -1,11 +1,16 @@ from __future__ import annotations +import sys + import trio from ..config import Config from ..typing import AppWrapper, ASGIReceiveEvent, ASGISendEvent, LifespanScope, LifespanState from ..utils import LifespanFailureError, LifespanTimeoutError +if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup + class UnexpectedMessageError(Exception): pass @@ -43,7 +48,13 @@ async def handle_lifespan( except LifespanFailureError: # Lifespan failures should crash the server raise - except Exception: + except (BaseExceptionGroup, Exception) as error: + if isinstance(error, BaseExceptionGroup): + failure_error = error.subgroup(LifespanFailureError) + if failure_error is not None: + # Lifespan failures should crash the server + raise failure_error + self.supported = False if not self.startup.is_set(): await self.config.log.warning( diff --git a/tests/asyncio/test_lifespan.py b/tests/asyncio/test_lifespan.py index bf2cfc6..e79d173 100644 --- a/tests/asyncio/test_lifespan.py +++ b/tests/asyncio/test_lifespan.py @@ -9,9 +9,14 @@ from hypercorn.app_wrappers import ASGIWrapper from hypercorn.asyncio.lifespan import Lifespan from hypercorn.config import Config -from hypercorn.typing import Scope +from hypercorn.typing import ASGIReceiveCallable, ASGISendCallable, Scope from hypercorn.utils import LifespanFailureError, LifespanTimeoutError -from ..helpers import lifespan_failure, SlowLifespanFramework +from ..helpers import SlowLifespanFramework + +try: + from asyncio import TaskGroup +except ImportError: + from taskgroup import TaskGroup # type: ignore async def no_lifespan_app(scope: Scope, receive: Callable, send: Callable) -> None: @@ -47,17 +52,27 @@ async def test_startup_timeout_error() -> None: await task +async def _lifespan_failure( + scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable +) -> None: + async with TaskGroup(): + while True: + message = await receive() + if message["type"] == "lifespan.startup": + await send({"type": "lifespan.startup.failed", "message": "Failure"}) + break + + @pytest.mark.asyncio async def test_startup_failure() -> None: event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() - lifespan = Lifespan(ASGIWrapper(lifespan_failure), Config(), event_loop, {}) + lifespan = Lifespan(ASGIWrapper(_lifespan_failure), Config(), event_loop, {}) lifespan_task = event_loop.create_task(lifespan.handle_lifespan()) await lifespan.wait_for_startup() assert lifespan_task.done() exception = lifespan_task.exception() - assert isinstance(exception, LifespanFailureError) - assert str(exception) == "Lifespan failure in startup. 'Failure'" + assert exception.subgroup(LifespanFailureError) is not None # type: ignore async def return_app(scope: Scope, receive: Callable, send: Callable) -> None: diff --git a/tests/helpers.py b/tests/helpers.py index b72b179..0e2d4d8 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -71,16 +71,6 @@ async def echo_framework( await send({"type": "websocket.send", "text": event["text"], "bytes": event["bytes"]}) -async def lifespan_failure( - scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable -) -> None: - while True: - message = await receive() - if message["type"] == "lifespan.startup": - await send({"type": "lifespan.startup.failed", "message": "Failure"}) - break - - async def sanity_framework( scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable ) -> None: diff --git a/tests/trio/test_lifespan.py b/tests/trio/test_lifespan.py index bdccf45..1dbc008 100644 --- a/tests/trio/test_lifespan.py +++ b/tests/trio/test_lifespan.py @@ -11,8 +11,9 @@ from hypercorn.app_wrappers import ASGIWrapper from hypercorn.config import Config from hypercorn.trio.lifespan import Lifespan +from hypercorn.typing import ASGIReceiveCallable, ASGISendCallable, Scope from hypercorn.utils import LifespanFailureError, LifespanTimeoutError -from ..helpers import lifespan_failure, SlowLifespanFramework +from ..helpers import SlowLifespanFramework @pytest.mark.trio @@ -26,19 +27,23 @@ async def test_startup_timeout_error(nursery: trio._core._run.Nursery) -> None: assert str(exc_info.value).startswith("Timeout whilst awaiting startup") +async def _lifespan_failure( + scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable +) -> None: + async with trio.open_nursery(): + while True: + message = await receive() + if message["type"] == "lifespan.startup": + await send({"type": "lifespan.startup.failed", "message": "Failure"}) + break + + @pytest.mark.trio async def test_startup_failure() -> None: - lifespan = Lifespan(ASGIWrapper(lifespan_failure), Config(), {}) - with pytest.raises(LifespanFailureError) as exc_info: - try: - async with trio.open_nursery() as lifespan_nursery: - await lifespan_nursery.start(lifespan.handle_lifespan) - await lifespan.wait_for_startup() - except ExceptionGroup as exception: - target_exception = exception - if len(exception.exceptions) == 1: - target_exception = exception.exceptions[0] - - raise target_exception.with_traceback(target_exception.__traceback__) - - assert str(exc_info.value) == "Lifespan failure in startup. 'Failure'" + lifespan = Lifespan(ASGIWrapper(_lifespan_failure), Config(), {}) + try: + async with trio.open_nursery() as lifespan_nursery: + await lifespan_nursery.start(lifespan.handle_lifespan) + await lifespan.wait_for_startup() + except ExceptionGroup as error: + assert error.subgroup(LifespanFailureError) is not None From c405deafb22d66587ea0aff4f8fa4f5688b74351 Mon Sep 17 00:00:00 2001 From: pgjones Date: Tue, 28 May 2024 21:53:56 +0100 Subject: [PATCH 147/151] Bump and release 0.17.3 --- CHANGELOG.rst | 7 +++++++ pyproject.toml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 44d4498..bf32d1b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,10 @@ +0.17.3 2024-05-28 +----------------- + +* Restore set TCP_NODELAY on TCP sockets +* Support uvloop >= 0.18 and the loop_factory argument +* Bugfix ensure ExceptionGroup lifespan failures crash the server. + 0.17.2 2024-05-27 ----------------- diff --git a/pyproject.toml b/pyproject.toml index 81c797a..15695b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "Hypercorn" -version = "0.17.2" +version = "0.17.3" description = "A ASGI Server based on Hyper libraries and inspired by Gunicorn" authors = ["pgjones "] classifiers = [ From 4cf352890e6869bfbc0940e75420ea897fa92518 Mon Sep 17 00:00:00 2001 From: pgjones Date: Mon, 3 Jun 2024 20:51:44 +0100 Subject: [PATCH 148/151] Bugfix don't handle Cancellation errors in lifespan This fixes a regression caused by bfb087756da53c25fd9390b4ea0acf914408ecbf whereby the cancellation error was caught. It should instead be re-raised. --- src/hypercorn/asyncio/lifespan.py | 10 ++++------ src/hypercorn/trio/lifespan.py | 10 ++++------ 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/hypercorn/asyncio/lifespan.py b/src/hypercorn/asyncio/lifespan.py index bd22c8f..3980345 100644 --- a/src/hypercorn/asyncio/lifespan.py +++ b/src/hypercorn/asyncio/lifespan.py @@ -59,15 +59,13 @@ def _call_soon(func: Callable, *args: Any) -> Any: partial(self.loop.run_in_executor, None), _call_soon, ) - except LifespanFailureError: - # Lifespan failures should crash the server + except (LifespanFailureError, asyncio.CancelledError): raise except (BaseExceptionGroup, Exception) as error: if isinstance(error, BaseExceptionGroup): - failure_error = error.subgroup(LifespanFailureError) - if failure_error is not None: - # Lifespan failures should crash the server - raise failure_error + reraise_error = error.subgroup((LifespanFailureError, asyncio.CancelledError)) + if reraise_error is not None: + raise reraise_error self.supported = False if not self.startup.is_set(): diff --git a/src/hypercorn/trio/lifespan.py b/src/hypercorn/trio/lifespan.py index cd80984..4aeba24 100644 --- a/src/hypercorn/trio/lifespan.py +++ b/src/hypercorn/trio/lifespan.py @@ -45,15 +45,13 @@ async def handle_lifespan( trio.to_thread.run_sync, trio.from_thread.run, ) - except LifespanFailureError: - # Lifespan failures should crash the server + except (LifespanFailureError, trio.Cancelled): raise except (BaseExceptionGroup, Exception) as error: if isinstance(error, BaseExceptionGroup): - failure_error = error.subgroup(LifespanFailureError) - if failure_error is not None: - # Lifespan failures should crash the server - raise failure_error + reraise_error = error.subgroup((LifespanFailureError, trio.Cancelled)) + if reraise_error is not None: + raise reraise_error self.supported = False if not self.startup.is_set(): From 84d06b8cf47798d2df7722273341e720ec0ea102 Mon Sep 17 00:00:00 2001 From: jakkdl Date: Tue, 4 Jun 2024 15:39:43 +0200 Subject: [PATCH 149/151] Improve typing against Trio --- pyproject.toml | 9 ++++- src/hypercorn/middleware/dispatcher.py | 6 ++- src/hypercorn/trio/__init__.py | 2 +- src/hypercorn/trio/lifespan.py | 8 ++-- src/hypercorn/trio/run.py | 4 +- src/hypercorn/trio/task_group.py | 9 +++-- src/hypercorn/trio/tcp_server.py | 2 +- src/hypercorn/trio/udp_server.py | 8 ++-- src/hypercorn/trio/worker_context.py | 2 +- tests/test_app_wrappers.py | 6 +-- tests/trio/test_keep_alive.py | 54 +++++++++++++++++--------- tests/trio/test_sanity.py | 15 ++++--- tox.ini | 1 + 13 files changed, 80 insertions(+), 46 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 15695b6..7a6b6a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,9 +92,16 @@ warn_unused_configs = true warn_unused_ignores = true [[tool.mypy.overrides]] -module =["aioquic.*", "cryptography.*", "h11.*", "h2.*", "priority.*", "pytest_asyncio.*", "trio.*", "uvloop.*"] +module =["aioquic.*", "cryptography.*", "h11.*", "h2.*", "priority.*", "pytest_asyncio.*", "uvloop.*"] ignore_missing_imports = true +[[tool.mypy.overrides]] +module = ["trio.*", "tests.trio.*"] +disallow_any_generics = true +disallow_untyped_calls = true +strict_optional = true +warn_return_any = true + [tool.pytest.ini_options] addopts = "--no-cov-on-fail --showlocals --strict-markers" asyncio_mode = "strict" diff --git a/src/hypercorn/middleware/dispatcher.py b/src/hypercorn/middleware/dispatcher.py index 009541b..427e26a 100644 --- a/src/hypercorn/middleware/dispatcher.py +++ b/src/hypercorn/middleware/dispatcher.py @@ -5,7 +5,7 @@ from typing import Callable, Dict from ..asyncio.task_group import TaskGroup -from ..typing import ASGIFramework, Scope +from ..typing import ASGIFramework, ASGIReceiveEvent, Scope MAX_QUEUE_SIZE = 10 @@ -74,7 +74,9 @@ class TrioDispatcherMiddleware(_DispatcherMiddleware): async def _handle_lifespan(self, scope: Scope, receive: Callable, send: Callable) -> None: import trio - self.app_queues = {path: trio.open_memory_channel(MAX_QUEUE_SIZE) for path in self.mounts} + self.app_queues = { + path: trio.open_memory_channel[ASGIReceiveEvent](MAX_QUEUE_SIZE) for path in self.mounts + } self.startup_complete = {path: False for path in self.mounts} self.shutdown_complete = {path: False for path in self.mounts} diff --git a/src/hypercorn/trio/__init__.py b/src/hypercorn/trio/__init__.py index 44a2eb9..0795706 100644 --- a/src/hypercorn/trio/__init__.py +++ b/src/hypercorn/trio/__init__.py @@ -16,7 +16,7 @@ async def serve( config: Config, *, shutdown_trigger: Optional[Callable[..., Awaitable[None]]] = None, - task_status: trio._core._run._TaskStatus = trio.TASK_STATUS_IGNORED, + task_status: trio.TaskStatus = trio.TASK_STATUS_IGNORED, mode: Optional[Literal["asgi", "wsgi"]] = None, ) -> None: """Serve an ASGI framework app given the config. diff --git a/src/hypercorn/trio/lifespan.py b/src/hypercorn/trio/lifespan.py index 4aeba24..087aa83 100644 --- a/src/hypercorn/trio/lifespan.py +++ b/src/hypercorn/trio/lifespan.py @@ -22,14 +22,14 @@ def __init__(self, app: AppWrapper, config: Config, state: LifespanState) -> Non self.config = config self.startup = trio.Event() self.shutdown = trio.Event() - self.app_send_channel, self.app_receive_channel = trio.open_memory_channel( - config.max_app_queue_size - ) + self.app_send_channel, self.app_receive_channel = trio.open_memory_channel[ + ASGIReceiveEvent + ](config.max_app_queue_size) self.state = state self.supported = True async def handle_lifespan( - self, *, task_status: trio._core._run._TaskStatus = trio.TASK_STATUS_IGNORED + self, *, task_status: trio.TaskStatus = trio.TASK_STATUS_IGNORED ) -> None: task_status.started() scope: LifespanScope = { diff --git a/src/hypercorn/trio/run.py b/src/hypercorn/trio/run.py index 0d25368..7c55df1 100644 --- a/src/hypercorn/trio/run.py +++ b/src/hypercorn/trio/run.py @@ -33,7 +33,7 @@ async def worker_serve( *, sockets: Optional[Sockets] = None, shutdown_trigger: Optional[Callable[..., Awaitable[None]]] = None, - task_status: trio._core._run._TaskStatus = trio.TASK_STATUS_IGNORED, + task_status: trio.TaskStatus = trio.TASK_STATUS_IGNORED, ) -> None: config.set_statsd_logger_class(StatsdLogger) @@ -57,7 +57,7 @@ async def worker_serve( sock.listen(config.backlog) ssl_context = config.create_ssl_context() - listeners = [] + listeners: list[trio.SSLListener[trio.SocketStream] | trio.SocketListener] = [] binds = [] for sock in sockets.secure_sockets: listeners.append( diff --git a/src/hypercorn/trio/task_group.py b/src/hypercorn/trio/task_group.py index 044ff85..7fad871 100644 --- a/src/hypercorn/trio/task_group.py +++ b/src/hypercorn/trio/task_group.py @@ -1,6 +1,7 @@ from __future__ import annotations import sys +from contextlib import AbstractAsyncContextManager from types import TracebackType from typing import Any, Awaitable, Callable, Optional @@ -41,8 +42,8 @@ async def _handle( class TaskGroup: def __init__(self) -> None: - self._nursery: Optional[trio._core._run.Nursery] = None - self._nursery_manager: Optional[trio._core._run.NurseryManager] = None + self._nursery: trio.Nursery | None = None + self._nursery_manager: AbstractAsyncContextManager[trio.Nursery] | None = None async def spawn_app( self, @@ -51,7 +52,9 @@ async def spawn_app( scope: Scope, send: Callable[[Optional[ASGISendEvent]], Awaitable[None]], ) -> Callable[[ASGIReceiveEvent], Awaitable[None]]: - app_send_channel, app_receive_channel = trio.open_memory_channel(config.max_app_queue_size) + app_send_channel, app_receive_channel = trio.open_memory_channel[ASGIReceiveEvent]( + config.max_app_queue_size + ) self._nursery.start_soon( _handle, app, diff --git a/src/hypercorn/trio/tcp_server.py b/src/hypercorn/trio/tcp_server.py index 7eb7711..5e2b633 100644 --- a/src/hypercorn/trio/tcp_server.py +++ b/src/hypercorn/trio/tcp_server.py @@ -23,7 +23,7 @@ def __init__( config: Config, context: WorkerContext, state: LifespanState, - stream: trio.abc.Stream, + stream: trio.SSLStream[trio.SocketStream], ) -> None: self.app = app self.config = config diff --git a/src/hypercorn/trio/udp_server.py b/src/hypercorn/trio/udp_server.py index d66b037..566c082 100644 --- a/src/hypercorn/trio/udp_server.py +++ b/src/hypercorn/trio/udp_server.py @@ -1,5 +1,7 @@ from __future__ import annotations +import socket + import trio from .task_group import TaskGroup @@ -19,7 +21,7 @@ def __init__( config: Config, context: WorkerContext, state: LifespanState, - socket: trio.socket.socket, + socket: socket.socket, ) -> None: self.app = app self.config = config @@ -27,9 +29,7 @@ def __init__( self.socket = trio.socket.from_stdlib_socket(socket) self.state = state - async def run( - self, task_status: trio._core._run._TaskStatus = trio.TASK_STATUS_IGNORED - ) -> None: + async def run(self, task_status: trio.TaskStatus = trio.TASK_STATUS_IGNORED) -> None: from ..protocol.quic import QuicProtocol # h3/Quic is an optional part of Hypercorn task_status.started() diff --git a/src/hypercorn/trio/worker_context.py b/src/hypercorn/trio/worker_context.py index dddcf42..1cac17e 100644 --- a/src/hypercorn/trio/worker_context.py +++ b/src/hypercorn/trio/worker_context.py @@ -11,7 +11,7 @@ def _cancel_wrapper(func: Callable[[], Awaitable[None]]) -> Callable[[], Awaitable[None]]: @wraps(func) async def wrapper( - task_status: trio._core._run._TaskStatus = trio.TASK_STATUS_IGNORED, + task_status: trio.TaskStatus = trio.TASK_STATUS_IGNORED, ) -> None: cancel_scope = trio.CancelScope() task_status.started(cancel_scope) diff --git a/tests/test_app_wrappers.py b/tests/test_app_wrappers.py index 4ddce09..0640350 100644 --- a/tests/test_app_wrappers.py +++ b/tests/test_app_wrappers.py @@ -8,7 +8,7 @@ import trio from hypercorn.app_wrappers import _build_environ, InvalidPathError, WSGIWrapper -from hypercorn.typing import ASGISendEvent, ConnectionState, HTTPScope +from hypercorn.typing import ASGIReceiveEvent, ASGISendEvent, ConnectionState, HTTPScope def echo_body(environ: dict, start_response: Callable) -> List[bytes]: @@ -41,8 +41,8 @@ async def test_wsgi_trio() -> None: "extensions": {}, "state": ConnectionState({}), } - send_channel, receive_channel = trio.open_memory_channel(1) - await send_channel.send({"type": "http.request"}) + send_channel, receive_channel = trio.open_memory_channel[ASGIReceiveEvent](1) + await send_channel.send({"type": "http.request"}) # type: ignore messages = [] diff --git a/tests/trio/test_keep_alive.py b/tests/trio/test_keep_alive.py index afbed0f..c570a2a 100644 --- a/tests/trio/test_keep_alive.py +++ b/tests/trio/test_keep_alive.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Callable, Generator +from typing import Awaitable, Callable, cast, Generator import h11 import pytest @@ -10,22 +10,36 @@ from hypercorn.config import Config from hypercorn.trio.tcp_server import TCPServer from hypercorn.trio.worker_context import WorkerContext -from hypercorn.typing import Scope +from hypercorn.typing import ASGIReceiveEvent, ASGISendEvent, Scope from ..helpers import MockSocket +try: + from typing import TypeAlias +except ImportError: + from typing_extensions import TypeAlias + + KEEP_ALIVE_TIMEOUT = 0.01 REQUEST = h11.Request(method="GET", target="/", headers=[(b"host", b"hypercorn")]) +ClientStream: TypeAlias = trio.StapledStream[ + trio.testing.MemorySendStream, trio.testing.MemoryReceiveStream +] + -async def slow_framework(scope: Scope, receive: Callable, send: Callable) -> None: +async def slow_framework( + scope: Scope, + receive: Callable[[], Awaitable[ASGIReceiveEvent]], + send: Callable[[ASGISendEvent], Awaitable[None]], +) -> None: while True: event = await receive() if event["type"] == "http.disconnect": break elif event["type"] == "lifespan.startup": - await send({"type": "lifspan.startup.complete"}) + await send({"type": "lifespan.startup.complete"}) elif event["type"] == "lifespan.shutdown": - await send({"type": "lifspan.shutdown.complete"}) + await send({"type": "lifespan.shutdown.complete"}) elif event["type"] == "http.request" and not event.get("more_body", False): await trio.sleep(2 * KEEP_ALIVE_TIMEOUT) await send( @@ -41,11 +55,12 @@ async def slow_framework(scope: Scope, receive: Callable, send: Callable) -> Non @pytest.fixture(name="client_stream", scope="function") def _client_stream( - nursery: trio._core._run.Nursery, -) -> Generator[trio.testing._memory_streams.MemorySendStream, None, None]: + nursery: trio.Nursery, +) -> Generator[ClientStream, None, None]: config = Config() config.keep_alive_timeout = KEEP_ALIVE_TIMEOUT client_stream, server_stream = trio.testing.memory_stream_pair() + server_stream = cast("trio.SSLStream[trio.SocketStream]", server_stream) server_stream.socket = MockSocket() server = TCPServer(ASGIWrapper(slow_framework), config, WorkerContext(None), {}, server_stream) nursery.start_soon(server.run) @@ -53,9 +68,7 @@ def _client_stream( @pytest.mark.trio -async def test_http1_keep_alive_pre_request( - client_stream: trio.testing._memory_streams.MemorySendStream, -) -> None: +async def test_http1_keep_alive_pre_request(client_stream: ClientStream) -> None: await client_stream.send_all(b"GET") await trio.sleep(2 * KEEP_ALIVE_TIMEOUT) # Only way to confirm closure is to invoke an error @@ -65,23 +78,26 @@ async def test_http1_keep_alive_pre_request( @pytest.mark.trio async def test_http1_keep_alive_during( - client_stream: trio.testing._memory_streams.MemorySendStream, + client_stream: ClientStream, ) -> None: client = h11.Connection(h11.CLIENT) - await client_stream.send_all(client.send(REQUEST)) + # client.send(h11.Request) and client.send(h11.EndOfMessage) only returns bytes. + # Fixed on master/ in the h11 repo, once released the ignore's can be removed. + # See https://github.com/python-hyper/h11/issues/175 + await client_stream.send_all(client.send(REQUEST)) # type: ignore[arg-type] await trio.sleep(2 * KEEP_ALIVE_TIMEOUT) # Key is that this doesn't error - await client_stream.send_all(client.send(h11.EndOfMessage())) + await client_stream.send_all(client.send(h11.EndOfMessage())) # type: ignore[arg-type] @pytest.mark.trio async def test_http1_keep_alive( - client_stream: trio.testing._memory_streams.MemorySendStream, + client_stream: ClientStream, ) -> None: client = h11.Connection(h11.CLIENT) - await client_stream.send_all(client.send(REQUEST)) + await client_stream.send_all(client.send(REQUEST)) # type: ignore[arg-type] await trio.sleep(2 * KEEP_ALIVE_TIMEOUT) - await client_stream.send_all(client.send(h11.EndOfMessage())) + await client_stream.send_all(client.send(h11.EndOfMessage())) # type: ignore[arg-type] while True: event = client.next_event() if event == h11.NEED_DATA: @@ -90,15 +106,15 @@ async def test_http1_keep_alive( elif isinstance(event, h11.EndOfMessage): break client.start_next_cycle() - await client_stream.send_all(client.send(REQUEST)) + await client_stream.send_all(client.send(REQUEST)) # type: ignore[arg-type] await trio.sleep(2 * KEEP_ALIVE_TIMEOUT) # Key is that this doesn't error - await client_stream.send_all(client.send(h11.EndOfMessage())) + await client_stream.send_all(client.send(h11.EndOfMessage())) # type: ignore[arg-type] @pytest.mark.trio async def test_http1_keep_alive_pipelining( - client_stream: trio.testing._memory_streams.MemorySendStream, + client_stream: ClientStream, ) -> None: await client_stream.send_all( b"GET / HTTP/1.1\r\nHost: hypercorn\r\n\r\nGET / HTTP/1.1\r\nHost: hypercorn\r\n\r\n" diff --git a/tests/trio/test_sanity.py b/tests/trio/test_sanity.py index 6410428..bea93f1 100644 --- a/tests/trio/test_sanity.py +++ b/tests/trio/test_sanity.py @@ -1,5 +1,6 @@ from __future__ import annotations +from typing import cast from unittest.mock import Mock, PropertyMock import h2 @@ -24,6 +25,7 @@ @pytest.mark.trio async def test_http1_request(nursery: trio._core._run.Nursery) -> None: client_stream, server_stream = trio.testing.memory_stream_pair() + server_stream = cast("trio.SSLStream[trio.SocketStream]", server_stream) server_stream.socket = MockSocket() server = TCPServer( ASGIWrapper(sanity_framework), Config(), WorkerContext(None), {}, server_stream @@ -31,7 +33,7 @@ async def test_http1_request(nursery: trio._core._run.Nursery) -> None: nursery.start_soon(server.run) client = h11.Connection(h11.CLIENT) await client_stream.send_all( - client.send( + client.send( # type: ignore[arg-type] h11.Request( method="POST", target="/", @@ -43,8 +45,8 @@ async def test_http1_request(nursery: trio._core._run.Nursery) -> None: ) ) ) - await client_stream.send_all(client.send(h11.Data(data=SANITY_BODY))) - await client_stream.send_all(client.send(h11.EndOfMessage())) + await client_stream.send_all(client.send(h11.Data(data=SANITY_BODY))) # type: ignore[arg-type] + await client_stream.send_all(client.send(h11.EndOfMessage())) # type: ignore[arg-type] events = [] while True: event = client.next_event() @@ -77,6 +79,7 @@ async def test_http1_request(nursery: trio._core._run.Nursery) -> None: @pytest.mark.trio async def test_http1_websocket(nursery: trio._core._run.Nursery) -> None: client_stream, server_stream = trio.testing.memory_stream_pair() + server_stream = cast("trio.SSLStream[trio.SocketStream]", server_stream) server_stream.socket = MockSocket() server = TCPServer( ASGIWrapper(sanity_framework), Config(), WorkerContext(None), {}, server_stream @@ -104,8 +107,9 @@ async def test_http1_websocket(nursery: trio._core._run.Nursery) -> None: @pytest.mark.trio async def test_http2_request(nursery: trio._core._run.Nursery) -> None: client_stream, server_stream = trio.testing.memory_stream_pair() + server_stream = cast("trio.SSLStream[trio.SocketStream]", server_stream) server_stream.transport_stream = Mock(return_value=PropertyMock(return_value=MockSocket())) - server_stream.do_handshake = AsyncMock() + server_stream.do_handshake = AsyncMock() # type: ignore[method-assign] server_stream.selected_alpn_protocol = Mock(return_value="h2") server = TCPServer( ASGIWrapper(sanity_framework), Config(), WorkerContext(None), {}, server_stream @@ -161,8 +165,9 @@ async def test_http2_request(nursery: trio._core._run.Nursery) -> None: @pytest.mark.trio async def test_http2_websocket(nursery: trio._core._run.Nursery) -> None: client_stream, server_stream = trio.testing.memory_stream_pair() + server_stream = cast("trio.SSLStream[trio.SocketStream]", server_stream) server_stream.transport_stream = Mock(return_value=PropertyMock(return_value=MockSocket())) - server_stream.do_handshake = AsyncMock() + server_stream.do_handshake = AsyncMock() # type: ignore[method-assign] server_stream.selected_alpn_protocol = Mock(return_value="h2") server = TCPServer( ASGIWrapper(sanity_framework), Config(), WorkerContext(None), {}, server_stream diff --git a/tox.ini b/tox.ini index 3d8fc90..d931bfa 100644 --- a/tox.ini +++ b/tox.ini @@ -49,6 +49,7 @@ basepython = python3.12 deps = mypy pytest + trio commands = mypy src/hypercorn/ tests/ From 6cb9c5cc11c5372d59ffb8348345e308bc2f1067 Mon Sep 17 00:00:00 2001 From: pgjones Date: Sun, 27 Apr 2025 15:50:27 +0100 Subject: [PATCH 150/151] Bugfix correct the dispatcher middleware root path and path The path shouldn't have been adjusted, rather the root_path should have been changed - this matches WSGI whereby the script_name is changed. In addition the changes should have been made in a copied scope to isolate them. --- src/hypercorn/middleware/dispatcher.py | 5 +++-- tests/middleware/test_dispatcher.py | 8 ++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/hypercorn/middleware/dispatcher.py b/src/hypercorn/middleware/dispatcher.py index 427e26a..abe0e7e 100644 --- a/src/hypercorn/middleware/dispatcher.py +++ b/src/hypercorn/middleware/dispatcher.py @@ -20,8 +20,9 @@ async def __call__(self, scope: Scope, receive: Callable, send: Callable) -> Non else: for path, app in self.mounts.items(): if scope["path"].startswith(path): - scope["path"] = scope["path"][len(path) :] or "/" - return await app(scope, receive, send) + local_scope = scope.copy() + local_scope["root_path"] += path + return await app(local_scope, receive, send) await send( { "type": "http.response.start", diff --git a/tests/middleware/test_dispatcher.py b/tests/middleware/test_dispatcher.py index 2d0b9e3..2c6d8a1 100644 --- a/tests/middleware/test_dispatcher.py +++ b/tests/middleware/test_dispatcher.py @@ -40,10 +40,10 @@ async def send(message: dict) -> None: await app({**http_scope, **{"path": "/api/b"}}, None, send) # type: ignore await app({**http_scope, **{"path": "/"}}, None, send) # type: ignore assert sent_events == [ - {"type": "http.response.start", "status": 200, "headers": [(b"content-length", b"7")]}, - {"type": "http.response.body", "body": b"apix-/b"}, - {"type": "http.response.start", "status": 200, "headers": [(b"content-length", b"6")]}, - {"type": "http.response.body", "body": b"api-/b"}, + {"type": "http.response.start", "status": 200, "headers": [(b"content-length", b"13")]}, + {"type": "http.response.body", "body": b"apix-/api/x/b"}, + {"type": "http.response.start", "status": 200, "headers": [(b"content-length", b"10")]}, + {"type": "http.response.body", "body": b"api-/api/b"}, {"type": "http.response.start", "status": 404, "headers": [(b"content-length", b"0")]}, {"type": "http.response.body"}, ] From 64612a16f6c7091f98e4997b2721cdb43650fcb1 Mon Sep 17 00:00:00 2001 From: synodriver Date: Mon, 15 Sep 2025 23:05:15 +0800 Subject: [PATCH 151/151] add support for thread-based worker --- src/hypercorn/__main__.py | 9 ++- src/hypercorn/asyncio/run.py | 6 +- src/hypercorn/config.py | 5 +- src/hypercorn/run.py | 127 ++++++++++++++++++++++------------- 4 files changed, 94 insertions(+), 53 deletions(-) diff --git a/src/hypercorn/__main__.py b/src/hypercorn/__main__.py index aed33b1..bcc59c8 100644 --- a/src/hypercorn/__main__.py +++ b/src/hypercorn/__main__.py @@ -28,6 +28,11 @@ def main(sys_args: Optional[List[str]] = None) -> int: parser.add_argument( "application", help="The application to dispatch to as path.to.module:instance.path" ) + parser.add_argument( + "--worker-type", + help="The worker type to use, process or thread, useful for free-threading python build", + default=sentinel, + ) parser.add_argument("--access-log", help="Deprecated, see access-logfile", default=sentinel) parser.add_argument( "--access-logfile", @@ -218,7 +223,9 @@ def _convert_verify_mode(value: str) -> ssl.VerifyMode: args = parser.parse_args(sys_args or sys.argv[1:]) config = _load_config(args.config) config.application_path = args.application - + + if args.worker_type is not sentinel: + config.worker_type = args.worker_type if args.log_level is not sentinel: config.loglevel = args.log_level if args.access_logformat is not sentinel: diff --git a/src/hypercorn/asyncio/run.py b/src/hypercorn/asyncio/run.py index 93bd7fc..be4a22a 100644 --- a/src/hypercorn/asyncio/run.py +++ b/src/hypercorn/asyncio/run.py @@ -111,7 +111,7 @@ async def _server_callback(reader: asyncio.StreamReader, writer: asyncio.StreamW servers = [] for sock in sockets.secure_sockets: - if config.workers > 1 and platform.system() == "Windows": + if config.workers > 1 and platform.system() == "Windows" and config.worker_class == "process": sock = _share_socket(sock) servers.append( @@ -127,7 +127,7 @@ async def _server_callback(reader: asyncio.StreamReader, writer: asyncio.StreamW await config.log.info(f"Running on https://{bind} (CTRL + C to quit)") for sock in sockets.insecure_sockets: - if config.workers > 1 and platform.system() == "Windows": + if config.workers > 1 and platform.system() == "Windows" and config.worker_class == "process": sock = _share_socket(sock) servers.append( @@ -137,7 +137,7 @@ async def _server_callback(reader: asyncio.StreamReader, writer: asyncio.StreamW await config.log.info(f"Running on http://{bind} (CTRL + C to quit)") for sock in sockets.quic_sockets: - if config.workers > 1 and platform.system() == "Windows": + if config.workers > 1 and platform.system() == "Windows" and config.worker_class == "process": sock = _share_socket(sock) _, protocol = await loop.create_datagram_endpoint( diff --git a/src/hypercorn/config.py b/src/hypercorn/config.py index 71d5b2e..79d3a5f 100644 --- a/src/hypercorn/config.py +++ b/src/hypercorn/config.py @@ -20,7 +20,7 @@ VerifyMode, ) from time import time -from typing import Any, AnyStr, Dict, List, Mapping, Optional, Tuple, Type, Union +from typing import Any, AnyStr, Dict, List, Mapping, Optional, Tuple, Type, Union, Literal from wsgiref.handlers import format_date_time if sys.version_info >= (3, 11): @@ -60,7 +60,8 @@ class Config: _quic_addresses: List[Tuple] = [] _log: Optional[Logger] = None _root_path: str = "" - + + worker_type: Literal["thread", "process"] = "process" access_log_format = '%(h)s %(l)s %(l)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"' accesslog: Union[logging.Logger, str, None] = None alpn_protocols = ["h2", "http/1.1"] diff --git a/src/hypercorn/run.py b/src/hypercorn/run.py index cfe801a..2589c52 100644 --- a/src/hypercorn/run.py +++ b/src/hypercorn/run.py @@ -2,6 +2,7 @@ import platform import signal +import threading import time from multiprocessing import get_context from multiprocessing.connection import wait @@ -9,7 +10,7 @@ from multiprocessing.process import BaseProcess from multiprocessing.synchronize import Event as EventType from pickle import PicklingError -from typing import Any, List +from typing import Any, List, Union from .config import Config, Sockets from .typing import WorkerFunc @@ -50,17 +51,23 @@ def run(config: Config) -> int: # changes, but only when the reloader is being used. load_application(config.application_path, config.wsgi_max_body_size) - ctx = get_context("spawn") - - active = True - shutdown_event = ctx.Event() - - def shutdown(*args: Any) -> None: - nonlocal active, shutdown_event - shutdown_event.set() - active = False + active = True + if config.worker_type == "process": + ctx = get_context("spawn") + shutdown_event = ctx.Event() + def shutdown(*args: Any) -> None: + nonlocal active, shutdown_event + shutdown_event.set() + active = False + else: + ctx = None # multithreading mode does not need a context + shutdown_event = threading.Event() + def shutdown(*args: Any) -> None: + nonlocal active, shutdown_event + shutdown_event.set() + active = False - processes: List[BaseProcess] = [] + processes: List[Union[BaseProcess, threading.Thread]] = [] while active: # Ignore SIGINT before creating the processes, so that they # inherit the signal handling. This means that the shutdown @@ -75,19 +82,28 @@ def shutdown(*args: Any) -> None: if config.use_reloader: files = files_to_watch() - while True: - finished = wait((process.sentinel for process in processes), timeout=1) - updated = check_for_updates(files) - if updated: - shutdown_event.set() - for process in processes: - process.join() - shutdown_event.clear() - break - if len(finished) > 0: - break + if config.worker_type == "process": + while True: + finished = wait((process.sentinel for process in processes), timeout=1) + updated = check_for_updates(files) + if updated: + shutdown_event.set() + for process in processes: + process.join() + shutdown_event.clear() + break + if len(finished) > 0: + break + else: + raise RuntimeError("Reloading not supported with threads") else: - wait(process.sentinel for process in processes) + if config.worker_type == "process": + wait(process.sentinel for process in processes) + else: + while True: + time.sleep(0.1) + if any(not process.is_alive() for process in processes): + break exitcode = _join_exited(processes) if exitcode != 0: @@ -95,7 +111,8 @@ def shutdown(*args: Any) -> None: active = False for process in processes: - process.terminate() + if isinstance(process, BaseProcess): + process.terminate() exitcode = _join_exited(processes) if exitcode != 0 else exitcode @@ -109,37 +126,53 @@ def shutdown(*args: Any) -> None: def _populate( - processes: List[BaseProcess], + processes: List[Union[BaseProcess, threading.Thread]], config: Config, worker_func: WorkerFunc, sockets: Sockets, shutdown_event: EventType, ctx: BaseContext, ) -> None: - for _ in range(config.workers - len(processes)): - process = ctx.Process( # type: ignore - target=worker_func, - kwargs={"config": config, "shutdown_event": shutdown_event, "sockets": sockets}, - ) - process.daemon = True - try: - process.start() - except PicklingError as error: - raise RuntimeError( - "Cannot pickle the config, see https://docs.python.org/3/library/pickle.html#pickle-picklable" # noqa: E501 - ) from error - processes.append(process) - if platform.system() == "Windows": - time.sleep(0.1) - - -def _join_exited(processes: List[BaseProcess]) -> int: + if config.worker_type == "process": + for _ in range(config.workers - len(processes)): + process = ctx.Process( # type: ignore + target=worker_func, + kwargs={"config": config, "shutdown_event": shutdown_event, "sockets": sockets}, + ) + process.daemon = True + try: + process.start() + except PicklingError as error: + raise RuntimeError( + "Cannot pickle the config, see https://docs.python.org/3/library/pickle.html#pickle-picklable" # noqa: E501 + ) from error + processes.append(process) + if platform.system() == "Windows": + time.sleep(0.1) + else: + for _ in range(config.workers - len(processes)): + thread = threading.Thread( + target=worker_func, + kwargs={"config": config, "shutdown_event": shutdown_event, "sockets": sockets}, + ) + thread.daemon = True + thread.start() + processes.append(thread) + if platform.system() == "Windows": + time.sleep(0.1) # let's simulate the same behavior as processes, in case something wrong happens + + +def _join_exited(processes: List[Union[BaseProcess, threading.Thread]]) -> int: exitcode = 0 for index in reversed(range(len(processes))): worker = processes[index] - if worker.exitcode is not None: - worker.join() - exitcode = worker.exitcode if exitcode == 0 else exitcode + if isinstance(worker, BaseProcess): + if worker.exitcode is not None: + worker.join() + exitcode = worker.exitcode if exitcode == 0 else exitcode + del processes[index] + else: + if worker.is_alive(): + worker.join() del processes[index] - return exitcode