Repository: encode/uvicorn

Issue:
websocket.disconnect events are not sent to applications when clients don’t close connections properly (wsproto only)
### Checklist

<!-- Please make sure you check all these items before submitting your bug report. -->

- [x] The bug is reproducible against the latest release and/or `master`.
- [x] There are no similar issues or pull requests to fix it yet.

### Describe the bug

When WebSocket connections are not properly closed by clients, uvicorn + wsproto leaves one zombie asyncio tasks running per connection and do not emit disconnect events to applications.

### To reproduce
1. Start any application with uvicorn using wsproto;
2. Create a websocket connection;
3. Do not close the websocket properly;
4. Observe that no `websocket.disconnect` events have been sent to your application;
5. Check for all asyncio tasks (`asyncio.all_tasks()`): there should be a zombie `run_asgi` task for each non properly closed connection.

I’ve setup this repo with instructions to make it easier to reproduce this issue: https://github.com/sephioh/uvicorn-wsproto-issue

### Expected behavior

A [disconnect](https://asgi.readthedocs.io/en/latest/specs/www.html#disconnect-receive-event-ws) event should be sent to applications. 

### Actual behavior

Disconnect event are not being sent to apps when client socket loss happens. 

### Environment

Running uvicorn 0.13.4 with CPython 3.9.0 on Linux
Must be executed with wsproto (—ws wsproto).

### Additional context

https://docs.python.org/3/library/asyncio-protocol.html#asyncio.BaseProtocol.connection_lost

No problems are found when running with websockets impl (—ws websockets).

I’ve opened a PR with a possible fix: https://github.com/encode/uvicorn/pull/996


Discussion:

Resolving patch:
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
@@ -70,8 +70,7 @@ def connection_made(self, transport):
             self.logger.log(TRACE_LOG_LEVEL, "%sWebSocket connection made", prefix)
 
     def connection_lost(self, exc):
-        if exc is not None:
-            self.queue.put_nowait({"type": "websocket.disconnect"})
+        self.queue.put_nowait({"type": "websocket.disconnect"})
         self.connections.remove(self)
 
         if self.logger.level <= TRACE_LOG_LEVEL:


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
@@ -10,6 +10,7 @@
 
 try:
     import websockets
+    import websockets.client
     import websockets.exceptions
     from websockets.extensions.permessage_deflate import ClientPerMessageDeflateFactory
 
@@ -64,7 +65,6 @@ def app(scope):
                     "connection": "upgrade",
                     "sec-webSocket-version": "11",
                 },
-                timeout=5,
             )
         if response.status_code == 426:
             # response.text == ""
@@ -517,6 +517,40 @@ async def websocket_session(url):
         await websocket_session("ws://127.0.0.1:8000")
 
 
+@pytest.mark.anyio
+@pytest.mark.parametrize("ws_protocol_cls", WS_PROTOCOLS)
+@pytest.mark.parametrize("http_protocol_cls", HTTP_PROTOCOLS)
+async def test_client_connection_lost(ws_protocol_cls, http_protocol_cls):
+    got_disconnect_event = False
+
+    async def app(scope, receive, send):
+        nonlocal got_disconnect_event
+        while True:
+            message = await receive()
+            if message["type"] == "websocket.connect":
+                print("accepted")
+                await send({"type": "websocket.accept"})
+            elif message["type"] == "websocket.disconnect":
+                break
+
+        got_disconnect_event = True
+
+    config = Config(
+        app=app,
+        ws=ws_protocol_cls,
+        http=http_protocol_cls,
+        lifespan="off",
+        ws_ping_interval=0.0,
+    )
+    async with run_server(config):
+        async with websockets.client.connect("ws://127.0.0.1:8000") as websocket:
+            websocket.transport.close()
+            await asyncio.sleep(0.1)
+            got_disconnect_event_before_shutdown = got_disconnect_event
+
+    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)