Download api/app.py from zlcy/abc: direct link, hf CLI and curl.
- Browser
- Download file 13.3 kB
-
https://huggingface.co/spaces/zlcy/abc/resolve/main/api/app.py
- Command line
-
hf download hf://spaces/zlcy/abc/api/app.py
-
curl -L -o app.py https://huggingface.co/spaces/zlcy/abc/resolve/main/api/app.py
13.3 kB
| 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() | |
| 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)) | |
| 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) | |
| 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 | |