File size: 13,328 Bytes
85270f9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 | from __future__ import annotations
import asyncio
from contextlib import asynccontextmanager
from html import escape
from mimetypes import guess_type
from pathlib import Path
import re
from threading import Event, Thread, active_count
import time
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse, Response
from starlette.middleware.gzip import GZipMiddleware
from api import accounts, ai, chat_room, checkin, image_tasks, openui, product_visual, register, shop, skills, system, third_party_images
from api.errors import install_exception_handlers
from api.support import resolve_web_asset, start_limited_account_watcher
from services.backup_service import backup_service
from services.config import config
from services.pool_pricing_service import pool_pricing_service
from services.share_evidence_service import share_evidence_service
from services.thread_pool import apply_thread_pool_tokens
from services.skill_service import skill_service
from services.sqlite_mirror_service import sqlite_mirror_service
STATIC_CACHE_CONTROL = "public, max-age=31536000, immutable"
ASSET_CACHE_CONTROL = "public, max-age=86400"
HTML_CACHE_CONTROL = "no-cache"
COMPRESSIBLE_SUFFIXES = {".css", ".html", ".js", ".json", ".map", ".svg", ".txt", ".xml"}
TITLE_RE = re.compile(r"<title>.*?</title>", re.IGNORECASE | re.DOTALL)
ICON_LINK_RE = re.compile(r"<link\b[^>]*\brel=[\"'][^\"']*(?:icon|apple-touch-icon)[^\"']*[\"'][^>]*>", re.IGNORECASE)
def _start_image_cleanup() -> Thread:
def worker() -> None:
started_at = time.monotonic()
try:
removed = config.cleanup_old_images()
elapsed = time.monotonic() - started_at
print(f"[image-cleanup] removed {removed} expired images in {elapsed:.2f}s")
except Exception as exc:
elapsed = time.monotonic() - started_at
print(f"[image-cleanup] failed after {elapsed:.2f}s: {exc}")
thread = Thread(target=worker, name="image-cleanup", daemon=True)
thread.start()
return thread
def _start_bundled_skill_sync() -> Thread:
"""Synchronize bundled Skills without blocking HTTP startup.
On Hugging Face the Skills directory can live on a Storage Bucket mount.
Copying a large bundle to that network filesystem may take minutes on the
first deployment, so doing it before ``yield`` makes the whole Space appear
stuck even though database recovery has already completed.
"""
def worker() -> None:
started_at = time.monotonic()
print("[skills] bundled Skill synchronization started in background")
try:
bundled_skills = skill_service.sync_bundled_skills()
elapsed = time.monotonic() - started_at
print(
f"[skills] synchronized {len(bundled_skills)} bundled Skills "
f"in {elapsed:.2f}s"
)
except Exception as exc:
elapsed = time.monotonic() - started_at
print(f"[skills] bundled Skill synchronization failed after {elapsed:.2f}s: {exc}")
thread = Thread(target=worker, name="bundled-skill-sync", daemon=True)
thread.start()
return thread
def _web_cache_headers(full_path: str, asset: Path) -> dict[str, str]:
clean_path = full_path.strip("/")
suffix = asset.suffix.lower()
headers: dict[str, str] = {}
if clean_path.startswith("_next/static/"):
headers["Cache-Control"] = STATIC_CACHE_CONTROL
elif suffix in {".html", ".txt"}:
headers["Cache-Control"] = HTML_CACHE_CONTROL
else:
headers["Cache-Control"] = ASSET_CACHE_CONTROL
return headers
def _select_web_asset(request: Request, full_path: str, asset: Path) -> tuple[Path, dict[str, str], str | None]:
headers = _web_cache_headers(full_path, asset)
media_type = guess_type(str(asset))[0]
accept_encoding = str(request.headers.get("accept-encoding") or "").lower()
gzip_asset = asset.with_name(f"{asset.name}.gz")
is_compressible = asset.suffix.lower() in COMPRESSIBLE_SUFFIXES
if is_compressible:
headers = dict(headers)
headers["Vary"] = "Accept-Encoding"
if "gzip" in accept_encoding and is_compressible and gzip_asset.is_file():
headers = dict(headers)
headers["Content-Encoding"] = "gzip"
return gzip_asset, headers, media_type
return asset, headers, media_type
def _is_api_like_path(full_path: str) -> bool:
clean_path = full_path.strip("/")
return clean_path.startswith(("api/", "auth/", "v1/"))
def _guess_icon_type(href: str) -> str:
clean_href = href.split("?", 1)[0].split("#", 1)[0].lower()
if clean_href.endswith(".svg"):
return "image/svg+xml"
if clean_href.endswith((".jpg", ".jpeg")):
return "image/jpeg"
if clean_href.endswith(".webp"):
return "image/webp"
if clean_href.endswith(".ico"):
return "image/x-icon"
return "image/png"
def _inject_branding_head(html_text: str) -> str:
branding = config.get_branding()
title = escape(str(branding.get("site_title") or "ACM 号池管理"), quote=False)
raw_icon_href = str(branding.get("site_logo_url") or "/favicon.ico")
icon_href = escape(raw_icon_href, quote=True)
icon_type = escape(_guess_icon_type(raw_icon_href), quote=True)
icon_markup = (
f'<link rel="icon" href="{icon_href}" type="{icon_type}" data-branding-icon="favicon">'
f'<link rel="apple-touch-icon" href="{icon_href}" data-branding-icon="apple-touch-icon">'
)
next_html = TITLE_RE.sub(f"<title>{title}</title>", html_text, count=1)
if next_html == html_text and "</head>" in next_html:
next_html = next_html.replace("</head>", f"<title>{title}</title></head>", 1)
next_html = ICON_LINK_RE.sub("", next_html)
if "</head>" in next_html:
next_html = next_html.replace("</head>", f"{icon_markup}</head>", 1)
return next_html
def _web_response(full_path: str, request: Request, asset: Path) -> Response:
if asset.suffix.lower() == ".html":
html_text = asset.read_text(encoding="utf-8")
return HTMLResponse(
_inject_branding_head(html_text),
headers=_web_cache_headers(full_path, asset),
)
selected_asset, headers, media_type = _select_web_asset(request, full_path, asset)
return FileResponse(selected_asset, headers=headers, media_type=media_type)
def create_app() -> FastAPI:
app_version = config.app_version
async def _loop_lag_watchdog() -> None:
"""把事件循环的真实卡顿写进日志。
⚠️ 排查「整站无响应」时,容器日志一切正常(后台线程照跑)却测不出来到底卡在哪,
就是因为没有任何关于事件循环本身的观测。这个探针每 20 秒量一次 sleep 漂移:
漂移 = 循环线程被同步调用/GIL 抢占的实际时长。
"""
from utils.log import logger as _logger
while True:
started = time.perf_counter()
await asyncio.sleep(20)
lag_ms = round((time.perf_counter() - started - 20) * 1000)
if lag_ms >= 800:
_logger.warning({
"event": "event_loop_lag",
"lag_ms": lag_ms,
"threads": active_count(),
})
def _start_loop_wedge_guard(loop: asyncio.AbstractEventLoop) -> None:
"""事件循环被彻底卡死时:抓全部线程堆栈 + 主动退出让 HF 重启容器。
⚠️ 为什么需要它:2026-10-19 线上出现「容器 RUNNING、后台线程照常打日志、
所有 HTTP 请求零字节/连接被关闭」的状态,重启也不自愈。这种情况循环线程已经
完全不调度,任何写在循环里的探针(包括 asyncio.sleep)都不会再执行,日志一片
安静 —— 既测不出来也没法自愈。只能用一个独立线程从外部观测:
往循环里投一个回调,健康时会立刻执行;15 秒都没跑 = 循环已死。
连续 3 次判死 → dump 所有线程堆栈(定位阻塞点)→ os._exit 让容器重建。
"""
import faulthandler
import os
import sys
from utils.log import logger as _logger
def run() -> None:
strikes = 0
while True:
done = Event()
started = time.time()
try:
loop.call_soon_threadsafe(done.set)
except RuntimeError:
return
if done.wait(15.0):
strikes = 0
done.wait(5.0)
continue
strikes += 1
_logger.warning({
"event": "event_loop_unresponsive",
"waited_seconds": round(time.time() - started, 1),
"strikes": strikes,
"threads": active_count(),
})
# ⚠️ 只抓堆栈、**不主动退出**:os._exit 会让 HF 把 Space 置为
# RUNTIME_ERROR(不会自动重建),反而要人工点重启;只留证据更好。
if strikes == 3 or strikes % 12 == 0:
_logger.error({"event": "event_loop_wedged", "action": "dump_stacks"})
try:
faulthandler.dump_traceback(file=sys.stderr, all_threads=True)
except Exception:
pass
sys.stderr.flush()
Thread(target=run, daemon=True, name="loop-wedge-guard").start()
@asynccontextmanager
async def lifespan(_: FastAPI):
# anyio's worker-thread limiter defaults to 40 and is shared by every
# run_in_threadpool() call in the process. Resize it here, inside the
# event loop, so the whole app sees the configured capacity.
tokens = apply_thread_pool_tokens()
print(f"[thread-pool] worker thread capacity set to {tokens}")
stop_event = Event()
thread = start_limited_account_watcher(stop_event)
backup_service.start()
sqlite_mirror_service.start()
pool_pricing_service.start()
share_evidence_service.start()
_start_bundled_skill_sync()
_start_image_cleanup()
watchdog = asyncio.create_task(_loop_lag_watchdog())
_start_loop_wedge_guard(asyncio.get_running_loop())
try:
yield
finally:
watchdog.cancel()
stop_event.set()
thread.join(timeout=1)
backup_service.stop()
sqlite_mirror_service.stop()
pool_pricing_service.stop()
share_evidence_service.stop()
app = FastAPI(title="CHATSAM", version=app_version, lifespan=lifespan)
install_exception_handlers(app)
app.add_middleware(GZipMiddleware, minimum_size=1024, compresslevel=1)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(ai.create_router())
app.include_router(accounts.create_router())
app.include_router(checkin.create_router())
app.include_router(chat_room.create_router())
app.include_router(image_tasks.create_router())
app.include_router(openui.create_router())
app.include_router(product_visual.create_router())
app.include_router(register.create_router())
app.include_router(shop.create_router())
app.include_router(skills.create_router())
app.include_router(third_party_images.create_router())
app.include_router(system.create_router(app_version))
@app.get("/{full_path:path}", include_in_schema=False)
async def serve_web(full_path: str, request: Request):
asset = resolve_web_asset(full_path)
if asset is not None:
return _web_response(full_path, request, asset)
if full_path.strip("/").startswith("_next/"):
raise HTTPException(status_code=404, detail="Not Found")
fallback = resolve_web_asset("")
if fallback is None:
raise HTTPException(status_code=404, detail="Not Found")
return _web_response("", request, fallback)
@app.head("/{full_path:path}", include_in_schema=False)
async def serve_web_head(full_path: str, request: Request):
if _is_api_like_path(full_path):
raise HTTPException(status_code=405, detail="Method Not Allowed")
asset = resolve_web_asset(full_path)
if asset is not None:
selected_asset, headers, media_type = _select_web_asset(request, full_path, asset)
return FileResponse(selected_asset, headers=headers, media_type=media_type)
if full_path.strip("/").startswith("_next/"):
raise HTTPException(status_code=404, detail="Not Found")
fallback = resolve_web_asset("")
if fallback is None:
raise HTTPException(status_code=404, detail="Not Found")
selected_asset, headers, media_type = _select_web_asset(request, "", fallback)
return FileResponse(selected_asset, headers=headers, media_type=media_type)
return app
|