""" 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