Repository: django/channels
Pull request #2097 (2024-05-02 01:04:38)

Ensure text message exists before handling on `WebsocketConsumer`
When using `uvicorn` or `daphne`, only one of `text` or `bytes` is sent.

But on `hypercorn`, both of them are sent, but with `None` value:
https://github.com/pgjones/hypercorn/blob/31639ec2f4d03aa920b95c84686163901224c6cf/src/hypercorn/protocol/ws_stream.py#L159

So if you just send `bytes`, `text` will be `None` and you won't be able to handle the message.

I just add an extra check.

bigfootjon: Hey @cacosandon, this looks reasonable to me. Can you add a regression test?
cacosandon: Sorry for the super late reaction @bigfootjon, I've added a regression test! Had to use `send_input` and `send_output` because `WebsocketCommunicator` has some safe checks that `hypercorn` is skipping.
carltongibson: (Sorry, @cacosandon : can you fix the lint error? Thanks.)

Patch:
diff --git a/channels/generic/websocket.py b/channels/generic/websocket.py
index 6d41c8e..b4d9911 100644
--- a/channels/generic/websocket.py
+++ b/channels/generic/websocket.py
@@ -60,7 +60,7 @@ class WebsocketConsumer(SyncConsumer):
         Called when a WebSocket frame is received. Decodes it and passes it
         to receive().
         """
-        if "text" in message:
+        if message.get("text") is not None:
             self.receive(text_data=message["text"])
         else:
             self.receive(bytes_data=message["bytes"])
@@ -200,7 +200,7 @@ class AsyncWebsocketConsumer(AsyncConsumer):
         Called when a WebSocket frame is received. Decodes it and passes it
         to receive().
         """
-        if "text" in message:
+        if message.get("text") is not None:
             await self.receive(text_data=message["text"])
         else:
             await self.receive(bytes_data=message["bytes"])


Test patch:
diff --git a/tests/test_generic_websocket.py b/tests/test_generic_websocket.py
index c553eb8..0ade1a0 100644
--- a/tests/test_generic_websocket.py
+++ b/tests/test_generic_websocket.py
@@ -485,3 +485,54 @@ async def test_close_reason(async_consumer):
     assert msg["type"] == "websocket.close"
     assert msg["code"] == 4007
     assert msg["reason"] == "test reason"
+
+
+@pytest.mark.django_db
+@pytest.mark.asyncio
+async def test_websocket_receive_with_none_text():
+    """
+    Tests that the receive method handles messages with None text data correctly.
+    """
+
+    class TestConsumer(WebsocketConsumer):
+        def receive(self, text_data=None, bytes_data=None):
+            if text_data:
+                self.send(text_data="Received text: " + text_data)
+            elif bytes_data:
+                self.send(text_data=f"Received bytes of length: {len(bytes_data)}")
+
+    app = TestConsumer()
+
+    # Open a connection
+    communicator = WebsocketCommunicator(app, "/testws/")
+    connected, _ = await communicator.connect()
+    assert connected
+
+    # Simulate Hypercorn behavior
+    # (both 'text' and 'bytes' keys present, but 'text' is None)
+    await communicator.send_input(
+        {
+            "type": "websocket.receive",
+            "text": None,
+            "bytes": b"test data",
+        }
+    )
+    response = await communicator.receive_output()
+    assert response["type"] == "websocket.send"
+    assert response["text"] == "Received bytes of length: 9"
+
+    # Test with only 'bytes' key (simulating uvicorn/daphne behavior)
+    await communicator.send_input({"type": "websocket.receive", "bytes": b"more data"})
+    response = await communicator.receive_output()
+    assert response["type"] == "websocket.send"
+    assert response["text"] == "Received bytes of length: 9"
+
+    # Test with valid text data
+    await communicator.send_input(
+        {"type": "websocket.receive", "text": "Hello, world!"}
+    )
+    response = await communicator.receive_output()
+    assert response["type"] == "websocket.send"
+    assert response["text"] == "Received text: Hello, world!"
+
+    await communicator.disconnect()