tts-voice-creator-clone-and.../server.py
mARTin-B78 6bc98e2b62 Fix ASGI middleware crash and stream/blocking lock race (v1.12.96)
The static-asset caching middleware used BaseHTTPMiddleware, which has a
known Starlette bug: a client disconnecting mid-StreamingResponse (the new
live-attribution SSE stream hitting its idle timeout) raced its internal
task group and raised "RuntimeError: No response returned", crashing that
request. Rewritten as plain ASGI middleware that only touches headers via
the raw send callable, removing the race.

Also found the real cause of the casting timeouts/405s: the streaming
attribution endpoint had its own lock instead of sharing the one the
blocking endpoint already used to serialize on the LLM's single slot -
letting a stream call and its own blocking fallback fire concurrently,
exactly the ghost-request pile-up that lock was built to prevent. Unified
onto one lock and added server-side logging for stream failures.

The "LLM Thinking" pane now shows the model's actual <think> reasoning
instead of the in-progress JSON answer echoed back at the user.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 18:08:54 +02:00

142 lines
6.6 KiB
Python

"""TTS Voice Creator - Clone and Design — FastAPI entry point (thin wrapper)."""
from __future__ import annotations
import logging
import os
from logging.handlers import RotatingFileHandler
from pathlib import Path
from contextlib import asynccontextmanager
from urllib.parse import parse_qs
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.gzip import GZipMiddleware
from starlette.datastructures import MutableHeaders
from core.constants import STATIC_DIR, _BufferHandler
from core.config import _load_settings
from core.voice_index import refresh_voice_index_background
from routes import admin, settings, library, stt, sources, docker, tts, conversation, reader, characters, rehearsals_db
logger = logging.getLogger("uvicorn.error")
# ── App ───────────────────────────────────────────────────────────────────────
@asynccontextmanager
async def lifespan(app: FastAPI):
# Uvicorn resets loggers during startup when run via CLI.
# We must attach our handlers here to ensure they capture the active logs.
for name in ("uvicorn", "uvicorn.error", "uvicorn.access", "fastapi"):
l = logging.getLogger(name)
if _buf_handler not in l.handlers:
l.addHandler(_buf_handler)
if "_file_handler" in globals() and _file_handler not in l.handlers:
l.addHandler(_file_handler)
try:
refresh_voice_index_background(_load_settings())
except Exception as exc:
logger.warning("Could not start voice index refresh: %s", exc)
yield
app = FastAPI(title="TTS Voice Creator - Clone and Design", lifespan=lifespan)
# ── Response compression ──────────────────────────────────────────────────────
# Gzip text assets (JS/CSS/HTML/JSON) above 1 KB — ~75% smaller transfer for the
# large JS bundle and voice-list JSON. Skips already-compressed audio/images.
app.add_middleware(GZipMiddleware, minimum_size=1024)
# ── In-memory log buffer handler ──────────────────────────────────────────────
_buf_handler = _BufferHandler()
_buf_handler.setLevel(logging.DEBUG)
logging.getLogger().addHandler(_buf_handler)
# ── File log handler (rotates at 5 MB, keeps 3 files) ────────────────────────
_LOG_DIR = Path(os.environ.get("LOG_DIR", "/logs"))
try:
_LOG_DIR.mkdir(parents=True, exist_ok=True)
_file_handler = RotatingFileHandler(
_LOG_DIR / "app.log",
maxBytes=5 * 1024 * 1024,
backupCount=3,
encoding="utf-8",
)
_file_handler.setLevel(logging.INFO)
_file_handler.setFormatter(logging.Formatter(
"%(asctime)s %(levelname)-8s %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
))
logging.getLogger().addHandler(_file_handler)
except Exception as _log_err:
logger.warning("Could not set up file logging at %s: %s", _LOG_DIR, _log_err)
# ── Static-asset caching middleware ──────────────────────────────────────────
# JS and CSS are served with ?v=<version> by loader.js — safe to cache for 1 year.
# Section HTML uses ?v=<timestamp> (always fresh) so no long-term cache there.
#
# Pure ASGI middleware, not @app.middleware("http") (= Starlette's
# BaseHTTPMiddleware) — that implementation wraps every request in a task
# group around call_next(), and a client disconnecting mid-StreamingResponse
# (e.g. the live-attribution SSE stream hitting its idle timeout) races that
# task group and raises "RuntimeError: No response returned." This class
# only touches response headers via the raw `send` callable, so it never
# holds the response open the way call_next() does.
class StaticCacheHeadersMiddleware:
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
path = scope["path"]
has_version = "v" in parse_qs((scope.get("query_string") or b"").decode())
async def send_wrapper(message):
if message["type"] == "http.response.start":
headers = MutableHeaders(scope=message)
if has_version and (path.startswith("/static/js/") or path.endswith(".css")):
headers["Cache-Control"] = "public, max-age=31536000, immutable"
elif path.startswith("/static/sections/"):
headers["Cache-Control"] = "no-store"
elif path.startswith("/static/vendor/"):
headers["Cache-Control"] = "public, max-age=86400"
elif path.startswith("/static/") and not has_version:
# Bootstrap files (style.css, loader.js, nav.js, …) are loaded without a ?v= query.
# Revalidate on every load so GUI edits appear after a normal reload — the server
# returns 304 when the file is unchanged, so this stays cheap.
headers["Cache-Control"] = "no-cache"
await send(message)
await self.app(scope, receive, send_wrapper)
app.add_middleware(StaticCacheHeadersMiddleware)
# ── Routers ───────────────────────────────────────────────────────────────────
app.include_router(admin.router)
app.include_router(settings.router)
app.include_router(library.router)
app.include_router(stt.router)
app.include_router(sources.router)
app.include_router(docker.router)
app.include_router(tts.router)
app.include_router(conversation.router)
app.include_router(reader.router)
app.include_router(characters.router)
app.include_router(rehearsals_db.router)
# ── Static files ──────────────────────────────────────────────────────────────
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
# ── Dev runner ────────────────────────────────────────────────────────────────
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=7890, log_level="info")