Repository: aio-libs/aiohttp

Issue:
An error occurred when publishing a static file using uvloop
🐞 **Describe the bug**
An error occurred when publishing a static file using aiohttp 3.7.x and uvloop.

💡 **To Reproduce**
``` Dockerfile
# Dockerfile

FROM python:3.8.6

WORKDIR /work

RUN pip install aiohttp==3.7.1 uvloop==0.14.0

ADD app.py .

RUN mkdir static && echo "/* DUMMY */" > static/index.css

CMD ["python", "app.py"]
```

``` python
# app.py

import uvloop
from aiohttp import web

uvloop.install()

app = web.Application()
app.add_routes([web.static("/static", "/work/static")])

if __name__ == "__main__":
    web.run_app(app)
```

Then, build and run server.

``` console
$ docker build . -t tmp
$ docker run --rm -it -p 8080:8080 tmp
```

Finally, try to access http://localhost:8080/static/index.css .

When accessed from a browser, the following error is displayed in the developer console.

> net::ERR_CONTENT_LENGTH_MISMATCH 200 (OK)

When accessed from a cURL, the following error is displayed.

> curl: (18) transfer closed with 12 bytes remaining to read

It looks like the connection was closed before sending the response body.

💡 **Expected behavior**
No error occurs.

📋 **Logs/tracebacks**
No messages are displayed in the Python log at all.

📋 **Your version of the Python**
```console
$ docker run --rm tmp python --version       
Python 3.8.6
```

📋 **Your version of the aiohttp/yarl/multidict distributions**
<!-- Attach your version of the distributions in the code blocks below. -->
```console
$ docker run --rm tmp python -m pip show aiohttp
Name: aiohttp
Version: 3.7.1
Summary: Async http client/server framework (asyncio)
Home-page: https://github.com/aio-libs/aiohttp
Author: Nikolay Kim
Author-email: fafhrd91@gmail.com
License: Apache 2
Location: /usr/local/lib/python3.8/site-packages
Requires: chardet, multidict, attrs, async-timeout, typing-extensions, yarl
Required-by:
```

```console
$ docker run --rm tmp python -m pip show multidict
Name: multidict
Version: 5.0.0
Summary: multidict implementation
Home-page: https://github.com/aio-libs/multidict
Author: Andrew Svetlov
Author-email: andrew.svetlov@gmail.com
License: Apache 2
Location: /usr/local/lib/python3.8/site-packages
Requires: 
Required-by: yarl, aiohttp
```

```console
$ docker run --rm tmp python -m pip show yarl     
Name: yarl
Version: 1.6.2
Summary: Yet another URL library
Home-page: https://github.com/aio-libs/yarl/
Author: Andrew Svetlov
Author-email: andrew.svetlov@gmail.com
License: Apache 2
Location: /usr/local/lib/python3.8/site-packages
Requires: idna, multidict
Required-by: aiohttp
```

📋 **Additional context**
- When aiphttp==3.6.*, it works fine
- When uvloop disabled, it works fine


Discussion:
Thanks for the very cool reproducible example for the bug!
I wish all bug reports look like this :)

I'm working on an investigation. As a quick workaround you can disable `sendfile` support for static files by setting `AIOHTTP_NOSENDFILE=1` environment variable.
Aha, I see the reason.
uvloop.sendfile() exists but it raises `NotImplementedError` -- which is 100% correct, it works exactly as I designed asyncio sendfile implementation.
Unfortunately, in aiohttp we forgot to handle this error properly by falling back to a regular loop over `file.read()` and `transport.write()`.

The thing is not as easy as it should be; earlier aiohttp versions used a hack with `sock.dup()` and `os.sendfile` to work around the fact that asyncio did not support native sendfile until Python 3.7.  This hack should send the whole message over the copy (dup) of the socket to avoid the corrupted internal state of the asyncio transport.

But the code cannot figure out is the native sendfile supported or not until `loop.sendfile()` call! Thus, the first `transport.write()` for sending HTTP headers conflicts with `sock.dup()` approach, the sent data is broken.

The solution is dropping custom `sock.dup()` + `os.sendfile()` workaround for the sake of standard `loop.sendfile()` and `transport.write()` fallback. `

`loop.sendfile()` works pretty well on Python 3.7+, the fallback is called for Python 3.6, compressed data, systems without native os.sendfile (I cannot name them, all Windows, Linux, and MacOS have the native sendfile). 

Ironically, uvloop uses the slow fallback until it supports the native `loop.sendfile()` itself.

This is the plan.  Implementation can take a day or two -- depending on my daily job. If somebody wants to pick the issue up and prepare a fix himself -- you are welcome.

Resolving patch:
diff --git a/aiohttp/web_fileresponse.py b/aiohttp/web_fileresponse.py
--- a/aiohttp/web_fileresponse.py
+++ b/aiohttp/web_fileresponse.py
@@ -2,7 +2,7 @@
 import mimetypes
 import os
 import pathlib
-from functools import partial
+import sys
 from typing import (  # noqa
     IO,
     TYPE_CHECKING,
@@ -17,10 +17,6 @@
 
 from . import hdrs
 from .abc import AbstractStreamWriter
-from .base_protocol import BaseProtocol
-from .helpers import set_exception, set_result
-from .http_writer import StreamWriter
-from .log import server_logger
 from .typedefs import LooseHeaders
 from .web_exceptions import (
     HTTPNotModified,
@@ -42,95 +38,6 @@
 NOSENDFILE = bool(os.environ.get("AIOHTTP_NOSENDFILE"))
 
 
-class SendfileStreamWriter(StreamWriter):
-    def __init__(
-        self,
-        protocol: BaseProtocol,
-        loop: asyncio.AbstractEventLoop,
-        fobj: IO[Any],
-        offset: int,
-        count: int,
-        on_chunk_sent: _T_OnChunkSent = None,
-    ) -> None:
-        super().__init__(protocol, loop, on_chunk_sent)
-        self._sendfile_buffer = []  # type: List[bytes]
-        self._fobj = fobj
-        self._count = count
-        self._offset = offset
-        self._in_fd = fobj.fileno()
-
-    def _write(self, chunk: bytes) -> None:
-        # we overwrite StreamWriter._write, so nothing can be appended to
-        # _buffer, and nothing is written to the transport directly by the
-        # parent class
-        self.output_size += len(chunk)
-        self._sendfile_buffer.append(chunk)
-
-    def _sendfile_cb(self, fut: "asyncio.Future[None]", out_fd: int) -> None:
-        if fut.cancelled():
-            return
-        try:
-            if self._do_sendfile(out_fd):
-                set_result(fut, None)
-        except Exception as exc:
-            set_exception(fut, exc)
-
-    def _do_sendfile(self, out_fd: int) -> bool:
-        try:
-            n = os.sendfile(out_fd, self._in_fd, self._offset, self._count)
-            if n == 0:  # in_fd EOF reached
-                n = self._count
-        except (BlockingIOError, InterruptedError):
-            n = 0
-        self.output_size += n
-        self._offset += n
-        self._count -= n
-        assert self._count >= 0
-        return self._count == 0
-
-    def _done_fut(self, out_fd: int, fut: "asyncio.Future[None]") -> None:
-        self.loop.remove_writer(out_fd)
-
-    async def sendfile(self) -> None:
-        assert self.transport is not None
-        loop = self.loop
-        data = b"".join(self._sendfile_buffer)
-        if hasattr(loop, "sendfile"):
-            # Python 3.7+
-            self.transport.write(data)
-            if self._count != 0:
-                await loop.sendfile(
-                    self.transport, self._fobj, self._offset, self._count
-                )
-            await super().write_eof()
-            return
-
-        self._fobj.seek(self._offset)
-        out_socket = self.transport.get_extra_info("socket").dup()
-        out_socket.setblocking(False)
-        out_fd = out_socket.fileno()
-
-        try:
-            await loop.sock_sendall(out_socket, data)
-            if not self._do_sendfile(out_fd):
-                fut = loop.create_future()
-                fut.add_done_callback(partial(self._done_fut, out_fd))
-                loop.add_writer(out_fd, self._sendfile_cb, fut, out_fd)
-                await fut
-        except asyncio.CancelledError:
-            raise
-        except Exception:
-            server_logger.debug("Socket error")
-            self.transport.close()
-        finally:
-            out_socket.close()
-
-        await super().write_eof()
-
-    async def write_eof(self, chunk: bytes = b"") -> None:
-        pass
-
-
 class FileResponse(StreamResponse):
     """A response object can be used to send files."""
 
@@ -150,52 +57,12 @@ def __init__(
         self._path = path
         self._chunk_size = chunk_size
 
-    async def _sendfile_system(
-        self, request: "BaseRequest", fobj: IO[Any], offset: int, count: int
-    ) -> AbstractStreamWriter:
-        # Write count bytes of fobj to resp using
-        # the os.sendfile system call.
-        #
-        # For details check
-        # https://github.com/KeepSafe/aiohttp/issues/1177
-        # See https://github.com/KeepSafe/aiohttp/issues/958 for details
-        #
-        # request should be an aiohttp.web.Request instance.
-        # fobj should be an open file object.
-        # count should be an integer > 0.
-
-        transport = request.transport
-        assert transport is not None
-        if (
-            transport.get_extra_info("sslcontext")
-            or transport.get_extra_info("socket") is None
-            or self.compression
-        ):
-            writer = await self._sendfile_fallback(request, fobj, offset, count)
-        else:
-            writer = SendfileStreamWriter(
-                request.protocol, request._loop, fobj, offset, count
-            )
-            request._payload_writer = writer
-
-            await super().prepare(request)
-            await writer.sendfile()
-
-        return writer
-
     async def _sendfile_fallback(
-        self, request: "BaseRequest", fobj: IO[Any], offset: int, count: int
+        self, writer: AbstractStreamWriter, fobj: IO[Any], offset: int, count: int
     ) -> AbstractStreamWriter:
-        # Mimic the _sendfile_system() method, but without using the
-        # os.sendfile() system call. This should be used on systems
-        # that don't support the os.sendfile().
-
         # To keep memory usage low,fobj is transferred in chunks
         # controlled by the constructor's chunk_size argument.
 
-        writer = await super().prepare(request)
-        assert writer is not None
-
         chunk_size = self._chunk_size
         loop = asyncio.get_event_loop()
 
@@ -212,10 +79,26 @@ async def _sendfile_fallback(
         await writer.drain()
         return writer
 
-    if hasattr(os, "sendfile") and not NOSENDFILE:  # pragma: no cover
-        _sendfile = _sendfile_system
-    else:  # pragma: no cover
-        _sendfile = _sendfile_fallback
+    async def _sendfile(
+        self, request: "BaseRequest", fobj: IO[Any], offset: int, count: int
+    ) -> AbstractStreamWriter:
+        writer = await super().prepare(request)
+        assert writer is not None
+
+        if NOSENDFILE or sys.version_info < (3, 7) or self.compression:
+            return await self._sendfile_fallback(writer, fobj, offset, count)
+
+        loop = request._loop
+        transport = request.transport
+        assert transport is not None
+
+        try:
+            await loop.sendfile(transport, fobj, offset, count)
+        except NotImplementedError:
+            return await self._sendfile_fallback(writer, fobj, offset, count)
+
+        await super().write_eof()
+        return writer
 
     async def prepare(self, request: "BaseRequest") -> Optional[AbstractStreamWriter]:
         filepath = self._path


Test patch:
diff --git a/tests/test_web_sendfile_functional.py b/tests/test_web_sendfile_functional.py
--- a/tests/test_web_sendfile_functional.py
+++ b/tests/test_web_sendfile_functional.py
@@ -14,12 +14,21 @@
     ssl = None  # type: ignore
 
 
-@pytest.fixture(params=["sendfile", "fallback"], ids=["sendfile", "fallback"])
-def sender(request):
+@pytest.fixture
+def loop_without_sendfile(loop):
+    def sendfile(*args, **kwargs):
+        raise NotImplementedError
+
+    loop.sendfile = sendfile
+    return loop
+
+
+@pytest.fixture(params=["sendfile", "no_sendfile"], ids=["sendfile", "no_sendfile"])
+def sender(request, loop_without_sendfile):
     def maker(*args, **kwargs):
         ret = web.FileResponse(*args, **kwargs)
-        if request.param == "fallback":
-            ret._sendfile = ret._sendfile_fallback
+        if request.param == "no_sendfile":
+            asyncio.set_event_loop(loop_without_sendfile)
         return ret
 
     return maker