From 547bc578b949aa3cc4214c29c45e4de2eb56901a Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Tue, 27 Oct 2026 06:42:37 -0700 Subject: [PATCH] fix(logs): declare the compressed length so a log download finishes The gzip middleware compressed a text body but kept the handler's declared Content-Length (the uncompressed size), so a browser waited for bytes that never came: downloading any log over 1 KB hung, in the workspace and on the admin page. The middleware now drops a declared length before compressing. Part of #329 --- py/api/__init__.py | 4 ++++ tests/test_api_core.py | 24 ++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/py/api/__init__.py b/py/api/__init__.py index ac839fc5..d45f2c47 100644 --- a/py/api/__init__.py +++ b/py/api/__init__.py @@ -295,6 +295,10 @@ async def _gzip_middleware(request, handler): if len(compressed) >= len(body): return response + # A length the handler declared is the uncompressed one: drop it so the + # compressed body's own length is sent (#329: a stale length made the + # browser wait for bytes that never came). + response.headers.pop("Content-Length", None) response.body = compressed response.headers["Content-Encoding"] = "gzip" response.headers["Vary"] = "Accept-Encoding" diff --git a/tests/test_api_core.py b/tests/test_api_core.py index e6e06bec..07e6ffa7 100644 --- a/tests/test_api_core.py +++ b/tests/test_api_core.py @@ -1,5 +1,6 @@ """Tests for the core API module (py/api/__init__.py): helpers and base routes.""" +import asyncio import json import os import shutil @@ -165,8 +166,19 @@ class TestGzipMiddleware(AioHTTPTestCase): await resp.write_eof() return resp + async def declared_length(request): + # The log download (#329) declares its length before the middleware + # compresses it. + body = b"x" * 3000 + return web.Response( + body=body, + content_type="text/plain", + headers={"Content-Length": str(len(body))}, + ) + app = web.Application(middlewares=[api_module._gzip_middleware]) app.router.add_get("/prompt_manager/big", big_json) + app.router.add_get("/prompt_manager/declared", declared_length) app.router.add_get("/prompt_manager/small", small_json) app.router.add_get("/prompt_manager/encoded", already_encoded) app.router.add_get("/prompt_manager/binary", binary) @@ -193,6 +205,18 @@ class TestGzipMiddleware(AioHTTPTestCase): self.assertIn(b'"data"', gzip.decompress(await resp.read())) + async def test_compressed_body_declares_its_own_length(self): + # A stale Content-Length (the uncompressed size) makes a browser wait + # for bytes that never come (#329: Download log hung on any log > 1 KB). + resp = await self._get("/prompt_manager/declared") + self.assertEqual(resp.headers.get("Content-Encoding"), "gzip") + # Bounded: a stale length would otherwise wait for the missing bytes. + body = await asyncio.wait_for(resp.read(), timeout=10) + self.assertEqual(int(resp.headers["Content-Length"]), len(body)) + import gzip + + self.assertEqual(gzip.decompress(body), b"x" * 3000) + async def test_small_body_not_compressed(self): resp = await self._get("/prompt_manager/small") self.assertIsNone(resp.headers.get("Content-Encoding"))