Files
Alex 54b540ea42 feat(api): serve long-lived streams on the event loop
Move GET /api/events, the remote-device command stream and artifact
downloads from Flask to Starlette routes mounted ahead of the Flask
catch-all. On Flask each held an a2wsgi threadpool slot for as long as
its response stayed open, and because uvicorn drops writes after a
client disconnects, a closed tab never released it.

- asgi_auth: one JWT/OIDC gate for Starlette routes; the chat reconnect
  reader uses it too
- ClosingStreamingResponse closes the body iterator and releases the
  SSE slot or file handle even when the client leaves before the first
  frame
- AsyncTopic liveness probe replaces the sync client's socket_timeout
  guard against half-open pub/sub sockets
- ASYNC_REDIS_MAX_CONNECTIONS sizes the async Redis pool; every open
  stream holds a connection and redis-py defaults to 100
2026-09-14 08:33:46 +01:00

63 lines
2.3 KiB
Python

"""ASGI entrypoint: Flask (WSGI) + FastMCP on the same process."""
from __future__ import annotations
from a2wsgi import WSGIMiddleware
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.cors import CORSMiddleware
from starlette.routing import Mount
from docsgpt.api.async_sse import async_sse_routes
from docsgpt.api.devices.session_events import device_session_routes
from docsgpt.api.events.routes import event_stream_routes
from docsgpt.api.user.artifacts.download import artifact_download_routes
from docsgpt.app import app as flask_app
from docsgpt.core.settings import settings
from docsgpt.mcp_server import mcp
from docsgpt.ui import StaticUI
_WSGI_THREADPOOL = int(settings.WSGI_THREADPOOL_WORKERS)
mcp_app = mcp.http_app(path="/")
# The web UI, when the package ships one (docsgpt/static) and SERVE_UI is on:
# files are served directly, Flask's own path prefixes pass through, and any
# other GET renders index.html for the client-side router.
_backend = StaticUI.wrap(
WSGIMiddleware(flask_app, workers=_WSGI_THREADPOOL), flask_app.url_map, enabled=settings.SERVE_UI
)
asgi_app = Starlette(
routes=[
Mount("/mcp", app=mcp_app),
# Native-async routes intercept their exact paths before the Flask
# catch-all. Each holds its response open for a long time (the chat
# reconnect tail, the notification stream, a device's command stream,
# large artifact downloads), so it rides the event loop instead of
# pinning a WSGI threadpool slot. Order matters: Starlette matches
# routes top-to-bottom, so these must precede the Mount("/") that
# hands everything else to Flask.
*async_sse_routes,
*event_stream_routes,
*device_session_routes,
*artifact_download_routes,
Mount("/", app=_backend),
],
middleware=[
Middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=[
"Content-Type",
"Authorization",
"Mcp-Session-Id",
"Idempotency-Key",
],
expose_headers=["Mcp-Session-Id"],
),
],
lifespan=mcp_app.lifespan,
)