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
1046 lines
42 KiB
Python
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")
|