Repository: urllib3/urllib3

Issue:
Fix "DeprecationWarning: There is no current event loop" in test suite
Starting with Python 3.10, using `asyncio.get_event_loop()` in a synchronous context is [deprecated in favor of `asyncio.run`](https://bugs.python.org/issue39529) and will stop working eventually. This is why we get a warning when calling `tornado.ioloop.IOLoop.current()` in our test suite: it calls `asyncio.get_event_loop()` under the hood.

Tornado has always encouraged users to do what we do: setup the server synchronously, and then start it in a background thread. This was quite cool as we did not have to worry about async at all. To address the Python 3.10 deprecation, [Tornado 6.2](https://www.tornadoweb.org/en/stable/releases/v6.2.0.html) now reluctantly encourages calling `asyncio.run()` and doing the setup in async code. However, just making `run_tornado_app` async won't be enough in our case because our synchronous test code needs to know the port number of the server. And retrieving that value from the background thread is likely to be annoying.

What we can do instead is partially setup the server synchronously (and thus get the port number) and then finalize setup in async. It does require some synchronization but I now have a working proof-of-concept that passes even with `-Werror`:

```python3
import asyncio
import threading

import tornado.web

import urllib3


class MainHandler(tornado.web.RequestHandler):
    def get(self):
        self.write("Hello, world")


class TornadoServerThread(threading.Thread):
    def __init__(self):
        super().__init__()
        app = tornado.web.Application([(r"/", MainHandler)])

        self.server = tornado.httpserver.HTTPServer(app)
        self.sockets = tornado.netutil.bind_sockets(None, address="localhost")
        self.port = self.sockets[0].getsockname()[1]

        self.started = threading.Event()
        self.stopped = asyncio.Event()

    async def main(self):
        self._loop = asyncio.get_event_loop()
        self.server.add_sockets(self.sockets)
        self.started.set()
        await self.stopped.wait()

    def run(self):
        print("run")
        asyncio.run(self.main())

    def stop(self):
        print("stop")
        self._loop.call_soon_threadsafe(self.stopped.set)


t = TornadoServerThread()
t.start()
t.started.wait()
print(t.port)
print(urllib3.request("GET", f"http://localhost:{t.port}").data)
t.stop()
```

(I initially tried to use `IOLoop(make_current=False)` as suggested in https://github.com/tornadoweb/tornado/issues/3156 but did not manage to make it work without warnings.)

This issue is about taking this proof-of-concept (or coming with a better one) and use it in urllib3's test suite. We should then verify that running with Python 3.10 and Tornado 6.2 does not raise any deprecation warning.


Discussion:
Will need to be something like this instead:

```python
import asyncio
import threading

import tornado.web

import urllib3


class MainHandler(tornado.web.RequestHandler):
    def get(self):
        self.write("Hello, world")


class TornadoServerThread(threading.Thread):
    def __init__(self):
        super().__init__()
        self.started = threading.Event()

    async def main(self):
        app = tornado.web.Application([(r"/", MainHandler)])

        self.server = tornado.httpserver.HTTPServer(app)
        self.sockets = tornado.netutil.bind_sockets(None, address="localhost")
        self.port = self.sockets[0].getsockname()[1]
        self._loop = asyncio.get_running_loop()
        self.server.add_sockets(self.sockets)
        self.started.set()
        self.stopped = asyncio.Event()
        await self.stopped.wait()

    def run(self):
        print("run")
        asyncio.run(self.main())

    def stop(self):
        print("stop")
        self._loop.call_soon_threadsafe(self.stopped.set)
        self.join()
```
@graingert Right putting more code in the main function is likely more elegant. (I would still initialize `self.stopped` in the constructor though.) But why do you say it *needs* to be like this?
`asyncio.Event()` calls get_event_loop prior to 3.10
But that's OK, the warnings only happen with Python 3.10+
But you want the code to work on all python versions
Oh, I get it now! On older versions we would have two different loops which will break. Thanks for the help and patience. :+1:

Resolving patch:
diff --git a/dummyserver/server.py b/dummyserver/server.py
--- a/dummyserver/server.py
+++ b/dummyserver/server.py
@@ -4,6 +4,11 @@
 Dummy server used for unit testing.
 """
 
+from __future__ import annotations
+
+import asyncio
+import concurrent.futures
+import contextlib
 import logging
 import os
 import socket
@@ -11,8 +16,9 @@
 import sys
 import threading
 import warnings
+from collections.abc import Coroutine, Generator
 from datetime import datetime
-from typing import Any, Callable, Dict, Optional, Tuple, Union
+from typing import TYPE_CHECKING, Any, Callable, TypeVar
 
 import tornado.httpserver
 import tornado.ioloop
@@ -25,10 +31,15 @@
 from urllib3.exceptions import HTTPWarning
 from urllib3.util import ALPN_PROTOCOLS, resolve_cert_reqs, resolve_ssl_version
 
+if TYPE_CHECKING:
+    from typing_extensions import ParamSpec
+
+    P = ParamSpec("P")
+
 log = logging.getLogger(__name__)
 
 CERTS_PATH = os.path.join(os.path.dirname(__file__), "certs")
-DEFAULT_CERTS: Dict[str, Any] = {
+DEFAULT_CERTS: dict[str, Any] = {
     "certfile": os.path.join(CERTS_PATH, "server.crt"),
     "keyfile": os.path.join(CERTS_PATH, "server.key"),
     "cert_reqs": ssl.CERT_OPTIONAL,
@@ -106,7 +117,7 @@ def __init__(
         self,
         socket_handler: Callable[[socket.socket], None],
         host: str = "localhost",
-        ready_event: Optional[threading.Event] = None,
+        ready_event: threading.Event | None = None,
     ) -> None:
         super().__init__()
         self.daemon = True
@@ -144,7 +155,7 @@ def ssl_options_to_context(  # type: ignore[no-untyped-def]
     certfile=None,
     server_side=None,
     cert_reqs=None,
-    ssl_version: Optional[Union[str, int]] = None,
+    ssl_version: str | int | None = None,
     ca_certs=None,
     do_handshake_on_connect=None,
     suppress_ragged_eofs=None,
@@ -172,20 +183,18 @@ def ssl_options_to_context(  # type: ignore[no-untyped-def]
     return ctx
 
 
-def run_tornado_app(  # type: ignore[no-untyped-def]
+def run_tornado_app(
     app: tornado.web.Application,
-    io_loop: tornado.ioloop.IOLoop,
-    certs,
+    certs: dict[str, Any] | None,
     scheme: str,
     host: str,
-) -> Tuple[tornado.httpserver.HTTPServer, int]:
-    assert io_loop == tornado.ioloop.IOLoop.current()
-
+) -> tuple[tornado.httpserver.HTTPServer, int]:
     # We can't use fromtimestamp(0) because of CPython issue 29097, so we'll
     # just construct the datetime object directly.
     app.last_req = datetime(1970, 1, 1)  # type: ignore[attr-defined]
 
     if scheme == "https":
+        assert certs is not None
         ssl_opts = ssl_options_to_context(**certs)
         http_server = tornado.httpserver.HTTPServer(app, ssl_options=ssl_opts)
     else:
@@ -197,31 +206,11 @@ def run_tornado_app(  # type: ignore[no-untyped-def]
     return http_server, port
 
 
-def run_loop_in_thread(io_loop: tornado.ioloop.IOLoop) -> threading.Thread:
-    t = threading.Thread(target=io_loop.start)
-    t.start()
-    return t
-
-
-def get_unreachable_address() -> Tuple[str, int]:
+def get_unreachable_address() -> tuple[str, int]:
     # reserved as per rfc2606
     return ("something.invalid", 54321)
 
 
-if __name__ == "__main__":
-    # For debugging dummyserver itself - python -m dummyserver.server
-    from .handlers import TestingApp
-
-    host = "127.0.0.1"
-
-    io_loop = tornado.ioloop.IOLoop.current()
-    app = tornado.web.Application([(r".*", TestingApp)])
-    server, port = run_tornado_app(app, io_loop, None, "http", host)
-    server_thread = run_loop_in_thread(io_loop)
-
-    print(f"Listening on http://{host}:{port}")
-
-
 def encrypt_key_pem(private_key_pem: trustme.Blob, password: bytes) -> trustme.Blob:
     private_key = serialization.load_pem_private_key(
         private_key_pem.bytes(), password=None, backend=default_backend()
@@ -232,3 +221,77 @@ def encrypt_key_pem(private_key_pem: trustme.Blob, password: bytes) -> trustme.B
         serialization.BestAvailableEncryption(password),
     )
     return trustme.Blob(encrypted_key)
+
+
+R = TypeVar("R")
+
+
+def _run_and_close_tornado(
+    async_fn: Callable[P, Coroutine[Any, Any, R]], *args: P.args, **kwargs: P.kwargs
+) -> R:
+    tornado_loop = None
+
+    async def inner_fn() -> R:
+        nonlocal tornado_loop
+        tornado_loop = tornado.ioloop.IOLoop.current()
+        return await async_fn(*args, **kwargs)
+
+    try:
+        return asyncio.run(inner_fn())
+    finally:
+        tornado_loop.close(all_fds=True)  # type: ignore[union-attr]
+
+
+@contextlib.contextmanager
+def run_loop_in_thread() -> Generator[tornado.ioloop.IOLoop, None, None]:
+    loop_started: concurrent.futures.Future[
+        tuple[tornado.ioloop.IOLoop, asyncio.Event]
+    ] = concurrent.futures.Future()
+    with concurrent.futures.ThreadPoolExecutor(
+        1, thread_name_prefix="test IOLoop"
+    ) as tpe:
+
+        async def run() -> None:
+            io_loop = tornado.ioloop.IOLoop.current()
+            stop_event = asyncio.Event()
+            loop_started.set_result((io_loop, stop_event))
+            await stop_event.wait()
+
+        # run asyncio.run in a thread and collect exceptions from *either*
+        # the loop failing to start, or failing to close
+        ran = tpe.submit(_run_and_close_tornado, run)  # type: ignore[arg-type]
+        for f in concurrent.futures.as_completed((loop_started, ran)):  # type: ignore[misc]
+            if f is loop_started:
+                io_loop, stop_event = loop_started.result()
+                try:
+                    yield io_loop
+                finally:
+                    io_loop.add_callback(stop_event.set)
+
+            elif f is ran:
+                # if this is the first iteration the loop failed to start
+                # if it's the second iteration the loop has finished or
+                # the loop failed to close and we need to raise the exception
+                ran.result()
+                return
+
+
+def main() -> int:
+    # For debugging dummyserver itself - python -m dummyserver.server
+    from .handlers import TestingApp
+
+    host = "127.0.0.1"
+
+    async def amain() -> int:
+        app = tornado.web.Application([(r".*", TestingApp)])
+        server, port = run_tornado_app(app, None, "http", host)
+
+        print(f"Listening on http://{host}:{port}")
+        await asyncio.Event().wait()
+        return 0
+
+    return asyncio.run(amain())
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/dummyserver/testcase.py b/dummyserver/testcase.py
--- a/dummyserver/testcase.py
+++ b/dummyserver/testcase.py
@@ -1,7 +1,8 @@
+import asyncio
+import contextlib
 import socket
 import ssl
 import threading
-from contextlib import contextmanager
 from typing import Any, Callable, ClassVar, Dict, Generator, Iterable, Optional, Union
 
 import pytest
@@ -160,21 +161,25 @@ class HTTPDummyServerTestCase:
     server: ClassVar[httpserver.HTTPServer]
     port: ClassVar[int]
     server_thread: ClassVar[threading.Thread]
+    _stack: ClassVar[contextlib.ExitStack]
 
     @classmethod
     def _start_server(cls) -> None:
-        cls.io_loop = ioloop.IOLoop.current()
-        app = web.Application([(r".*", TestingApp)])
-        cls.server, cls.port = run_tornado_app(
-            app, cls.io_loop, cls.certs, cls.scheme, cls.host
-        )
-        cls.server_thread = run_loop_in_thread(cls.io_loop)
+        with contextlib.ExitStack() as stack:
+            io_loop = stack.enter_context(run_loop_in_thread())
+
+            async def run_app() -> None:
+                app = web.Application([(r".*", TestingApp)])
+                cls.server, cls.port = run_tornado_app(
+                    app, cls.certs, cls.scheme, cls.host
+                )
+
+            asyncio.run_coroutine_threadsafe(run_app(), io_loop.asyncio_loop).result()  # type: ignore[attr-defined]
+            cls._stack = stack.pop_all()
 
     @classmethod
     def _stop_server(cls) -> None:
-        cls.io_loop.add_callback(cls.server.stop)
-        cls.io_loop.add_callback(cls.io_loop.stop)
-        cls.server_thread.join()
+        cls._stack.close()
 
     @classmethod
     def setup_class(cls) -> None:
@@ -224,44 +229,43 @@ class HTTPDummyProxyTestCase:
     bad_ca_path: ClassVar[str] = ""
 
     server_thread: ClassVar[threading.Thread]
+    _stack: ClassVar[contextlib.ExitStack]
 
     @classmethod
     def setup_class(cls) -> None:
-        cls.io_loop = ioloop.IOLoop.current()
-
-        app = web.Application([(r".*", TestingApp)])
-        cls.http_server, cls.http_port = run_tornado_app(
-            app, cls.io_loop, None, "http", cls.http_host
-        )
-
-        app = web.Application([(r".*", TestingApp)])
-        cls.https_server, cls.https_port = run_tornado_app(
-            app, cls.io_loop, cls.https_certs, "https", cls.http_host
-        )
-
-        app = web.Application([(r".*", ProxyHandler)])
-        cls.proxy_server, cls.proxy_port = run_tornado_app(
-            app, cls.io_loop, None, "http", cls.proxy_host
-        )
-
-        upstream_ca_certs = cls.https_certs.get("ca_certs")
-        app = web.Application(
-            [(r".*", ProxyHandler)], upstream_ca_certs=upstream_ca_certs
-        )
-        cls.https_proxy_server, cls.https_proxy_port = run_tornado_app(
-            app, cls.io_loop, cls.https_certs, "https", cls.proxy_host
-        )
-
-        cls.server_thread = run_loop_in_thread(cls.io_loop)
+        with contextlib.ExitStack() as stack:
+            io_loop = stack.enter_context(run_loop_in_thread())
+
+            async def run_app() -> None:
+                app = web.Application([(r".*", TestingApp)])
+                cls.http_server, cls.http_port = run_tornado_app(
+                    app, None, "http", cls.http_host
+                )
+
+                app = web.Application([(r".*", TestingApp)])
+                cls.https_server, cls.https_port = run_tornado_app(
+                    app, cls.https_certs, "https", cls.http_host
+                )
+
+                app = web.Application([(r".*", ProxyHandler)])
+                cls.proxy_server, cls.proxy_port = run_tornado_app(
+                    app, None, "http", cls.proxy_host
+                )
+
+                upstream_ca_certs = cls.https_certs.get("ca_certs")
+                app = web.Application(
+                    [(r".*", ProxyHandler)], upstream_ca_certs=upstream_ca_certs
+                )
+                cls.https_proxy_server, cls.https_proxy_port = run_tornado_app(
+                    app, cls.https_certs, "https", cls.proxy_host
+                )
+
+            asyncio.run_coroutine_threadsafe(run_app(), io_loop.asyncio_loop).result()  # type: ignore[attr-defined]
+            cls._stack = stack.pop_all()
 
     @classmethod
     def teardown_class(cls) -> None:
-        cls.io_loop.add_callback(cls.http_server.stop)
-        cls.io_loop.add_callback(cls.https_server.stop)
-        cls.io_loop.add_callback(cls.proxy_server.stop)
-        cls.io_loop.add_callback(cls.https_proxy_server.stop)
-        cls.io_loop.add_callback(cls.io_loop.stop)
-        cls.server_thread.join()
+        cls._stack.close()
 
 
 @pytest.mark.skipif(not HAS_IPV6, reason="IPv6 not available")
@@ -294,7 +298,7 @@ class ConnectionMarker:
     MARK_FORMAT = b"$#MARK%04x*!"
 
     @classmethod
-    @contextmanager
+    @contextlib.contextmanager
     def mark(cls, monkeypatch: pytest.MonkeyPatch) -> Generator[None, None, None]:
         """
         Mark connections under in that context.


Test patch:
diff --git a/test/conftest.py b/test/conftest.py
--- a/test/conftest.py
+++ b/test/conftest.py
@@ -1,14 +1,13 @@
+import asyncio
 import contextlib
-import platform
 import socket
 import ssl
-import sys
 from pathlib import Path
 from typing import AbstractSet, Any, Dict, Generator, NamedTuple, Optional, Tuple
 
 import pytest
 import trustme
-from tornado import ioloop, web
+from tornado import web
 
 from dummyserver.handlers import TestingApp
 from dummyserver.proxy import ProxyHandler
@@ -19,15 +18,6 @@
 from .tz_stub import stub_timezone_ctx
 
 
-# The Python 3.8+ default loop on Windows breaks Tornado
-@pytest.fixture(scope="session", autouse=True)
-def configure_windows_event_loop() -> None:
-    if sys.version_info >= (3, 8) and platform.system() == "Windows":
-        import asyncio
-
-        asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())  # type: ignore[attr-defined]
-
-
 class ServerConfig(NamedTuple):
     scheme: str
     host: str
@@ -61,16 +51,17 @@ def run_server_in_thread(
     ca.cert_pem.write_to_path(ca_cert_path)
     server_certs = _write_cert_to_dir(server_cert, tmpdir)
 
-    io_loop = ioloop.IOLoop.current()
-    app = web.Application([(r".*", TestingApp)])
-    server, port = run_tornado_app(app, io_loop, server_certs, scheme, host)
-    server_thread = run_loop_in_thread(io_loop)
+    with run_loop_in_thread() as io_loop:
 
-    yield ServerConfig("https", host, port, ca_cert_path)
+        async def run_app() -> int:
+            app = web.Application([(r".*", TestingApp)])
+            server, port = run_tornado_app(app, server_certs, scheme, host)
+            return port
 
-    io_loop.add_callback(server.stop)
-    io_loop.add_callback(io_loop.stop)
-    server_thread.join()
+        port = asyncio.run_coroutine_threadsafe(
+            run_app(), io_loop.asyncio_loop  # type: ignore[attr-defined]
+        ).result()
+        yield ServerConfig("https", host, port, ca_cert_path)
 
 
 @contextlib.contextmanager
@@ -88,26 +79,26 @@ 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")
 
-    io_loop = ioloop.IOLoop.current()
-    app = web.Application([(r".*", TestingApp)])
-    server_app, port = run_tornado_app(app, io_loop, 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, io_loop, proxy_certs, proxy_scheme, proxy_host
-    )
-    proxy_config = ServerConfig(proxy_scheme, proxy_host, proxy_port, ca_cert_path)
+    with run_loop_in_thread() as io_loop:
 
-    loop_thread = run_loop_in_thread(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)
 
-    yield (proxy_config, server_config)
-
-    io_loop.add_callback(server_app.stop)
-    io_loop.add_callback(proxy_app.stop)
-    io_loop.add_callback(io_loop.stop)
+            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
 
-    loop_thread.join()
+        proxy_config, server_config = asyncio.run_coroutine_threadsafe(
+            run_app(), io_loop.asyncio_loop  # type: ignore[attr-defined]
+        ).result()
+        yield (proxy_config, server_config)
 
 
 @pytest.fixture(params=["localhost", "127.0.0.1", "::1"])