Repository: falconry/falcon

Issue:
[ASGI] close() not called if iterating over resp.stream raises an exception
At the time of writing, the `close()` method of [`resp.stream`](https://falcon.readthedocs.io/en/stable/api/request_and_response_asgi.html#falcon.asgi.Response.stream) is only called if the provided generator is successfully exhausted (or yields `None`).

However, the primary use case of the `close()` method is freeing up resources, so one might expect the method to be called regardless. It is also what [PEP 3333 compliant](https://www.python.org/dev/peps/pep-3333/#specification-details) WSGI servers do.

I would suggest introducing a `finally` clause, and moving the invocation of `close()` there.

Make sure to also write proper regression tests:
>  Kurt Griffiths @kgriffs
> We also need a test that forces stream to raise an error.
> (and ensures close() is called)



Discussion:
Hi Vytautas,

I am interested in helping contribute to Falcon. Can I have this issued assigned to me? 
Hi @dimucciojonathan! 
Sure, go ahead! 
(A comment here is enough so that ppl know you're working on it.)
Awesome, thanks!

Resolving patch:
diff --git a/falcon/asgi/app.py b/falcon/asgi/app.py
--- a/falcon/asgi/app.py
+++ b/falcon/asgi/app.py
@@ -651,19 +651,24 @@ async def watch_disconnect():
             #
 
             if hasattr(stream, 'read'):
-                while True:
-                    data = await stream.read(self._STREAM_BLOCK_SIZE)
-                    if data == b'':
-                        break
-                    else:
-                        await send(
-                            {
-                                'type': EventType.HTTP_RESPONSE_BODY,
-                                # NOTE(kgriffs): Handle the case in which data == None
-                                'body': data or b'',
-                                'more_body': True,
-                            }
-                        )
+                try:
+                    while True:
+                        data = await stream.read(self._STREAM_BLOCK_SIZE)
+                        if data == b'':
+                            break
+                        else:
+                            await send(
+                                {
+                                    'type': EventType.HTTP_RESPONSE_BODY,
+                                    # NOTE(kgriffs): Handle the case in which
+                                    #   data is None
+                                    'body': data or b'',
+                                    'more_body': True,
+                                }
+                            )
+                finally:
+                    if hasattr(stream, 'close'):
+                        await stream.close()
             else:
                 # NOTE(kgriffs): Works for both async generators and iterators
                 try:
@@ -698,9 +703,12 @@ async def watch_disconnect():
                         '__aiter__ method. Error raised while iterating over '
                         'Response.stream: ' + str(ex)
                     )
-
-            if hasattr(stream, 'close'):
-                await stream.close()
+                finally:
+                    # NOTE(vytas): This could be DRYed with the above identical
+                    #   twoliner in a one large block, but OTOH we would be
+                    #   unable to reuse the current try.. except.
+                    if hasattr(stream, 'close'):
+                        await stream.close()
 
         await send(_EVT_RESP_EOF)
 


Test patch:
diff --git a/tests/asgi/test_hello_asgi.py b/tests/asgi/test_hello_asgi.py
--- a/tests/asgi/test_hello_asgi.py
+++ b/tests/asgi/test_hello_asgi.py
@@ -107,6 +107,47 @@ async def on_get(self, req, resp):
         resp.set_stream(self.stream, self.stream_len)
 
 
+class ClosingStreamResource:
+    class Emitter:
+        def __init__(self, value, divisor):
+            self._value = value
+            self._divisor = divisor
+            self._remainder = None
+
+            self.closed = False
+
+        async def close(self):
+            self.closed = True
+
+        def __aiter__(self):
+            if self._remainder is None:
+                quotient, self._remainder = divmod(self._value, self._divisor)
+                assert quotient >= 0
+
+            return self
+
+        async def __anext__(self):
+            if self._value == 0:
+                raise StopAsyncIteration
+
+            if self._value >= self._divisor:
+                self._value -= self._divisor
+                return f'{self._divisor}\n'.encode()
+
+            self._value = 0
+            return f'{self._remainder}\n'.encode()
+
+    def __init__(self):
+        self.stream = None
+
+    async def on_get(self, req, resp):
+        self.stream = None
+
+        value = req.get_param_as_int('value', default=10)
+        divisor = req.get_param_as_int('divisor', default=3)
+        self.stream = resp.stream = self.Emitter(value, divisor)
+
+
 class AIOFilesHelloResource:
     def __init__(self):
         self.sample_utf8 = testing.rand_string(8 * SIZE_1_KB, 16 * SIZE_1_KB).encode()
@@ -297,6 +338,33 @@ def test_filelike_using_helper(self, client):
         assert actual_len == expected_len
         assert len(result.content) == expected_len
 
+    @pytest.mark.parametrize(
+        'value,divisor,text,error',
+        [
+            (10, 3, '3\n3\n3\n1\n', None),
+            (10, 7, '7\n3\n', None),
+            (10, 17, '10\n', None),
+            (20, 0, '', ZeroDivisionError),
+        ],
+    )
+    def test_closing_stream(self, client, value, divisor, text, error):
+        resource = ClosingStreamResource()
+        client.app.add_route('/stream', resource)
+
+        if error:
+            with pytest.raises(error):
+                client.simulate_get(
+                    '/stream', params={'value': value, 'divisor': divisor}
+                )
+        else:
+            result = client.simulate_get(
+                '/stream', params={'value': value, 'divisor': divisor}
+            )
+            assert result.status_code == 200
+            assert result.text == text
+
+        assert resource.stream.closed
+
     def test_status_not_set(self, client):
         client.app.add_route('/nostatus', NoStatusResource())