Files
wehub-resource-sync adf0d17497
publish / version_or_publish (push) Has been cancelled
storybook-build / changes (push) Has been cancelled
storybook-build / :storybook-build (push) Has been cancelled
Sync Gradio Skills to Hugging Face / sync-skills (push) Has been cancelled
functional / changes (push) Has been cancelled
functional / build-frontend (push) Has been cancelled
functional / functional-test-SSR=false (push) Has been cancelled
functional / functional-reload (push) Has been cancelled
js / changes (push) Has been cancelled
js / js-test (push) Has been cancelled
docs-build / changes (push) Has been cancelled
docs-build / docs-build (push) Has been cancelled
docs-build / website-build (push) Has been cancelled
functional / functional-test-SSR=true (push) Has been cancelled
hygiene / hygiene-test (push) Has been cancelled
python / changes (push) Has been cancelled
python / build (push) Has been cancelled
python / test-ubuntu-latest-flaky (push) Has been cancelled
python / test-ubuntu-latest-not-flaky (push) Has been cancelled
python / test-windows-latest-flaky (push) Has been cancelled
python / test-windows-latest-not-flaky (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:17:32 +08:00

248 lines
8.1 KiB
Python

"""
Lightweight static file server for multi-process Gradio.
Handles file serving, uploads, and static assets in separate processes
to free the main Gradio server for queue/SSE/state work.
"""
from __future__ import annotations
import asyncio
import json
import math
import multiprocessing
import multiprocessing.process
import time
from dataclasses import dataclass, field
from pathlib import Path
import fastapi
import uvicorn
from gradio_client import utils as client_utils
from starlette.formparsers import MultiPartException
from starlette.responses import PlainTextResponse, StreamingResponse
from gradio.route_utils import (
BUILD_PATH_LIB,
STATIC_PATH_LIB,
FileUploadProgress,
FileUploadProgressNotQueuedError,
FileUploadProgressNotTrackedError,
favicon,
file_fetch,
file_response,
secure_url_stream_response,
upload_fn,
)
from gradio.utils import (
DeveloperPath,
UserProvidedPath,
)
@dataclass
class StaticServerConfig:
build_path: str = ""
static_path: str = ""
uploaded_file_dir: str = ""
allowed_paths: list[str] = field(default_factory=list)
blocked_paths: list[str] = field(default_factory=list)
max_file_size: int | float | None = None
favicon_path: str | None = None
def create_static_app(config: StaticServerConfig) -> fastapi.FastAPI:
"""Create a minimal FastAPI app that only serves static files and handles uploads."""
from starlette.middleware.cors import CORSMiddleware
app = fastapi.FastAPI()
app.add_middleware(
CORSMiddleware, # type: ignore
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
upload_dir = config.uploaded_file_dir
max_file_size = (
config.max_file_size if config.max_file_size is not None else math.inf
)
@app.get("/svelte/{path:path}")
async def svelte_resource(path: str):
svelte_path = DeveloperPath(str(Path(BUILD_PATH_LIB) / "svelte"))
return file_response(svelte_path, UserProvidedPath(path))
@app.get("/static/{path:path}")
async def static_resource(path: str):
return file_response(STATIC_PATH_LIB, UserProvidedPath(path))
@app.get("/assets/{path:path}")
async def build_resource(path: str):
return file_response(BUILD_PATH_LIB, UserProvidedPath(path))
@app.get("/favicon.ico")
async def _():
return favicon(config.favicon_path)
@app.head("/gradio_api/file={path_or_url:path}")
@app.get("/gradio_api/file={path_or_url:path}")
@app.head("/file={path_or_url:path}")
@app.get("/file={path_or_url:path}")
async def file(path_or_url: str, request: fastapi.Request):
if client_utils.is_http_url_like(path_or_url):
return await secure_url_stream_response(path_or_url, request)
return file_fetch(path_or_url, request, config, upload_dir)
file_upload_statuses = FileUploadProgress()
@app.post("/gradio_api/upload")
@app.post("/upload")
async def upload_file(
request: fastapi.Request,
upload_id: str | None = None,
):
try:
output_files, _, _ = await upload_fn(
request,
upload_dir,
max_file_size,
upload_id=upload_id,
force_move=False,
upload_progress=file_upload_statuses if upload_id else None,
)
except MultiPartException as exc:
code = 413 if "maximum allowed size" in exc.message else 400
return PlainTextResponse(exc.message, status_code=code)
return output_files
@app.get("/gradio_api/upload_progress")
@app.get("/upload_progress")
async def get_upload_progress(upload_id: str, request: fastapi.Request):
async def sse_stream(request: fastapi.Request):
last_heartbeat = time.perf_counter()
is_done = False
while True:
if await request.is_disconnected():
file_upload_statuses.stop_tracking(upload_id)
return
if is_done:
file_upload_statuses.stop_tracking(upload_id)
return
heartbeat_rate = 15
check_rate = 0.05
try:
if file_upload_statuses.is_done(upload_id):
message = {"msg": "done"}
is_done = True
else:
update = file_upload_statuses.pop(upload_id)
message = {
"msg": "update",
"orig_name": update.filename,
"chunk_size": update.chunk_size,
}
yield f"data: {json.dumps(message)}\n\n"
except FileUploadProgressNotTrackedError:
return
except FileUploadProgressNotQueuedError:
await asyncio.sleep(check_rate)
if time.perf_counter() - last_heartbeat > heartbeat_rate:
message = {"msg": "heartbeat"}
yield f"data: {json.dumps(message)}\n\n"
last_heartbeat = time.perf_counter()
try:
await asyncio.wait_for(
file_upload_statuses.is_tracked(upload_id), timeout=3
)
except (asyncio.TimeoutError, TimeoutError):
return PlainTextResponse("Upload not found", status_code=404)
return StreamingResponse(
sse_stream(request),
media_type="text/event-stream",
)
@app.get("/health")
def health():
return {"status": "ok"}
return app
def _run_static_worker(port: int, config_dict: dict):
"""Entry point for a static worker subprocess."""
config = StaticServerConfig(**config_dict)
app = create_static_app(config)
uvicorn.run(app, host="127.0.0.1", port=port, log_level="warning")
class StaticWorkerPool:
"""Manages N static file server processes."""
def __init__(self, num_workers: int, config: StaticServerConfig, ports: list[int]):
self.num_workers = num_workers
self.config = config
self.workers: list[multiprocessing.process.BaseProcess] = []
self.ports = list(ports)
self._counter = 0
self._started = False
def start(self):
"""Spawn all static worker processes."""
if self._started:
return
self._started = True
config_dict = {
"build_path": self.config.build_path,
"static_path": self.config.static_path,
"uploaded_file_dir": self.config.uploaded_file_dir,
"allowed_paths": self.config.allowed_paths,
"blocked_paths": self.config.blocked_paths,
"max_file_size": self.config.max_file_size
if self.config.max_file_size is not None
and self.config.max_file_size != math.inf
else None,
"favicon_path": self.config.favicon_path,
}
for i, port in enumerate(self.ports):
process = multiprocessing.Process(
target=_run_static_worker,
args=(port, config_dict),
daemon=True,
name=f"gradio-static-{i}",
)
process.start()
self.workers.append(process)
# Wait for workers to be ready
import httpx
for port in self.ports:
for _ in range(50):
try:
r = httpx.get(f"http://127.0.0.1:{port}/health", timeout=1)
if r.status_code == 200:
break
except Exception:
time.sleep(0.1)
def get_next_url(self) -> str:
"""Round-robin to the next static worker."""
port = self.ports[self._counter % len(self.ports)]
self._counter += 1
return f"http://127.0.0.1:{port}"
def shutdown(self):
"""Stop all static worker processes."""
for process in self.workers:
process.kill()
for process in self.workers:
process.join(timeout=0.5)
self.workers.clear()
self.ports.clear()
self._started = False