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

1348 lines
44 KiB
Python

# 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)