Repository: tornadoweb/tornado
Pull request #2789 (2019-12-10 15:07:03)

Provided default User-Agent to SimpleAsyncHTTPClient if not provided.
The User-Agent format is "Tornado\\{Tornado_Version}".

If self.request.user_agent isn't set and self.request.headers has
no User-Agent in it's keys the default User-Agent is added.

Fixes: #2702 


Patch:
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 00000000..facf16e4
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,4 @@
+# Tests of static file handling assume unix-style line endings.
+tornado/test/static/*.txt text eol=lf
+tornado/test/static/dir/*.html text eol=lf
+tornado/test/templates/*.html text eol=lf
diff --git a/.travis.yml b/.travis.yml
index 9bb46ffa..474bc90e 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -2,16 +2,15 @@
 
 dist: xenial
 language: python
-matrix:
-    fast_finish: true
 addons:
-  apt: 
+  apt:
     packages:
       - libgnutls-dev
 
 # For a list of available versions, run
 #     aws s3 ls s3://travis-python-archives/binaries/ubuntu/16.04/x86_64/
 jobs:
+  fast_finish: true
   include:
     # 3.5.2 is interesting because it's the version in ubuntu 16.04, and due to python's
     # "provisional feature" rules there are significant differences between patch
diff --git a/appveyor.yml b/appveyor.yml
index fae4516e..732a7043 100644
--- a/appveyor.yml
+++ b/appveyor.yml
@@ -50,6 +50,18 @@ environment:
       TOX_ENV: "py37"
       TOX_ARGS: ""
 
+    - PYTHON: "C:\\Python38"
+      PYTHON_VERSION: "3.8.x"
+      PYTHON_ARCH: "32"
+      TOX_ENV: "py38"
+      TOX_ARGS: "tornado.test.websocket_test"
+
+    - PYTHON: "C:\\Python38-x64"
+      PYTHON_VERSION: "3.8.x"
+      PYTHON_ARCH: "64"
+      TOX_ENV: "py38"
+      TOX_ARGS: ""
+
 install:
   # Make sure the right python version is first on the PATH.
   - "SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%"
diff --git a/docs/guide/security.rst b/docs/guide/security.rst
index 0dedabc3..b65cd3f3 100644
--- a/docs/guide/security.rst
+++ b/docs/guide/security.rst
@@ -279,7 +279,7 @@ all requests::
 For ``PUT`` and ``DELETE`` requests (as well as ``POST`` requests that
 do not use form-encoded arguments), the XSRF token may also be passed
 via an HTTP header named ``X-XSRFToken``.  The XSRF cookie is normally
-set when ``xsrf_form_html`` is used, but in a pure-Javascript application
+set when ``xsrf_form_html`` is used, but in a pure-JavaScript application
 that does not use any regular forms you may need to access
 ``self.xsrf_token`` manually (just reading the property is enough to
 set the cookie as a side effect).
diff --git a/docs/guide/templates.rst b/docs/guide/templates.rst
index 9230be23..61ce753e 100644
--- a/docs/guide/templates.rst
+++ b/docs/guide/templates.rst
@@ -132,7 +132,7 @@ instead of ``None``.
 
 Note that while Tornado's automatic escaping is helpful in avoiding
 XSS vulnerabilities, it is not sufficient in all cases.  Expressions
-that appear in certain locations, such as in Javascript or CSS, may need
+that appear in certain locations, such as in JavaScript or CSS, may need
 additional escaping.  Additionally, either care must be taken to always
 use double quotes and `.xhtml_escape` in HTML attributes that may contain
 untrusted content, or a separate escaping function must be used for
diff --git a/docs/releases.rst b/docs/releases.rst
index bb8a6f99..81babaa3 100644
--- a/docs/releases.rst
+++ b/docs/releases.rst
@@ -4,6 +4,7 @@ Release notes
 .. toctree::
    :maxdepth: 2
 
+   releases/v6.0.4
    releases/v6.0.3
    releases/v6.0.2
    releases/v6.0.1
diff --git a/docs/releases/v6.0.4.rst b/docs/releases/v6.0.4.rst
new file mode 100644
index 00000000..f9864bff
--- /dev/null
+++ b/docs/releases/v6.0.4.rst
@@ -0,0 +1,21 @@
+What's new in Tornado 6.0.4
+===========================
+
+Mar 3, 2020
+-----------
+
+General changes
+~~~~~~~~~~~~~~~
+
+- Binary wheels are now available for Python 3.8 on Windows. Note that it is
+  still necessary to use
+  ``asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())`` for
+  this platform/version.
+
+Bug fixes
+~~~~~~~~~
+
+- Fixed an issue in `.IOStream` (introduced in 6.0.0) that resulted in
+  ``StreamClosedError`` being incorrectly raised if a stream is closed mid-read
+  but there is enough buffered data to satisfy the read. 
+- `.AnyThreadEventLoopPolicy` now always uses the selector event loop on Windows.
\ No newline at end of file
diff --git a/maint/requirements.txt b/maint/requirements.txt
index 6b1e7aed..6707ee87 100644
--- a/maint/requirements.txt
+++ b/maint/requirements.txt
@@ -4,7 +4,7 @@ attrs==19.1.0
 automat==0.7.0
 babel==2.6.0
 black==19.3b0
-bleach==3.1.0
+bleach==3.1.1
 certifi==2019.3.9
 cffi==1.12.3
 chardet==3.0.4
diff --git a/setup.py b/setup.py
index 61ffec87..c3c8b473 100644
--- a/setup.py
+++ b/setup.py
@@ -183,6 +183,7 @@ setup(
         "Programming Language :: Python :: 3.5",
         "Programming Language :: Python :: 3.6",
         "Programming Language :: Python :: 3.7",
+        "Programming Language :: Python :: 3.8",
         "Programming Language :: Python :: Implementation :: CPython",
         "Programming Language :: Python :: Implementation :: PyPy",
     ],
diff --git a/tornado/escape.py b/tornado/escape.py
index 8d2d6bc8..3cf7ff2e 100644
--- a/tornado/escape.py
+++ b/tornado/escape.py
@@ -69,7 +69,7 @@ def json_encode(value: Any) -> str:
     # JSON permits but does not require forward slashes to be escaped.
     # This is useful when json data is emitted in a <script> tag
     # in HTML, as it prevents </script> tags from prematurely terminating
-    # the javascript.  Some json libraries do this escaping by default,
+    # the JavaScript.  Some json libraries do this escaping by default,
     # although python's standard library does not, so we do it here.
     # http://stackoverflow.com/questions/1580647/json-why-are-forward-slashes-escaped
     return json.dumps(value).replace("</", "<\\/")
diff --git a/tornado/gen.py b/tornado/gen.py
index 7cc7ec78..c58884cc 100644
--- a/tornado/gen.py
+++ b/tornado/gen.py
@@ -221,7 +221,7 @@ def coroutine(
                     # We do this by exploiting the public API
                     # add_done_callback() instead of putting a private
                     # attribute on the Future.
-                    # (Github issues #1769, #2229).
+                    # (GitHub issues #1769, #2229).
                     runner = Runner(result, future, yielded)
                     future.add_done_callback(lambda _: runner)
                 yielded = None
diff --git a/tornado/iostream.py b/tornado/iostream.py
index 89d87a56..b11f65ee 100644
--- a/tornado/iostream.py
+++ b/tornado/iostream.py
@@ -599,6 +599,15 @@ class BaseIOStream(object):
             if self._read_until_close:
                 self._read_until_close = False
                 self._finish_read(self._read_buffer_size, False)
+            elif self._read_future is not None:
+                # resolve reads that are pending and ready to complete
+                try:
+                    pos = self._find_read_pos()
+                except UnsatisfiableReadError:
+                    pass
+                else:
+                    if pos is not None:
+                        self._read_from_buffer(pos)
             if self._state is not None:
                 self.io_loop.remove_handler(self.fileno())
                 self._state = None
@@ -784,8 +793,25 @@ class BaseIOStream(object):
             self._read_from_buffer(pos)
 
     def _start_read(self) -> Future:
-        self._check_closed()  # Before reading, check that stream is not closed.
-        assert self._read_future is None, "Already reading"
+        if self._read_future is not None:
+            # It is an error to start a read while a prior read is unresolved.
+            # However, if the prior read is unresolved because the stream was
+            # closed without satisfying it, it's better to raise
+            # StreamClosedError instead of AssertionError. In particular, this
+            # situation occurs in harmless situations in http1connection.py and
+            # an AssertionError would be logged noisily.
+            #
+            # On the other hand, it is legal to start a new read while the
+            # stream is closed, in case the read can be satisfied from the
+            # read buffer. So we only want to check the closed status of the
+            # stream if we need to decide what kind of error to raise for
+            # "already reading".
+            #
+            # These conditions have proven difficult to test; we have no
+            # unittests that reliably verify this behavior so be careful
+            # when making changes here. See #2651 and #2719.
+            self._check_closed()
+            assert self._read_future is None, "Already reading"
         self._read_future = Future()
         return self._read_future
 
@@ -1387,6 +1413,10 @@ class SSLIOStream(IOStream):
                 )
                 return self.close(exc_info=err)
             raise
+        except ssl.CertificateError as err:
+            # CertificateError can happen during handshake (hostname
+            # verification) and should be passed to user
+            return self.close(exc_info=err)
         except socket.error as err:
             # Some port scans (e.g. nmap in -sT mode) have been known
             # to cause do_handshake to raise EBADF and ENOTCONN, so make
diff --git a/tornado/platform/asyncio.py b/tornado/platform/asyncio.py
index 0b79f82c..a859e7f2 100644
--- a/tornado/platform/asyncio.py
+++ b/tornado/platform/asyncio.py
@@ -21,6 +21,7 @@ the same event loop.
 
 import concurrent.futures
 import functools
+import sys
 
 from threading import get_ident
 from tornado.gen import convert_yielded
@@ -307,7 +308,15 @@ def to_asyncio_future(tornado_future: asyncio.Future) -> asyncio.Future:
     return convert_yielded(tornado_future)
 
 
-class AnyThreadEventLoopPolicy(asyncio.DefaultEventLoopPolicy):  # type: ignore
+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
+
+
+class AnyThreadEventLoopPolicy(_BasePolicy):  # type: ignore
     """Event loop policy that allows loop creation on any thread.
 
     The default `asyncio` event loop policy only automatically creates
diff --git a/tornado/routing.py b/tornado/routing.py
index e137a728..f8f94e85 100644
--- a/tornado/routing.py
+++ b/tornado/routing.py
@@ -142,7 +142,7 @@ Of course a nested `RuleRouter` or a `~.web.Application` is allowed:
 
     router = RuleRouter([
         Rule(HostMatches("example.com"), RuleRouter([
-            Rule(PathMatches("/app1/.*"), Application([(r"/app1/handler", Handler)]))),
+            Rule(PathMatches("/app1/.*"), Application([(r"/app1/handler", Handler)])),
         ]))
     ])
 
diff --git a/tornado/simple_httpclient.py b/tornado/simple_httpclient.py
index 1e057865..ec1b84fd 100644
--- a/tornado/simple_httpclient.py
+++ b/tornado/simple_httpclient.py
@@ -1,5 +1,5 @@
 from tornado.escape import _unicode
-from tornado import gen
+from tornado import gen, version
 from tornado.httpclient import (
     HTTPResponse,
     HTTPError,
@@ -392,6 +392,8 @@ class _HTTPConnection(httputil.HTTPMessageDelegate):
                     )
                 if self.request.user_agent:
                     self.request.headers["User-Agent"] = self.request.user_agent
+                elif self.request.headers.get("User-Agent") is None:
+                    self.request.headers["User-Agent"] = "Tornado/{}".format(version)
                 if not self.request.allow_nonstandard_methods:
                     # Some HTTP methods nearly always have bodies while others
                     # almost never do. Fail in this case unless the user has
diff --git a/tornado/web.py b/tornado/web.py
index 0847a9c5..604aeb36 100644
--- a/tornado/web.py
+++ b/tornado/web.py
@@ -3264,7 +3264,7 @@ class TemplateModule(UIModule):
     Template()) instead of inheriting the outer template's namespace.
 
     Templates rendered through this module also get access to UIModule's
-    automatic javascript/css features.  Simply call set_resources
+    automatic JavaScript/CSS features.  Simply call set_resources
     inside the template and give it keyword arguments corresponding to
     the methods on UIModule: {{ set_resources(js_files=static_url("my.js")) }}
     Note that these resources are output once per template file, not once
diff --git a/tornado/websocket.py b/tornado/websocket.py
index 93ceedb6..c88cc207 100644
--- a/tornado/websocket.py
+++ b/tornado/websocket.py
@@ -190,7 +190,7 @@ class WebSocketHandler(tornado.web.RequestHandler):
 
     Web browsers allow any site to open a websocket connection to any other,
     instead of using the same-origin policy that governs other network
-    access from javascript.  This can be surprising and is a potential
+    access from JavaScript.  This can be surprising and is a potential
     security hole, so since Tornado 4.0 `WebSocketHandler` requires
     applications that wish to receive cross-origin websockets to opt in
     by overriding the `~WebSocketHandler.check_origin` method (see that


Test patch:
diff --git a/tornado/test/__init__.py b/tornado/test/__init__.py
index e69de29b..bfc2656d 100644
--- a/tornado/test/__init__.py
+++ b/tornado/test/__init__.py
@@ -0,0 +1,8 @@
+import asyncio
+import sys
+
+# Use the selector event loop on windows. Do this in tornado/test/__init__.py
+# instead of runtests.py so it happens no matter how the test is run (such as
+# through editor integrations).
+if sys.platform == "win32" and hasattr(asyncio, "WindowsSelectorEventLoopPolicy"):
+    asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())  # type: ignore
diff --git a/tornado/test/gen_test.py b/tornado/test/gen_test.py
index 659e2260..2655a509 100644
--- a/tornado/test/gen_test.py
+++ b/tornado/test/gen_test.py
@@ -952,7 +952,7 @@ class RunnerGCTest(AsyncTestCase):
 
     @gen_test
     def test_gc(self):
-        # Github issue 1769: Runner objects can get GCed unexpectedly
+        # GitHub issue 1769: Runner objects can get GCed unexpectedly
         # while their future is alive.
         weakref_scope = [None]  # type: List[Optional[weakref.ReferenceType]]
 
@@ -970,7 +970,7 @@ class RunnerGCTest(AsyncTestCase):
         yield gen.with_timeout(datetime.timedelta(seconds=0.2), tester())
 
     def test_gc_infinite_coro(self):
-        # Github issue 2229: suspended coroutines should be GCed when
+        # GitHub issue 2229: suspended coroutines should be GCed when
         # their loop is closed, even if they're involved in a reference
         # cycle.
         loop = self.get_new_ioloop()
diff --git a/tornado/test/httpserver_test.py b/tornado/test/httpserver_test.py
index 63052ba1..ed91a99f 100644
--- a/tornado/test/httpserver_test.py
+++ b/tornado/test/httpserver_test.py
@@ -34,6 +34,7 @@ from tornado.web import Application, RequestHandler, stream_request_body
 from contextlib import closing
 import datetime
 import gzip
+import logging
 import os
 import shutil
 import socket
@@ -480,7 +481,7 @@ class HTTPServerRawTest(AsyncHTTPTestCase):
         self.wait()
 
     def test_malformed_first_line_response(self):
-        with ExpectLog(gen_log, ".*Malformed HTTP request line"):
+        with ExpectLog(gen_log, ".*Malformed HTTP request line", level=logging.INFO):
             self.stream.write(b"asdf\r\n\r\n")
             start_line, headers, response = self.io_loop.run_sync(
                 lambda: read_stream_body(self.stream)
@@ -490,7 +491,7 @@ class HTTPServerRawTest(AsyncHTTPTestCase):
             self.assertEqual("Bad Request", start_line.reason)
 
     def test_malformed_first_line_log(self):
-        with ExpectLog(gen_log, ".*Malformed HTTP request line"):
+        with ExpectLog(gen_log, ".*Malformed HTTP request line", level=logging.INFO):
             self.stream.write(b"asdf\r\n\r\n")
             # TODO: need an async version of ExpectLog so we don't need
             # hard-coded timeouts here.
@@ -498,7 +499,11 @@ class HTTPServerRawTest(AsyncHTTPTestCase):
             self.wait()
 
     def test_malformed_headers(self):
-        with ExpectLog(gen_log, ".*Malformed HTTP message.*no colon in header line"):
+        with ExpectLog(
+            gen_log,
+            ".*Malformed HTTP message.*no colon in header line",
+            level=logging.INFO,
+        ):
             self.stream.write(b"GET / HTTP/1.0\r\nasdf\r\n\r\n")
             self.io_loop.add_timeout(datetime.timedelta(seconds=0.05), self.stop)
             self.wait()
@@ -553,7 +558,9 @@ bar
 
     @gen_test
     def test_invalid_content_length(self):
-        with ExpectLog(gen_log, ".*Only integer Content-Length is allowed"):
+        with ExpectLog(
+            gen_log, ".*Only integer Content-Length is allowed", level=logging.INFO
+        ):
             self.stream.write(
                 b"""\
 POST /echo HTTP/1.1
@@ -1215,14 +1222,14 @@ class BodyLimitsTest(AsyncHTTPTestCase):
         self.assertEqual(response.body, b"4096")
 
     def test_large_body_buffered(self):
-        with ExpectLog(gen_log, ".*Content-Length too long"):
+        with ExpectLog(gen_log, ".*Content-Length too long", level=logging.INFO):
             response = self.fetch("/buffered", method="PUT", body=b"a" * 10240)
         self.assertEqual(response.code, 400)
 
     @unittest.skipIf(os.name == "nt", "flaky on windows")
     def test_large_body_buffered_chunked(self):
         # This test is flaky on windows for unknown reasons.
-        with ExpectLog(gen_log, ".*chunked body too large"):
+        with ExpectLog(gen_log, ".*chunked body too large", level=logging.INFO):
             response = self.fetch(
                 "/buffered",
                 method="PUT",
@@ -1231,13 +1238,13 @@ class BodyLimitsTest(AsyncHTTPTestCase):
         self.assertEqual(response.code, 400)
 
     def test_large_body_streaming(self):
-        with ExpectLog(gen_log, ".*Content-Length too long"):
+        with ExpectLog(gen_log, ".*Content-Length too long", level=logging.INFO):
             response = self.fetch("/streaming", method="PUT", body=b"a" * 10240)
         self.assertEqual(response.code, 400)
 
     @unittest.skipIf(os.name == "nt", "flaky on windows")
     def test_large_body_streaming_chunked(self):
-        with ExpectLog(gen_log, ".*chunked body too large"):
+        with ExpectLog(gen_log, ".*chunked body too large", level=logging.INFO):
             response = self.fetch(
                 "/streaming",
                 method="PUT",
@@ -1270,7 +1277,7 @@ class BodyLimitsTest(AsyncHTTPTestCase):
                 b"PUT /streaming?body_timeout=0.1 HTTP/1.0\r\n"
                 b"Content-Length: 42\r\n\r\n"
             )
-            with ExpectLog(gen_log, "Timeout reading body"):
+            with ExpectLog(gen_log, "Timeout reading body", level=logging.INFO):
                 response = yield stream.read_until_close()
             self.assertEqual(response, b"")
         finally:
@@ -1294,7 +1301,7 @@ class BodyLimitsTest(AsyncHTTPTestCase):
             stream.write(
                 b"PUT /streaming HTTP/1.1\r\n" b"Content-Length: 10240\r\n\r\n"
             )
-            with ExpectLog(gen_log, ".*Content-Length too long"):
+            with ExpectLog(gen_log, ".*Content-Length too long", level=logging.INFO):
                 data = yield stream.read_until_close()
             self.assertEqual(data, b"HTTP/1.1 400 Bad Request\r\n\r\n")
         finally:
diff --git a/tornado/test/iostream_test.py b/tornado/test/iostream_test.py
index d9e2c666..7e400c75 100644
--- a/tornado/test/iostream_test.py
+++ b/tornado/test/iostream_test.py
@@ -23,8 +23,10 @@ from tornado.testing import (
 )
 from tornado.test.util import skipIfNonUnix, refusing_port, skipPypy3V58
 from tornado.web import RequestHandler, Application
+import asyncio
 import errno
 import hashlib
+import logging
 import os
 import platform
 import random
@@ -165,6 +167,27 @@ class TestReadWriteMixin(object):
     def make_iostream_pair(self, **kwargs):
         raise NotImplementedError
 
+    def iostream_pair(self, **kwargs):
+        """Like make_iostream_pair, but called by ``async with``.
+
+        In py37 this becomes simpler with contextlib.asynccontextmanager.
+        """
+
+        class IOStreamPairContext:
+            def __init__(self, test, kwargs):
+                self.test = test
+                self.kwargs = kwargs
+
+            async def __aenter__(self):
+                self.pair = await self.test.make_iostream_pair(**self.kwargs)
+                return self.pair
+
+            async def __aexit__(self, typ, value, tb):
+                for s in self.pair:
+                    s.close()
+
+        return IOStreamPairContext(self, kwargs)
+
     @gen_test
     def test_write_zero_bytes(self):
         # Attempting to write zero bytes should run the callback without
@@ -260,6 +283,40 @@ class TestReadWriteMixin(object):
             ws.close()
             rs.close()
 
+    @gen_test
+    async def test_read_until_with_close_after_second_packet(self):
+        # This is a regression test for a regression in Tornado 6.0
+        # (maybe 6.0.3?) reported in
+        # https://github.com/tornadoweb/tornado/issues/2717
+        #
+        # The data arrives in two chunks; the stream is closed at the
+        # same time that the second chunk is received. If the second
+        # chunk is larger than the first, it works, but when this bug
+        # existed it would fail if the second chunk were smaller than
+        # the first. This is due to the optimization that the
+        # read_until condition is only checked when the buffer doubles
+        # in size
+        async with self.iostream_pair() as (rs, ws):
+            rf = asyncio.ensure_future(rs.read_until(b"done"))
+            await ws.write(b"x" * 2048)
+            ws.write(b"done")
+            ws.close()
+            await rf
+
+    @gen_test
+    async def test_read_until_unsatisfied_after_close(self: typing.Any):
+        # If a stream is closed while reading, it raises
+        # StreamClosedError instead of UnsatisfiableReadError (the
+        # latter should only be raised when byte limits are reached).
+        # The particular scenario tested here comes from #2717.
+        async with self.iostream_pair() as (rs, ws):
+            rf = asyncio.ensure_future(rs.read_until(b"done"))
+            await ws.write(b"x" * 2048)
+            ws.write(b"foo")
+            ws.close()
+            with self.assertRaises(StreamClosedError):
+                await rf
+
     @gen_test
     def test_close_callback_with_pending_read(self: typing.Any):
         # Regression test for a bug that was introduced in 2.3
@@ -366,7 +423,7 @@ class TestReadWriteMixin(object):
 
             # Not enough space, but we don't know it until all we can do is
             # log a warning and close the connection.
-            with ExpectLog(gen_log, "Unsatisfiable read"):
+            with ExpectLog(gen_log, "Unsatisfiable read", level=logging.INFO):
                 fut = rs.read_until(b"def", max_bytes=5)
                 ws.write(b"123456")
                 yield closed.wait()
@@ -385,7 +442,7 @@ class TestReadWriteMixin(object):
             # inline.  For consistency with the out-of-line case, we
             # do not raise the error synchronously.
             ws.write(b"123456")
-            with ExpectLog(gen_log, "Unsatisfiable read"):
+            with ExpectLog(gen_log, "Unsatisfiable read", level=logging.INFO):
                 with self.assertRaises(StreamClosedError):
                     yield rs.read_until(b"def", max_bytes=5)
             yield closed.wait()
@@ -403,7 +460,7 @@ class TestReadWriteMixin(object):
             # puts us over the limit, we fail the request because it was not
             # found within the limit.
             ws.write(b"abcdef")
-            with ExpectLog(gen_log, "Unsatisfiable read"):
+            with ExpectLog(gen_log, "Unsatisfiable read", level=logging.INFO):
                 rs.read_until(b"def", max_bytes=5)
                 yield closed.wait()
         finally:
@@ -430,7 +487,7 @@ class TestReadWriteMixin(object):
 
             # Not enough space, but we don't know it until all we can do is
             # log a warning and close the connection.
-            with ExpectLog(gen_log, "Unsatisfiable read"):
+            with ExpectLog(gen_log, "Unsatisfiable read", level=logging.INFO):
                 rs.read_until_regex(b"def", max_bytes=5)
                 ws.write(b"123456")
                 yield closed.wait()
@@ -449,7 +506,7 @@ class TestReadWriteMixin(object):
             # inline.  For consistency with the out-of-line case, we
             # do not raise the error synchronously.
             ws.write(b"123456")
-            with ExpectLog(gen_log, "Unsatisfiable read"):
+            with ExpectLog(gen_log, "Unsatisfiable read", level=logging.INFO):
                 rs.read_until_regex(b"def", max_bytes=5)
                 yield closed.wait()
         finally:
@@ -466,7 +523,7 @@ class TestReadWriteMixin(object):
             # puts us over the limit, we fail the request because it was not
             # found within the limit.
             ws.write(b"abcdef")
-            with ExpectLog(gen_log, "Unsatisfiable read"):
+            with ExpectLog(gen_log, "Unsatisfiable read", level=logging.INFO):
                 rs.read_until_regex(b"def", max_bytes=5)
                 yield closed.wait()
         finally:
diff --git a/tornado/test/netutil_test.py b/tornado/test/netutil_test.py
index 8ebc8e6a..310cf045 100644
--- a/tornado/test/netutil_test.py
+++ b/tornado/test/netutil_test.py
@@ -163,6 +163,7 @@ class ThreadedResolverImportTest(unittest.TestCase):
 # name with spaces used in this test.
 @skipIfNoNetwork
 @unittest.skipIf(pycares is None, "pycares module not present")
+@unittest.skipIf(sys.platform == "win32", "pycares doesn't return loopback on windows")
 class CaresResolverTest(AsyncTestCase, _ResolverTestMixin):
     def setUp(self):
         super(CaresResolverTest, self).setUp()
@@ -181,6 +182,7 @@ class CaresResolverTest(AsyncTestCase, _ResolverTestMixin):
 @unittest.skipIf(
     getattr(twisted, "__version__", "0.0") < "12.1", "old version of twisted"
 )
+@unittest.skipIf(sys.platform == "win32", "twisted resolver hangs on windows")
 class TwistedResolverTest(AsyncTestCase, _ResolverTestMixin):
     def setUp(self):
         super(TwistedResolverTest, self).setUp()
diff --git a/tornado/test/simple_httpclient_test.py b/tornado/test/simple_httpclient_test.py
index a883786d..18e09691 100644
--- a/tornado/test/simple_httpclient_test.py
+++ b/tornado/test/simple_httpclient_test.py
@@ -10,7 +10,7 @@ import sys
 import typing  # noqa: F401
 
 from tornado.escape import to_unicode, utf8
-from tornado import gen
+from tornado import gen, version
 from tornado.httpclient import AsyncHTTPClient
 from tornado.httputil import HTTPHeaders, ResponseStartLine
 from tornado.ioloop import IOLoop
@@ -28,6 +28,7 @@ from tornado.test.httpclient_test import (
     CountdownHandler,
     HelloWorldHandler,
     RedirectHandler,
+    UserAgentHandler,
 )
 from tornado.test import httpclient_test
 from tornado.testing import (
@@ -171,6 +172,7 @@ class SimpleHTTPClientTestMixin(object):
                 url("/echo_post", EchoPostHandler),
                 url("/respond_in_prepare", RespondInPrepareHandler),
                 url("/redirect", RedirectHandler),
+                url("/user_agent", UserAgentHandler),
             ],
             gzip=True,
         )
@@ -246,6 +248,11 @@ class SimpleHTTPClientTestMixin(object):
         self.fetch("/hello", headers=headers)
         self.assertEqual(list(headers.get_all()), [("User-Agent", "Foo")])
 
+    def test_default_user_agent(self: typing.Any):
+        response = self.fetch("/user_agent", method="GET")
+        self.assertEqual(200, response.code)
+        self.assertEqual(response.body.decode(), "Tornado/{}".format(version))
+
     def test_see_other_redirect(self: typing.Any):
         for code in (302, 303):
             response = self.fetch("/see_other_post", method="POST", body="%d" % code)
@@ -316,7 +323,9 @@ class SimpleHTTPClientTestMixin(object):
         response = self.fetch("/content_length?value=2,%202,2")
         self.assertEqual(response.body, b"ok")
 
-        with ExpectLog(gen_log, ".*Multiple unequal Content-Lengths"):
+        with ExpectLog(
+            gen_log, ".*Multiple unequal Content-Lengths", level=logging.INFO
+        ):
             with self.assertRaises(HTTPStreamClosedError):
                 self.fetch("/content_length?value=2,4", raise_error=True)
             with self.assertRaises(HTTPStreamClosedError):
@@ -657,7 +666,9 @@ class HTTP204NoContentTestCase(AsyncHTTPTestCase):
 
     def test_204_invalid_content_length(self):
         # 204 status with non-zero content length is malformed
-        with ExpectLog(gen_log, ".*Response with code 204 should not have body"):
+        with ExpectLog(
+            gen_log, ".*Response with code 204 should not have body", level=logging.INFO
+        ):
             with self.assertRaises(HTTPStreamClosedError):
                 self.fetch("/?error=1", raise_error=True)
                 if not self.http1:
@@ -768,7 +779,9 @@ class MaxBodySizeTest(AsyncHTTPTestCase):
 
     def test_large_body(self):
         with ExpectLog(
-            gen_log, "Malformed HTTP message from None: Content-Length too long"
+            gen_log,
+            "Malformed HTTP message from None: Content-Length too long",
+            level=logging.INFO,
         ):
             with self.assertRaises(HTTPStreamClosedError):
                 self.fetch("/large", raise_error=True)
@@ -815,6 +828,7 @@ class ChunkedWithContentLengthTest(AsyncHTTPTestCase):
                 "Malformed HTTP message from None: Response "
                 "with both Transfer-Encoding and Content-Length"
             ),
+            level=logging.INFO,
         ):
             with self.assertRaises(HTTPStreamClosedError):
                 self.fetch("/chunkwithcl", raise_error=True)
diff --git a/tornado/test/twisted_test.py b/tornado/test/twisted_test.py
index 0e03cec9..2fc3f8e3 100644
--- a/tornado/test/twisted_test.py
+++ b/tornado/test/twisted_test.py
@@ -51,7 +51,10 @@ skipIfNoTwisted = unittest.skipUnless(have_twisted, "twisted module not present"
 
 def save_signal_handlers():
     saved = {}
-    for sig in [signal.SIGINT, signal.SIGTERM, signal.SIGCHLD]:
+    signals = [signal.SIGINT, signal.SIGTERM]
+    if hasattr(signal, "SIGCHLD"):
+        signals.append(signal.SIGCHLD)
+    for sig in signals:
         saved[sig] = signal.getsignal(sig)
     if "twisted" in repr(saved):
         # This indicates we're not cleaning up after ourselves properly.
diff --git a/tornado/testing.py b/tornado/testing.py
index da275cbc..4bc83e46 100644
--- a/tornado/testing.py
+++ b/tornado/testing.py
@@ -657,6 +657,7 @@ class ExpectLog(logging.Filter):
         logger: Union[logging.Logger, basestring_type],
         regex: str,
         required: bool = True,
+        level: Optional[int] = None,
     ) -> None:
         """Constructs an ExpectLog context manager.
 
@@ -666,6 +667,15 @@ class ExpectLog(logging.Filter):
             the specified logger that match this regex will be suppressed.
         :param required: If true, an exception will be raised if the end of
             the ``with`` statement is reached without matching any log entries.
+        :param level: A constant from the ``logging`` module indicating the
+            expected log level. If this parameter is provided, only log messages
+            at this level will be considered to match. Additionally, the
+            supplied ``logger`` will have its level adjusted if necessary
+            (for the duration of the ``ExpectLog`` to enable the expected
+            message.
+
+        .. versionchanged:: 6.1
+           Added the ``level`` parameter.
         """
         if isinstance(logger, basestring_type):
             logger = logging.getLogger(logger)
@@ -674,17 +684,28 @@ class ExpectLog(logging.Filter):
         self.required = required
         self.matched = False
         self.logged_stack = False
+        self.level = level
+        self.orig_level = None  # type: Optional[int]
 
     def filter(self, record: logging.LogRecord) -> bool:
         if record.exc_info:
             self.logged_stack = True
         message = record.getMessage()
         if self.regex.match(message):
+            if self.level is not None and record.levelno != self.level:
+                app_log.warning(
+                    "Got expected log message %r at unexpected level (%s vs %s)"
+                    % (message, logging.getLevelName(self.level), record.levelname)
+                )
+                return True
             self.matched = True
             return False
         return True
 
     def __enter__(self) -> "ExpectLog":
+        if self.level is not None and self.level < self.logger.getEffectiveLevel():
+            self.orig_level = self.logger.level
+            self.logger.setLevel(self.level)
         self.logger.addFilter(self)
         return self
 
@@ -694,6 +715,8 @@ class ExpectLog(logging.Filter):
         value: Optional[BaseException],
         tb: Optional[TracebackType],
     ) -> None:
+        if self.orig_level is not None:
+            self.logger.setLevel(self.orig_level)
         self.logger.removeFilter(self)
         if not typ and self.required and not self.matched:
             raise Exception("did not get expected log message")