0fe1674753
The image library is on a CIFS/SMB share (mounted rsize=4 MiB, actimeo=1), and Quart's FileBody streams in 8 KiB chunks — so serving one large original was ~19k network round-trips to the storage server, i.e. 30–58s per download (operator-flagged). That's what starved the GPU agent (constant "curator unreachable" backoff) AND slowed the browser: every byte is read off CIFS and streamed through the Python app (no reverse-proxy sendfile), and only 2 hypercorn workers meant the agent + the browser's thumbnail grid queued behind each other. In-container fix, no new service: - Raise FileBody.buffer_size 8 KiB → 4 MiB in create_app, matching the mount's read size: one round-trip per read, ~500× fewer. buffer_size is the MAX read so small thumbnails still read in one gulp, and Range/mime/ETag/conditional handling lives on Response — all preserved. Guarded so a Quart-internal change can't break boot. - HYPERCORN_WORKERS default 2 → 4 so concurrent /images requests stop queuing. Expected: large-file transfers drop from ~40s toward link speed (a few seconds) for the agent and the browser. See issue #1223. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
92 lines
3.7 KiB
Python
92 lines
3.7 KiB
Python
"""Quart app factory."""
|
||
|
||
import logging
|
||
from pathlib import Path
|
||
|
||
from quart import Quart, request
|
||
|
||
from .api import all_blueprints
|
||
from .config import get_config
|
||
from .frontend import frontend_bp
|
||
from .services.credential_crypto import CredentialCrypto
|
||
|
||
# Browser-extension origins. The FabledCurator extension fetches from
|
||
# moz-extension://<uuid>/ on Firefox and chrome-extension://<uuid>/ on
|
||
# Chromium-based browsers. Operator-flagged 2026-05-26: extension's
|
||
# 'Test connection' returned `NetworkError` because the X-Extension-Key
|
||
# header on /api/credentials triggers a CORS preflight that our routes
|
||
# don't handle. Whitelisting only these two schemes (not opening CORS
|
||
# up generally) lets the extension talk to a plain-HTTP self-hosted FC
|
||
# without weakening the no-CORS posture for normal browser usage.
|
||
_EXTENSION_ORIGIN_SCHEMES = ("moz-extension://", "chrome-extension://")
|
||
|
||
_CREDENTIAL_KEY_PATH = Path("/images/secrets/credential_key.b64")
|
||
|
||
|
||
def create_app() -> Quart:
|
||
cfg = get_config()
|
||
logging.basicConfig(level=cfg.log_level)
|
||
|
||
# Bootstrap the credential encryption key file (mode 0600) before
|
||
# any request can land. FC-3b: file-based system-generated key.
|
||
CredentialCrypto(_CREDENTIAL_KEY_PATH)
|
||
|
||
app = Quart(__name__)
|
||
app.secret_key = cfg.secret_key
|
||
|
||
# Stream files in 4 MiB chunks instead of Quart's 8 KiB default. The image
|
||
# library lives on a CIFS/SMB share (mounted rsize=4 MiB), so 8 KiB reads
|
||
# meant ~19k network round-trips for one large original — 30–58s downloads
|
||
# that starved both the GPU agent and the browser (operator-flagged
|
||
# 2026-07-01). 4 MiB matches the mount's read size → one round-trip per read,
|
||
# ~500× fewer. buffer_size is the MAX read, so small thumbnails still read in
|
||
# a single gulp, and Range/mime/ETag/conditional handling lives on Response,
|
||
# so this keeps all of it. Guarded so a future Quart-internal change can't
|
||
# break boot — worst case we fall back to the slow default.
|
||
try:
|
||
from quart.wrappers.response import FileBody
|
||
FileBody.buffer_size = 4 * 1024 * 1024
|
||
except Exception:
|
||
logging.getLogger(__name__).warning(
|
||
"could not raise FileBody.buffer_size — file serving stays on 8 KiB chunks"
|
||
)
|
||
|
||
for bp in all_blueprints():
|
||
app.register_blueprint(bp)
|
||
# Registered last so /api/* routes win over the SPA catch-all.
|
||
app.register_blueprint(frontend_bp)
|
||
|
||
@app.before_request
|
||
async def _extension_cors_preflight():
|
||
# Short-circuit OPTIONS preflight from the browser extension with a
|
||
# 204 + CORS headers (the after_request hook below adds them).
|
||
# Without this, OPTIONS lands on routes that only declared POST/GET
|
||
# methods and 405s before the after_request gets a chance.
|
||
if request.method != "OPTIONS":
|
||
return None
|
||
origin = request.headers.get("Origin", "")
|
||
if any(origin.startswith(s) for s in _EXTENSION_ORIGIN_SCHEMES):
|
||
return "", 204
|
||
return None
|
||
|
||
@app.after_request
|
||
async def _extension_cors_headers(response):
|
||
origin = request.headers.get("Origin", "")
|
||
if any(origin.startswith(s) for s in _EXTENSION_ORIGIN_SCHEMES):
|
||
response.headers["Access-Control-Allow-Origin"] = origin
|
||
response.headers["Access-Control-Allow-Methods"] = (
|
||
"GET, POST, PATCH, DELETE, OPTIONS"
|
||
)
|
||
response.headers["Access-Control-Allow-Headers"] = (
|
||
"Content-Type, X-Extension-Key"
|
||
)
|
||
response.headers["Access-Control-Max-Age"] = "86400"
|
||
return response
|
||
|
||
@app.after_serving
|
||
async def _dispose_db_engine() -> None:
|
||
from .extensions import dispose_engine
|
||
await dispose_engine()
|
||
|
||
return app
|