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:
jalaziz: Can confirm that `wscat`, Chrome, and Firefox all show the correct close codes. I also checked that the client receives the correct close code if an app sends `{"type": "websocket.close", "code": code}` and that works too.

Just trying to figure out what's going on with the `websockets` client.

Patch:
diff --git a/docs/deployment.md b/docs/deployment.md
index 65c6f0e..b65c431 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -119,7 +119,7 @@ Options:
   --version                       Display the uvicorn version and exit.
   --app-dir TEXT                  Look for APP in the specified directory, by
                                   adding this to the PYTHONPATH. Defaults to
-                                  the current working directory.  [default: .]
+                                  the current working directory.
   --h11-max-incomplete-event-size INTEGER
                                   For h11, the maximum number of bytes to
                                   buffer of an incomplete event.
diff --git a/docs/index.md b/docs/index.md
index 945b807..2447f28 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -186,7 +186,7 @@ Options:
   --version                       Display the uvicorn version and exit.
   --app-dir TEXT                  Look for APP in the specified directory, by
                                   adding this to the PYTHONPATH. Defaults to
-                                  the current working directory.  [default: .]
+                                  the current working directory.
   --h11-max-incomplete-event-size INTEGER
                                   For h11, the maximum number of bytes to
                                   buffer of an incomplete event.
@@ -417,7 +417,8 @@ async def app(scope, receive, send):
         'type': 'http.response.start',
         'status': 200,
         'headers': [
-            [b'content-type', b'text/plain'],
+            (b'content-type', b'text/plain'),
+            (b'content-length', str(len(body)).encode())
         ]
     })
     await send({
diff --git a/requirements.txt b/requirements.txt
index 70b163d..00adbd9 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -16,11 +16,11 @@ black==22.10.0
 flake8==3.9.2
 importlib-metadata==4.13.0; python_version>='3.7'
 isort==5.10.1
-pytest==7.1.3
+pytest==7.2.0
 pytest-mock==3.10.0
 mypy==0.991
 types-click==7.1.8
-types-pyyaml==6.0.12.1
+types-pyyaml==6.0.12.2
 trustme==0.9.0
 cryptography==38.0.3
 coverage==6.5.0
diff --git a/scripts/check b/scripts/check
index be0c31d..e42dc21 100755
--- a/scripts/check
+++ b/scripts/check
@@ -3,6 +3,7 @@
 export PREFIX=""
 if [ -d 'venv' ] ; then
     export PREFIX="venv/bin/"
+    export PATH=${PREFIX}:${PATH}
 fi
 export SOURCE_FILES="uvicorn tests"
 
diff --git a/scripts/lint b/scripts/lint
index 5325f26..142c534 100755
--- a/scripts/lint
+++ b/scripts/lint
@@ -3,6 +3,7 @@
 export PREFIX=""
 if [ -d 'venv' ] ; then
     export PREFIX="venv/bin/"
+    export PATH=${PREFIX}:${PATH}
 fi
 export SOURCE_FILES="uvicorn tests"
 
diff --git a/setup.cfg b/setup.cfg
index 71d63a3..e90b314 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -44,7 +44,7 @@ plugins =
 
 [coverage:report]
 precision = 2
-fail_under = 98.50
+fail_under = 98.80
 show_missing = true
 skip_covered = true
 exclude_lines =
diff --git a/setup.py b/setup.py
deleted file mode 100644
index b022e37..0000000
--- a/setup.py
+++ /dev/null
@@ -1,30 +0,0 @@
-import sys
-
-from setuptools import setup
-
-sys.stderr.write(
-    """
-===============================
-Unsupported installation method
-===============================
-Uvicorn no longer supports installation with `python setup.py install`.
-Please use `python -m pip install .` instead.
-"""
-)
-sys.exit(1)
-
-
-# The below code will never execute, however GitHub is particularly
-# picky about where it finds Python packaging metadata.
-# See: https://github.com/github/feedback/discussions/6456
-#
-# To be removed once GitHub catches up.
-
-setup(
-    name="uvicorn",
-    install_requires=[
-        "click>=7.0",
-        "h11>=0.8",
-        "typing-extensions;python_version < '3.8'",
-    ],
-)
diff --git a/uvicorn/main.py b/uvicorn/main.py
index 3c59fa1..ced75d0 100644
--- a/uvicorn/main.py
+++ b/uvicorn/main.py
@@ -335,7 +335,7 @@ def print_version(ctx: click.Context, param: click.Parameter, value: bool) -> No
 )
 @click.option(
     "--app-dir",
-    default=".",
+    default="",
     show_default=True,
     help="Look for APP in the specified directory, by adding this to the PYTHONPATH."
     " Defaults to the current working directory.",
diff --git a/uvicorn/protocols/websockets/websockets_impl.py b/uvicorn/protocols/websockets/websockets_impl.py
index d7650d1..297203e 100644
--- a/uvicorn/protocols/websockets/websockets_impl.py
+++ b/uvicorn/protocols/websockets/websockets_impl.py
@@ -90,7 +90,6 @@ class WebSocketProtocol(WebSocketServerProtocol):
         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 @@ class WebSocketProtocol(WebSocketServerProtocol):
 
     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
index f2677e0..1d76f3a 100644
--- a/uvicorn/protocols/websockets/wsproto_impl.py
+++ b/uvicorn/protocols/websockets/wsproto_impl.py
@@ -4,7 +4,6 @@ import sys
 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 @@ class WSProtocol(asyncio.Protocol):
             (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
index c92de84..bb5c47c 100644
--- 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 test_connection_lost_before_handshake_complete(
         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 test_connection_lost_before_handshake_complete(
             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 test_send_close_on_server_shutdown(
                 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 test_send_close_on_server_shutdown(
         )
         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()