Repository: encode/uvicorn

Issue:
Handshaking may not be completed yet at `shutdown` in wsproto impl
Traceback:
```
LocalProtocolError: Event CloseConnection(code=1012, reason=None) cannot be sent during the handshake
  File "gunicorn/arbiter.py", line 583, in spawn_worker
    worker.init_process()
  File "uvicorn/workers.py", line 57, in init_process
    super(UvicornWorker, self).init_process()
  File "gunicorn/workers/base.py", line 140, in init_process
    self.run()
  File "uvicorn/workers.py", line 66, in run
    loop.run_until_complete(server.serve(sockets=self.sockets))
  File "uvloop/loop.pyx", line 1456, in uvloop.loop.Loop.run_until_complete
  File "uvicorn/main.py", line 403, in serve
    await self.shutdown(sockets=sockets)
  File "uvicorn/main.py", line 539, in shutdown
    connection.shutdown()
  File "uvicorn/protocols/websockets/wsproto_impl.py", line 115, in shutdown
    output = self.conn.send(wsproto.events.CloseConnection(code=1012))
  File "__init__.py", line 61, in send
    data += self.handshake.send(event)
  File "wsproto/handshake.py", line 101, in send
    "Event {} cannot be sent during the handshake".format(event)
```

Discussion:

Patch:
diff --git a/setup.cfg b/setup.cfg
index 46f4e3b..ac52be5 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -82,7 +82,7 @@ plugins =
 
 [coverage:report]
 precision = 2
-fail_under = 97.82
+fail_under = 97.92
 show_missing = true
 skip_covered = true
 exclude_lines =
diff --git a/uvicorn/config.py b/uvicorn/config.py
index df91a6c..d2faf76 100644
--- a/uvicorn/config.py
+++ b/uvicorn/config.py
@@ -372,6 +372,9 @@ class Config:
         else:
             self.forwarded_allow_ips = forwarded_allow_ips
 
+        if self.reload and self.workers > 1:
+            logger.warning('"workers" flag is ignored when reloading is enabled.')
+
     @property
     def asgi_version(self) -> Literal["2.0", "3.0"]:
         mapping: Dict[str, Literal["2.0", "3.0"]] = {
diff --git a/uvicorn/protocols/websockets/websockets_impl.py b/uvicorn/protocols/websockets/websockets_impl.py
index 01133b7..87e7baa 100644
--- a/uvicorn/protocols/websockets/websockets_impl.py
+++ b/uvicorn/protocols/websockets/websockets_impl.py
@@ -345,6 +345,8 @@ class WebSocketProtocol(WebSocketServerProtocol):
             data = await self.recv()
         except ConnectionClosed as exc:
             self.closed_event.set()
+            if self.ws_server.closing:
+                return {"type": "websocket.disconnect", "code": 1012}
             return {"type": "websocket.disconnect", "code": exc.code}
 
         msg: WebSocketReceiveEvent = {  # type: ignore[typeddict-item]
diff --git a/uvicorn/protocols/websockets/wsproto_impl.py b/uvicorn/protocols/websockets/wsproto_impl.py
index 6e4f505..a97766f 100644
--- a/uvicorn/protocols/websockets/wsproto_impl.py
+++ b/uvicorn/protocols/websockets/wsproto_impl.py
@@ -87,11 +87,8 @@ class WSProtocol(asyncio.Protocol):
         try:
             self.conn.receive_data(data)
         except RemoteProtocolError as err:
-            if err.event_hint is not None:
-                self.transport.write(self.conn.send(err.event_hint))
-                self.transport.close()
-            else:
-                self.handle_no_connect(events.CloseConnection())
+            self.transport.write(self.conn.send(err.event_hint))
+            self.transport.close()
         else:
             self.handle_events()
 
@@ -125,9 +122,12 @@ class WSProtocol(asyncio.Protocol):
         self.writable.set()
 
     def shutdown(self):
-        self.queue.put_nowait({"type": "websocket.disconnect", "code": 1012})
-        output = self.conn.send(wsproto.events.CloseConnection(code=1012))
-        self.transport.write(output)
+        if self.handshake_complete:
+            self.queue.put_nowait({"type": "websocket.disconnect", "code": 1012})
+            output = self.conn.send(wsproto.events.CloseConnection(code=1012))
+            self.transport.write(output)
+        else:
+            self.send_500_response()
         self.transport.close()
 
     def on_task_complete(self, task):
@@ -222,9 +222,8 @@ class WSProtocol(asyncio.Protocol):
     async def run_asgi(self):
         try:
             result = await self.app(self.scope, self.receive, self.send)
-        except BaseException as exc:
-            msg = "Exception in ASGI application\n"
-            self.logger.error(msg, exc_info=exc)
+        except BaseException:
+            self.logger.exception("Exception in ASGI application\n")
             if not self.handshake_complete:
                 self.send_500_response()
             self.transport.close()
@@ -257,14 +256,15 @@ class WSProtocol(asyncio.Protocol):
                 extensions = []
                 if self.config.ws_per_message_deflate:
                     extensions.append(PerMessageDeflate())
-                output = self.conn.send(
-                    wsproto.events.AcceptConnection(
-                        subprotocol=subprotocol,
-                        extensions=extensions,
-                        extra_headers=extra_headers,
+                if not self.transport.is_closing():
+                    output = self.conn.send(
+                        wsproto.events.AcceptConnection(
+                            subprotocol=subprotocol,
+                            extensions=extensions,
+                            extra_headers=extra_headers,
+                        )
                     )
-                )
-                self.transport.write(output)
+                    self.transport.write(output)
 
             elif message_type == "websocket.close":
                 self.queue.put_nowait({"type": "websocket.disconnect", "code": None})
diff --git a/uvicorn/workers.py b/uvicorn/workers.py
index c7d16ff..82b18e9 100644
--- a/uvicorn/workers.py
+++ b/uvicorn/workers.py
@@ -2,7 +2,7 @@ import asyncio
 import logging
 import signal
 import sys
-from typing import Any
+from typing import Any, Dict
 
 from gunicorn.arbiter import Arbiter
 from gunicorn.workers.base import Worker
@@ -17,7 +17,7 @@ class UvicornWorker(Worker):
     rather than a WSGI callable.
     """
 
-    CONFIG_KWARGS = {"loop": "auto", "http": "auto"}
+    CONFIG_KWARGS: Dict[str, Any] = {"loop": "auto", "http": "auto"}
 
     def __init__(self, *args: Any, **kwargs: Any) -> None:
         super(UvicornWorker, self).__init__(*args, **kwargs)


Test patch:
diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml
index 04c13b5..ff82ebd 100644
--- a/.github/workflows/test-suite.yml
+++ b/.github/workflows/test-suite.yml
@@ -13,7 +13,7 @@ jobs:
     runs-on: "${{ matrix.os }}"
     strategy:
       matrix:
-        python-version: ["3.7", "3.8", "3.9", "3.10", "3.11-dev"]
+        python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"]
         os: [windows-latest, ubuntu-latest, macos-latest]
     steps:
       - uses: "actions/checkout@v3"
diff --git a/tests/protocols/test_websocket.py b/tests/protocols/test_websocket.py
index a5fe93d..d495f51 100644
--- a/tests/protocols/test_websocket.py
+++ b/tests/protocols/test_websocket.py
@@ -528,7 +528,6 @@ async def test_client_connection_lost(ws_protocol_cls, http_protocol_cls):
         while True:
             message = await receive()
             if message["type"] == "websocket.connect":
-                print("accepted")
                 await send({"type": "websocket.accept"})
             elif message["type"] == "websocket.disconnect":
                 break
@@ -551,6 +550,66 @@ async def test_client_connection_lost(ws_protocol_cls, http_protocol_cls):
     assert got_disconnect_event_before_shutdown is True
 
 
+@pytest.mark.anyio
+@pytest.mark.parametrize("ws_protocol_cls", WS_PROTOCOLS)
+@pytest.mark.parametrize("http_protocol_cls", HTTP_PROTOCOLS)
+async def test_not_accept_on_connection_lost(ws_protocol_cls, http_protocol_cls):
+    send_accept_task = asyncio.Event()
+
+    async def app(scope, receive, send):
+        while True:
+            message = await receive()
+            if message["type"] == "websocket.connect":
+                await send_accept_task.wait()
+                await send({"type": "websocket.accept"})
+            elif message["type"] == "websocket.disconnect":
+                break
+
+    async def websocket_session(uri):
+        async with websockets.client.connect(uri):
+            while True:
+                await asyncio.sleep(0.1)
+
+    config = Config(app=app, ws=ws_protocol_cls, http=http_protocol_cls, lifespan="off")
+    async with run_server(config):
+        task = asyncio.create_task(websocket_session("ws://127.0.0.1:8000"))
+        await asyncio.sleep(0.1)
+        task.cancel()
+        send_accept_task.set()
+
+
+@pytest.mark.anyio
+@pytest.mark.parametrize("ws_protocol_cls", WS_PROTOCOLS)
+@pytest.mark.parametrize("http_protocol_cls", HTTP_PROTOCOLS)
+async def test_send_close_on_server_shutdown(ws_protocol_cls, http_protocol_cls):
+    disconnect_message = {}
+
+    async def app(scope, receive, send):
+        nonlocal disconnect_message
+        while True:
+            message = await receive()
+            if message["type"] == "websocket.connect":
+                await send({"type": "websocket.accept"})
+            elif message["type"] == "websocket.disconnect":
+                disconnect_message = message
+                break
+
+    async def websocket_session(uri):
+        async with websockets.client.connect(uri):
+            while True:
+                await asyncio.sleep(0.1)
+
+    config = Config(app=app, ws=ws_protocol_cls, http=http_protocol_cls, lifespan="off")
+    async with run_server(config):
+        task = asyncio.create_task(websocket_session("ws://127.0.0.1:8000"))
+        await asyncio.sleep(0.1)
+        disconnect_message_before_shutdown = disconnect_message
+
+    assert disconnect_message_before_shutdown == {}
+    assert disconnect_message == {"type": "websocket.disconnect", "code": 1012}
+    task.cancel()
+
+
 @pytest.mark.anyio
 @pytest.mark.parametrize("ws_protocol_cls", WS_PROTOCOLS)
 @pytest.mark.parametrize("http_protocol_cls", HTTP_PROTOCOLS)
diff --git a/tests/test_config.py b/tests/test_config.py
index 403112f..d11c632 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -557,3 +557,12 @@ def test_config_use_subprocess(reload, workers, expected):
     config = Config(app=asgi_app, reload=reload, workers=workers)
     config.load()
     assert config.use_subprocess == expected
+
+
+def test_warn_when_using_reload_and_workers(caplog: pytest.LogCaptureFixture) -> None:
+    Config(app=asgi_app, reload=True, workers=2)
+    assert len(caplog.records) == 1
+    assert (
+        '"workers" flag is ignored when reloading is enabled.'
+        in caplog.records[0].message
+    )