Repository: encode/uvicorn

Issue:
Incorrect websocket close code on server shutdown
### Checklist

- [X] The bug is reproducible against the latest release or `master`.
- [X] There are no similar issues or pull requests to fix it yet.

### Describe the bug

This seems related to #1140 but may be a simpler issue.

When the server is shutdown with `SIGTERM`, websocket connections are terminated with a close code of `1006`. This is seen on both the server and the client.

According to [RFC 6455](https://www.rfc-editor.org/rfc/rfc6455.html), `1006` indicates an abnormal connection termination (no `Close` control frame) and `1001` indicates `Going Away`.

### Steps to reproduce the bug

1. Create `websocket_test.py` with:
    ```
    class App:
        def __init__(self, scope, receive, send):
            self.scope = scope
            self.receive = receive
            self.send = send
    
        def __await__(self):
            return self.asgi().__await__()
    
        async def asgi(self):
            while True:
                message = await self.receive()
                message_type = message["type"].replace(".", "_")
                print(message)
                handler = getattr(self, message_type, None)
                if handler is not None:
                    await handler(message)
                if message_type == "websocket_disconnect":
                    break
    
        async def websocket_connect(self, message):
            await self.send({"type": "websocket.accept"})
    ```
2. Run `uvicorn --ws-ping-interval 5 --ws-ping-timeout 5 --log-level debug --lifespan off websocket_test:App`
3. Connect using a websocket client
4. Kill the server with `Ctrl+C` or sending a `SIGTERM` signal
5. Observe that both the server and client connections are closed with code `1006`.

### Expected behavior

uvicorn should send a `Close` frame and the client connection should be closed with code `1001`.

### Actual behavior

Client connection is closed with code `1006`.

### Debugging material

_No response_

### Environment

- uvicorn 0.17.1 with CPython 3.10.1 on Darwin
- `uvicorn --ws-ping-interval 5 --ws-ping-timeout 5 --log-level debug --lifespan off websocket_test:App`

### Additional context

When testing with daphne, the server close code is 1001 and the client close code is 1006.

Looking at the `WebSocketCommonProtocol`, there is a `close` method that properly implements the close handshake. However, it is not called from the `WebSocketProtocol` class on shutdown in uvicorn (although it is called as part of unwinding `handler` in `WebSocketServerProtocol`). Not calling `transport.close` from `shutdown` may solve the problem, but need to test it.


Discussion:
I just reproduced the issue and confirmed the code 1006 is used even after commenting out 

```shell
diff --git a/uvicorn/protocols/websockets/websockets_impl.py b/uvicorn/protocols/websockets/websockets_impl.py
index 9a07a5d..a90dc1c 100644
--- a/uvicorn/protocols/websockets/websockets_impl.py
+++ b/uvicorn/protocols/websockets/websockets_impl.py
@@ -97,7 +97,7 @@ class WebSocketProtocol(websockets.WebSocketServerProtocol):
 
     def shutdown(self):
         self.ws_server.closing = True
-        self.transport.close()
+        # self.transport.close()
 
     def on_task_complete(self, task):
         self.tasks.discard(task)
```

The terminal output

```shell
^CINFO:     Shutting down
INFO:     Waiting for connections to close. (CTRL+C to force quit)
DEBUG:    ! failing connection with code 1006
DEBUG:    = connection is CLOSED
DEBUG:    x half-closing TCP connection
INFO:     connection closed
INFO:     Finished server process [3676]
```
- This was closed by #1737.

It will be available on Uvicorn `0.20.0`.
@Kludex this is still an issue unfortunately, even with `0.20.0`.

Here's what I'm seeing:
1. Start server using example above with `uvicorn --ws-ping-interval 5 --ws-ping-timeout 5 --log-level debug --lifespan off websocket_test:App`.
2. Connect a client with `python -m websockets "ws://127.0.0.1:8000"`
3. Terminate server gracefully (Ctrl+C or TERM)

Server output:
```
INFO:     Started server process [51276]
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
DEBUG:    = connection is CONNECTING
DEBUG:    < GET / HTTP/1.1
DEBUG:    < host: 127.0.0.1:8000
DEBUG:    < upgrade: websocket
DEBUG:    < connection: Upgrade
DEBUG:    < sec-websocket-key: b3JBG07qZaR91p1Wh6jx6Q==
DEBUG:    < sec-websocket-version: 13
DEBUG:    < sec-websocket-extensions: permessage-deflate; client_max_window_bits
DEBUG:    < user-agent: Python/3.9 websockets/8.1
{'type': 'websocket.connect'}
INFO:     ('127.0.0.1', 54434) - "WebSocket /" [accepted]
DEBUG:    > HTTP/1.1 101 Switching Protocols
DEBUG:    > Upgrade: websocket
DEBUG:    > Connection: Upgrade
DEBUG:    > Sec-WebSocket-Accept: p0F1WTQ9R+c4afLilnTByg0oBZQ=
DEBUG:    > Sec-WebSocket-Extensions: permessage-deflate
DEBUG:    > date: Mon, 26 Dec 2022 10:35:34 GMT
DEBUG:    > server: uvicorn
INFO:     connection open
DEBUG:    = connection is OPEN
^CINFO:     Shutting down
DEBUG:    = connection is CLOSED
DEBUG:    ! failing connection with code 1006
DEBUG:    x half-closing TCP connection
{'type': 'websocket.disconnect', 'code': 1012}
INFO:     connection closed
INFO:     Finished server process [51276]
```

Client output:
```
Connected to ws://127.0.0.1:8000.
Connection closed: code = 1006 (connection closed abnormally [internal]), no reason.
```

This is happening for both the `websockets` and `wsproto` implementations.
Can you please try with another client? The server logs point out that we are sending 1012, which is the intention.
I've tested with https://github.com/websockets/wscat, and indeed, this is still happening, but only on `websockets` implementation. The `wsproto` works as expected.

Resolving patch:
diff --git a/uvicorn/protocols/websockets/websockets_impl.py b/uvicorn/protocols/websockets/websockets_impl.py
--- a/uvicorn/protocols/websockets/websockets_impl.py
+++ b/uvicorn/protocols/websockets/websockets_impl.py
@@ -90,7 +90,6 @@ def __init__(
         self.connect_sent = False
         self.lost_connection_before_handshake = False
         self.accepted_subprotocol: Optional[Subprotocol] = None
-        self.transfer_data_task: asyncio.Task = None  # type: ignore[assignment]
 
         self.ws_server: Server = Server()  # type: ignore[assignment]
 
@@ -145,6 +144,10 @@ def connection_lost(self, exc: Optional[Exception]) -> None:
 
     def shutdown(self) -> None:
         self.ws_server.closing = True
+        if self.handshake_completed_event.is_set():
+            self.fail_connection(1012)
+        else:
+            self.send_500_response()
         self.transport.close()
 
     def on_task_complete(self, task: asyncio.Task) -> None:
diff --git a/uvicorn/protocols/websockets/wsproto_impl.py b/uvicorn/protocols/websockets/wsproto_impl.py
--- a/uvicorn/protocols/websockets/wsproto_impl.py
+++ b/uvicorn/protocols/websockets/wsproto_impl.py
@@ -4,7 +4,6 @@
 import typing
 from urllib.parse import unquote
 
-import h11
 import wsproto
 from wsproto import ConnectionType, events
 from wsproto.connection import ConnectionState
@@ -232,17 +231,14 @@ def send_500_response(self) -> None:
             (b"content-type", b"text/plain; charset=utf-8"),
             (b"connection", b"close"),
         ]
-        if self.conn.connection is None:
-            output = self.conn.send(wsproto.events.RejectConnection(status_code=500))
-        else:
-            msg = h11.Response(
-                status_code=500, headers=headers, reason="Internal Server Error"
+        output = self.conn.send(
+            wsproto.events.RejectConnection(
+                status_code=500, headers=headers, has_body=True
             )
-            output = self.conn.send(msg)
-            msg = h11.Data(data=b"Internal Server Error")
-            output += self.conn.send(msg)
-            msg = h11.EndOfMessage()
-            output += self.conn.send(msg)
+        )
+        output += self.conn.send(
+            wsproto.events.RejectData(data=b"Internal Server Error")
+        )
         self.transport.write(output)
 
     async def run_asgi(self) -> None:


Test patch:
diff --git a/tests/protocols/test_websocket.py b/tests/protocols/test_websocket.py
--- a/tests/protocols/test_websocket.py
+++ b/tests/protocols/test_websocket.py
@@ -1,4 +1,5 @@
 import asyncio
+import typing
 
 import httpx
 import pytest
@@ -713,11 +714,22 @@ async def app(scope, receive, send):
         message = await receive()
         if message["type"] == "websocket.connect":
             await send_accept_task.wait()
-            await send({"type": "websocket.accept"})
         disconnect_message = await receive()
 
+    response: typing.Optional[httpx.Response] = None
+
     async def websocket_session(uri):
-        await websockets.client.connect(uri)
+        nonlocal response
+        async with httpx.AsyncClient() as client:
+            response = await client.get(
+                f"http://127.0.0.1:{unused_tcp_port}",
+                headers={
+                    "upgrade": "websocket",
+                    "connection": "upgrade",
+                    "sec-websocket-version": "13",
+                    "sec-websocket-key": "dGhlIHNhbXBsZSBub25jZQ==",
+                },
+            )
 
     config = Config(
         app=app,
@@ -731,9 +743,12 @@ async def websocket_session(uri):
             websocket_session(f"ws://127.0.0.1:{unused_tcp_port}")
         )
         await asyncio.sleep(0.1)
-        task.cancel()
         send_accept_task.set()
 
+    task.cancel()
+    assert response is not None
+    assert response.status_code == 500, response.text
+    assert response.text == "Internal Server Error"
     assert disconnect_message == {"type": "websocket.disconnect", "code": 1006}
 
 
@@ -744,6 +759,7 @@ async def test_send_close_on_server_shutdown(
     ws_protocol_cls, http_protocol_cls, unused_tcp_port: int
 ):
     disconnect_message = {}
+    server_shutdown_event = asyncio.Event()
 
     async def app(scope, receive, send):
         nonlocal disconnect_message
@@ -755,10 +771,13 @@ async def app(scope, receive, send):
                 disconnect_message = message
                 break
 
+    websocket: typing.Optional[websockets.client.WebSocketClientProtocol] = None
+
     async def websocket_session(uri):
-        async with websockets.client.connect(uri):
-            while True:
-                await asyncio.sleep(0.1)
+        nonlocal websocket
+        async with websockets.client.connect(uri) as ws_connection:
+            websocket = ws_connection
+            await server_shutdown_event.wait()
 
     config = Config(
         app=app,
@@ -773,7 +792,10 @@ async def websocket_session(uri):
         )
         await asyncio.sleep(0.1)
         disconnect_message_before_shutdown = disconnect_message
+    server_shutdown_event.set()
 
+    assert websocket is not None
+    assert websocket.close_code == 1012
     assert disconnect_message_before_shutdown == {}
     assert disconnect_message == {"type": "websocket.disconnect", "code": 1012}
     task.cancel()