Repository: encode/uvicorn

Issue:
WSGI interface: reading request body is O(n^2) wrt payload size
Hello!
This is related to https://github.com/encode/uvicorn/issues/371 and potentially a duplicate/complementary information, feel free to close if it is deemed to be the case.

I was fooling around with different WSGI and ASGI servers while hacking on a web framework that would support both in the future.

As noted in https://github.com/encode/uvicorn/issues/371, this is indeed a very inefficient scenario, however, what is worse, I found out the time required to turn around the request is at least O(n^2) or worse wrt the payload size.

`POST`ing some files and multipart forms with `curl`:
```
Payload size: 94171344
real	0m5.763s
user	0m0.033s
sys	0m0.012s
```
```
Payload size: 186054864
real	0m24.183s
user	0m0.018s
sys	0m0.061s
```
```
Payload size: 372178858
real	1m37.760s
user	0m0.044s
sys	0m0.152s
```

So it is not just inefficient loading everything into memory, but something else is going on.

Test case used:
```python
def app(environ, start_response):
    status = '200 OK'  # HTTP Status
    headers = [('Content-type', 'text/plain')]  # HTTP Headers
    start_response(status, headers)

    stream = environ['wsgi.input']
    length = 0
    while True:
        chunk = stream.read(8192)
        if not chunk:
            break
        length += len(chunk)

    resp = "Payload size: {}".format(length)
    return [resp.encode()]
```

Running `uvicorn==0.11.5` as `uvicorn --interface wsgi simple_reader:app`.


Discussion:
The issue most probably lies in bytestring concatenation: https://github.com/encode/uvicorn/blob/master/uvicorn/middleware/wsgi.py#L85
`chunks.append(chunk)... b''.join(chunks)`, accumulating in a `BytesIO`, `bytearray_var += ...` should all work O(n).
Maybe you can try https://github.com/abersheeran/a2wsgi
It works very well on our production program.

Resolving patch:
diff --git a/uvicorn/middleware/wsgi.py b/uvicorn/middleware/wsgi.py
--- a/uvicorn/middleware/wsgi.py
+++ b/uvicorn/middleware/wsgi.py
@@ -19,7 +19,9 @@
 from uvicorn._types import Environ, ExcInfo, StartResponse, WSGIApp
 
 
-def build_environ(scope: HTTPScope, message: ASGIReceiveEvent, body: bytes) -> Environ:
+def build_environ(
+    scope: HTTPScope, message: ASGIReceiveEvent, body: io.BytesIO
+) -> Environ:
     """
     Builds a scope and request message into a WSGI environ object.
     """
@@ -31,7 +33,7 @@ def build_environ(scope: HTTPScope, message: ASGIReceiveEvent, body: bytes) -> E
         "SERVER_PROTOCOL": "HTTP/%s" % scope["http_version"],
         "wsgi.version": (1, 0),
         "wsgi.url_scheme": scope.get("scheme", "http"),
-        "wsgi.input": io.BytesIO(body),
+        "wsgi.input": body,
         "wsgi.errors": sys.stdout,
         "wsgi.multithread": True,
         "wsgi.multiprocess": True,
@@ -105,12 +107,17 @@ async def __call__(
         self, receive: ASGIReceiveCallable, send: ASGISendCallable
     ) -> None:
         message: HTTPRequestEvent = await receive()  # type: ignore[assignment]
-        body = message.get("body", b"")
+        body = io.BytesIO(message.get("body", b""))
         more_body = message.get("more_body", False)
-        while more_body:
-            body_message: HTTPRequestEvent = await receive()  # type: ignore[assignment]
-            body += body_message.get("body", b"")
-            more_body = body_message.get("more_body", False)
+        if more_body:
+            body.seek(0, io.SEEK_END)
+            while more_body:
+                body_message: HTTPRequestEvent = (
+                    await receive()  # type: ignore[assignment]
+                )
+                body.write(body_message.get("body", b""))
+                more_body = body_message.get("more_body", False)
+            body.seek(0)
         environ = build_environ(self.scope, message, body)
         self.loop = asyncio.get_event_loop()
         wsgi = self.loop.run_in_executor(


Test patch:
diff --git a/tests/middleware/test_wsgi.py b/tests/middleware/test_wsgi.py
--- a/tests/middleware/test_wsgi.py
+++ b/tests/middleware/test_wsgi.py
@@ -1,5 +1,6 @@
+import io
 import sys
-from typing import List
+from typing import AsyncGenerator, List
 
 import httpx
 import pytest
@@ -67,6 +68,19 @@ async def test_wsgi_post() -> None:
     assert response.text == '{"example": 123}'
 
 
+@pytest.mark.asyncio
+async def test_wsgi_put_more_body() -> None:
+    async def generate_body() -> AsyncGenerator[bytes, None]:
+        for _ in range(1024):
+            yield b"123456789abcdef\n" * 64
+
+    app = WSGIMiddleware(echo_body)
+    async with httpx.AsyncClient(app=app, base_url="http://testserver") as client:
+        response = await client.put("/", content=generate_body())
+    assert response.status_code == 200
+    assert response.text == "123456789abcdef\n" * 64 * 1024
+
+
 @pytest.mark.asyncio
 async def test_wsgi_exception() -> None:
     # Note that we're testing the WSGI app directly here.
@@ -120,6 +134,6 @@ def test_build_environ_encoding() -> None:
         "body": b"",
         "more_body": False,
     }
-    environ = build_environ(scope, message, b"")
+    environ = build_environ(scope, message, io.BytesIO(b""))
     assert environ["PATH_INFO"] == "/文".encode("utf8").decode("latin-1")
     assert environ["HTTP_KEY"] == "value1,value2"