Repository: sanic-org/sanic

Issue:
Exception when terminating while receiving a request
**Describe the bug**
Chrome, Safari and probably other browsers seem to open surplus connections to the server in advance in order to improve performance (see for example [this stackoverflow thread](https://stackoverflow.com/questions/4761913/server-socket-receives-2-http-requests-when-i-send-from-chrome-and-receives-one)). These connections are kept open in the state "wait for request".

If Sanic is terminated with such a pending request, the request gets cancelled after `GRACEFUL_SHUTDOWN_TIMEOUT` and the receive is cancelled. In reaction the the `CancelledError`, Sanic will try to send an error response, but the transport has already been cleaned out, and as the request has not been received yet a new empty, request with `transport=None` is created. The symptom is this trace:

```
  File "/Users/pestix/.virtualenvs/server-25sdMkop/lib/python3.9/site-packages/sanic/http.py", line 126, in http1
    await self.http1_request_header()
  File "/Users/pestix/.virtualenvs/server-25sdMkop/lib/python3.9/site-packages/sanic/http.py", line 188, in http1_request_header
    await self._receive_more()
  File "/Users/pestix/.virtualenvs/server-25sdMkop/lib/python3.9/site-packages/sanic/server.py", line 222, in receive_more
    await self._data_received.wait()
  File "/usr/local/Cellar/python@3.9/3.9.5/Frameworks/Python.framework/Versions/3.9/lib/python3.9/asyncio/locks.py", line 226, in wait
    await fut
asyncio.exceptions.CancelledError

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/Users/pestix/.virtualenvs/server-25sdMkop/lib/python3.9/site-packages/sanic/server.py", line 197, in connection_task
    await self._http.http1()
  File "/Users/pestix/.virtualenvs/server-25sdMkop/lib/python3.9/site-packages/sanic/http.py", line 142, in http1
    await self.error_response(e)
  File "/Users/pestix/.virtualenvs/server-25sdMkop/lib/python3.9/site-packages/sanic/http.py", line 405, in error_response
    await app.handle_exception(self.request, exception)
  File "/Users/pestix/.virtualenvs/server-25sdMkop/lib/python3.9/site-packages/sanic/app.py", line 704, in handle_exception
    await response.send(end_stream=True)
  File "/Users/pestix/.virtualenvs/server-25sdMkop/lib/python3.9/site-packages/sanic/response.py", line 122, in send
    await self.stream.send(data, end_stream=end_stream)
  File "/Users/pestix/.virtualenvs/server-25sdMkop/lib/python3.9/site-packages/sanic/http.py", line 335, in http1_response_header
    await self._send(ret)
  File "/Users/pestix/.virtualenvs/server-25sdMkop/lib/python3.9/site-packages/sanic/server.py", line 267, in send
    if self.transport.is_closing():
AttributeError: 'NoneType' object has no attribute 'is_closing'
``` 


**Code snippet**
This can be reproduced with the hello world example. Load the page in Chrome, then terminate. Sanic will wait 15 seconds, then die with above trace. On my Mac, this happens about 75% of the time.


**Expected behavior**
Clean exit.


**Environment (please complete the following information):**
 - OS: MacOS
 - Version: 21.6.0



Discussion:
I've opened a PR... 😏 

Resolving patch:
diff --git a/sanic/http.py b/sanic/http.py
--- a/sanic/http.py
+++ b/sanic/http.py
@@ -148,6 +148,12 @@ async def http1(self):
                     await self.response.send(end_stream=True)
             except CancelledError:
                 # Write an appropriate response before exiting
+                if not self.protocol.transport:
+                    logger.info(
+                        f"Request: {self.request.method} {self.request.url} "
+                        "stopped. Transport is closed."
+                    )
+                    return
                 e = self.exception or ServiceUnavailable("Cancelled")
                 self.exception = None
                 self.keep_alive = False
diff --git a/sanic/server/protocols/http_protocol.py b/sanic/server/protocols/http_protocol.py
--- a/sanic/server/protocols/http_protocol.py
+++ b/sanic/server/protocols/http_protocol.py
@@ -109,7 +109,7 @@ async def connection_task(self):  # no cov
         except Exception:
             error_logger.exception("protocol.connection_task uncaught")
         finally:
-            if self.app.debug and self._http:
+            if self.app.debug and self._http and self.transport:
                 ip = self.transport.get_extra_info("peername")
                 error_logger.error(
                     "Connection lost before response written"


Test patch:
diff --git a/tests/test_graceful_shutdown.py b/tests/test_graceful_shutdown.py
new file mode 100644
--- /dev/null
+++ b/tests/test_graceful_shutdown.py
@@ -0,0 +1,46 @@
+import asyncio
+import logging
+import time
+
+from collections import Counter
+from multiprocessing import Process
+
+import httpx
+
+
+PORT = 42101
+
+
+def test_no_exceptions_when_cancel_pending_request(app, caplog):
+    app.config.GRACEFUL_SHUTDOWN_TIMEOUT = 1
+
+    @app.get("/")
+    async def handler(request):
+        await asyncio.sleep(5)
+
+    @app.after_server_start
+    def shutdown(app, _):
+        time.sleep(0.2)
+        app.stop()
+
+    def ping():
+        time.sleep(0.1)
+        response = httpx.get("http://127.0.0.1:8000")
+        print(response.status_code)
+
+    p = Process(target=ping)
+    p.start()
+
+    with caplog.at_level(logging.INFO):
+        app.run()
+
+    p.kill()
+
+    counter = Counter([r[1] for r in caplog.record_tuples])
+
+    assert counter[logging.INFO] == 5
+    assert logging.ERROR not in counter
+    assert (
+        caplog.record_tuples[3][2]
+        == "Request: GET http://127.0.0.1:8000/ stopped. Transport is closed."
+    )
diff --git a/tests/test_signal_handlers.py b/tests/test_signal_handlers.py
--- a/tests/test_signal_handlers.py
+++ b/tests/test_signal_handlers.py
@@ -7,7 +7,6 @@
 
 import pytest
 
-from sanic_testing.reusable import ReusableClient
 from sanic_testing.testing import HOST, PORT
 
 from sanic.compat import ctrlc_workaround_for_windows
@@ -29,13 +28,9 @@ def set_loop(app, loop):
         signal.signal = mock
     else:
         loop.add_signal_handler = mock
-    print(">>>>>>>>>>>>>>>1", id(loop))
-    print(">>>>>>>>>>>>>>>1", loop.add_signal_handler)
 
 
 def after(app, loop):
-    print(">>>>>>>>>>>>>>>2", id(loop))
-    print(">>>>>>>>>>>>>>>2", loop.add_signal_handler)
     calledq.put(mock.called)
 
 
@@ -100,7 +95,7 @@ async def atest(stop_first):
         os.kill(os.getpid(), signal.SIGINT)
         await asyncio.sleep(0.2)
         assert app.is_stopping
-        assert app.stay_active_task.result() == None
+        assert app.stay_active_task.result() is None
         # Second Ctrl+C should raise
         with pytest.raises(KeyboardInterrupt):
             os.kill(os.getpid(), signal.SIGINT)