Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion s3proxy/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 9 additions & 3 deletions s3proxy/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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:
Expand Down
42 changes: 42 additions & 0 deletions tests/unit/test_main_loop_config.py
Original file line number Diff line number Diff line change
@@ -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"