Repository: falconry/falcon

Issue:
ASGI: iterating over `req.stream` hangs for chunked requests
When reading a streaming request without `Content-Length` (i.e., using "chunked" `Transfer-Encoding`), iterating over `req.stream` hangs in the case the request payload consists of more than one chunk.

It looks like this is caused by the receive loop implementation in `asgi/stream.py`. It only checks for the number of remaining bytes, and disregards the `more_body: False` hint in the case of an empty body. The logic to account for it is in place, but it is not reached due to the `continue` clause for an empty body chunk in the following event sequence:
```
RECV {'type': 'http.request', 'body': b'123456789abcdef\n', 'more_body': True}

RECV {'type': 'http.request', 'body': b'123456789abcdef\n', 'more_body': True}

RECV {'type': 'http.request', 'body': b'', 'more_body': False}
```

Eventually, the client (I was using `httpx`) times out, and an `http.disconnect` event is received which is handled correctly. But the request has already failed (timeout) from the client's perspective at this point.


Discussion:
The following patch fixes the case in point, but I'll need to do more testing and add regression tests:
```diff
--- a/falcon/asgi/stream.py
+++ b/falcon/asgi/stream.py
@@ -434,22 +434,20 @@ class BoundedStream:
                 pass
             else:
                 # NOTE(kgriffs): No need to yield empty body chunks.
-                if not next_chunk:
-                    continue
-
-                next_chunk_len = len(next_chunk)
-
-                if next_chunk_len <= self._bytes_remaining:
-                    self._bytes_remaining -= next_chunk_len
-                    self._pos += next_chunk_len
-                else:
-                    # NOTE(kgriffs): We received more data than expected,
-                    #   so truncate to the expected length.
-                    next_chunk = next_chunk[: self._bytes_remaining]
-                    self._pos += self._bytes_remaining
-                    self._bytes_remaining = 0
-
-                yield next_chunk
+                if next_chunk:
+                    next_chunk_len = len(next_chunk)
+
+                    if next_chunk_len <= self._bytes_remaining:
+                        self._bytes_remaining -= next_chunk_len
+                        self._pos += next_chunk_len
+                    else:
+                        # NOTE(kgriffs): We received more data than expected,
+                        #   so truncate to the expected length.
+                        next_chunk = next_chunk[: self._bytes_remaining]
+                        self._pos += self._bytes_remaining
+                        self._bytes_remaining = 0
+
+                    yield next_chunk
 
             # NOTE(kgriffs): Per the ASGI spec, more_body is optional
             #   and should be considered False if not present.
```

Resolving patch:
diff --git a/falcon/asgi/stream.py b/falcon/asgi/stream.py
--- a/falcon/asgi/stream.py
+++ b/falcon/asgi/stream.py
@@ -434,22 +434,20 @@ async def _iter_content(self):
                 pass
             else:
                 # NOTE(kgriffs): No need to yield empty body chunks.
-                if not next_chunk:
-                    continue
-
-                next_chunk_len = len(next_chunk)
-
-                if next_chunk_len <= self._bytes_remaining:
-                    self._bytes_remaining -= next_chunk_len
-                    self._pos += next_chunk_len
-                else:
-                    # NOTE(kgriffs): We received more data than expected,
-                    #   so truncate to the expected length.
-                    next_chunk = next_chunk[: self._bytes_remaining]
-                    self._pos += self._bytes_remaining
-                    self._bytes_remaining = 0
-
-                yield next_chunk
+                if next_chunk:
+                    next_chunk_len = len(next_chunk)
+
+                    if next_chunk_len <= self._bytes_remaining:
+                        self._bytes_remaining -= next_chunk_len
+                        self._pos += next_chunk_len
+                    else:
+                        # NOTE(kgriffs): We received more data than expected,
+                        #   so truncate to the expected length.
+                        next_chunk = next_chunk[: self._bytes_remaining]
+                        self._pos += self._bytes_remaining
+                        self._bytes_remaining = 0
+
+                    yield next_chunk
 
             # NOTE(kgriffs): Per the ASGI spec, more_body is optional
             #   and should be considered False if not present.


Test patch:
diff --git a/tests/asgi/_asgi_test_app.py b/tests/asgi/_asgi_test_app.py
--- a/tests/asgi/_asgi_test_app.py
+++ b/tests/asgi/_asgi_test_app.py
@@ -107,6 +107,13 @@ class Bucket:
     async def on_post(self, req, resp):
         resp.text = await req.stream.read()
 
+    async def on_put_drops(self, req, resp):
+        drops = 0
+        async for drop in req.stream:
+            drops += 1
+
+        resp.media = {'drops': drops}
+
 
 class Feed:
     async def on_websocket(self, req, ws, feed_id):
@@ -256,13 +263,17 @@ async def on_post(self, req, resp):
 
 def create_app():
     app = falcon.asgi.App()
+    bucket = Bucket()
+    lifespan_handler = LifespanHandler()
+
     app.add_route('/', Things())
-    app.add_route('/bucket', Bucket())
+    app.add_route('/bucket', bucket)
+    app.add_route('/bucket/drops', bucket, suffix='drops')
     app.add_route('/events', Events())
     app.add_route('/forms', Multipart())
     app.add_route('/jars', TestJar())
     app.add_route('/feeds/{feed_id}', Feed())
-    lifespan_handler = LifespanHandler()
+
     app.add_middleware(lifespan_handler)
 
     async def _on_ws_error(req, resp, error, params, ws=None):
diff --git a/tests/asgi/test_asgi_servers.py b/tests/asgi/test_asgi_servers.py
--- a/tests/asgi/test_asgi_servers.py
+++ b/tests/asgi/test_asgi_servers.py
@@ -8,6 +8,7 @@
 import sys
 import time
 
+import httpx
 import pytest
 import requests
 import requests.exceptions
@@ -141,6 +142,23 @@ def test_sse_client_disconnects_early(self, server_base_url):
                 timeout=(_asgi_test_app.SSE_TEST_MAX_DELAY_SEC / 2),
             )
 
+    @pytest.mark.asyncio
+    async def test_stream_chunked_request(self, server_base_url):
+        """Regression test for https://github.com/falconry/falcon/issues/2024"""
+
+        async def emitter():
+            for _ in range(64):
+                yield b'123456789ABCDEF\n'
+
+        async with httpx.AsyncClient() as client:
+            resp = await client.put(
+                server_base_url + 'bucket/drops',
+                content=emitter(),
+                timeout=_REQUEST_TIMEOUT,
+            )
+            resp.raise_for_status()
+            assert resp.json().get('drops') >= 1
+
 
 class TestWebSocket:
     @pytest.mark.asyncio
diff --git a/tests/asgi/test_boundedstream_asgi.py b/tests/asgi/test_boundedstream_asgi.py
--- a/tests/asgi/test_boundedstream_asgi.py
+++ b/tests/asgi/test_boundedstream_asgi.py
@@ -164,6 +164,30 @@ async def test_iteration():
     falcon.async_to_sync(test_iteration)
 
 
+@falcon.runs_sync
+async def test_iterate_streaming_request():
+    events = iter(
+        (
+            {'type': 'http.request', 'body': b'Hello, ', 'more_body': True},
+            {'type': 'http.request', 'body': b'World', 'more_body': True},
+            {'type': 'http.request', 'body': b'!\n', 'more_body': True},
+            {'type': 'http.request', 'body': b'', 'more_body': False},
+            {'type': 'http.disconnect'},
+        )
+    )
+
+    async def receive():
+        event = next(events)
+        assert (
+            event['type'] != 'http.disconnect'
+        ), 'would hang until the client times out'
+        return event
+
+    s = asgi.BoundedStream(receive)
+
+    assert b''.join([chunk async for chunk in s]) == b'Hello, World!\n'
+
+
 @pytest.mark.parametrize(
     'body',
     [