Repository: tornadoweb/tornado (python)

Issue:
run_sync raises TimeoutError on explicit event-loop stop
When a coroutine passed to `run_sync` doesn't finish because the event loop is stopped explicitly, the error raised is a somewhat confusing:

> TimeoutError: Operation timed out after None seconds


Repro:

```python
import asyncio
from tornado.ioloop import IOLoop

async def main():
    await asyncio.sleep(1)
    # ask the loop to stop
    IOLoop.current().stop()
    # coroutine doesn't finish
    await asyncio.sleep(10)

if __name__ == "__main__":
    IOLoop.current().run_sync(main)
```

I'm not even sure it needs to not be a TimeoutError, but the message should probably at least distinguish between the timeout having triggered and the loop stopping for other reasons. Right now, [this bit](https://github.com/tornadoweb/tornado/blob/385fd8823db043b787f2ca38c46628739deff6b4/tornado/ioloop.py#L534-L535) assumes a timeout is the only way the loop might stop before the given coroutine exits.


Pull request description:
improve error message when run_sync does not complete for reasons other than timeout
keeps TimeoutError for maximum compatibility in case any existing code is handling this case (I just wrote some, but may not keep it), but e.g. RuntimeError or other might be most appropriate.

closes #3435 

Patch:
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 680bc2c2..8c4d515f 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -17,6 +17,8 @@ on:
   workflow_dispatch:
     # Allow this workflow to be run manually (pushing to testpypi instead of pypi)
 
+permissions: {}
+
 env:
   python-version: '3.9'
 
@@ -26,6 +28,8 @@ jobs:
     runs-on: ubuntu-22.04
     steps:
       - uses: actions/checkout@v4
+        with:
+          persist-credentials: false
       - uses: actions/setup-python@v5
         name: Install Python
         with:
@@ -47,22 +51,19 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        os: [ubuntu-22.04, windows-2022, macos-15]
+        os: [ubuntu-22.04, ubuntu-22.04-arm, windows-2022, macos-15]
 
     steps:
       - uses: actions/checkout@v4
+        with:
+          persist-credentials: false
       - uses: actions/setup-python@v5
         name: Install Python
         with:
           python-version: ${{ env.python-version }}
-      - name: Set up QEMU
-        if: runner.os == 'Linux'
-        uses: docker/setup-qemu-action@v3
-        with:
-          platforms: all
 
       - name: Build wheels
-        uses: pypa/cibuildwheel@v2.18
+        uses: pypa/cibuildwheel@v2.22
 
       - name: Audit ABI3 compliance
         # This may be moved into cibuildwheel itself in the future. See
diff --git a/docs/asyncio.rst b/docs/asyncio.rst
index fc737451..1f90bf68 100644
--- a/docs/asyncio.rst
+++ b/docs/asyncio.rst
@@ -3,3 +3,13 @@
 
 .. automodule:: tornado.platform.asyncio
    :members:
+
+
+   ..
+      AnyThreadEventLoopPolicy is created dynamically in getattr, so
+      introspection won't find it automatically. This has the unfortunate
+      side effect of moving it to the top of the page but it's better than
+      having it missing entirely.
+
+   .. autoclass:: AnyThreadEventLoopPolicy
+      :members:
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index 755fb8ab..a4a79997 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -11,23 +11,21 @@ test-command = "python -m tornado.test"
 
 [tool.cibuildwheel.macos]
 archs = "x86_64 universal2"
-# The arm portion of a universal wheel is a cross-compile and cannot
-# be tested on an x86 host. This must be configured explicitly to silence
-# a warning.
-test-skip = "*_universal2:arm64"
 
 [tool.cibuildwheel.windows]
+archs = "AMD64 x86 ARM64"
 # TODO: figure out what's going on with these occasional log messages.
 test-command = "python -m tornado.test --fail-if-logs=false"
+# Arm builds are cross-compiled and cannot be tested on the x86 host
+test-skip = "*-win_arm64"
 
 [tool.cibuildwheel.linux]
-# Build wheels for the native platform (i.e. x86) as well as an emulated
-# build for aarch64.
-archs = "auto aarch64"
-
-[[tool.cibuildwheel.overrides]]
-# The emulated arm build is quite slow, so only run a portion of the test
-# suite. websocket_test is the most platform-dependent part of the tests
-# because it uses the C speedups module.
-select = "*linux_aarch64"
-test-command = "python -m tornado.test tornado.test.websocket_test"
+# This configuration has a bug which appears unrelated to Tornado:
+# https://github.com/python/cpython/issues/130522
+# If the underlying bug is not fixed by the time 3.14 is released,
+# we may need to skip that in musllinux_i686 as well.
+#
+# Note that because we use the stable ABI, the wheels built for
+# cp39-musllinux_i686 will still be available for users of 3.13, this just
+# means we won't be testing them in this configuration.
+test-skip = "cp313-musllinux_i686"
diff --git a/requirements.txt b/requirements.txt
index bfc1433e..ed280c06 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -42,7 +42,7 @@ idna==3.7
     # via requests
 imagesize==1.4.1
     # via sphinx
-jinja2==3.1.4
+jinja2==3.1.6
     # via sphinx
 markupsafe==2.1.5
     # via jinja2
@@ -117,7 +117,7 @@ typing-extensions==4.12.2
     # via mypy
 urllib3==2.2.2
     # via requests
-virtualenv==20.26.2
+virtualenv==20.26.6
     # via tox
 wheel==0.43.0
     # via pip-tools
diff --git a/setup.py b/setup.py
index 0b175af8..de6cd3f0 100644
--- a/setup.py
+++ b/setup.py
@@ -18,6 +18,7 @@
 import os
 import platform
 import setuptools
+import sysconfig
 
 
 kwargs = {}
@@ -35,6 +36,9 @@ if (
     platform.python_implementation() == "CPython"
     and os.environ.get("TORNADO_EXTENSION") != "0"
 ):
+
+    can_use_limited_api = not sysconfig.get_config_var("Py_GIL_DISABLED")
+
     # This extension builds and works on pypy as well, although pypy's jit
     # produces equivalent performance.
     kwargs["ext_modules"] = [
@@ -46,12 +50,13 @@ if (
             optional=os.environ.get("TORNADO_EXTENSION") != "1",
             # Use the stable ABI so our wheels are compatible across python
             # versions.
-            py_limited_api=True,
+            py_limited_api=can_use_limited_api,
             define_macros=[("Py_LIMITED_API", "0x03090000")],
         )
     ]
 
-    kwargs["options"] = {"bdist_wheel": {"py_limited_api": "cp39"}}
+    if can_use_limited_api:
+        kwargs["options"] = {"bdist_wheel": {"py_limited_api": "cp39"}}
 
 
 setuptools.setup(
diff --git a/tornado/httpserver.py b/tornado/httpserver.py
index 58c74e47..68c6301b 100644
--- a/tornado/httpserver.py
+++ b/tornado/httpserver.py
@@ -89,7 +89,7 @@ class HTTPServer(TCPServer, Configurable, httputil.HTTPServerConnectionDelegate)
             async def main():
                 server = HTTPServer()
                 server.listen(8888)
-                await asyncio.Event.wait()
+                await asyncio.Event().wait()
 
             asyncio.run(main())
 
diff --git a/tornado/httputil.py b/tornado/httputil.py
index 4ec7b68f..2f1a9ba8 100644
--- a/tornado/httputil.py
+++ b/tornado/httputil.py
@@ -72,6 +72,42 @@ else:
 HTTP_WHITESPACE = " \t"
 
 
+class _ABNF:
+    """Class that holds a subset of ABNF rules from RFC 9110 and friends.
+
+    Class attributes are re.Pattern objects, with the same name as in the RFC
+    (with hyphens changed to underscores). Currently contains only the subset
+    we use (which is why this class is not public). Unfortunately the fields
+    cannot be alphabetized as they are in the RFCs because of dependencies.
+    """
+
+    # RFC 5234 (ABNF)
+    VCHAR = re.compile(r"[\x21-\x7E]")
+
+    # RFC 9110 (HTTP Semantics)
+    obs_text = re.compile(r"[\x80-\xFF]")
+    field_vchar = re.compile(rf"(?:{VCHAR.pattern}|{obs_text.pattern})")
+    tchar = re.compile(r"[!#$%&'*+\-.^_`|~0-9A-Za-z]")
+    token = re.compile(rf"{tchar.pattern}+")
+    field_name = token
+    method = token
+
+    # RFC 9112 (HTTP/1.1)
+    HTTP_version = re.compile(r"HTTP/[0-9]\.[0-9]")
+    reason_phrase = re.compile(rf"(?:[\t ]|{VCHAR.pattern}|{obs_text.pattern})+")
+    # request_target delegates to the URI RFC 3986, which is complex and may be
+    # too restrictive (for example, the WHATWG version of the URL spec allows non-ASCII
+    # characters). Instead, we allow everything but control chars and whitespace.
+    request_target = re.compile(rf"{field_vchar.pattern}+")
+    request_line = re.compile(
+        rf"({method.pattern}) ({request_target.pattern}) ({HTTP_version.pattern})"
+    )
+    status_code = re.compile(r"[0-9]{3}")
+    status_line = re.compile(
+        rf"({HTTP_version.pattern}) ({status_code.pattern}) ({reason_phrase.pattern})?"
+    )
+
+
 @lru_cache(1000)
 def _normalize_header(name: str) -> str:
     """Map a header name to Http-Header-Case.
@@ -143,6 +179,8 @@ class HTTPHeaders(StrMutableMapping):
 
     def add(self, name: str, value: str) -> None:
         """Adds a new value for the given key."""
+        if not _ABNF.field_name.fullmatch(name):
+            raise HTTPInputError("Invalid header name %r" % name)
         norm_name = _normalize_header(name)
         self._last_key = norm_name
         if norm_name in self:
@@ -859,7 +897,7 @@ def parse_multipart_form_data(
 
 
 def format_timestamp(
-    ts: Union[int, float, tuple, time.struct_time, datetime.datetime]
+    ts: Union[int, float, tuple, time.struct_time, datetime.datetime],
 ) -> str:
     """Formats a timestamp in the format used by HTTP.
 
@@ -888,9 +926,6 @@ class RequestStartLine(typing.NamedTuple):
     version: str
 
 
-_http_version_re = re.compile(r"^HTTP/1\.[0-9]$")
-
-
 def parse_request_start_line(line: str) -> RequestStartLine:
     """Returns a (method, path, version) tuple for an HTTP 1.x request line.
 
@@ -899,17 +934,18 @@ def parse_request_start_line(line: str) -> RequestStartLine:
     >>> parse_request_start_line("GET /foo HTTP/1.1")
     RequestStartLine(method='GET', path='/foo', version='HTTP/1.1')
     """
-    try:
-        method, path, version = line.split(" ")
-    except ValueError:
+    match = _ABNF.request_line.fullmatch(line)
+    if not match:
         # https://tools.ietf.org/html/rfc7230#section-3.1.1
         # invalid request-line SHOULD respond with a 400 (Bad Request)
         raise HTTPInputError("Malformed HTTP request line")
-    if not _http_version_re.match(version):
-        raise HTTPInputError(
-            "Malformed HTTP version in HTTP Request-Line: %r" % version
-        )
-    return RequestStartLine(method, path, version)
+    r = RequestStartLine(match.group(1), match.group(2), match.group(3))
+    if not r.version.startswith("HTTP/1"):
+        # HTTP/2 and above doesn't use parse_request_start_line.
+        # This could be folded into the regex but we don't want to deviate
+        # from the ABNF in the RFCs.
+        raise HTTPInputError("Unexpected HTTP version %r" % r.version)
+    return r
 
 
 class ResponseStartLine(typing.NamedTuple):
@@ -918,9 +954,6 @@ class ResponseStartLine(typing.NamedTuple):
     reason: str
 
 
-_http_response_line_re = re.compile(r"(HTTP/1.[0-9]) ([0-9]+) ([^\r]*)")
-
-
 def parse_response_start_line(line: str) -> ResponseStartLine:
     """Returns a (version, code, reason) tuple for an HTTP 1.x response line.
 
@@ -929,11 +962,14 @@ def parse_response_start_line(line: str) -> ResponseStartLine:
     >>> parse_response_start_line("HTTP/1.1 200 OK")
     ResponseStartLine(version='HTTP/1.1', code=200, reason='OK')
     """
-    line = native_str(line)
-    match = _http_response_line_re.match(line)
+    match = _ABNF.status_line.fullmatch(line)
     if not match:
         raise HTTPInputError("Error parsing response start line")
-    return ResponseStartLine(match.group(1), int(match.group(2)), match.group(3))
+    r = ResponseStartLine(match.group(1), int(match.group(2)), match.group(3))
+    if not r.version.startswith("HTTP/1"):
+        # HTTP/2 and above doesn't use parse_response_start_line.
+        raise HTTPInputError("Unexpected HTTP version %r" % r.version)
+    return r
 
 
 # _parseparam and _parse_header are copied and modified from python2.7's cgi.py
@@ -1065,15 +1101,20 @@ def qs_to_qsl(qs: Dict[str, List[AnyStr]]) -> Iterable[Tuple[str, AnyStr]]:
             yield (k, v)
 
 
-_OctalPatt = re.compile(r"\\[0-3][0-7][0-7]")
-_QuotePatt = re.compile(r"[\\].")
-_nulljoin = "".join
+_unquote_sub = re.compile(r"\\(?:([0-3][0-7][0-7])|(.))").sub
+
+
+def _unquote_replace(m: re.Match) -> str:
+    if m[1]:
+        return chr(int(m[1], 8))
+    else:
+        return m[2]
 
 
 def _unquote_cookie(s: str) -> str:
     """Handle double quotes and escaping in cookie values.
 
-    This method is copied verbatim from the Python 3.5 standard
+    This method is copied verbatim from the Python 3.13 standard
     library (http.cookies._unquote) so we don't have to depend on
     non-public interfaces.
     """
@@ -1094,30 +1135,7 @@ def _unquote_cookie(s: str) -> str:
     #    \012 --> \n
     #    \"   --> "
     #
-    i = 0
-    n = len(s)
-    res = []
-    while 0 <= i < n:
-        o_match = _OctalPatt.search(s, i)
-        q_match = _QuotePatt.search(s, i)
-        if not o_match and not q_match:  # Neither matched
-            res.append(s[i:])
-            break
-        # else:
-        j = k = -1
-        if o_match:
-            j = o_match.start(0)
-        if q_match:
-            k = q_match.start(0)
-        if q_match and (not o_match or k < j):  # QuotePatt matched
-            res.append(s[i:k])
-            res.append(s[k + 1])
-            i = k + 2
-        else:  # OctalPatt matched
-            res.append(s[i:j])
-            res.append(chr(int(s[j + 1 : j + 4], 8)))
-            i = j + 4
-    return _nulljoin(res)
+    return _unquote_sub(_unquote_replace, s)
 
 
 def parse_cookie(cookie: str) -> Dict[str, str]:
diff --git a/tornado/ioloop.py b/tornado/ioloop.py
index 31874fd2..647b8a7d 100644
--- a/tornado/ioloop.py
+++ b/tornado/ioloop.py
@@ -50,7 +50,7 @@ import typing
 from typing import Union, Any, Type, Optional, Callable, TypeVar, Tuple, Awaitable
 
 if typing.TYPE_CHECKING:
-    from typing import Dict, List, Set  # noqa: F401
+    from typing import Dict, List, Set, TypedDict  # noqa: F401
 
     from typing_extensions import Protocol
 else:
@@ -491,7 +491,11 @@ class IOLoop(Configurable):
         .. versionchanged:: 6.2
            ``tornado.util.TimeoutError`` is now an alias to ``asyncio.TimeoutError``.
         """
-        future_cell = [None]  # type: List[Optional[Future]]
+        if typing.TYPE_CHECKING:
+            FutureCell = TypedDict(  # noqa: F841
+                "FutureCell", {"future": Optional[Future], "timeout_called": bool}
+            )
+        future_cell = {"future": None, "timeout_called": False}  # type: FutureCell
 
         def run() -> None:
             try:
@@ -502,38 +506,45 @@ class IOLoop(Configurable):
                     result = convert_yielded(result)
             except Exception:
                 fut = Future()  # type: Future[Any]
-                future_cell[0] = fut
+                future_cell["future"] = fut
                 future_set_exc_info(fut, sys.exc_info())
             else:
                 if is_future(result):
-                    future_cell[0] = result
+                    future_cell["future"] = result
                 else:
                     fut = Future()
-                    future_cell[0] = fut
+                    future_cell["future"] = fut
                     fut.set_result(result)
-            assert future_cell[0] is not None
-            self.add_future(future_cell[0], lambda future: self.stop())
+            assert future_cell["future"] is not None
+            self.add_future(future_cell["future"], lambda future: self.stop())
 
         self.add_callback(run)
         if timeout is not None:
 
             def timeout_callback() -> None:
+                # signal that timeout is triggered
+                future_cell["timeout_called"] = True
                 # If we can cancel the future, do so and wait on it. If not,
                 # Just stop the loop and return with the task still pending.
                 # (If we neither cancel nor wait for the task, a warning
                 # will be logged).
-                assert future_cell[0] is not None
-                if not future_cell[0].cancel():
+                assert future_cell["future"] is not None
+                if not future_cell["future"].cancel():
                     self.stop()
 
             timeout_handle = self.add_timeout(self.time() + timeout, timeout_callback)
         self.start()
         if timeout is not None:
             self.remove_timeout(timeout_handle)
-        assert future_cell[0] is not None
-        if future_cell[0].cancelled() or not future_cell[0].done():
-            raise TimeoutError("Operation timed out after %s seconds" % timeout)
-        return future_cell[0].result()
+        assert future_cell["future"] is not None
+        if future_cell["future"].cancelled() or not future_cell["future"].done():
+            if future_cell["timeout_called"]:
+                raise TimeoutError("Operation timed out after %s seconds" % timeout)
+            else:
+                # timeout not called; maybe stop() was called explicitly
+                # or some other cancellation
+                raise RuntimeError("Event loop stopped before Future completed.")
+        return future_cell["future"].result()
 
     def time(self) -> float:
         """Returns the current time according to the `IOLoop`'s clock.
diff --git a/tornado/platform/asyncio.py b/tornado/platform/asyncio.py
index 2e9f4248..4635fecb 100644
--- a/tornado/platform/asyncio.py
+++ b/tornado/platform/asyncio.py
@@ -386,58 +386,76 @@ def to_asyncio_future(tornado_future: asyncio.Future) -> asyncio.Future:
     return convert_yielded(tornado_future)
 
 
-if sys.platform == "win32" and hasattr(asyncio, "WindowsSelectorEventLoopPolicy"):
-    # "Any thread" and "selector" should be orthogonal, but there's not a clean
-    # interface for composing policies so pick the right base.
-    _BasePolicy = asyncio.WindowsSelectorEventLoopPolicy  # type: ignore
-else:
-    _BasePolicy = asyncio.DefaultEventLoopPolicy
+_AnyThreadEventLoopPolicy = None
 
 
-class AnyThreadEventLoopPolicy(_BasePolicy):  # type: ignore
-    """Event loop policy that allows loop creation on any thread.
+def __getattr__(name: str) -> typing.Any:
+    # The event loop policy system is deprecated in Python 3.14; simply accessing
+    # the name asyncio.DefaultEventLoopPolicy will raise a warning. Lazily create
+    # the AnyThreadEventLoopPolicy class so that the warning is only raised if
+    # the policy is used.
+    if name != "AnyThreadEventLoopPolicy":
+        raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
 
-    The default `asyncio` event loop policy only automatically creates
-    event loops in the main threads. Other threads must create event
-    loops explicitly or `asyncio.get_event_loop` (and therefore
-    `.IOLoop.current`) will fail. Installing this policy allows event
-    loops to be created automatically on any thread, matching the
-    behavior of Tornado versions prior to 5.0 (or 5.0 on Python 2).
+    global _AnyThreadEventLoopPolicy
+    if _AnyThreadEventLoopPolicy is None:
+        if sys.platform == "win32" and hasattr(
+            asyncio, "WindowsSelectorEventLoopPolicy"
+        ):
+            # "Any thread" and "selector" should be orthogonal, but there's not a clean
+            # interface for composing policies so pick the right base.
+            _BasePolicy = asyncio.WindowsSelectorEventLoopPolicy  # type: ignore
+        else:
+            _BasePolicy = asyncio.DefaultEventLoopPolicy
 
-    Usage::
+        class AnyThreadEventLoopPolicy(_BasePolicy):  # type: ignore
+            """Event loop policy that allows loop creation on any thread.
 
-        asyncio.set_event_loop_policy(AnyThreadEventLoopPolicy())
+            The default `asyncio` event loop policy only automatically creates
+            event loops in the main threads. Other threads must create event
+            loops explicitly or `asyncio.get_event_loop` (and therefore
+            `.IOLoop.current`) will fail. Installing this policy allows event
+            loops to be created automatically on any thread, matching the
+            behavior of Tornado versions prior to 5.0 (or 5.0 on Python 2).
 
-    .. versionadded:: 5.0
+            Usage::
 
-    .. deprecated:: 6.2
+                asyncio.set_event_loop_policy(AnyThreadEventLoopPolicy())
 
-        ``AnyThreadEventLoopPolicy`` affects the implicit creation
-        of an event loop, which is deprecated in Python 3.10 and
-        will be removed in a future version of Python. At that time
-        ``AnyThreadEventLoopPolicy`` will no longer be useful.
-        If you are relying on it, use `asyncio.new_event_loop`
-        or `asyncio.run` explicitly in any non-main threads that
-        need event loops.
-    """
+            .. versionadded:: 5.0
 
-    def __init__(self) -> None:
-        super().__init__()
-        warnings.warn(
-            "AnyThreadEventLoopPolicy is deprecated, use asyncio.run "
-            "or asyncio.new_event_loop instead",
-            DeprecationWarning,
-            stacklevel=2,
-        )
+            .. deprecated:: 6.2
 
-    def get_event_loop(self) -> asyncio.AbstractEventLoop:
-        try:
-            return super().get_event_loop()
-        except RuntimeError:
-            # "There is no current event loop in thread %r"
-            loop = self.new_event_loop()
-            self.set_event_loop(loop)
-            return loop
+                ``AnyThreadEventLoopPolicy`` affects the implicit creation
+                of an event loop, which is deprecated in Python 3.10 and
+                will be removed in a future version of Python. At that time
+                ``AnyThreadEventLoopPolicy`` will no longer be useful.
+                If you are relying on it, use `asyncio.new_event_loop`
+                or `asyncio.run` explicitly in any non-main threads that
+                need event loops.
+            """
+
+            def __init__(self) -> None:
+                super().__init__()
+                warnings.warn(
+                    "AnyThreadEventLoopPolicy is deprecated, use asyncio.run "
+                    "or asyncio.new_event_loop instead",
+                    DeprecationWarning,
+                    stacklevel=2,
+                )
+
+            def get_event_loop(self) -> asyncio.AbstractEventLoop:
+                try:
+                    return super().get_event_loop()
+                except RuntimeError:
+                    # "There is no current event loop in thread %r"
+                    loop = self.new_event_loop()
+                    self.set_event_loop(loop)
+                    return loop
+
+        _AnyThreadEventLoopPolicy = AnyThreadEventLoopPolicy
+
+    return _AnyThreadEventLoopPolicy
 
 
 class SelectorThread:
diff --git a/tornado/tcpserver.py b/tornado/tcpserver.py
index 6d74224b..4bf9f793 100644
--- a/tornado/tcpserver.py
+++ b/tornado/tcpserver.py
@@ -75,7 +75,7 @@ class TCPServer:
             async def main():
                 server = TCPServer()
                 server.listen(8888)
-                await asyncio.Event.wait()
+                await asyncio.Event().wait()
 
             asyncio.run(main())
 
diff --git a/tornado/web.py b/tornado/web.py
index 357c5f1a..a0949572 100644
--- a/tornado/web.py
+++ b/tornado/web.py
@@ -399,7 +399,8 @@ class RequestHandler:
         if name in self._headers:
             del self._headers[name]
 
-    _INVALID_HEADER_CHAR_RE = re.compile(r"[\x00-\x1f]")
+    # https://www.rfc-editor.org/rfc/rfc9110#name-field-values
+    _VALID_HEADER_CHARS = re.compile(r"[\x09\x20-\x7e\x80-\xff]*")
 
     def _convert_header_value(self, value: _HeaderTypes) -> str:
         # Convert the input value to a str. This type check is a bit
@@ -421,7 +422,7 @@ class RequestHandler:
             raise TypeError("Unsupported header value %r" % value)
         # If \n is allowed into the header, it is possible to inject
         # additional headers or split the request.
-        if RequestHandler._INVALID_HEADER_CHAR_RE.search(retval):
+        if RequestHandler._VALID_HEADER_CHARS.fullmatch(retval) is None:
             raise ValueError("Unsafe header value %r", retval)
         return retval
 


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index e947689c..7329ecdb 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -9,6 +9,8 @@ name: Test
 
 on: pull_request
 
+permissions: {}
+
 jobs:
   # Before starting the full build matrix, run one test configuration
   # and the linter (the `black` linter is especially likely to catch
@@ -18,6 +20,8 @@ jobs:
     runs-on: ubuntu-22.04
     steps:
       - uses: actions/checkout@v4
+        with:
+          persist-credentials: false
       - uses: actions/setup-python@v5
         name: Install Python
         with:
@@ -65,6 +69,8 @@ jobs:
 
     steps:
       - uses: actions/checkout@v4
+        with:
+          persist-credentials: false
       - uses: actions/setup-python@v5
         name: Install Python
         with:
@@ -88,6 +94,8 @@ jobs:
     runs-on: windows-2022
     steps:
       - uses: actions/checkout@v4
+        with:
+          persist-credentials: false
       - uses: actions/setup-python@v5
         name: Install Python
         with:
@@ -95,3 +103,50 @@ jobs:
       - name: Run test suite
         # TODO: figure out what's up with these log messages
         run: py -m tornado.test --fail-if-logs=false
+
+  zizmor:
+    name: Analyze action configs with zizmor
+    runs-on: ubuntu-22.04
+    needs: test_quick
+    steps:
+      - uses: actions/checkout@v4
+        with:
+          persist-credentials: false
+      - uses: astral-sh/setup-uv@v5
+        name: Install uv
+      - name: Run zizmor
+        run: uvx zizmor .github/workflows
+
+  test_cibw:
+    # cibuildwheel is the tool that we use for release builds in build.yml.
+    # Run it in the every-PR workflow because it's slightly different from our
+    # regular build and this gives us easier ways to test freethreading changes.
+    #
+    # Note that test_cibw and test_tox both take about a minute to run, but test_tox runs
+    # more tests; test_cibw spends a lot of its time installing dependencies. Replacing
+    # test_tox with test_cibw would entail either increasing test runtime or reducing
+    # test coverage.
+    name: Test with cibuildwheel
+    runs-on: ubuntu-22.04
+    needs: test_quick
+    steps:
+      - uses: actions/checkout@v4
+        with:
+          persist-credentials: false
+      - name: Run cibuildwheel
+        uses: pypa/cibuildwheel@v2.22
+        env:
+          # For speed, we only build one python version and one arch. We throw away the wheels
+          # built here; the real build is defined in build.yml.
+          CIBW_ARCHS: native
+          CIBW_BUILD: cp313-manylinux*
+
+          # Alternatively, uncomment the following lines (and replace the previous CIBW_BUILD)
+          # to test a freethreading build of python.
+          #CIBW_BUILD: cp313t-manylinux*
+          #CIBW_ENABLE: cpython-freethreading
+          # I don't understand what this does but auditwheel seems to fail in this configuration.
+          # Since we're throwing away the wheels here, just skip it.
+          # TODO: When we no longer need to disable this, we can enable freethreading in
+          # build.yml.
+          #CIBW_REPAIR_WHEEL_COMMAND: ""
diff --git a/tornado/test/asyncio_test.py b/tornado/test/asyncio_test.py
index bb6416a5..6c355c04 100644
--- a/tornado/test/asyncio_test.py
+++ b/tornado/test/asyncio_test.py
@@ -17,15 +17,16 @@ import unittest
 import warnings
 
 from concurrent.futures import ThreadPoolExecutor
+import tornado.platform.asyncio
 from tornado import gen
 from tornado.ioloop import IOLoop
 from tornado.platform.asyncio import (
     AsyncIOLoop,
     to_asyncio_future,
-    AnyThreadEventLoopPolicy,
     AddThreadSelectorEventLoop,
 )
-from tornado.testing import AsyncTestCase, gen_test
+from tornado.testing import AsyncTestCase, gen_test, setup_with_context_manager
+from tornado.test.util import ignore_deprecation
 
 
 class AsyncIOLoopTest(AsyncTestCase):
@@ -111,10 +112,6 @@ class LeakTest(unittest.TestCase):
     def setUp(self):
         # Trigger a cleanup of the mapping so we start with a clean slate.
         AsyncIOLoop(make_current=False).close()
-        # If we don't clean up after ourselves other tests may fail on
-        # py34.
-        self.orig_policy = asyncio.get_event_loop_policy()
-        asyncio.set_event_loop_policy(asyncio.DefaultEventLoopPolicy())
 
     def tearDown(self):
         try:
@@ -124,7 +121,6 @@ class LeakTest(unittest.TestCase):
             pass
         else:
             loop.close()
-        asyncio.set_event_loop_policy(self.orig_policy)
 
     def test_ioloop_close_leak(self):
         orig_count = len(IOLoop._ioloop_for_asyncio)
@@ -205,6 +201,12 @@ class SelectorThreadLeakTest(unittest.TestCase):
 
 class AnyThreadEventLoopPolicyTest(unittest.TestCase):
     def setUp(self):
+        setup_with_context_manager(self, ignore_deprecation())
+        # Referencing the event loop policy attributes raises deprecation warnings,
+        # so instead of importing this at the top of the file we capture it here.
+        self.AnyThreadEventLoopPolicy = (
+            tornado.platform.asyncio.AnyThreadEventLoopPolicy
+        )
         self.orig_policy = asyncio.get_event_loop_policy()
         self.executor = ThreadPoolExecutor(1)
 
@@ -237,7 +239,7 @@ class AnyThreadEventLoopPolicyTest(unittest.TestCase):
                 RuntimeError, self.executor.submit(asyncio.get_event_loop).result
             )
             # Set the policy and we can get a loop.
-            asyncio.set_event_loop_policy(AnyThreadEventLoopPolicy())
+            asyncio.set_event_loop_policy(self.AnyThreadEventLoopPolicy())
             self.assertIsInstance(
                 self.executor.submit(asyncio.get_event_loop).result(),
                 asyncio.AbstractEventLoop,
@@ -256,6 +258,6 @@ class AnyThreadEventLoopPolicyTest(unittest.TestCase):
             # IOLoop doesn't (currently) close the underlying loop.
             self.executor.submit(lambda: asyncio.get_event_loop().close()).result()  # type: ignore
 
-            asyncio.set_event_loop_policy(AnyThreadEventLoopPolicy())
+            asyncio.set_event_loop_policy(self.AnyThreadEventLoopPolicy())
             self.assertIsInstance(self.executor.submit(IOLoop.current).result(), IOLoop)
             self.executor.submit(lambda: asyncio.get_event_loop().close()).result()  # type: ignore
diff --git a/tornado/test/httpserver_test.py b/tornado/test/httpserver_test.py
index 7b411ed6..69ff065c 100644
--- a/tornado/test/httpserver_test.py
+++ b/tornado/test/httpserver_test.py
@@ -30,7 +30,7 @@ from tornado.testing import (
 from tornado.test.util import abstract_base_test
 from tornado.web import Application, RequestHandler, stream_request_body
 
-from contextlib import closing
+from contextlib import closing, contextmanager
 import datetime
 import gzip
 import logging
@@ -634,6 +634,38 @@ ok
                     )
                     yield stream.read_until_close()
 
+    @gen_test
+    def test_invalid_methods(self):
+        # RFC 9110 distinguishes between syntactically invalid methods and those that are
+        # valid but unknown. The former must give a 400 status code, while the latter should
+        # give a 405.
+        test_cases = [
+            ("FOO", 405, None),
+            ("FOO,BAR", 400, ".*Malformed HTTP request line"),
+        ]
+        for method, code, log_msg in test_cases:
+            if log_msg is not None:
+                expect_log = ExpectLog(gen_log, log_msg, level=logging.INFO)
+            else:
+
+                @contextmanager
+                def noop_context():
+                    yield
+
+                expect_log = noop_context()  # type: ignore
+            with (
+                self.subTest(method=method),
+                closing(IOStream(socket.socket())) as stream,
+                expect_log,
+            ):
+                yield stream.connect(("127.0.0.1", self.get_http_port()))
+                stream.write(utf8(f"{method} /echo HTTP/1.1\r\n\r\n"))
+                resp = yield stream.read_until(b"\r\n\r\n")
+                self.assertTrue(
+                    resp.startswith(b"HTTP/1.1 %d" % code),
+                    f"expected status code {code} in {resp!r}",
+                )
+
 
 class XHeaderTest(HandlerBaseTestCase):
     class Handler(RequestHandler):
diff --git a/tornado/test/httputil_test.py b/tornado/test/httputil_test.py
index 75c92fff..53ae4607 100644
--- a/tornado/test/httputil_test.py
+++ b/tornado/test/httputil_test.py
@@ -409,6 +409,22 @@ Foo: even
         headers2 = HTTPHeaders.parse(str(headers))
         self.assertEqual(headers, headers2)
 
+    def test_invalid_header_names(self):
+        invalid_names = [
+            "",
+            "foo bar",
+            "foo\tbar",
+            "foo\nbar",
+            "foo\x00bar",
+            "foo ",
+            " foo",
+            "é",
+        ]
+        for name in invalid_names:
+            headers = HTTPHeaders()
+            with self.assertRaises(HTTPInputError):
+                headers.add(name, "bar")
+
 
 class FormatTimestampTest(unittest.TestCase):
     # Make sure that all the input types are supported.
@@ -467,8 +483,8 @@ class HTTPServerRequestTest(unittest.TestCase):
         HTTPServerRequest(uri="/")
 
     def test_body_is_a_byte_string(self):
-        requets = HTTPServerRequest(uri="/")
-        self.assertIsInstance(requets.body, bytes)
+        request = HTTPServerRequest(uri="/")
+        self.assertIsInstance(request.body, bytes)
 
     def test_repr_does_not_contain_headers(self):
         request = HTTPServerRequest(
@@ -560,3 +576,49 @@ class ParseCookieTest(unittest.TestCase):
         self.assertEqual(
             parse_cookie("  =  b  ;  ;  =  ;   c  =  ;  "), {"": "b", "c": ""}
         )
+
+    def test_unquote(self):
+        # Copied from
+        # https://github.com/python/cpython/blob/dc7a2b6522ec7af41282bc34f405bee9b306d611/Lib/test/test_http_cookies.py#L62
+        cases = [
+            (r'a="b=\""', 'b="'),
+            (r'a="b=\\"', "b=\\"),
+            (r'a="b=\="', "b=="),
+            (r'a="b=\n"', "b=n"),
+            (r'a="b=\042"', 'b="'),
+            (r'a="b=\134"', "b=\\"),
+            (r'a="b=\377"', "b=\xff"),
+            (r'a="b=\400"', "b=400"),
+            (r'a="b=\42"', "b=42"),
+            (r'a="b=\\042"', "b=\\042"),
+            (r'a="b=\\134"', "b=\\134"),
+            (r'a="b=\\\""', 'b=\\"'),
+            (r'a="b=\\\042"', 'b=\\"'),
+            (r'a="b=\134\""', 'b=\\"'),
+            (r'a="b=\134\042"', 'b=\\"'),
+        ]
+        for encoded, decoded in cases:
+            with self.subTest(encoded):
+                c = parse_cookie(encoded)
+                self.assertEqual(c["a"], decoded)
+
+    def test_unquote_large(self):
+        # Adapted from
+        # https://github.com/python/cpython/blob/dc7a2b6522ec7af41282bc34f405bee9b306d611/Lib/test/test_http_cookies.py#L87
+        # Modified from that test because we handle semicolons differently from the stdlib.
+        #
+        # This is a performance regression test: prior to improvements in Tornado 6.4.2, this test
+        # would take over a minute with n= 100k. Now it runs in tens of milliseconds.
+        n = 100000
+        for encoded in r"\\", r"\134":
+            with self.subTest(encoded):
+                start = time.time()
+                data = 'a="b=' + encoded * n + '"'
+                value = parse_cookie(data)["a"]
+                end = time.time()
+                self.assertEqual(value[:3], "b=\\")
+                self.assertEqual(value[-3:], "\\\\\\")
+                self.assertEqual(len(value), n + 2)
+
+                # Very loose performance check to avoid false positives
+                self.assertLess(end - start, 1, "Test took too long")
diff --git a/tornado/test/import_test.py b/tornado/test/import_test.py
index 1ff52206..261d3d34 100644
--- a/tornado/test/import_test.py
+++ b/tornado/test/import_test.py
@@ -9,7 +9,10 @@ _import_everything = b"""
 # Explicitly disallow the default event loop so that an error will be raised
 # if something tries to touch it.
 import asyncio
-asyncio.set_event_loop(None)
+import warnings
+with warnings.catch_warnings():
+    warnings.simplefilter("ignore", DeprecationWarning)
+    asyncio.set_event_loop(None)
 
 import importlib
 import tornado
diff --git a/tornado/test/ioloop_test.py b/tornado/test/ioloop_test.py
index 047b3614..37ac6deb 100644
--- a/tornado/test/ioloop_test.py
+++ b/tornado/test/ioloop_test.py
@@ -132,7 +132,7 @@ class TestIOLoop(AsyncTestCase):
         # Very crude test, just to make sure that we cover this case.
         # This also happens to be the first test where we run an IOLoop in
         # a non-main thread.
-        other_ioloop = IOLoop()
+        other_ioloop = IOLoop(make_current=False)
         thread = threading.Thread(target=other_ioloop.start)
         thread.start()
         with ignore_deprecation():
@@ -152,7 +152,7 @@ class TestIOLoop(AsyncTestCase):
             closing.set()
             other_ioloop.close(all_fds=True)
 
-        other_ioloop = IOLoop()
+        other_ioloop = IOLoop(make_current=False)
         thread = threading.Thread(target=target)
         thread.start()
         closing.wait()
@@ -276,8 +276,12 @@ class TestIOLoop(AsyncTestCase):
 
         sockobj, port = bind_unused_port()
         socket_wrapper = SocketWrapper(sockobj)
-        io_loop = IOLoop()
-        io_loop.add_handler(socket_wrapper, lambda fd, events: None, IOLoop.READ)
+        io_loop = IOLoop(make_current=False)
+        io_loop.run_sync(
+            lambda: io_loop.add_handler(
+                socket_wrapper, lambda fd, events: None, IOLoop.READ
+            )
+        )
         io_loop.close(all_fds=True)
         self.assertTrue(socket_wrapper.closed)
 
@@ -623,6 +627,16 @@ class TestIOLoopRunSync(unittest.TestCase):
 
         self.io_loop.run_sync(f2)
 
+    def test_stop_no_timeout(self):
+        async def f():
+            await asyncio.sleep(0.1)
+            IOLoop.current().stop()
+            await asyncio.sleep(10)
+
+        with self.assertRaises(RuntimeError) as cm:
+            self.io_loop.run_sync(f)
+        assert "Event loop stopped" in str(cm.exception)
+
 
 class TestPeriodicCallbackMath(unittest.TestCase):
     def simulate_calls(self, pc, durations):
@@ -782,6 +796,9 @@ class TestIOLoopConfiguration(unittest.TestCase):
         )
         self.assertEqual(cls, "AsyncIOMainLoop")
 
+    @unittest.skipIf(
+        sys.version_info >= (3, 14), "implicit event loop creation not available"
+    )
     def test_asyncio_main(self):
         cls = self.run_python(
             "from tornado.platform.asyncio import AsyncIOMainLoop",
diff --git a/tornado/test/simple_httpclient_test.py b/tornado/test/simple_httpclient_test.py
index c1cff83c..a40435e8 100644
--- a/tornado/test/simple_httpclient_test.py
+++ b/tornado/test/simple_httpclient_test.py
@@ -210,7 +210,7 @@ class SimpleHTTPClientTestMixin(AsyncTestCase):
             SimpleAsyncHTTPClient(), SimpleAsyncHTTPClient(force_instance=True)
         )
         # different IOLoops use different objects
-        with closing(IOLoop()) as io_loop2:
+        with closing(IOLoop(make_current=False)) as io_loop2:
 
             async def make_client():
                 await gen.sleep(0)
diff --git a/tornado/test/util.py b/tornado/test/util.py
index 25dd2000..c409b4ce 100644
--- a/tornado/test/util.py
+++ b/tornado/test/util.py
@@ -3,6 +3,7 @@ import os
 import platform
 import socket
 import sys
+import sysconfig
 import textwrap
 import typing
 import unittest
@@ -21,7 +22,11 @@ skipIfNonUnix = unittest.skipIf(
 skipIfNoNetwork = unittest.skipIf("NO_NETWORK" in os.environ, "network access disabled")
 
 skipNotCPython = unittest.skipIf(
-    platform.python_implementation() != "CPython", "Not CPython implementation"
+    # "CPython" here essentially refers to the traditional synchronous refcounting GC,
+    # so we skip these tests in free-threading builds of cpython too.
+    platform.python_implementation() != "CPython"
+    or sysconfig.get_config_var("Py_GIL_DISABLED"),
+    "Not CPython implementation",
 )
 
 
diff --git a/tornado/test/web_test.py b/tornado/test/web_test.py
index a829657a..6a04059d 100644
--- a/tornado/test/web_test.py
+++ b/tornado/test/web_test.py
@@ -706,6 +706,26 @@ class HeaderInjectionHandler(RequestHandler):
                 raise
 
 
+class SetHeaderHandler(RequestHandler):
+    def get(self):
+        # tests the validity of web.RequestHandler._VALID_HEADER_CHARS
+        illegal_chars = [chr(o) for o in range(0, 0x20)]
+        illegal_chars.append(chr(0x7F))
+        illegal_chars.remove("\t")
+        for char in illegal_chars:
+            try:
+                self.set_header("X-Foo", "foo" + char + "bar")
+                raise Exception("Didn't get expected exception")
+            except ValueError as e:
+                if "Unsafe header value" not in str(e):
+                    raise
+
+        # an empty header value is valid as well
+        self.set_header("X-Foo", "")
+
+        self.finish(b"ok")
+
+
 class GetArgumentHandler(RequestHandler):
     def prepare(self):
         if self.get_argument("source", None) == "query":
@@ -790,6 +810,7 @@ class WSGISafeWebTest(WebTestCase):
             url("/header_injection", HeaderInjectionHandler),
             url("/get_argument", GetArgumentHandler),
             url("/get_arguments", GetArgumentsHandler),
+            url("/set_header", SetHeaderHandler),
         ]
         return urls
 
@@ -938,6 +959,10 @@ js_embed()
         response = self.fetch("/header_injection")
         self.assertEqual(response.body, b"ok")
 
+    def test_set_header(self):
+        response = self.fetch("/set_header")
+        self.assertEqual(response.body, b"ok")
+
     def test_get_argument(self):
         response = self.fetch("/get_argument?foo=bar")
         self.assertEqual(response.body, b"bar")
diff --git a/tornado/testing.py b/tornado/testing.py
index a397e3fa..9f54a1e2 100644
--- a/tornado/testing.py
+++ b/tornado/testing.py
@@ -155,6 +155,19 @@ class AsyncTestCase(unittest.TestCase):
                 category=DeprecationWarning,
                 module=r"tornado\..*",
             )
+        if (3, 14) <= py_ver:
+            # TODO: This is a temporary hack pending resolution of
+            # https://github.com/python/cpython/issues/130322
+            # If set_event_loop is undeprecated, we can remove it; if not
+            # we need substantial changes to this class to use asyncio.Runner
+            # like IsolatedAsyncioTestCase does.
+            setup_with_context_manager(self, warnings.catch_warnings())
+            warnings.filterwarnings(
+                "ignore",
+                message="'asyncio.set_event_loop' is deprecated",
+                category=DeprecationWarning,
+                module="tornado.testing",
+            )
         super().setUp()
         if type(self).get_new_ioloop is not AsyncTestCase.get_new_ioloop:
             warnings.warn("get_new_ioloop is deprecated", DeprecationWarning)