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:
codecov[bot]: # [Codecov](https://codecov.io/gh/falconry/falcon/pull/2027?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry) Report
> Merging [#2027](https://codecov.io/gh/falconry/falcon/pull/2027?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry) (24cb94a) into [master](https://codecov.io/gh/falconry/falcon/commit/280863a521f3eb8e2fa4b6209c6dd42c09ec96a7?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry) (280863a) will **decrease** coverage by `11.82%`.
> The diff coverage is `n/a`.

[![Impacted file tree graph](https://codecov.io/gh/falconry/falcon/pull/2027/graphs/tree.svg?width=650&height=150&src=pr&token=zOtkfNQ3h6&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry)](https://codecov.io/gh/falconry/falcon/pull/2027?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry)

```diff
@@             Coverage Diff              @@
##            master    #2027       +/-   ##
============================================
- Coverage   100.00%   88.17%   -11.83%     
============================================
  Files           63       54        -9     
  Lines         6715     5203     -1512     
  Branches      1243      791      -452     
============================================
- Hits          6715     4588     -2127     
- Misses           0      601      +601     
- Partials         0       14       +14     
```


| [Impacted Files](https://codecov.io/gh/falconry/falcon/pull/2027?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry) | Coverage Δ | |
|---|---|---|
| [falcon/asgi/\_request\_helpers.py](https://codecov.io/gh/falconry/falcon/pull/2027/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry#diff-ZmFsY29uL2FzZ2kvX3JlcXVlc3RfaGVscGVycy5weQ==) | `0.00% <0.00%> (-100.00%)` | :arrow_down: |
| [falcon/asgi/\_\_init\_\_.py](https://codecov.io/gh/falconry/falcon/pull/2027/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry#diff-ZmFsY29uL2FzZ2kvX19pbml0X18ucHk=) | `27.27% <0.00%> (-72.73%)` | :arrow_down: |
| [falcon/testing/helpers.py](https://codecov.io/gh/falconry/falcon/pull/2027/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry#diff-ZmFsY29uL3Rlc3RpbmcvaGVscGVycy5weQ==) | `38.05% <0.00%> (-61.95%)` | :arrow_down: |
| [falcon/responders.py](https://codecov.io/gh/falconry/falcon/pull/2027/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry#diff-ZmFsY29uL3Jlc3BvbmRlcnMucHk=) | `64.70% <0.00%> (-35.30%)` | :arrow_down: |
| [falcon/testing/client.py](https://codecov.io/gh/falconry/falcon/pull/2027/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry#diff-ZmFsY29uL3Rlc3RpbmcvY2xpZW50LnB5) | `67.01% <0.00%> (-32.99%)` | :arrow_down: |
| [falcon/app\_helpers.py](https://codecov.io/gh/falconry/falcon/pull/2027/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry#diff-ZmFsY29uL2FwcF9oZWxwZXJzLnB5) | `68.13% <0.00%> (-31.87%)` | :arrow_down: |
| [falcon/media/validators/jsonschema.py](https://codecov.io/gh/falconry/falcon/pull/2027/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry#diff-ZmFsY29uL21lZGlhL3ZhbGlkYXRvcnMvanNvbnNjaGVtYS5weQ==) | `69.04% <0.00%> (-30.96%)` | :arrow_down: |
| [falcon/util/\_\_init\_\_.py](https://codecov.io/gh/falconry/falcon/pull/2027/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry#diff-ZmFsY29uL3V0aWwvX19pbml0X18ucHk=) | `75.55% <0.00%> (-24.45%)` | :arrow_down: |
| [falcon/util/sync.py](https://codecov.io/gh/falconry/falcon/pull/2027/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry#diff-ZmFsY29uL3V0aWwvc3luYy5weQ==) | `76.74% <0.00%> (-23.26%)` | :arrow_down: |
| [falcon/media/json.py](https://codecov.io/gh/falconry/falcon/pull/2027/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry#diff-ZmFsY29uL21lZGlhL2pzb24ucHk=) | `77.08% <0.00%> (-22.92%)` | :arrow_down: |
| ... and [33 more](https://codecov.io/gh/falconry/falcon/pull/2027/diff?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/falconry/falcon/pull/2027?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/falconry/falcon/pull/2027?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry). Last update [280863a...24cb94a](https://codecov.io/gh/falconry/falcon/pull/2027?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry).


Patch:
diff --git a/docs/_newsfragments/2024.bugfix.rst b/docs/_newsfragments/2024.bugfix.rst
new file mode 100644
index 0000000..b72d6af
--- /dev/null
+++ b/docs/_newsfragments/2024.bugfix.rst
@@ -0,0 +1,5 @@
+When a request was streamed using the chunked transfer encoding (with no
+``Content-Length`` known in advance), iterating over
+:attr:`req.stream <falcon.asgi.Request.stream>` could hang until the client had
+disconnected. This bug has been fixed, and iteration now stops upon receiving
+the last body chunk as expected.
diff --git a/falcon/asgi/stream.py b/falcon/asgi/stream.py
index 2776e21..4b052be 100644
--- 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.


Test patch:
diff --git a/tests/asgi/_asgi_test_app.py b/tests/asgi/_asgi_test_app.py
index 54fdfd4..268a0bd 100644
--- 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 @@ class TestJar:
 
 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
index af07a04..337e13b 100644
--- a/tests/asgi/test_asgi_servers.py
+++ b/tests/asgi/test_asgi_servers.py
@@ -8,6 +8,7 @@ import subprocess
 import sys
 import time
 
+import httpx
 import pytest
 import requests
 import requests.exceptions
@@ -141,6 +142,23 @@ class TestASGIServer:
                 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
index 29c56a2..3d80c7f 100644
--- a/tests/asgi/test_boundedstream_asgi.py
+++ b/tests/asgi/test_boundedstream_asgi.py
@@ -164,6 +164,30 @@ def test_filelike():
     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',
     [