From f10f1c665fefc6685a4bacf4a22ec52a2c03fb99 Mon Sep 17 00:00:00 2001 From: serversidehannes Date: Sat, 18 Jul 2026 18:28:03 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20pin=20uvicorn=20loop=20to=20asyncio=20?= =?UTF-8?q?=E2=80=94=20loop=3Dauto=20silently=20re-enables=20uvloop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- s3proxy/app.py | 8 +++++- s3proxy/main.py | 12 ++++++--- tests/unit/test_main_loop_config.py | 42 +++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 tests/unit/test_main_loop_config.py diff --git a/s3proxy/app.py b/s3proxy/app.py index 1547fa8..41701a2 100644 --- a/s3proxy/app.py +++ b/s3proxy/app.py @@ -157,7 +157,13 @@ def create_lifespan(settings: Settings, credentials_store: dict[str, str]) -> As @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: _silence_health_probe_access_logs() - logger.info("Starting", endpoint=settings.s3_endpoint, port=settings.port) + loop = asyncio.get_running_loop() + logger.info( + "Starting", + endpoint=settings.s3_endpoint, + port=settings.port, + event_loop=f"{type(loop).__module__}.{type(loop).__qualname__}", + ) # Initialize Redis FIRST, then create manager with correct store await init_redis(settings.redis_url or None, settings.redis_password or None) diff --git a/s3proxy/main.py b/s3proxy/main.py index 5f3337c..34cfa5f 100644 --- a/s3proxy/main.py +++ b/s3proxy/main.py @@ -23,15 +23,20 @@ def main(): # uvloop 0.22.1 on Python 3.14 dies with a libuv abort (uv__io_poll assertion, # preceded by "OSError: [Errno 9] Bad file descriptor" from TCPTransport) when # the backend drops connections under load — killed 3 pods on 2026-07-16 with - # every in-flight upload. Off unless explicitly re-enabled. - if os.environ.get("S3PROXY_UVLOOP", "0") == "1": + # every in-flight upload. Off unless explicitly re-enabled. Skipping + # uvloop.install() here is NOT enough: uvicorn's default loop="auto" installs + # uvloop on its own whenever the package is importable (confirmed by a + # 2026-07-18 crash trace with uv__io_poll frames on a build without this + # install call), so the loop choice must be forced in the uvicorn config too. + use_uvloop = os.environ.get("S3PROXY_UVLOOP", "0") == "1" + if use_uvloop: try: import uvloop uvloop.install() logger.info("Using uvloop for improved performance") except ImportError: - pass + use_uvloop = False parser = argparse.ArgumentParser(description="S3Proxy - Transparent S3 encryption") parser.add_argument("--ip", default="0.0.0.0", help="Bind address") @@ -61,6 +66,7 @@ def main(): "host": settings.ip, "port": settings.port, "log_level": settings.log_level.lower(), + "loop": "auto" if use_uvloop else "asyncio", } if settings.memory_limit_mb > 0: diff --git a/tests/unit/test_main_loop_config.py b/tests/unit/test_main_loop_config.py new file mode 100644 index 0000000..8375343 --- /dev/null +++ b/tests/unit/test_main_loop_config.py @@ -0,0 +1,42 @@ +"""uvicorn must not auto-install uvloop: the loop has to be pinned to asyncio. + +2026-07-18: a build that skipped uvloop.install() still died with libuv's +uv__io_poll abort — uvicorn's default loop="auto" installs uvloop by itself +whenever the package is importable. The uvicorn config must pass +loop="asyncio" unless S3PROXY_UVLOOP=1 opts back in. +""" + +import json +import sys + +import uvicorn + +from s3proxy import main as main_module + +_CREDS = json.dumps([{"access_key": "AK", "secret_key": "SK", "kek": "kek-secret"}]) + + +def _captured_uvicorn_config(monkeypatch, extra_env=None): + captured = {} + monkeypatch.setattr(uvicorn, "run", lambda **config: captured.update(config)) + monkeypatch.setenv("S3PROXY_CREDENTIALS", _CREDS) + for key in ("IP", "PORT", "NO_TLS", "CERT_PATH", "REGION", "LOG_LEVEL"): + monkeypatch.delenv(f"S3PROXY_{key}", raising=False) + for k, v in (extra_env or {}).items(): + monkeypatch.setenv(k, v) + monkeypatch.setattr(sys, "argv", ["s3proxy", "--no-tls"]) + main_module.main() + return captured + + +def test_loop_defaults_to_asyncio(monkeypatch): + config = _captured_uvicorn_config(monkeypatch) + assert config["loop"] == "asyncio" + + +def test_loop_auto_only_when_uvloop_opted_in(monkeypatch): + import uvloop + + monkeypatch.setattr(uvloop, "install", lambda: None) + config = _captured_uvicorn_config(monkeypatch, {"S3PROXY_UVLOOP": "1"}) + assert config["loop"] == "auto"