Files
Bentlybro a38762dd05 Switch gunicorn worker from eventlet to gthread
Cloud Run was 500-ing every async route on the eventlet worker:

  RuntimeError: asyncio.run() cannot be called from a running event loop
  RuntimeError: You cannot use AsyncToSync in the same thread as an
                async event loop - just await the async function directly.

Root cause: standard Flask decorators (@login_required from Flask-Login,
@limiter.limit from Flask-Limiter, and Flask's own async-view wrapper)
call ``flask.current_app.ensure_sync(obj)(*a, **k)``, which uses
``asgiref.AsyncToSync``, which calls ``asyncio.run()``. Under eventlet's
greenlet hub ``asyncio.run()`` raises because the hub *is* the running
loop. Every ``async def`` route wrapped by any of those decorators (i.e.
all of /api/moderate, /auth/*, /dashboard/*) dies at the decorator
boundary before the view body ever executes. Sentry AUTOMOD-3B/3C/3D.

The dev server didn't show this because werkzeug gives each request a
fresh OS thread with no existing event loop, so asgiref can spin up its
own loop fine.

gthread worker + SOCKETIO_ASYNC_MODE=threading:
- Real OS threads for each request → asgiref's AsyncToSync works.
- Flask-SocketIO threading mode supports WebSockets for our
  dashboard-scale realtime traffic.
- Dev (run.py) and prod now use the same async mode, so behaviour
  matches end-to-end.
- 50 threads/worker = plenty of concurrency for HTTP + long-lived
  WebSocket connections.

Dropped eventlet and psycogreen from requirements.txt — they were pinned
specifically for the eventlet worker, neither is used any more.
2026-04-22 14:22:26 +01:00

35 lines
1.3 KiB
Python

"""Production WSGI entry point.
Served by gunicorn with the gthread worker class. Example:
gunicorn --worker-class gthread --workers 1 --threads 50 \
--bind 0.0.0.0:6217 wsgi:app
Why not eventlet
----------------
We previously used eventlet for WebSocket support but it's incompatible
with Flask's ``async def`` routes under standard decorators
(``flask_login.login_required``, ``flask_limiter.limit``, Flask's own
``@app.route`` on async views). Those decorators call
``flask.current_app.ensure_sync(...)`` which uses ``asgiref.AsyncToSync``
to invoke the async view, which in turn calls ``asyncio.run()``. Under
eventlet's greenlet hub ``asyncio.run()`` raises
"asyncio.run() cannot be called from a running event loop" — so every
authenticated or rate-limited route 500s in production.
gthread + ``SOCKETIO_ASYNC_MODE=threading`` gives us:
- Each request handled by a real OS thread, so asgiref can create a loop.
- Flask-SocketIO in threading mode handles WebSockets via those same
threads; perfectly fine for our dashboard-scale realtime traffic.
- Identical behaviour in dev (``run.py``) and prod, so there's one less
axis along which "works on my machine" can happen.
"""
import os
os.environ.setdefault('SOCKETIO_ASYNC_MODE', 'threading')
from app import create_app # noqa: E402
app = create_app(os.getenv('FLASK_CONFIG', 'production'))