Repository: urllib3/urllib3

Issue:
Migrate run_server_in_thread and run_server_and_proxy_in_thread to Hypercorn
The first blocker to add HTTP/2 support to urllib3 is to have a test server that supports HTTP/2. Since https://github.com/tornadoweb/tornado/issues/1438, we're migrating to Hypercorn. This issue is about migrating all uses of `run_tornado_loop_in_thread` in test/conftest.py.

- [x] #3198 
- [x] #3222


Discussion:
From our discussion it seems like proxies might be much harder than a regular server. Let's split the proxy issue off?

Resolving patch:
diff --git a/dummyserver/asgi_proxy.py b/dummyserver/asgi_proxy.py
new file mode 100755
--- /dev/null
+++ b/dummyserver/asgi_proxy.py
@@ -0,0 +1,108 @@
+from __future__ import annotations
+
+import typing
+
+import httpx
+import trio
+from hypercorn.typing import (
+    ASGIReceiveCallable,
+    ASGISendCallable,
+    HTTPResponseBodyEvent,
+    HTTPResponseStartEvent,
+    HTTPScope,
+    Scope,
+)
+
+
+async def _read_body(receive: ASGIReceiveCallable) -> bytes:
+    body = bytearray()
+    body_consumed = False
+    while not body_consumed:
+        event = await receive()
+        if event["type"] == "http.request":
+            body.extend(event["body"])
+            body_consumed = not event["more_body"]
+        else:
+            raise ValueError(event["type"])
+    return bytes(body)
+
+
+async def absolute_uri(
+    scope: HTTPScope,
+    receive: ASGIReceiveCallable,
+    send: ASGISendCallable,
+) -> None:
+    async with httpx.AsyncClient() as client:
+        client_response = await client.request(
+            method=scope["method"],
+            url=scope["path"],
+            headers=list(scope["headers"]),
+            content=await _read_body(receive),
+        )
+
+    headers = []
+    for header in (
+        "Date",
+        "Cache-Control",
+        "Server",
+        "Content-Type",
+        "Location",
+    ):
+        v = client_response.headers.get(header)
+        if v:
+            headers.append((header.encode(), v.encode()))
+    headers.append((b"Content-Length", str(len(client_response.content)).encode()))
+
+    await send(
+        HTTPResponseStartEvent(
+            type="http.response.start",
+            status=client_response.status_code,
+            headers=headers,
+        )
+    )
+    await send(
+        HTTPResponseBodyEvent(
+            type="http.response.body",
+            body=client_response.content,
+            more_body=False,
+        )
+    )
+
+
+async def connect(scope: HTTPScope, send: ASGISendCallable) -> None:
+    async def start_forward(
+        reader: trio.SocketStream, writer: trio.SocketStream
+    ) -> None:
+        while True:
+            try:
+                data = await reader.receive_some(4096)
+            except trio.ClosedResourceError:
+                break
+            if not data:
+                break
+            await writer.send_all(data)
+        await writer.aclose()
+
+    host, port = scope["path"].split(":")
+    upstream = await trio.open_tcp_stream(host, int(port))
+
+    await send({"type": "http.response.start", "status": 200, "headers": []})
+    await send({"type": "http.response.body", "body": b"", "more_body": True})
+
+    client = typing.cast(trio.SocketStream, scope["extensions"]["_transport"])
+
+    async with trio.open_nursery(strict_exception_groups=True) as nursery:
+        nursery.start_soon(start_forward, client, upstream)
+        nursery.start_soon(start_forward, upstream, client)
+
+
+async def proxy_app(
+    scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable
+) -> None:
+    assert scope["type"] == "http"
+    if scope["method"] in ["GET", "POST"]:
+        await absolute_uri(scope, receive, send)
+    elif scope["method"] == "CONNECT":
+        await connect(scope, send)
+    else:
+        raise ValueError(scope["method"])
diff --git a/dummyserver/hypercornserver.py b/dummyserver/hypercornserver.py
--- a/dummyserver/hypercornserver.py
+++ b/dummyserver/hypercornserver.py
@@ -9,6 +9,7 @@
 
 import hypercorn
 import hypercorn.trio
+import hypercorn.typing
 import trio
 from quart_trio import QuartTrio
 
@@ -41,7 +42,7 @@ async def _start_server(
 
 @contextlib.contextmanager
 def run_hypercorn_in_thread(
-    config: hypercorn.Config, app: QuartTrio
+    config: hypercorn.Config, app: hypercorn.typing.ASGIFramework
 ) -> Generator[None, None, None]:
     ready_event = threading.Event()
     shutdown_event = threading.Event()


Test patch:
diff --git a/test/conftest.py b/test/conftest.py
--- a/test/conftest.py
+++ b/test/conftest.py
@@ -1,6 +1,5 @@
 from __future__ import annotations
 
-import asyncio
 import contextlib
 import socket
 import ssl
@@ -10,18 +9,12 @@
 import hypercorn
 import pytest
 import trustme
-from tornado import web
 
 from dummyserver.app import hypercorn_app
-from dummyserver.handlers import TestingApp
+from dummyserver.asgi_proxy import proxy_app
 from dummyserver.hypercornserver import run_hypercorn_in_thread
-from dummyserver.proxy import ProxyHandler
 from dummyserver.testcase import HTTPSHypercornDummyServerTestCase
-from dummyserver.tornadoserver import (
-    HAS_IPV6,
-    run_tornado_app,
-    run_tornado_loop_in_thread,
-)
+from dummyserver.tornadoserver import HAS_IPV6
 from urllib3.util import ssl_
 from urllib3.util.url import parse_url
 
@@ -111,26 +104,25 @@ def run_server_and_proxy_in_thread(
     server_certs = _write_cert_to_dir(server_cert, tmpdir)
     proxy_certs = _write_cert_to_dir(proxy_cert, tmpdir, "proxy")
 
-    with run_tornado_loop_in_thread() as io_loop:
-
-        async def run_app() -> tuple[ServerConfig, ServerConfig]:
-            app = web.Application([(r".*", TestingApp)])
-            server_app, port = run_tornado_app(app, server_certs, "https", "localhost")
-            server_config = ServerConfig("https", "localhost", port, ca_cert_path)
-
-            proxy = web.Application([(r".*", ProxyHandler)])
-            proxy_app, proxy_port = run_tornado_app(
-                proxy, proxy_certs, proxy_scheme, proxy_host
-            )
-            proxy_config = ServerConfig(
-                proxy_scheme, proxy_host, proxy_port, ca_cert_path
-            )
-            return proxy_config, server_config
-
-        proxy_config, server_config = asyncio.run_coroutine_threadsafe(
-            run_app(), io_loop.asyncio_loop  # type: ignore[attr-defined]
-        ).result()
-        yield (proxy_config, server_config)
+    with contextlib.ExitStack() as stack:
+        server_config = hypercorn.Config()
+        server_config.certfile = server_certs["certfile"]
+        server_config.keyfile = server_certs["keyfile"]
+        server_config.bind = ["localhost:0"]
+        stack.enter_context(run_hypercorn_in_thread(server_config, hypercorn_app))
+        port = typing.cast(int, parse_url(server_config.bind[0]).port)
+
+        proxy_config = hypercorn.Config()
+        proxy_config.certfile = proxy_certs["certfile"]
+        proxy_config.keyfile = proxy_certs["keyfile"]
+        proxy_config.bind = [f"{proxy_host}:0"]
+        stack.enter_context(run_hypercorn_in_thread(proxy_config, proxy_app))
+        proxy_port = typing.cast(int, parse_url(proxy_config.bind[0]).port)
+
+        yield (
+            ServerConfig(proxy_scheme, proxy_host, proxy_port, ca_cert_path),
+            ServerConfig("https", "localhost", port, ca_cert_path),
+        )
 
 
 @pytest.fixture(params=["localhost", "127.0.0.1", "::1"])