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
1348 lines
44 KiB
Python
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)
|