Files
wehub-resource-sync 85742ab165
Deploy Documentation / deploy (push) Has been cancelled
CPU Test / Test (Utilities, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (LLM proxy, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Others, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Store, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Utilities, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Weave, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (AgentOps, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (LLM proxy, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Others, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Weave, latest, Python 3.13) (push) Has been cancelled
Dashboard / Chromatic (push) Has been cancelled
CPU Test / Lint - fast (push) Has been cancelled
CPU Test / Lint - next (push) Has been cancelled
CPU Test / Lint - slow (push) Has been cancelled
CPU Test / Lint - JavaScript (push) Has been cancelled
CPU Test / Build documentation (push) Has been cancelled
CPU Test / Test (AgentOps, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (LLM proxy, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (Others, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (Store, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (Weave, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (AgentOps, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Store, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Utilities, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Weave, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (AgentOps, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (LLM proxy, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (Others, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (Store, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (Utilities, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (JavaScript) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:44:17 +08:00

1046 lines
42 KiB
Python

# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import asyncio
import inspect
import logging
import multiprocessing
import os
import queue
import signal
import socket
import threading
import time
import traceback
from contextlib import asynccontextmanager, suppress
from dataclasses import dataclass
from multiprocessing.process import BaseProcess
from typing import Any, AsyncContextManager, AsyncIterator, Dict, Literal, Optional, cast
import aiohttp
import requests
import uvicorn
from fastapi import FastAPI
from gunicorn.app.base import BaseApplication
from gunicorn.arbiter import Arbiter
from portpicker import pick_unused_port
__all__ = ["PythonServerLauncher", "PythonServerLauncherArgs", "LaunchMode"]
LaunchMode = Literal["asyncio", "thread", "mp"]
"""The launch mode for the server."""
@dataclass
class PythonServerLauncherArgs:
port: Optional[int] = None
"""The TCP port to listen on. If not provided, the server will use a random available port."""
host: Optional[str] = None
"""The hostname or IP address to bind the server to."""
access_host: Optional[str] = None
"""The hostname or IP address to advertise to the client. If not provided, the server will use the default outbound IPv4 address for this machine."""
launch_mode: LaunchMode = "asyncio"
"""The launch mode. `asyncio` is the default mode to runs the server in the current thread.
`thread` runs the server in a separate thread. `mp` runs the server in a separate process."""
n_workers: int = 1
"""The number of workers to run in the server. Only applicable for `mp` mode.
When `n_workers > 1`, the server will be run using Gunicorn.
"""
healthcheck_url: Optional[str] = None
"""The health check URL to use.
If not provided, the server will not be checked for healthiness after starting.
"""
log_level: int = logging.INFO
"""The log level to use."""
access_log: bool = False
"""Whether to turn on access logs."""
startup_timeout: float = 60.0
"""The timeout to wait for the server to start up."""
kill_unhealthy_server: bool = True
"""Whether to kill the server if it is not healthy after startup.
This setting is ignored when `launch_mode` is not `asyncio`.
"""
thread_join_timeout: float = 10.0
"""The timeout to wait for the thread to join."""
process_join_timeout: float = 10.0
"""The timeout to wait for the process to join."""
timeout_keep_alive: int = 30
"""The timeout to keep the connection alive."""
@dataclass
class ChildEvent:
"""An event that occurred in a child process."""
kind: Literal["ready", "error"]
"""The kind of message."""
exc_type: Optional[str] = None
"""The type of the exception, only used for error messages."""
message: Optional[str] = None
"""The message of the exception, only used for error messages."""
traceback: Optional[str] = None
"""The traceback of the exception, only used for error messages."""
logger = logging.getLogger(__name__)
class GunicornApp(BaseApplication):
"""
Programmatic Gunicorn application that:
- Accepts a `FastAPI` app object and option dict.
- Uses `uvicorn_worker.UvicornWorker`.
"""
def __init__(self, app: FastAPI, options: Dict[str, Any]):
self.application = app
self.options = options
super().__init__() # type: ignore
def load_config(self):
cfg = self.cfg
valid_keys = cfg.settings.keys() # type: ignore
for k, v in (self.options or {}).items():
if k in valid_keys and v is not None:
cfg.set(k, v) # type: ignore
def load(self):
return self.application
async def shutdown_uvicorn_server(server: uvicorn.Server, task: asyncio.Task[None], timeout: float = 5.0) -> None:
"""Shutdown a uvicorn server and await the serving task."""
logger.debug("Requesting graceful shutdown of uvicorn server.")
server.should_exit = True
# Give uvicorn a brief window to shut down cleanly.
try:
logger.debug("Waiting for graceful shutdown of uvicorn server.")
await asyncio.wait_for(task, timeout=timeout)
logger.debug("Graceful shutdown of uvicorn server completed.")
except asyncio.TimeoutError:
logger.error("Graceful shutdown of uvicorn server timed out.")
# As a last resort, cancel; this shouldn't happen under normal circumstances.
task.cancel()
with suppress(asyncio.CancelledError):
await task
logger.warning("Uvicorn server forced to stop.")
@asynccontextmanager
async def noop_context() -> AsyncIterator[None]:
"""A real async context manager that does nothing (satisfies serve_context)."""
yield
async def run_uvicorn_asyncio(
uvicorn_server: uvicorn.Server,
serve_context: AsyncContextManager[Any],
timeout: float = 60.0,
health_url: Optional[str] = None,
wait_for_serve: bool = True,
kill_unhealthy_server: bool = True,
) -> asyncio.Task[None]:
"""Run two Asyncio tasks in parallel:
- A watcher task that waits for the server to start up and then checks for healthiness.
- A server task that serves the server.
"""
server_start_exception: Optional[BaseException] = None
# watcher: when server.started flips True, announce READY once
async def _watch_server() -> None:
start_time = time.time()
deadline = start_time + timeout # child-side startup window
logger.debug(f"Waiting for server to start up for {timeout:.2f} seconds...")
# Wait for the server to start up or the deadline to be reached, or an exception to be raised.
while time.time() < deadline and not uvicorn_server.started and server_start_exception is None:
await asyncio.sleep(0.1)
if not uvicorn_server.started:
# Normally, the program will not reach this point, as the server will throw the exception itself earlier.
raise RuntimeError(
f"Server did not start up within {time.time() - start_time:.2f} seconds."
) from server_start_exception
logger.info(f"Server started up in {time.time() - start_time:.2f} seconds.")
# Check for health endpoint status if provided
if health_url is not None:
logger.info(f"Probing health endpoint {health_url}...")
async with aiohttp.ClientSession() as session:
while time.time() < deadline:
try:
async with session.get(health_url) as resp:
if resp.status == 200:
logger.info(
f"Server is healthy at {health_url} in {time.time() - start_time:.2f} seconds."
)
return
else:
logger.debug(
f"Server is NOT healthy at {health_url} in {time.time() - start_time:.2f} seconds. Got status {resp.status}."
)
except Exception as e:
logger.debug(f"Error probing health endpoint {health_url}: {str(e)}")
await asyncio.sleep(0.1)
# If the server is not healthy, kill it if requested.
health_failed_seconds = time.time() - start_time
if kill_unhealthy_server:
logger.error(
f"Server is not healthy at {health_url} after {health_failed_seconds:.2f} seconds. Shutting down server gracefully."
)
uvicorn_server.should_exit = True
await serve_task
raise RuntimeError(
f"Server is not healthy at {health_url} after {health_failed_seconds:.2f} seconds. It has been killed."
)
else:
logger.error(
f"Server is not healthy at {health_url} after {health_failed_seconds:.2f} seconds. It has been left running."
)
else:
logger.info("Server does not provide a health check endpoint. Skipping health check.")
async def _serve_server() -> None:
nonlocal server_start_exception
async with serve_context:
try:
await uvicorn_server.serve()
except (asyncio.CancelledError, KeyboardInterrupt):
# Normal shutdown path; propagate without rewrapping
raise
except BaseException as exc:
server_start_exception = exc
if wait_for_serve:
# This probably sends out earlier than watcher exception; but either one is fine.
raise RuntimeError("Uvicorn server failed to serve") from exc
else:
# If the caller is not waiting for this coroutine, we just log the error.
# It will be handled by the watch task.
logger.exception("Uvicorn server failed to serve. Inspect the logs for details.")
serve_task = asyncio.create_task(_serve_server())
watch_task = asyncio.create_task(_watch_server())
if wait_for_serve:
await asyncio.gather(watch_task, serve_task)
else:
# Wait for watch only, the serve task will run in the background.
await watch_task
return serve_task
def run_uvicorn_thread(
uvicorn_server: uvicorn.Server,
serve_context: AsyncContextManager[Any],
event_queue: queue.Queue[ChildEvent],
stop_event: threading.Event,
timeout: float = 60.0,
health_url: Optional[str] = None,
):
"""
Run a uvicorn server in a thread.
How to stop programmatically (from the main thread):
uvicorn_server.should_exit = True
This function:
- starts the server and waits for startup/health (if provided),
- then blocks until the server exits,
- shuts down cleanly if an error happens during startup/health,
- or if the thread is stopped by stop event.
"""
async def _main() -> None:
# Start server without waiting for full lifecycle; return once startup/health is done.
serve_task: Optional[asyncio.Task[None]] = None
try:
serve_task = await run_uvicorn_asyncio(
uvicorn_server=uvicorn_server,
serve_context=serve_context,
timeout=timeout,
health_url=health_url,
wait_for_serve=False, # return after startup watcher finishes
kill_unhealthy_server=True, # raise if health fails within timeout
)
event_queue.put(ChildEvent(kind="ready"))
except Exception as exc:
# Startup/health failed; nothing is running in the background.
logger.exception("Uvicorn failed to start or was unhealthy.")
event_queue.put(
ChildEvent(
kind="error", exc_type=type(exc).__name__, message=str(exc), traceback=traceback.format_exc()
)
)
return
logger.debug("Thread server started and ready.")
try:
# At this point, the server is up and serving in the same thread's loop.
# Block here until it exits (caller can stop it via setting the stop_event).
while not stop_event.is_set():
await asyncio.sleep(0.1)
except asyncio.CancelledError:
# Shutdown the server.
logger.warning(
"Thread server received asyncio cancellation signal. Shutting down gracefully. This is not the recommended way to stop the server."
)
raise
except Exception as exc:
logger.exception("Exception during the thread event waiting loop.")
event_queue.put(
ChildEvent(
kind="error", exc_type=type(exc).__name__, message=str(exc), traceback=traceback.format_exc()
)
)
finally:
logger.info("Requesting graceful shutdown of uvicorn server.")
await shutdown_uvicorn_server(uvicorn_server, serve_task)
logger.info("Uvicorn server shut down gracefully.")
# Each thread needs its own event loop; use asyncio.run to manage it cleanly.
try:
asyncio.run(_main())
except Exception:
# Exceptions are already logged above; don't crash the process from a thread.
# (Caller can inspect logs or add a queue/handler if they need to propagate.)
logger.exception("Exception within the thread server loop. Inspect the logs for details.")
def run_uvicorn_subprocess(
uvicorn_server: uvicorn.Server,
serve_context: AsyncContextManager[Any],
event_queue: multiprocessing.Queue[ChildEvent],
timeout: float = 60.0,
health_url: Optional[str] = None,
):
"""Run a uvicorn server in a subprocess.
Behavior:
- Start uvicorn and wait for startup/health (if provided).
- Post `ChildEvent(kind="ready")` once the server is up.
- Stay alive until a termination signal (SIGTERM/SIGINT).
- On signal, request graceful shutdown and wait for the server to exit.
This must be used with forked multiprocessing.Process.
"""
async def _main() -> None:
stop_event = asyncio.Event()
# Register signal handlers
loop = asyncio.get_running_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, stop_event.set)
logger.debug("Subprocess signal handlers registered.")
serve_task: Optional[asyncio.Task[None]] = None
try:
# Start server but don't block on its full lifecycle; this returns once the watcher finishes.
serve_task = await run_uvicorn_asyncio(
uvicorn_server=uvicorn_server,
serve_context=serve_context,
timeout=timeout,
health_url=health_url,
wait_for_serve=False, # return after startup/health passes
kill_unhealthy_server=True, # if unhealthy, fail fast in the child
)
# Announce readiness only after watcher success.
event_queue.put(ChildEvent(kind="ready"))
logger.debug("Subprocess server started and ready.")
# Wait until we're told to stop.
await stop_event.wait()
except Exception as exc:
# Propagate any startup/health errors to the parent.
event_queue.put(
ChildEvent(
kind="error",
exc_type=type(exc).__name__,
message=str(exc),
traceback=traceback.format_exc(),
)
)
logger.exception("Subprocess server failed to start or was unhealthy.")
finally:
# Request graceful shutdown if the server is running.
if serve_task is not None:
logger.info("Requesting graceful shutdown of subprocess server.")
await shutdown_uvicorn_server(uvicorn_server, serve_task)
logger.info("Subprocess server shut down gracefully.")
else:
logger.info("Subprocess server was not running. Nothing to stop.")
try:
asyncio.run(_main())
except Exception as exc:
# If something escapes _main(), make sure the parent hears about it.
event_queue.put(
ChildEvent(
kind="error",
exc_type=type(exc).__name__,
message=str(exc),
traceback=traceback.format_exc(),
)
)
def run_gunicorn(
gunicorn_app: GunicornApp,
serve_context: AsyncContextManager[Any],
event_queue: multiprocessing.Queue[ChildEvent],
timeout: float = 60.0,
health_url: Optional[str] = None,
):
"""Run a gunicorn server in a subprocess.
The master arbiter will reside in a non-daemon subprocess,
and the workers will be forked from the arbiter.
Behavior:
- Start Arbiter.run() (blocking) in this process.
- A watchdog thread waits for workers to spawn, then (optionally) verifies a health URL.
- On success: put `ChildEvent(kind="ready")`.
- On failure/timeout: put `ChildEvent(kind="error")` and request a graceful shutdown.
`serve_context` will be applied around the `arbiter.run()` call.
"""
# Create the arbiter up-front so the watchdog can inspect it.
try:
arbiter = Arbiter(gunicorn_app)
except Exception as exc:
logger.exception("Failed to initialize Gunicorn Arbiter.")
event_queue.put(
ChildEvent(
kind="error",
exc_type=type(exc).__name__,
message=str(exc),
traceback=traceback.format_exc(),
)
)
return
runtime_error: Optional[BaseException] = None
def _watchdog() -> None:
start = time.time()
deadline = start + timeout
# First, wait for arbiter.workers to get populated
while time.time() < deadline and not arbiter.WORKERS: # type: ignore
# If arbiter died early, abort quickly.
if runtime_error is not None:
logger.error("Gunicorn arbiter exited during startup. Watchdog exiting.")
return
time.sleep(0.1)
if not arbiter.WORKERS: # type: ignore
elapsed_time = time.time() - start
logger.error("Gunicorn workers did not start within %.2f seconds.", elapsed_time)
if runtime_error is None:
# Timeout case: arbiter throws no exception.
event_queue.put(
ChildEvent(
kind="error",
exc_type="RuntimeError",
message=f"Gunicorn workers did not start within {elapsed_time:.2f} seconds.",
traceback=None,
)
)
logger.info("Halting Gunicorn arbiter.")
# Ask arbiter to stop if it's still alive.
# It will make the watchdog exit too.
arbiter.signal(signal.SIGTERM, inspect.currentframe()) # type: ignore
else:
# Timeout case: arbiter has thrown an exception.
logger.error("Gunicorn arbiter exited during startup. Watchdog exiting.")
return
# Second, check for health endpoint status if provided
if health_url:
while time.time() < deadline:
# If arbiter died early, abort.
if runtime_error is not None:
logger.error("Gunicorn arbiter exited during health check. Watchdog exiting.")
return
# Check if the server is healthy.
try:
resp = requests.get(health_url, timeout=2.0)
if resp.status_code == 200:
logger.debug(f"Server is healthy at {health_url} in {time.time() - start:.2f} seconds.")
# Check arbiter status again.
if runtime_error is None:
event_queue.put(ChildEvent(kind="ready"))
else:
logger.error(
"Response status is 200 but arbiter has thrown an exception. This should not happen."
)
return
except Exception:
logger.debug(
f"Server is still not healthy at {health_url} in {time.time() - start:.2f} seconds.",
exc_info=True,
)
time.sleep(0.1)
# Health failed: report and shut down.
elapsed = time.time() - start
logger.error(
"Server is not healthy at %s after %.2f seconds. Shutting down.",
health_url,
elapsed,
)
if runtime_error is None:
# Arbiter throws no exception. This is a simple timeout case.
event_queue.put(
ChildEvent(
kind="error",
exc_type="RuntimeError",
message=(
f"Server is not healthy at {health_url} after "
f"{elapsed:.2f} seconds. It will be killed by the watchdog."
),
traceback=None,
)
)
logger.info("Halting Gunicorn arbiter.")
# Ask arbiter to stop if it's still alive.
arbiter.signal(signal.SIGTERM, inspect.currentframe()) # type: ignore
else:
# If arbiter has thrown an exception, report it.
logger.error("Gunicorn arbiter exited during health check. Watchdog exiting.")
else:
# No health check; workers up => ready.
if runtime_error is None:
event_queue.put(ChildEvent(kind="ready"))
else:
# If arbiter has thrown an exception, report it.
logger.error("Gunicorn arbiter exited unexpectedly before health check. Watchdog exiting.")
def _watchdog_with_exception() -> None:
try:
_watchdog()
except Exception as exc:
logger.exception("Exception in watchdog thread.")
event_queue.put(
ChildEvent(
kind="error", exc_type=type(exc).__name__, message=str(exc), traceback=traceback.format_exc()
)
)
watchdog_thread = threading.Thread(target=_watchdog_with_exception, daemon=True)
watchdog_thread.start()
async def _serve() -> None:
nonlocal runtime_error
try:
async with serve_context:
arbiter.run()
except Exception as exc:
runtime_error = exc
event_queue.put(
ChildEvent(
kind="error",
exc_type=type(exc).__name__,
message=str(exc),
traceback=traceback.format_exc(),
)
)
logger.exception("Gunicorn server failed to start.")
try:
asyncio.run(_serve())
# Most exceptions should have been caught within the _serve() coroutine.
finally:
# Ensure watchdog doesn't try to act on a dead arbiter for long.
watchdog_thread.join(timeout=5.0)
def _get_default_ipv4_address() -> str:
"""Determine the default outbound IPv4 address for this machine.
Implementation:
Opens a UDP socket and "connects" to a public address to force route
selection, then inspects the socket's local address. No packets are sent.
Returns:
str: Best-guess IPv4 like `192.168.x.y`. Falls back to `127.0.0.1`.
"""
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
# Doesn't actually contact 8.8.8.8; just forces the OS to pick a route.
s.connect(("8.8.8.8", 80))
return s.getsockname()[0]
except Exception:
return "127.0.0.1"
finally:
s.close()
class PythonServerLauncher:
"""Unified launcher for FastAPI, using uvicorn or gunicorn per mode/worker count.
See [`PythonServerLauncherArgs`][agentlightning.utils.server_launcher.PythonServerLauncherArgs] for configuration options.
Args:
app: The FastAPI app to launch.
args: The configuration for the server.
serve_context: An optional context manager to apply around the server startup.
"""
def __init__(
self, app: FastAPI, args: PythonServerLauncherArgs, serve_context: Optional[AsyncContextManager[Any]] = None
):
"""Initialize the launcher with the FastAPI app, configuration, and optional serve context."""
self.app = app
self.args = args
self.serve_context = serve_context
self._host: Optional[str] = self.args.host
self._port: Optional[int] = self.args.port
self._access_host: Optional[str] = self.args.access_host
self.initialize()
def initialize(self):
# ensure the host/port/access_host are set
self._ensure_host()
self._ensure_port()
self._ensure_access_host()
# uvicorn (in-proc asyncio)
self._uvicorn_server: Optional[uvicorn.Server] = None
self._uvicorn_task: Optional[asyncio.Task[None]] = None # returned by run_uvicorn_asyncio()
# uvicorn (thread)
self._thread: Optional[threading.Thread] = None
self._thread_event_queue: Optional[queue.Queue[ChildEvent]] = None
self._thread_stop_event: Optional[threading.Event] = None
# subprocess (uvicorn / gunicorn)
self._proc: Optional[BaseProcess] = None
self._mp_event_queue: Optional[multiprocessing.Queue[ChildEvent]] = None
self._gunicorn_app: Optional[GunicornApp] = None # programmatic gunicorn wrapper
# is_running flag
self._is_running: bool = False
def __getstate__(self):
"""Control pickling to prevent server state from being sent to subprocesses."""
return {
"app": self.app,
"args": self.args,
"serve_context": self.serve_context,
"_host": self._host,
"_port": self._port,
"_access_host": self._access_host,
}
def __setstate__(self, state: Dict[str, Any]):
self.app = state["app"]
self.args = cast(PythonServerLauncherArgs, state["args"])
self.serve_context = state["serve_context"]
self._host = state["_host"]
self._port = state["_port"]
self._access_host = state["_access_host"]
self.initialize()
@property
def endpoint(self) -> str:
"""Return the externally advertised host:port pair regardless of accessibility."""
return f"http://{self._ensure_host()}:{self._ensure_port()}"
@property
def access_endpoint(self) -> str:
"""Return a loopback-friendly URL so health checks succeed even when binding to 0.0.0.0."""
return f"http://{self._ensure_access_host()}:{self._ensure_port()}"
@property
def health_url(self) -> Optional[str]:
"""Build the absolute health-check endpoint from args, if one is configured."""
if not self.args.healthcheck_url:
return None
path = self.args.healthcheck_url
if not path.startswith("/"):
path = "/" + path
return f"{self.access_endpoint}{path}"
async def start(self):
"""Starts the server according to launch_mode and n_workers."""
logger.info(f"Starting server {self._normalize_app_ref(self.app)}...")
mode = self.args.launch_mode
if mode == "mp":
await self._start_serving_process()
elif mode == "thread":
await self._start_uvicorn_thread()
elif mode == "asyncio":
await self._start_uvicorn_asyncio()
else:
raise ValueError(f"Unsupported launch mode: {mode}")
logger.info(f"Server {self._normalize_app_ref(self.app)} started at {self.endpoint}")
async def stop(self):
"""Stop the server using the inverse of whatever launch mode was used to start it."""
logger.info(f"Stopping server {self._normalize_app_ref(self.app)}...")
mode = self.args.launch_mode
if mode == "mp":
await self._stop_serving_process()
elif mode == "thread":
await self._stop_uvicorn_thread()
elif mode == "asyncio":
await self._stop_uvicorn_asyncio()
else:
raise ValueError(f"Unsupported launch mode: {mode}")
logger.info(f"Server {self._normalize_app_ref(self.app)} stopped")
async def reload(self):
"""Restart the server by stopping it if necessary and invoking start again."""
if self.is_running():
await self.stop()
await self.start()
async def run_forever(self):
"""Start the server and block the caller until it exits, respecting the configured mode."""
mode = self.args.launch_mode
if mode == "asyncio":
await self._start_uvicorn_asyncio()
try:
if self._uvicorn_task is not None:
# Wait for the server
# Won't allow outer cancel to directly cancel the inner task
await asyncio.shield(self._uvicorn_task)
except (asyncio.CancelledError, KeyboardInterrupt):
logger.warning("Server received cancellation signal. Shutting down gracefully.")
await self._stop_uvicorn_asyncio()
raise
elif mode == "thread":
await self._start_uvicorn_thread()
try:
# Wait for the thread to exit
while self._thread and self._thread.is_alive():
await asyncio.sleep(0.5)
except (asyncio.CancelledError, KeyboardInterrupt):
logger.warning("Server thread received cancellation signal. Shutting down gracefully.")
await self._stop_uvicorn_thread()
raise
elif mode == "mp":
await self._start_serving_process()
try:
# Wait for the process to exit
while self._proc and self._proc.is_alive():
await asyncio.sleep(0.5)
except (asyncio.CancelledError, KeyboardInterrupt):
logger.warning("Server process received cancellation signal. Shutting down gracefully.")
await self._stop_serving_process()
raise
else:
raise ValueError(f"Unsupported launch mode: {mode}")
def is_running(self) -> bool:
"""Return True if the server has been started and not yet stopped."""
return self._is_running
@staticmethod
def _normalize_app_ref(app: FastAPI) -> str:
module = getattr(app, "__module__", None)
if module and module != "__main__":
return f"{module}:app"
return "unknown:app"
def _ensure_host(self) -> str:
if self._host is None:
logger.warning("No host provided, using 0.0.0.0.")
self._host = "0.0.0.0"
return self._host
def _ensure_port(self) -> int:
if self._port is None:
logger.warning("No port provided, using pick_unused_port to pick a random unused port.")
self._port = pick_unused_port()
return self._port
def _ensure_access_host(self) -> str:
if self._access_host is None:
if self.args.access_host is None:
if self._ensure_host() in ("0.0.0.0", "::"):
# Probe host normalization for 0.0.0.0
logger.warning("No access host provided, using default outbound IPv4 address for this machine.")
self._access_host = _get_default_ipv4_address()
else:
logger.warning("No access host provided, using the host provided.")
self._access_host = self._ensure_host()
else:
self._access_host = self.args.access_host
return self._access_host # type: ignore
def _create_uvicorn_server(self) -> uvicorn.Server:
config = uvicorn.Config(
app=self.app,
host=self._ensure_host(),
port=self._ensure_port(),
log_level=self.args.log_level,
access_log=self.args.access_log,
loop="asyncio",
timeout_keep_alive=self.args.timeout_keep_alive,
)
return uvicorn.Server(config)
def _ctx(self) -> AsyncContextManager[Any]:
# Use the provided serve_context if any; otherwise a no-op async CM
if self.serve_context is None:
logger.info("No serve_context provided, using noop_context.")
return noop_context()
return self.serve_context
# --- Mode 1: asyncio (in-proc) using run_uvicorn_asyncio ---
async def _start_uvicorn_asyncio(self):
if self.is_running():
raise RuntimeError("Server is already running. Stopping it first.")
logger.info("Starting uvicorn asyncio server...")
self._uvicorn_server = self._create_uvicorn_server()
# Start server; return after health passes; keep serving in background task
self._uvicorn_task = await run_uvicorn_asyncio(
uvicorn_server=self._uvicorn_server,
serve_context=self._ctx(),
timeout=self.args.startup_timeout,
health_url=self.health_url,
wait_for_serve=False, # return once startup/health OK
kill_unhealthy_server=self.args.kill_unhealthy_server,
)
self._is_running = True
logger.info("Uvicorn asyncio server started")
async def _stop_uvicorn_asyncio(self):
# Gracefully shut down the in-proc uvicorn server task if running
logger.info("Stopping uvicorn asyncio server...")
if self._uvicorn_server and self._uvicorn_task:
await shutdown_uvicorn_server(self._uvicorn_server, self._uvicorn_task)
self._uvicorn_task = None
self._uvicorn_server = None
self._is_running = False
logger.info("Uvicorn asyncio server stopped")
# --- Mode 2: thread (in-proc) using run_uvicorn_thread ---
async def _start_uvicorn_thread(self):
if self.is_running():
raise RuntimeError("Server is already running. Stopping it first.")
logger.info("Starting uvicorn thread server...")
self._uvicorn_server = self._create_uvicorn_server()
self._thread_event_queue = queue.Queue()
self._thread_stop_event = threading.Event()
self._thread = threading.Thread(
target=run_uvicorn_thread,
kwargs={
"uvicorn_server": self._uvicorn_server,
"serve_context": self._ctx(),
"event_queue": self._thread_event_queue,
"stop_event": self._thread_stop_event,
"timeout": self.args.startup_timeout,
"health_url": self.health_url,
},
daemon=True,
)
self._thread.start()
# Wait for ready or error event from the thread
timeout = self.args.startup_timeout * 2 # Allows twice the timeout for the thread to get the event
try:
evt: ChildEvent = await asyncio.to_thread(self._thread_event_queue.get, True, timeout)
except queue.Empty:
if not self._thread.is_alive():
raise RuntimeError("Threaded server failed to start and is not alive. No error event was received.")
logger.error(
"Threaded server failed to start and sends no event. This should not happen. Shutting down server."
)
await self._stop_uvicorn_thread()
raise RuntimeError("Threaded server failed to start and sends no event. This should not happen.")
if evt.kind == "error":
logger.error("Threaded server failed to start (%s): %s\n%s", evt.exc_type, evt.message, evt.traceback)
await asyncio.to_thread(self._thread.join, self.args.thread_join_timeout)
if self._thread.is_alive():
logger.error("Threaded server failed to start and refused to shut down.")
raise RuntimeError(evt.message)
else:
logger.info("Threaded server started successfully.")
self._is_running = True
async def _stop_uvicorn_thread(self):
logger.info("Stopping uvicorn thread server...")
if self._thread_stop_event:
self._thread_stop_event.set()
if self._thread:
await asyncio.to_thread(self._thread.join, self.args.thread_join_timeout)
if self._thread.is_alive():
raise RuntimeError("Threaded server refused to shut down.")
else:
logger.info("Uvicorn thread server was not running. Nothing to stop.")
self._thread = None
self._thread_event_queue = None
self._thread_stop_event = None
self._uvicorn_server = None
self._is_running = False
logger.info("Uvicorn thread server stopped")
# --- Mode 3: subprocess (uvicorn / gunicorn) using run_uvicorn_subprocess or run_gunicorn ---
async def _start_serving_process(self):
if self.is_running():
raise RuntimeError("Server process is already running. Stopping it first.")
host = self._ensure_host()
port = self._ensure_port()
try:
ctx = multiprocessing.get_context("fork")
except ValueError as e:
raise RuntimeError(
"Process launch requires 'fork' start method (Linux/macOS). "
"On Windows, use 'thread' or 'asyncio' modes."
) from e
self._mp_event_queue = ctx.Queue()
# Gunicorn path when n_workers > 1
if self.args.n_workers > 1:
logger.info(f"Starting Gunicorn server...")
options = {
"bind": f"{host}:{port}",
"workers": int(self.args.n_workers),
"worker_class": "uvicorn_worker.UvicornWorker",
"loglevel": logging.getLevelName(self.args.log_level).lower(),
"accesslog": "-" if self.args.access_log else None,
"errorlog": "-",
"preload_app": True,
"graceful_timeout": int(
self.args.process_join_timeout / 2
), # Allow half the timeout for graceful shutdown
}
if "PROMETHEUS_MULTIPROC_DIR" in os.environ:
from agentlightning.utils.metrics import shutdown_metrics
options["child_exit"] = shutdown_metrics # type: ignore
self._gunicorn_app = GunicornApp(self.app, options)
self._proc = ctx.Process(
target=run_gunicorn,
kwargs={
"gunicorn_app": self._gunicorn_app,
"serve_context": self._ctx(),
"event_queue": self._mp_event_queue,
"timeout": self.args.startup_timeout,
"health_url": self.health_url,
},
daemon=False,
)
self._proc.start()
else:
# Single-worker subprocess uvicorn
logger.info("Starting uvicorn subprocess server...")
self._uvicorn_server = self._create_uvicorn_server()
self._proc = ctx.Process(
target=run_uvicorn_subprocess,
kwargs={
"uvicorn_server": self._uvicorn_server,
"serve_context": self._ctx(),
"event_queue": self._mp_event_queue,
"timeout": self.args.startup_timeout,
"health_url": self.health_url,
},
daemon=True,
)
self._proc.start()
# Wait for ready or error event from the thread
timeout = self.args.startup_timeout * 2 # Allows twice the timeout for the thread to get the event
try:
evt: ChildEvent = await asyncio.to_thread(self._mp_event_queue.get, True, timeout)
except queue.Empty:
if not self._proc.is_alive():
raise RuntimeError("Server process failed to start and is not alive. No error event was received.")
logger.error(
"Server process failed to start and sends no event. This should not happen. Shutting down server."
)
await self._stop_serving_process()
raise RuntimeError("Server process failed to start and sends no event. This should not happen.")
if evt.kind == "error":
logger.error(
"Server process (%s) failed to start (%s): %s\n%s",
"gunicorn" if self.args.n_workers > 1 else "uvicorn",
evt.exc_type,
evt.message,
evt.traceback,
)
await asyncio.to_thread(self._proc.join, self.args.process_join_timeout)
if self._proc.is_alive():
logger.error("Server process failed to start and refused to shut down.")
raise RuntimeError(evt.message)
else:
logger.info("Subprocess server started successfully.")
self._is_running = True
async def _stop_serving_process(self):
logger.info("Stopping subprocess server...")
if self._proc is not None:
if self._proc.is_alive():
# Prefer graceful: SIGTERM, then wait
try:
self._proc.terminate()
except Exception:
logger.exception("Error sending SIGTERM to server process.")
await asyncio.to_thread(self._proc.join, self.args.process_join_timeout)
if self._proc.is_alive():
# Still alive, send SIGKILL
try:
self._proc.kill()
except Exception:
logger.exception("Error sending SIGKILL to server process.")
await asyncio.to_thread(self._proc.join, 5.0) # Use a constant timeout for SIGKILL
if self._proc.is_alive():
raise RuntimeError("Server process failed to shut down after SIGTERM and SIGKILL.")
else:
logger.info("Subprocess server was not running. Nothing to stop.")
if self._mp_event_queue is not None:
self._mp_event_queue.close()
try:
self._mp_event_queue.join_thread()
except Exception:
logger.exception("Error joining event queue thread.")
self._proc = None
self._mp_event_queue = None
self._gunicorn_app = None
self._uvicorn_server = None
self._is_running = False
logger.info("Subprocess server stopped")