# Copyright (c) Microsoft. All rights reserved. from __future__ import annotations import asyncio import logging import multiprocessing import os import queue import signal import socket import threading import time from contextlib import asynccontextmanager, closing from typing import Any, AsyncContextManager, Dict, cast import aiohttp import portpicker import pytest import uvicorn from fastapi import FastAPI, Response from agentlightning.utils.server_launcher import ( ChildEvent, GunicornApp, LaunchMode, PythonServerLauncher, PythonServerLauncherArgs, noop_context, run_gunicorn, run_uvicorn_asyncio, run_uvicorn_subprocess, run_uvicorn_thread, shutdown_uvicorn_server, ) pytestmark = pytest.mark.utils def _make_app_health(always_ok: bool = True) -> FastAPI: app = FastAPI() @app.get("/health") async def health(): # pyright: ignore[reportUnusedFunction] if always_ok: return {"ok": True} # Return non-200 to simulate failing health. return Response(status_code=503) @app.get("/") async def root(): # pyright: ignore[reportUnusedFunction] return {"hello": "world"} return app def _new_server(app: FastAPI, host: str, port: int) -> uvicorn.Server: cfg = uvicorn.Config( app=app, host=host, port=port, log_level="warning", # keep test output quiet lifespan="on", # exercise real lifespan loop="asyncio", ) return uvicorn.Server(cfg) def _make_flag_context(flag_dict: Dict[str, bool], key_prefix: str = "ctx") -> AsyncContextManager[None]: @asynccontextmanager async def ctx(): flag_dict[f"{key_prefix}_entered"] = True try: yield finally: flag_dict[f"{key_prefix}_exited"] = True return ctx() async def _queue_get_async( q: queue.Queue[ChildEvent] | multiprocessing.Queue[ChildEvent], timeout: float ) -> ChildEvent: """Wait for a queue event without blocking the asyncio loop.""" return await asyncio.to_thread(q.get, True, timeout) def _build_gunicorn_app(app: FastAPI, host: str, port: int, workers: int = 2) -> GunicornApp: """ Build a GunicornApp with preload enabled and uvicorn workers. """ options = { "bind": f"{host}:{port}", "workers": workers, "worker_class": "uvicorn_worker.UvicornWorker", "loglevel": "warning", "accesslog": None, "errorlog": "-", "preload_app": True, "graceful_timeout": 2, } return GunicornApp(app=app, options=options) async def _probe_json(url: str, expected: Dict[str, Any], timeout: float = 5.0) -> None: start = time.time() last_exc = None while time.time() - start < timeout: try: async with aiohttp.ClientSession() as session: async with session.get(url) as resp: if resp.status == 200 and await resp.json() == expected: return except Exception as exc: # server may not be up yet last_exc = exc await asyncio.sleep(0.05) if last_exc: raise last_exc raise AssertionError(f"Failed to probe {url} within {timeout:.1f}s") def _free_port(host: str, port: int) -> None: # Ensure port is free with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind((host, port)) @pytest.mark.asyncio async def test_fastapi_health_ok_background_then_shutdown(): """Server starts, health=200, wait_for_serve=False returns quickly; we then stop it.""" host = "127.0.0.1" port = portpicker.pick_unused_port() app = _make_app_health(always_ok=True) server = _new_server(app, host, port) serve_task = await run_uvicorn_asyncio( uvicorn_server=server, serve_context=noop_context(), timeout=10.0, health_url=f"http://{host}:{port}/health", wait_for_serve=False, # return after health passes, leave server running ) # After watcher returns, the server should be started and the task running assert isinstance(serve_task, asyncio.Task) assert not serve_task.done() assert server.started is True # Try the root endpoint async with aiohttp.ClientSession() as session: async with session.get(f"http://{host}:{port}/") as resp: assert resp.status == 200 assert await resp.json() == {"hello": "world"} # Clean shutdown await shutdown_uvicorn_server(server, serve_task) @pytest.mark.asyncio async def test_fastapi_no_health_background_then_shutdown(): """Server starts without a health URL; watcher skips health and returns.""" host = "127.0.0.1" port = portpicker.pick_unused_port() app = _make_app_health(always_ok=True) server = _new_server(app, host, port) serve_task = await run_uvicorn_asyncio( uvicorn_server=server, serve_context=noop_context(), timeout=10.0, health_url=None, wait_for_serve=False, ) assert isinstance(serve_task, asyncio.Task) assert not serve_task.done() assert server.started is True await shutdown_uvicorn_server(server, serve_task) @pytest.mark.asyncio async def test_fastapi_health_timeout_raises_and_server_is_stopped(): """ Server starts but /health never returns 200 -> watcher should raise RuntimeError. We still need to stop the running server task afterwards. """ host = "127.0.0.1" port = portpicker.pick_unused_port() app = _make_app_health(always_ok=False) # returns 503 server = _new_server(app, host, port) serve_task: asyncio.Task[None] | None = None with pytest.raises(RuntimeError) as ei: serve_task = await run_uvicorn_asyncio( uvicorn_server=server, serve_context=noop_context(), timeout=2.0, # short to keep tests snappy health_url=f"http://{host}:{port}/health", wait_for_serve=False, # only waits for watcher; it will raise kill_unhealthy_server=True, ) assert "Server is not healthy" in str(ei.value) assert "has been killed" in str(ei.value) assert serve_task is None # The function raised before returning a serve_task, but server should be stopped. # Check if the port is still occupied. with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind((host, port)) s.listen(1) assert s.fileno() is not None @pytest.mark.asyncio async def test_uvicorn_startup_failure_is_wrapped_as_runtimeerror(): """ Bind a port first to force uvicorn to fail binding the socket. That should cause run_uvicorn_asyncio to raise RuntimeError("Uvicorn server failed to serve"). """ host = "127.0.0.1" conflict_port = portpicker.pick_unused_port() # occupy the port to force bind failure with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind((host, conflict_port)) s.listen(1) app = _make_app_health(always_ok=True) server = _new_server(app, host, conflict_port) with pytest.raises(RuntimeError) as ei: # wait_for_serve=True so we propagate the serve exception path await run_uvicorn_asyncio( uvicorn_server=server, serve_context=noop_context(), timeout=3.0, health_url=None, wait_for_serve=True, ) assert "Uvicorn server failed to serve" in str(ei.value) @pytest.mark.asyncio async def test_startup_deadline_if_server_never_starts(): """ Point health check to a valid URL but prevent server from flipping 'started' by never actually launching uvicorn (simulate by using a server configured for an unreachable host to fail before started). Alternatively, we can create a server that immediately raises before 'started' is set by using an invalid host. """ # Using an invalid bind address triggers an early failure; the watcher should # treat it as startup deadline with the original exception chained. host = "203.0.113.123" # TEST-NET-3, won't be a local addr to bind port = portpicker.pick_unused_port() app = _make_app_health(always_ok=True) server = _new_server(app, host, port) with pytest.raises(RuntimeError) as ei: await run_uvicorn_asyncio( uvicorn_server=server, serve_context=noop_context(), timeout=1.0, health_url=None, wait_for_serve=True, ) # Either path is acceptable: wrapped serve failure or startup-timeout message msg = str(ei.value) assert ("Uvicorn server failed to serve" in msg) or ("Server did not start up within" in msg) @pytest.mark.asyncio async def test_port_already_used_conflict_raises_runtimeerror(): """ Explicitly occupy a port before starting uvicorn. The server should fail its bind, and run_uvicorn_asyncio should surface it as RuntimeError('Uvicorn server failed to serve'). """ host = "127.0.0.1" port = portpicker.pick_unused_port() # Occupy the port with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind((host, port)) s.listen(1) app = _make_app_health(always_ok=True) server = _new_server(app, host, port) with pytest.raises(RuntimeError) as ei: await run_uvicorn_asyncio( uvicorn_server=server, serve_context=noop_context(), timeout=3.0, health_url=f"http://{host}:{port}/health", wait_for_serve=True, # ensures we await the serve path that raises ) assert "Uvicorn server failed to serve" in str(ei.value) @pytest.mark.asyncio async def test_wait_for_serve_true_graceful_shutdown(): """ Start the server with health check and wait_for_serve=True. We run run_uvicorn_asyncio in the background, wait for startup, then signal shutdown. Finally, we verify the returned serve task completed cleanly. """ host = "127.0.0.1" port = portpicker.pick_unused_port() app = _make_app_health(always_ok=True) server = _new_server(app, host, port) # Kick off run_uvicorn_asyncio in the background because wait_for_serve=True # will await the server's lifecycle (we'll signal shutdown below). run_task = asyncio.create_task( run_uvicorn_asyncio( uvicorn_server=server, serve_context=noop_context(), timeout=10.0, health_url=f"http://{host}:{port}/health", wait_for_serve=True, ) ) # Wait until uvicorn flips `started` or time out start = time.time() while not server.started and (time.time() - start) < 5.0: await asyncio.sleep(0.05) assert server.started is True, "Server did not report started in time" # Try the root endpoint async with aiohttp.ClientSession() as session: async with session.get(f"http://{host}:{port}/") as resp: assert resp.status == 200 assert await resp.json() == {"hello": "world"} # Trigger graceful shutdown and wait for run_task to finish run_task.cancel() with pytest.raises(asyncio.CancelledError): await run_task @pytest.mark.asyncio async def test_unhealthy_left_running_background_then_manual_shutdown(caplog: pytest.LogCaptureFixture): """ When kill_unhealthy_server=False and wait_for_serve=False, the watcher raises while the server continues running. We verify it's still reachable, then shut it down. """ host = "127.0.0.1" port = portpicker.pick_unused_port() app = _make_app_health(always_ok=False) # health returns 503 server = _new_server(app, host, port) # run_uvicorn_asyncio will raise; server continues running in background caplog.set_level(logging.ERROR) serve_task = await run_uvicorn_asyncio( uvicorn_server=server, serve_context=noop_context(), timeout=2.0, health_url=f"http://{host}:{port}/health", wait_for_serve=False, kill_unhealthy_server=False, # <-- leave server running ) assert any("left running" in rec.message for rec in caplog.records) # Should still be started and serving: assert server.started is True # Root endpoint should be reachable even though /health is failing. async with aiohttp.ClientSession() as session: async with session.get(f"http://{host}:{port}/") as resp: assert resp.status == 200 assert await resp.json() == {"hello": "world"} # Now manually stop the server and wait for it to go down. await shutdown_uvicorn_server(server, serve_task) @pytest.mark.asyncio async def test_unhealthy_left_running_wait_for_serve_true(caplog: pytest.LogCaptureFixture): """ With kill_unhealthy_server=False and wait_for_serve=True, the call raises, but uvicorn keeps running. Verify it's serving, then stop it. """ host = "127.0.0.1" port = portpicker.pick_unused_port() app = _make_app_health(always_ok=False) server = _new_server(app, host, port) caplog.set_level(logging.ERROR) run_task = asyncio.create_task( run_uvicorn_asyncio( uvicorn_server=server, serve_context=noop_context(), timeout=2.0, health_url=f"http://{host}:{port}/health", wait_for_serve=True, kill_unhealthy_server=False, # leave server running ) ) # Wait until uvicorn flips `started` or time out start = time.time() while not server.started and (time.time() - start) < 5.0: await asyncio.sleep(0.05) assert server.started is True, "Server did not report started in time" await asyncio.sleep(max(0.01, start + 3.0 - time.time())) assert any("left running" in rec.message for rec in caplog.records) # Verify we can still hit the root. async with aiohttp.ClientSession() as session: async with session.get(f"http://{host}:{port}/") as resp: assert resp.status == 200 assert await resp.json() == {"hello": "world"} # Trigger graceful shutdown and wait for run_task to finish run_task.cancel() with pytest.raises(asyncio.CancelledError): await run_task @pytest.mark.asyncio async def test_run_uvicorn_thread_reports_ready_and_stops_cleanly(): host = "127.0.0.1" port = portpicker.pick_unused_port() app = _make_app_health(always_ok=True) server = _new_server(app, host, port) event_queue: queue.Queue[ChildEvent] = queue.Queue() stop_event = threading.Event() thread = threading.Thread( target=run_uvicorn_thread, args=( server, noop_context(), event_queue, stop_event, 10.0, f"http://{host}:{port}/health", ), daemon=True, ) thread.start() try: event = await _queue_get_async(event_queue, timeout=10.0) assert event.kind == "ready" async with aiohttp.ClientSession() as session: async with session.get(f"http://{host}:{port}/") as resp: assert resp.status == 200 assert await resp.json() == {"hello": "world"} finally: stop_event.set() await asyncio.to_thread(thread.join, 10.0) assert not thread.is_alive() with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind((host, port)) @pytest.mark.asyncio async def test_run_uvicorn_thread_reports_health_failure_event(): host = "127.0.0.1" port = portpicker.pick_unused_port() app = _make_app_health(always_ok=False) server = _new_server(app, host, port) event_queue: queue.Queue[ChildEvent] = queue.Queue() stop_event = threading.Event() thread = threading.Thread( target=run_uvicorn_thread, args=( server, noop_context(), event_queue, stop_event, 5.0, f"http://{host}:{port}/health", ), daemon=True, ) thread.start() event = await _queue_get_async(event_queue, timeout=10.0) assert event.kind == "error" assert "Server is not healthy" in (event.message or "") stop_event.set() await asyncio.to_thread(thread.join, 10.0) assert not thread.is_alive() # The port should be available again because the thread shut down the server. with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind((host, port)) @pytest.mark.asyncio async def test_run_uvicorn_subprocess_ready_and_handles_sigterm(): host = "127.0.0.1" port = portpicker.pick_unused_port() ctx = multiprocessing.get_context("fork") event_queue: multiprocessing.Queue[ChildEvent] = ctx.Queue() app = _make_app_health(always_ok=True) server = _new_server(app, host, port) health_url = f"http://{host}:{port}/health" process = ctx.Process( target=run_uvicorn_subprocess, args=(server, noop_context(), event_queue, 10.0, health_url), ) process.start() try: event = await _queue_get_async(event_queue, timeout=20.0) assert event.kind == "ready" async with aiohttp.ClientSession() as session: async with session.get(f"http://{host}:{port}/") as resp: assert resp.status == 200 assert await resp.json() == {"hello": "world"} if process.pid is not None: os.kill(process.pid, signal.SIGTERM) await asyncio.to_thread(process.join, 20.0) finally: if process.is_alive(): if process.pid is not None: os.kill(process.pid, signal.SIGTERM) await asyncio.to_thread(process.join, 20.0) assert process.exitcode == 0 with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind((host, port)) @pytest.mark.asyncio async def test_run_uvicorn_subprocess_reports_health_failure(): host = "127.0.0.1" port = portpicker.pick_unused_port() ctx = multiprocessing.get_context("fork") event_queue: multiprocessing.Queue[ChildEvent] = ctx.Queue() app = _make_app_health(always_ok=False) server = _new_server(app, host, port) health_url = f"http://{host}:{port}/health" process = ctx.Process( target=run_uvicorn_subprocess, args=(server, noop_context(), event_queue, 5.0, health_url), ) process.start() event = await _queue_get_async(event_queue, timeout=20.0) assert event.kind == "error" assert "Server is not healthy" in (event.message or "") await asyncio.to_thread(process.join, 20.0) assert process.exitcode == 0 with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind((host, port)) @pytest.mark.asyncio async def test_run_uvicorn_thread_no_health_reports_ready_and_stops_cleanly(): host = "127.0.0.1" port = portpicker.pick_unused_port() app = _make_app_health(always_ok=True) server = _new_server(app, host, port) event_queue: queue.Queue[ChildEvent] = queue.Queue() stop_event = threading.Event() thread = threading.Thread( target=run_uvicorn_thread, args=( server, # uvicorn.Server noop_context(), event_queue, stop_event, 10.0, # timeout None, ), # <-- health_url=None daemon=True, ) thread.start() try: event = await _queue_get_async(event_queue, timeout=10.0) assert event.kind == "ready" # Should be serving even without health checks async with aiohttp.ClientSession() as session: async with session.get(f"http://{host}:{port}/") as resp: assert resp.status == 200 assert await resp.json() == {"hello": "world"} finally: stop_event.set() await asyncio.to_thread(thread.join, 10.0) assert not thread.is_alive() # Port should be released with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind((host, port)) @pytest.mark.asyncio async def test_run_uvicorn_thread_reports_startup_bind_failure_event(): host = "127.0.0.1" conflict_port = portpicker.pick_unused_port() # Occupy the port to force a bind failure with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind((host, conflict_port)) s.listen(1) app = _make_app_health(always_ok=True) server = _new_server(app, host, conflict_port) event_queue: queue.Queue[ChildEvent] = queue.Queue() stop_event = threading.Event() thread = threading.Thread( target=run_uvicorn_thread, args=(server, noop_context(), event_queue, stop_event, 5.0, None), daemon=True, ) thread.start() try: event = await _queue_get_async(event_queue, timeout=10.0) assert event.kind == "error" # Message should reflect uvicorn startup/serve failure assert "Server did not start up within" in (event.message or "") finally: stop_event.set() await asyncio.to_thread(thread.join, 10.0) assert not thread.is_alive() # After thread cleanup, port remains available because we released s above with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s2: s2.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s2.bind((host, conflict_port)) @pytest.mark.asyncio async def test_run_uvicorn_subprocess_reports_startup_bind_failure(): host = "127.0.0.1" conflict_port = portpicker.pick_unused_port() ctx = multiprocessing.get_context("fork") event_queue: multiprocessing.Queue[ChildEvent] = ctx.Queue() # Occupy the port in parent so child fails to bind with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind((host, conflict_port)) s.listen(1) app = _make_app_health(always_ok=True) server = _new_server(app, host, conflict_port) process = ctx.Process( target=run_uvicorn_subprocess, args=(server, noop_context(), event_queue, 5.0, None), ) process.start() try: event = await _queue_get_async(event_queue, timeout=20.0) assert event.kind == "error" # Expect a wrapped serve/startup error message assert "Server did not start up within" in (event.message or "") await asyncio.to_thread(process.join, 20.0) finally: if process.is_alive(): await asyncio.to_thread(process.join, 20.0) # Child should exit cleanly even on error assert process.exitcode == 0 # Port remains available now that parent released it with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s2: s2.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s2.bind((host, conflict_port)) @pytest.mark.asyncio async def test_run_uvicorn_subprocess_ready_and_handles_sigint(): host = "127.0.0.1" port = portpicker.pick_unused_port() ctx = multiprocessing.get_context("fork") event_queue: multiprocessing.Queue[ChildEvent] = ctx.Queue() app = _make_app_health(always_ok=True) server = _new_server(app, host, port) health_url = f"http://{host}:{port}/health" process = ctx.Process( target=run_uvicorn_subprocess, args=(server, noop_context(), event_queue, 10.0, health_url), ) process.start() try: event = await _queue_get_async(event_queue, timeout=20.0) assert event.kind == "ready" # Confirm it serves async with aiohttp.ClientSession() as session: async with session.get(f"http://{host}:{port}/") as resp: assert resp.status == 200 assert await resp.json() == {"hello": "world"} # Send SIGINT instead of SIGTERM to exercise both handlers if process.pid is not None: os.kill(process.pid, signal.SIGINT) await asyncio.to_thread(process.join, 20.0) finally: assert not process.is_alive() assert process.exitcode == 0 # Port should be free after shutdown with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind((host, port)) @pytest.mark.asyncio async def test_run_gunicorn_ready_and_handles_sigterm_preload(): """ Gunicorn (preload, fork) starts healthy, serves '/', then shuts down cleanly on SIGTERM. """ host = "127.0.0.1" port = portpicker.pick_unused_port() ctx = multiprocessing.get_context("fork") event_queue: multiprocessing.Queue[ChildEvent] = ctx.Queue() app = _make_app_health(always_ok=True) gapp = _build_gunicorn_app(app, host, port, workers=2) health_url = f"http://{host}:{port}/health" proc = ctx.Process( target=run_gunicorn, args=(gapp, noop_context(), event_queue, 10.0, health_url), daemon=False, # master must be able to fork workers ) proc.start() try: event = await _queue_get_async(event_queue, timeout=20.0) assert event.kind == "ready" async with aiohttp.ClientSession() as session: async with session.get(f"http://{host}:{port}/") as resp: assert resp.status == 200 assert await resp.json() == {"hello": "world"} if proc.pid is not None: os.kill(proc.pid, signal.SIGTERM) await asyncio.to_thread(proc.join, 20.0) finally: if proc.is_alive(): if proc.pid is not None: os.kill(proc.pid, signal.SIGTERM) await asyncio.to_thread(proc.join, 20.0) assert proc.exitcode == 0 # Port released with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind((host, port)) @pytest.mark.asyncio async def test_run_gunicorn_reports_health_failure_preload(): """ Health endpoint returns 503 -> watchdog posts error and requests graceful shutdown. """ host = "127.0.0.1" port = portpicker.pick_unused_port() ctx = multiprocessing.get_context("fork") event_queue: multiprocessing.Queue[ChildEvent] = ctx.Queue() app = _make_app_health(always_ok=False) # /health -> 503 gapp = _build_gunicorn_app(app, host, port, workers=2) health_url = f"http://{host}:{port}/health" proc = ctx.Process( target=run_gunicorn, args=(gapp, noop_context(), event_queue, 3.0, health_url), daemon=False, ) proc.start() try: event = await _queue_get_async(event_queue, timeout=20.0) assert event.kind == "error" assert "Server is not healthy" in (event.message or "") await asyncio.to_thread(proc.join, 20.0) finally: if proc.is_alive(): await asyncio.to_thread(proc.join, 20.0) assert proc.exitcode == 0 # Port released with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind((host, port)) @pytest.mark.asyncio async def test_run_gunicorn_no_health_reports_ready_and_sigint_preload(): """ Start Gunicorn without health URL; readiness is based on workers spawning. Serve '/', then SIGINT to stop. """ host = "127.0.0.1" port = portpicker.pick_unused_port() ctx = multiprocessing.get_context("fork") event_queue: multiprocessing.Queue[ChildEvent] = ctx.Queue() app = _make_app_health(always_ok=True) gapp = _build_gunicorn_app(app, host, port, workers=2) proc = ctx.Process( target=run_gunicorn, args=(gapp, noop_context(), event_queue, 3.0, None), # no health check daemon=False, ) proc.start() try: event = await _queue_get_async(event_queue, timeout=10.0) assert event.kind == "ready" async with aiohttp.ClientSession() as session: async with session.get(f"http://{host}:{port}/") as resp: assert resp.status == 200 assert await resp.json() == {"hello": "world"} if proc.pid is not None: os.kill(proc.pid, signal.SIGINT) await asyncio.to_thread(proc.join, 10.0) finally: if proc.is_alive(): await asyncio.to_thread(proc.join, 10.0) assert proc.exitcode == 0 # Port released with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind((host, port)) @pytest.mark.asyncio async def test_run_gunicorn_reports_startup_bind_failure_preload(capfd: pytest.CaptureFixture[str]): """ Occupy the port so Gunicorn cannot bind; watchdog should emit an error like 'Gunicorn workers did not start within ... seconds.' """ host = "127.0.0.1" conflict_port = portpicker.pick_unused_port() ctx = multiprocessing.get_context("fork") event_queue: multiprocessing.Queue[ChildEvent] = ctx.Queue() # Occupy the port in parent so child fails to bind with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind((host, conflict_port)) s.listen(1) app = _make_app_health(always_ok=True) gapp = _build_gunicorn_app(app, host, conflict_port, workers=2) proc = ctx.Process( target=run_gunicorn, args=(gapp, noop_context(), event_queue, 3.0, None), daemon=False, ) proc.start() try: event = await _queue_get_async(event_queue, timeout=10.0) assert event.kind == "error" assert "Gunicorn workers did not start within" in (event.message or "") await asyncio.to_thread(proc.join, 10.0) finally: if proc.is_alive(): await asyncio.to_thread(proc.join, 10.0) assert proc.exitcode != 0 captured = capfd.readouterr() assert "Address already in use" in captured.err # After releasing 's', the port should be available again with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s2: s2.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s2.bind((host, conflict_port)) @pytest.mark.asyncio async def test_run_uvicorn_asyncio_applies_serve_context(): """ run_uvicorn_asyncio should enter and exit the provided serve_context. """ host = "127.0.0.1" port = portpicker.pick_unused_port() app = _make_app_health(always_ok=True) server = _new_server(app, host, port) # local dict is fine (same-process) flags: Dict[str, bool] = {} serve_task = await run_uvicorn_asyncio( uvicorn_server=server, serve_context=_make_flag_context(flags, "asyncio"), timeout=10.0, health_url=f"http://{host}:{port}/health", wait_for_serve=False, # return after health passes ) # Confirm serving async with aiohttp.ClientSession() as session: async with session.get(f"http://{host}:{port}/") as resp: assert resp.status == 200 assert await resp.json() == {"hello": "world"} # Now shut down and confirm context exit happened await shutdown_uvicorn_server(server, serve_task) assert flags.get("asyncio_entered") is True assert flags.get("asyncio_exited") is True @pytest.mark.asyncio async def test_run_uvicorn_thread_applies_serve_context(): """ run_uvicorn_thread should enter and exit the provided serve_context. """ host = "127.0.0.1" port = portpicker.pick_unused_port() app = _make_app_health(always_ok=True) server = _new_server(app, host, port) event_queue: queue.Queue[ChildEvent] = queue.Queue() stop_event = threading.Event() flags: Dict[str, Any] = {} thread = threading.Thread( target=run_uvicorn_thread, args=( server, _make_flag_context(flags, "thread"), event_queue, stop_event, 10.0, f"http://{host}:{port}/health", ), daemon=True, ) thread.start() try: # Wait for readiness event = await _queue_get_async(event_queue, timeout=10.0) assert event.kind == "ready" # Probe root async with aiohttp.ClientSession() as session: async with session.get(f"http://{host}:{port}/") as resp: assert resp.status == 200 assert await resp.json() == {"hello": "world"} finally: stop_event.set() await asyncio.to_thread(thread.join, 10.0) assert flags.get("thread_entered") is True assert flags.get("thread_exited") is True _free_port(host, port) @pytest.mark.asyncio async def test_run_uvicorn_subprocess_applies_serve_context(): """ run_uvicorn_subprocess should enter and exit the provided serve_context. We track entry/exit via a multiprocessing.Manager dict. """ host = "127.0.0.1" port = portpicker.pick_unused_port() ctx = multiprocessing.get_context("fork") event_queue: multiprocessing.Queue[ChildEvent] = ctx.Queue() mgr = ctx.Manager() flags = cast(Dict[str, bool], mgr.dict()) # shared across processes app = _make_app_health(always_ok=True) server = _new_server(app, host, port) health_url = f"http://{host}:{port}/health" proc = ctx.Process( target=run_uvicorn_subprocess, args=(server, _make_flag_context(flags, "mp_uvicorn"), event_queue, 10.0, health_url), ) proc.start() try: event = await _queue_get_async(event_queue, timeout=20.0) assert event.kind == "ready" # Confirm serving async with aiohttp.ClientSession() as session: async with session.get(f"http://{host}:{port}/") as resp: assert resp.status == 200 assert await resp.json() == {"hello": "world"} # Stop child if proc.pid is not None: os.kill(proc.pid, signal.SIGTERM) await asyncio.to_thread(proc.join, 20.0) finally: if proc.is_alive(): if proc.pid is not None: os.kill(proc.pid, signal.SIGTERM) await asyncio.to_thread(proc.join, 20.0) assert proc.exitcode == 0 assert flags.get("mp_uvicorn_entered") is True assert flags.get("mp_uvicorn_exited") is True _free_port(host, port) @pytest.mark.asyncio async def test_run_gunicorn_applies_serve_context_preload(): """ run_gunicorn should enter and exit the provided serve_context. We verify via a multiprocessing.Manager dict. """ host = "127.0.0.1" port = portpicker.pick_unused_port() ctx = multiprocessing.get_context("fork") event_queue: multiprocessing.Queue[ChildEvent] = ctx.Queue() mgr = ctx.Manager() flags = cast(Dict[str, bool], mgr.dict()) app = _make_app_health(always_ok=True) gapp = _build_gunicorn_app(app, host, port, workers=2) health_url = f"http://{host}:{port}/health" proc = ctx.Process( target=run_gunicorn, args=(gapp, _make_flag_context(flags, "mp_gunicorn"), event_queue, 10.0, health_url), daemon=False, # gunicorn master must be non-daemon; mirrors your other tests ) proc.start() try: event = await _queue_get_async(event_queue, timeout=20.0) assert event.kind == "ready" # Confirm serving async with aiohttp.ClientSession() as session: async with session.get(f"http://{host}:{port}/") as resp: assert resp.status == 200 assert await resp.json() == {"hello": "world"} # Stop master if proc.pid is not None: os.kill(proc.pid, signal.SIGTERM) await asyncio.to_thread(proc.join, 20.0) finally: if proc.is_alive(): if proc.pid is not None: os.kill(proc.pid, signal.SIGTERM) await asyncio.to_thread(proc.join, 20.0) assert proc.exitcode == 0 assert flags.get("mp_gunicorn_entered") is True assert flags.get("mp_gunicorn_exited") is True _free_port(host, port) @pytest.mark.asyncio @pytest.mark.parametrize("launch_mode", ["asyncio", "thread"]) async def test_launcher_start_stop_basic_modes(launch_mode: LaunchMode): host = "127.0.0.1" port = portpicker.pick_unused_port() app = _make_app_health() args = PythonServerLauncherArgs( host=host, port=port, launch_mode=launch_mode, healthcheck_url="/health", startup_timeout=10.0, ) launcher = PythonServerLauncher(app, args) # Start await launcher.start() assert launcher.is_running() assert launcher.endpoint.endswith(f":{port}") assert launcher.health_url == f"http://{host}:{port}/health" # Probe root await _probe_json(f"http://{host}:{port}/", {"hello": "world"}) # Stop await launcher.stop() assert not launcher.is_running() _free_port(host, port) @pytest.mark.asyncio @pytest.mark.parametrize("n_workers", [1, 2]) # 1 => uvicorn, 2 => gunicorn async def test_launcher_start_stop_mp_modes(n_workers: int): host = "127.0.0.1" port = portpicker.pick_unused_port() app = _make_app_health() args = PythonServerLauncherArgs( host=host, port=port, launch_mode="mp", n_workers=n_workers, healthcheck_url="/health", startup_timeout=15.0, process_join_timeout=10.0, ) launcher = PythonServerLauncher(app, args) await launcher.start() assert launcher.is_running() await _probe_json(f"http://{host}:{port}/", {"hello": "world"}) await launcher.stop() assert not launcher.is_running() _free_port(host, port) @pytest.mark.asyncio @pytest.mark.parametrize("launch_mode", ["asyncio", "thread"]) async def test_launcher_reload_preserves_port_and_recovers(launch_mode: LaunchMode): host = "127.0.0.1" port = portpicker.pick_unused_port() app = _make_app_health() args = PythonServerLauncherArgs( host=host, port=port, launch_mode=launch_mode, healthcheck_url="/health", startup_timeout=10.0, ) launcher = PythonServerLauncher(app, args) await launcher.start() assert launcher.is_running() await _probe_json(f"http://{host}:{port}/", {"hello": "world"}) # Reload (stop + start) await launcher.reload() assert launcher.is_running() # Endpoint/port must be identical assert launcher.endpoint.endswith(f":{port}") await _probe_json(f"http://{host}:{port}/", {"hello": "world"}) await launcher.stop() _free_port(host, port) @pytest.mark.asyncio @pytest.mark.parametrize("n_workers", [1, 2]) async def test_launcher_reload_preserves_port_mp(n_workers: int): host = "127.0.0.1" port = portpicker.pick_unused_port() app = _make_app_health() args = PythonServerLauncherArgs( host=host, port=port, launch_mode="mp", n_workers=n_workers, healthcheck_url="/health", startup_timeout=15.0, ) launcher = PythonServerLauncher(app, args) await launcher.start() assert launcher.is_running() await _probe_json(f"http://{host}:{port}/", {"hello": "world"}) await launcher.reload() assert launcher.is_running() assert launcher.endpoint.endswith(f":{port}") await _probe_json(f"http://{host}:{port}/", {"hello": "world"}) await launcher.stop() _free_port(host, port) @pytest.mark.asyncio @pytest.mark.parametrize("launch_mode", ["asyncio", "thread"]) async def test_launcher_run_forever_cancellable(launch_mode: LaunchMode): host = "127.0.0.1" port = portpicker.pick_unused_port() app = _make_app_health() args = PythonServerLauncherArgs( host=host, port=port, launch_mode=launch_mode, healthcheck_url="/health", startup_timeout=10.0, ) launcher = PythonServerLauncher(app, args) # Run in background so we can cancel it run_task = asyncio.create_task(launcher.run_forever()) # Wait until started start = time.time() while not launcher.is_running() and (time.time() - start) < 5.0: await asyncio.sleep(0.05) assert launcher.is_running() await _probe_json(f"http://{host}:{port}/", {"hello": "world"}) # Cancel run_forever which should shut things down (the method handles cancellation) run_task.cancel() with pytest.raises(asyncio.CancelledError): await run_task # Give a moment for shutdown to propagate await asyncio.sleep(1.0) assert not launcher.is_running() _free_port(host, port) @pytest.mark.asyncio @pytest.mark.parametrize("n_workers", [1, 2]) async def test_launcher_run_forever_cancellable_mp(n_workers: int): host = "127.0.0.1" port = portpicker.pick_unused_port() app = _make_app_health() args = PythonServerLauncherArgs( host=host, port=port, launch_mode="mp", n_workers=n_workers, healthcheck_url="/health", startup_timeout=15.0, process_join_timeout=10.0, ) launcher = PythonServerLauncher(app, args) run_task = asyncio.create_task(launcher.run_forever()) # Wait until started start = time.time() while not launcher.is_running() and (time.time() - start) < 7.0: await asyncio.sleep(0.05) assert launcher.is_running() await _probe_json(f"http://{host}:{port}/", {"hello": "world"}) # Cancel run_forever (should trigger graceful process stop) run_task.cancel() with pytest.raises(asyncio.CancelledError): await run_task # Allow cleanup to finish await asyncio.sleep(1.0) assert not launcher.is_running() _free_port(host, port) @pytest.mark.asyncio @pytest.mark.parametrize("launch_mode", ["asyncio", "thread"]) async def test_launcher_assigns_random_port_when_none(launch_mode: LaunchMode): host = "127.0.0.1" app = _make_app_health() args = PythonServerLauncherArgs( host=host, port=None, # force random launch_mode=launch_mode, healthcheck_url="/health", startup_timeout=10.0, ) launcher = PythonServerLauncher(app, args) await launcher.start() assert launcher.is_running() # Port is chosen and embedded in endpoint/access_endpoint assert launcher.endpoint.startswith(f"http://{host}:") assert launcher.access_endpoint.startswith(f"http://{host}:") await _probe_json(f"{launcher.access_endpoint}/", {"hello": "world"}) await launcher.stop() @pytest.mark.asyncio async def test_launcher_endpoint_access_endpoint_health_url_normalization(): host = "0.0.0.0" # should flip to 127.0.0.1 for access_endpoint port = portpicker.pick_unused_port() app = _make_app_health() args = PythonServerLauncherArgs( host=host, port=port, launch_mode="mp", n_workers=1, healthcheck_url="health", # missing leading slash should be normalized startup_timeout=15.0, ) launcher = PythonServerLauncher(app, args) await launcher.start() try: assert launcher.endpoint == f"http://{host}:{port}" # access_endpoint should map 0.0.0.0 -> 127.0.0.1 assert launcher.access_endpoint.startswith("http://") and launcher.access_endpoint.endswith(f":{port}") assert ( launcher.health_url and launcher.health_url.startswith("http://") and launcher.health_url.endswith(f"/health") ) await _probe_json(f"{launcher.access_endpoint}/", {"hello": "world"}) finally: await launcher.stop() _free_port("127.0.0.1", port)