Repository: falconry/falcon

Issue:
StaticRouteAsync leaves open files
When using static routes with a [`falcon.asgi.App`](https://falcon.readthedocs.io/en/stable/api/app.html#asgi-app), it seems that the `_AsyncFileReader` wrapper does not implement any `.close()` method, so files are left open.

On CPython, I wasn't able to demonstrate any practical impact of this bug as the file object in question is refcounted to 0 and garbage collected as soon as it goes out of scope. However, that isn't the case when running `uvicorn` on PyPy 3.7, as PyPy uses a different GC implementation.

Test case in point:
```
import io
import logging
import os.path
import unittest.mock

import falcon.asgi

logging.basicConfig(
    format='%(asctime)s [%(levelname)s] %(message)s', level=logging.INFO)


class DebugIO(io.BytesIO):

    @classmethod
    def open(cls, *args, **kwargs):
        return cls(b'Test data!\n')

    def close(self):
        logging.info(f'{self}.close()')
        super().close()


app = falcon.asgi.App()
app.add_static_route('/files', '/tmp')

debug = unittest.mock.patch('io.open', DebugIO.open)
debug.start()
```
StaticRouteAsync leaves open files
When using static routes with a [`falcon.asgi.App`](https://falcon.readthedocs.io/en/stable/api/app.html#asgi-app), it seems that the `_AsyncFileReader` wrapper does not implement any `.close()` method, so files are left open.

On CPython, I wasn't able to demonstrate any practical impact of this bug as the file object in question is refcounted to 0 and garbage collected as soon as it goes out of scope. However, that isn't the case when running `uvicorn` on PyPy 3.7, as PyPy uses a different GC implementation.

Test case in point:
```
import io
import logging
import os.path
import unittest.mock

import falcon.asgi

logging.basicConfig(
    format='%(asctime)s [%(levelname)s] %(message)s', level=logging.INFO)


class DebugIO(io.BytesIO):

    @classmethod
    def open(cls, *args, **kwargs):
        return cls(b'Test data!\n')

    def close(self):
        logging.info(f'{self}.close()')
        super().close()


app = falcon.asgi.App()
app.add_static_route('/files', '/tmp')

debug = unittest.mock.patch('io.open', DebugIO.open)
debug.start()
```


Discussion:
When solving this, note that we probably need to stay on the safe side, and offload `.close()` to an executor, even though it is in most cases very fast. `aiofiles` also opts to run `.close()` in an executor: https://github.com/Tinche/aiofiles#usage.
Hey! Can I take this one?
Yes, you are welcome! 
@s-kamil just checking if you are still working on fixing this bug?
This is now open for contributions again!
(A great issue to get that last Hacktoberfest PR IMHO)
When solving this, note that we probably need to stay on the safe side, and offload `.close()` to an executor, even though it is in most cases very fast. `aiofiles` also opts to run `.close()` in an executor: https://github.com/Tinche/aiofiles#usage.
Hey! Can I take this one?
Yes, you are welcome! 
@s-kamil just checking if you are still working on fixing this bug?
This is now open for contributions again!
(A great issue to get that last Hacktoberfest PR IMHO)

Resolving patch:
diff --git a/falcon/routing/static.py b/falcon/routing/static.py
--- a/falcon/routing/static.py
+++ b/falcon/routing/static.py
@@ -241,3 +241,6 @@ def __init__(self, file):
 
     async def read(self, size=-1):
         return await self._loop.run_in_executor(None, partial(self._file.read, size))
+
+    async def close(self):
+        await self._loop.run_in_executor(None, self._file.close)


Test patch:
diff --git a/tests/test_static.py b/tests/test_static.py
--- a/tests/test_static.py
+++ b/tests/test_static.py
@@ -1,5 +1,3 @@
-# -*- coding: utf-8 -*-
-
 import errno
 import io
 import os
@@ -47,11 +45,14 @@ def __init__(self, size):
             fd = FakeFD(1337)
             fd._stat = FakeStat(len(data))
             fake_file.fileno = lambda: fd
+
+            patch.current_file = fake_file
             return fake_file
 
         monkeypatch.setattr(io, 'open', open)
         monkeypatch.setattr(os, 'fstat', lambda fileno: fileno._stat)
 
+    patch.current_file = None
     return patch
 
 
@@ -575,3 +576,16 @@ def test_bounded_file_wrapper():
     assert not buffer.closed
     fh.close()
     assert buffer.closed
+
+
+def test_file_closed(client, patch_open):
+    patch_open(b'test_data')
+
+    client.app.add_static_route('/static', '/var/www/statics')
+
+    resp = client.simulate_request(path='/static/foo/bar.txt')
+    assert resp.status_code == 200
+    assert resp.text == 'test_data'
+
+    assert patch_open.current_file is not None
+    assert patch_open.current_file.closed