jehiah: RFR @bdarnell 

I've also done some preliminary validation that this solves the original issue I ran into.
jehiah: 🤦‍♂️  w/r/t executable bit. It seems my editor was doing that because those files begin with `#!/usr/bin/env python`. Is there a reason for/value in that if they should not be executable?
bdarnell: Thanks, just one more thing to figure out: the large-body chunked test cases are failing for some reason on windows (the fact that this happens on 2 of the 4 configurations on appveyor doesn't mean anything - we only run the full test suite on 2 configurations because tornado on windows is so slow). The `ExpectLog` is passing so the correct exception is getting raised. Maybe closing immediately after `yield stream.write` is not actually waiting for the outgoing data to be sent on windows because there is also unread data in the incoming socket buffer. This isn't too important so maybe these tests should just accept both 400 and 599 response codes. 

There's no reason for the `#!/usr/bin/env python` line to be present in files that are not executable; this line could just be removed.
jehiah: @bdarnell updated to address flaky tests; if this looks good i'll squash/cleanup commits.
diff --git a/tornado/http1connection.py b/tornado/http1connection.py
index c6d3e336..6069e027 100644
--- a/tornado/http1connection.py
+++ b/tornado/http1connection.py
@@ -250,6 +250,8 @@ class HTTP1Connection(httputil.HTTPConnection):
         except httputil.HTTPInputError as e:
             gen_log.info("Malformed HTTP message from %s: %s",
                          self.context, e)
+            if not self.is_client:
+                yield self.stream.write(b'HTTP/1.1 400 Bad Request\r\n\r\n')
             self.close()
             raise gen.Return(False)
         finally:
diff --git a/tornado/httputil.py b/tornado/httputil.py
old mode 100644
new mode 100755
index 818ea914..5b87ce61
--- a/tornado/httputil.py
+++ b/tornado/httputil.py
@@ -829,6 +829,8 @@ def parse_request_start_line(line):
     try:
         method, path, version = line.split(" ")
     except ValueError:
+        # 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 re.match(r"^HTTP/1\.[0-9]$", version):
         raise HTTPInputError(

Empty reply on malformed HTTP request
When an incorrect HTTP request is performed, tornado makes no reply (log + return in code). I suggest returning an 400 error code.

For example, when working behind an nginx proxy-server, it causes a 502 error code to the end-user and a record in nginx's error log, which is incorrect (should be 400 and a warning).

diff --git a/tornado/test/httpserver_test.py b/tornado/test/httpserver_test.py
index f5f91a9d..4169a43a 100644
--- a/tornado/test/httpserver_test.py
+++ b/tornado/test/httpserver_test.py
@@ -29,18 +29,19 @@ from io import BytesIO
 
 def read_stream_body(stream, callback):
     """Reads an HTTP response from `stream` and runs callback with its
-    headers and body."""
+    start_line, headers and body."""
     chunks = []
 
     class Delegate(HTTPMessageDelegate):
         def headers_received(self, start_line, headers):
             self.headers = headers
+            self.start_line = start_line
 
         def data_received(self, chunk):
             chunks.append(chunk)
 
         def finish(self):
-            callback((self.headers, b''.join(chunks)))
+            callback((self.start_line, self.headers, b''.join(chunks)))
     conn = HTTP1Connection(stream, True)
     conn.read_response(Delegate())
 
@@ -217,7 +218,7 @@ class HTTPConnectionTest(AsyncHTTPTestCase):
                              [utf8("Content-Length: %d" % len(body))]) +
                 newline + newline + body)
             read_stream_body(stream, self.stop)
-            headers, body = self.wait()
+            start_line, headers, body = self.wait()
             return body
 
     def test_multipart_form(self):
@@ -406,7 +407,15 @@ class HTTPServerRawTest(AsyncHTTPTestCase):
         self.io_loop.add_timeout(datetime.timedelta(seconds=0.001), self.stop)
         self.wait()
 
-    def test_malformed_first_line(self):
+    def test_malformed_first_line_response(self):
+        self.stream.write(b'asdf\r\n\r\n')
+        read_stream_body(self.stream, self.stop)
+        start_line, headers, response = self.wait()
+        self.assertEqual('HTTP/1.1', start_line.version)
+        self.assertEqual(400, start_line.code)
+        self.assertEqual('Bad Request', start_line.reason)
+
+    def test_malformed_first_line_log(self):
         with ExpectLog(gen_log, '.*Malformed HTTP request line'):
             self.stream.write(b'asdf\r\n\r\n')
             # TODO: need an async version of ExpectLog so we don't need
@@ -438,7 +447,7 @@ bar
 
 """.replace(b"\n", b"\r\n"))
         read_stream_body(self.stream, self.stop)
-        headers, response = self.wait()
+        start_line, headers, response = self.wait()
         self.assertEqual(json_decode(response), {u'foo': [u'bar']})
 
     def test_chunked_request_uppercase(self):
@@ -457,7 +466,7 @@ bar
 
 """.replace(b"\n", b"\r\n"))
         read_stream_body(self.stream, self.stop)
-        headers, response = self.wait()
+        start_line, headers, response = self.wait()
         self.assertEqual(json_decode(response), {u'foo': [u'bar']})
 
     def test_invalid_content_length(self):
@@ -627,7 +636,7 @@ class UnixSocketTest(AsyncTestCase):
             self.stream.write(b"garbage\r\n\r\n")
             self.stream.read_until_close(self.stop)
             response = self.wait()
-        self.assertEqual(response, b"")
+        self.assertEqual(response, b"HTTP/1.1 400 Bad Request\r\n\r\n")
 
 
 class KeepAliveTest(AsyncHTTPTestCase):
@@ -1036,24 +1045,26 @@ class BodyLimitsTest(AsyncHTTPTestCase):
     def test_large_body_buffered(self):
         with ExpectLog(gen_log, '.*Content-Length too long'):
             response = self.fetch('/buffered', method='PUT', body=b'a' * 10240)
-        self.assertEqual(response.code, 599)
+        self.assertEqual(response.code, 400)
 
     def test_large_body_buffered_chunked(self):
         with ExpectLog(gen_log, '.*chunked body too large'):
             response = self.fetch('/buffered', method='PUT',
                                   body_producer=lambda write: write(b'a' * 10240))
-        self.assertEqual(response.code, 599)
+        # this test is flaky on windows; accept 400 (expected) or 599
+        self.assertIn(response.code, [400, 599])
 
     def test_large_body_streaming(self):
         with ExpectLog(gen_log, '.*Content-Length too long'):
             response = self.fetch('/streaming', method='PUT', body=b'a' * 10240)
-        self.assertEqual(response.code, 599)
+        self.assertEqual(response.code, 400)
 
     def test_large_body_streaming_chunked(self):
         with ExpectLog(gen_log, '.*chunked body too large'):
             response = self.fetch('/streaming', method='PUT',
                                   body_producer=lambda write: write(b'a' * 10240))
-        self.assertEqual(response.code, 599)
+        # this test is flaky on windows; accept 400 (expected) or 599
+        self.assertIn(response.code, [400, 599])
 
     def test_large_body_streaming_override(self):
         response = self.fetch('/streaming?expected_size=10240', method='PUT',
@@ -1090,14 +1101,14 @@ class BodyLimitsTest(AsyncHTTPTestCase):
             stream.write(b'PUT /streaming?expected_size=10240 HTTP/1.1\r\n'
                          b'Content-Length: 10240\r\n\r\n')
             stream.write(b'a' * 10240)
-            headers, response = yield gen.Task(read_stream_body, stream)
+            start_line, headers, response = yield gen.Task(read_stream_body, stream)
             self.assertEqual(response, b'10240')
             # Without the ?expected_size parameter, we get the old default value
             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'):
                 data = yield stream.read_until_close()
-            self.assertEqual(data, b'')
+            self.assertEqual(data, b'HTTP/1.1 400 Bad Request\r\n\r\n')
         finally:
             stream.close()
 

pip install -e .
parse_log_pytest
pytest
futures mock monotonic trollius sphinx sphinx_rtd_theme codecov virtualenv pytest
apt-get update apt-get install -y gcc
3.6
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
alabaster==0.7.13
attrs @ file:///opt/conda/conda-bld/attrs_1642510447205/work
Babel==2.11.0
certifi==2021.5.30
charset-normalizer==2.0.12
codecov==2.1.13
coverage==6.2
distlib==0.3.9
docutils==0.18.1
filelock==3.4.1
futures==2.2.0
idna==3.10
imagesize==1.4.1
importlib-metadata==4.8.3
importlib-resources==5.4.0
iniconfig @ file:///home/linux1/recipes/ci/iniconfig_1610983019677/work
Jinja2==3.0.3
MarkupSafe==2.0.1
mock==5.2.0
monotonic==1.6
more-itertools @ file:///tmp/build/80754af9/more-itertools_1637733554872/work
packaging @ file:///tmp/build/80754af9/packaging_1637314298585/work
platformdirs==2.4.0
pluggy @ file:///tmp/build/80754af9/pluggy_1615976315926/work
py @ file:///opt/conda/conda-bld/py_1644396412707/work
Pygments==2.14.0
pyparsing @ file:///tmp/build/80754af9/pyparsing_1635766073266/work
pytest==6.2.4
pytz==2025.2
requests==2.27.1
six==1.17.0
snowballstemmer==2.2.0
Sphinx==5.3.0
sphinx-rtd-theme==2.0.0
sphinxcontrib-applehelp==1.0.2
sphinxcontrib-devhelp==1.0.2
sphinxcontrib-htmlhelp==2.0.0
sphinxcontrib-jquery==4.1
sphinxcontrib-jsmath==1.0.1
sphinxcontrib-qthelp==1.0.3
sphinxcontrib-serializinghtml==1.1.5
toml @ file:///tmp/build/80754af9/toml_1616166611790/work
-e git+https://github.com/tornadoweb/tornado.git@09e255779dc1db78a0c76e720660f802d7dc9cab#egg=tornado
trollius==2.1.post2
typing_extensions @ file:///opt/conda/conda-bld/typing_extensions_1647553014482/work
urllib3==1.26.20
virtualenv==20.17.1
zipp @ file:///tmp/build/80754af9/zipp_1633618647012/work

name: tornado
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - attrs=21.4.0=pyhd3eb1b0_0
  - ca-certificates=2025.2.25=h06a4308_0
  - certifi=2021.5.30=py36h06a4308_0
  - importlib_metadata=4.8.1=hd3eb1b0_0
  - iniconfig=1.1.1=pyhd3eb1b0_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.3=he6710b0_2
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - more-itertools=8.12.0=pyhd3eb1b0_0
  - ncurses=6.4=h6a678d5_0
  - openssl=1.1.1w=h7f8727e_0
  - packaging=21.3=pyhd3eb1b0_0
  - pip=21.2.2=py36h06a4308_0
  - pluggy=0.13.1=py36h06a4308_0
  - py=1.11.0=pyhd3eb1b0_0
  - pyparsing=3.0.4=pyhd3eb1b0_0
  - pytest=6.2.4=py36h06a4308_2
  - python=3.6.13=h12debd9_1
  - readline=8.2=h5eee18b_0
  - setuptools=58.0.4=py36h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - toml=0.10.2=pyhd3eb1b0_0
  - typing_extensions=4.1.1=pyh06a4308_0
  - wheel=0.37.1=pyhd3eb1b0_0
  - xz=5.6.4=h5eee18b_1
  - zipp=3.6.0=pyhd3eb1b0_0
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - alabaster==0.7.13
      - babel==2.11.0
      - charset-normalizer==2.0.12
      - codecov==2.1.13
      - coverage==6.2
      - distlib==0.3.9
      - docutils==0.18.1
      - filelock==3.4.1
      - futures==2.2.0
      - idna==3.10
      - imagesize==1.4.1
      - importlib-metadata==4.8.3
      - importlib-resources==5.4.0
      - jinja2==3.0.3
      - markupsafe==2.0.1
      - mock==5.2.0
      - monotonic==1.6
      - platformdirs==2.4.0
      - pygments==2.14.0
      - pytz==2025.2
      - requests==2.27.1
      - six==1.17.0
      - snowballstemmer==2.2.0
      - sphinx==5.3.0
      - sphinx-rtd-theme==2.0.0
      - sphinxcontrib-applehelp==1.0.2
      - sphinxcontrib-devhelp==1.0.2
      - sphinxcontrib-htmlhelp==2.0.0
      - sphinxcontrib-jquery==4.1
      - sphinxcontrib-jsmath==1.0.1
      - sphinxcontrib-qthelp==1.0.3
      - sphinxcontrib-serializinghtml==1.1.5
      - trollius==2.1.post2
      - urllib3==1.26.20
      - virtualenv==20.17.1
prefix: /opt/conda/envs/tornado

tornado/test/httpserver_test.py::HTTPServerRawTest::test_malformed_first_line_response
tornado/test/httpserver_test.py::HTTPServerRawTest::test_invalid_content_length tornado/test/httpserver_test.py::HTTPServerRawTest::test_malformed_first_line_log tornado/test/httpserver_test.py::HTTPServerRawTest::test_malformed_headers tornado/test/httpserver_test.py::UnixSocketTest::test_unix_socket_bad_request tornado/test/httpserver_test.py::BodyLimitsTest::test_body_size_override_reset tornado/test/httpserver_test.py::BodyLimitsTest::test_large_body_buffered tornado/test/httpserver_test.py::BodyLimitsTest::test_large_body_buffered_chunked tornado/test/httpserver_test.py::BodyLimitsTest::test_large_body_streaming tornado/test/httpserver_test.py::BodyLimitsTest::test_large_body_streaming_chunked tornado/test/httpserver_test.py::BodyLimitsTest::test_timeout
tornado/test/httpserver_test.py::SSLv23Test::test_error_logging tornado/test/httpserver_test.py::SSLv23Test::test_large_post tornado/test/httpserver_test.py::SSLv23Test::test_non_ssl_request tornado/test/httpserver_test.py::SSLv23Test::test_ssl tornado/test/httpserver_test.py::SSLv3Test::test_error_logging tornado/test/httpserver_test.py::SSLv3Test::test_large_post tornado/test/httpserver_test.py::SSLv3Test::test_non_ssl_request tornado/test/httpserver_test.py::SSLv3Test::test_ssl tornado/test/httpserver_test.py::TLSv1Test::test_error_logging tornado/test/httpserver_test.py::TLSv1Test::test_large_post tornado/test/httpserver_test.py::TLSv1Test::test_non_ssl_request tornado/test/httpserver_test.py::TLSv1Test::test_ssl tornado/test/httpserver_test.py::SSLContextTest::test_error_logging tornado/test/httpserver_test.py::SSLContextTest::test_large_post tornado/test/httpserver_test.py::SSLContextTest::test_non_ssl_request tornado/test/httpserver_test.py::SSLContextTest::test_ssl tornado/test/httpserver_test.py::BadSSLOptionsTest::test_missing_arguments tornado/test/httpserver_test.py::BadSSLOptionsTest::test_missing_key tornado/test/httpserver_test.py::HTTPConnectionTest::test_100_continue tornado/test/httpserver_test.py::HTTPConnectionTest::test_multipart_form tornado/test/httpserver_test.py::HTTPConnectionTest::test_newlines tornado/test/httpserver_test.py::HTTPServerTest::test_double_slash tornado/test/httpserver_test.py::HTTPServerTest::test_empty_post_parameters tornado/test/httpserver_test.py::HTTPServerTest::test_empty_query_string tornado/test/httpserver_test.py::HTTPServerTest::test_malformed_body tornado/test/httpserver_test.py::HTTPServerTest::test_query_string_encoding tornado/test/httpserver_test.py::HTTPServerTest::test_types tornado/test/httpserver_test.py::HTTPServerRawTest::test_chunked_request_body tornado/test/httpserver_test.py::HTTPServerRawTest::test_chunked_request_uppercase tornado/test/httpserver_test.py::HTTPServerRawTest::test_empty_request tornado/test/httpserver_test.py::XHeaderTest::test_ip_headers tornado/test/httpserver_test.py::XHeaderTest::test_scheme_headers tornado/test/httpserver_test.py::XHeaderTest::test_trusted_downstream tornado/test/httpserver_test.py::SSLXHeaderTest::test_request_without_xprotocol tornado/test/httpserver_test.py::ManualProtocolTest::test_manual_protocol tornado/test/httpserver_test.py::UnixSocketTest::test_unix_socket tornado/test/httpserver_test.py::KeepAliveTest::test_cancel_during_download tornado/test/httpserver_test.py::KeepAliveTest::test_finish_while_closed tornado/test/httpserver_test.py::KeepAliveTest::test_http10 tornado/test/httpserver_test.py::KeepAliveTest::test_http10_keepalive tornado/test/httpserver_test.py::KeepAliveTest::test_http10_keepalive_extra_crlf tornado/test/httpserver_test.py::KeepAliveTest::test_keepalive_chunked tornado/test/httpserver_test.py::KeepAliveTest::test_pipelined_cancel tornado/test/httpserver_test.py::KeepAliveTest::test_pipelined_requests tornado/test/httpserver_test.py::KeepAliveTest::test_request_close tornado/test/httpserver_test.py::KeepAliveTest::test_two_requests tornado/test/httpserver_test.py::GzipTest::test_gzip tornado/test/httpserver_test.py::GzipTest::test_uncompressed tornado/test/httpserver_test.py::GzipUnsupportedTest::test_gzip_unsupported tornado/test/httpserver_test.py::GzipUnsupportedTest::test_uncompressed tornado/test/httpserver_test.py::StreamingChunkSizeTest::test_chunked_body tornado/test/httpserver_test.py::StreamingChunkSizeTest::test_chunked_compressed tornado/test/httpserver_test.py::StreamingChunkSizeTest::test_compressed_body tornado/test/httpserver_test.py::StreamingChunkSizeTest::test_regular_body tornado/test/httpserver_test.py::MaxHeaderSizeTest::test_large_headers tornado/test/httpserver_test.py::MaxHeaderSizeTest::test_small_headers tornado/test/httpserver_test.py::IdleTimeoutTest::test_idle_after_use tornado/test/httpserver_test.py::IdleTimeoutTest::test_unused_connection tornado/test/httpserver_test.py::BodyLimitsTest::test_large_body_streaming_chunked_override tornado/test/httpserver_test.py::BodyLimitsTest::test_large_body_streaming_override tornado/test/httpserver_test.py::BodyLimitsTest::test_small_body tornado/test/httpserver_test.py::LegacyInterfaceTest::test_legacy_interface
tornado/httputil.py tornado/http1connection.py
tornado/httputil.py tornado/http1connection.py